mirror of
https://github.com/discourse/discourse.git
synced 2025-03-15 11:01:14 +00:00
DEV: Add the AsyncContent
component (#31101)
Co-authored-by: David Taylor <david@taylorhq.com>
This commit is contained in:
app/assets
javascripts
admin/addon/components
discourse
app
components
lib
tests/integration/components
stylesheets/common/modal
plugins/chat/assets/javascripts/discourse/components
pnpm-lock.yaml@ -3,7 +3,7 @@ import { tracked } from "@glimmer/tracking";
|
||||
import { fn } from "@ember/helper";
|
||||
import { on } from "@ember/modifier";
|
||||
import { service } from "@ember/service";
|
||||
import ConditionalLoadingSpinner from "discourse/components/conditional-loading-spinner";
|
||||
import AsyncContent from "discourse/components/async-content";
|
||||
import withEventValue from "discourse/helpers/with-event-value";
|
||||
import { ajax } from "discourse/lib/ajax";
|
||||
import { bind } from "discourse/lib/decorators";
|
||||
@ -13,32 +13,24 @@ import AdminSectionLandingWrapper from "admin/components/admin-section-landing-w
|
||||
|
||||
export default class AdminReports extends Component {
|
||||
@service siteSettings;
|
||||
@tracked reports = null;
|
||||
@tracked reports;
|
||||
@tracked filter = "";
|
||||
@tracked isLoading = true;
|
||||
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
this.loadReports();
|
||||
@bind
|
||||
async loadReports() {
|
||||
const response = await ajax("/admin/reports");
|
||||
return response.reports;
|
||||
}
|
||||
|
||||
@bind
|
||||
loadReports() {
|
||||
ajax("/admin/reports")
|
||||
.then((json) => {
|
||||
this.reports = json.reports;
|
||||
})
|
||||
.finally(() => (this.isLoading = false));
|
||||
}
|
||||
|
||||
get filteredReports() {
|
||||
if (!this.reports) {
|
||||
filterReports(reports, filter) {
|
||||
if (!reports) {
|
||||
return [];
|
||||
}
|
||||
|
||||
let filteredReports = this.reports;
|
||||
if (this.filter) {
|
||||
const lowerCaseFilter = this.filter.toLowerCase();
|
||||
let filteredReports = reports;
|
||||
if (filter) {
|
||||
const lowerCaseFilter = filter.toLowerCase();
|
||||
filteredReports = filteredReports.filter((report) => {
|
||||
return (
|
||||
(report.title || "").toLowerCase().includes(lowerCaseFilter) ||
|
||||
@ -58,28 +50,30 @@ export default class AdminReports extends Component {
|
||||
}
|
||||
|
||||
<template>
|
||||
<ConditionalLoadingSpinner @condition={{this.isLoading}}>
|
||||
<div class="d-admin-filter admin-reports-header">
|
||||
<div class="admin-filter__input-container">
|
||||
<input
|
||||
type="text"
|
||||
class="admin-filter__input admin-reports-header__filter"
|
||||
placeholder={{i18n "admin.filter_reports"}}
|
||||
value={{this.filter}}
|
||||
{{on "input" (withEventValue (fn (mut this.filter)))}}
|
||||
/>
|
||||
<AsyncContent @asyncData={{this.loadReports}}>
|
||||
<:content as |reports|>
|
||||
<div class="d-admin-filter admin-reports-header">
|
||||
<div class="admin-filter__input-container">
|
||||
<input
|
||||
type="text"
|
||||
class="admin-filter__input admin-reports-header__filter"
|
||||
placeholder={{i18n "admin.filter_reports"}}
|
||||
value={{this.filter}}
|
||||
{{on "input" (withEventValue (fn (mut this.filter)))}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<AdminSectionLandingWrapper class="admin-reports-list">
|
||||
{{#each this.filteredReports as |report|}}
|
||||
<AdminSectionLandingItem
|
||||
@titleLabelTranslated={{report.title}}
|
||||
@descriptionLabelTranslated={{report.description}}
|
||||
@titleRoute="adminReports.show"
|
||||
@titleRouteModel={{report.type}}
|
||||
/>
|
||||
{{/each}}
|
||||
</AdminSectionLandingWrapper>
|
||||
</ConditionalLoadingSpinner>
|
||||
<AdminSectionLandingWrapper class="admin-reports-list">
|
||||
{{#each (this.filterReports reports this.filter) as |report|}}
|
||||
<AdminSectionLandingItem
|
||||
@titleLabelTranslated={{report.title}}
|
||||
@descriptionLabelTranslated={{report.description}}
|
||||
@titleRoute="adminReports.show"
|
||||
@titleRouteModel={{report.type}}
|
||||
/>
|
||||
{{/each}}
|
||||
</AdminSectionLandingWrapper>
|
||||
</:content>
|
||||
</AsyncContent>
|
||||
</template>
|
||||
}
|
||||
|
@ -0,0 +1,126 @@
|
||||
import Component from "@glimmer/component";
|
||||
import { cached } from "@glimmer/tracking";
|
||||
import { hash } from "@ember/helper";
|
||||
import { htmlSafe } from "@ember/template";
|
||||
import { TrackedAsyncData } from "ember-async-data";
|
||||
import { Promise as RsvpPromise } from "rsvp";
|
||||
import { eq } from "truth-helpers";
|
||||
import ConditionalLoadingSpinner from "discourse/components/conditional-loading-spinner";
|
||||
import FlashMessage from "discourse/components/flash-message";
|
||||
import { popupAjaxError } from "discourse/lib/ajax-error";
|
||||
import discourseDebounce from "discourse/lib/debounce";
|
||||
import { bind } from "discourse/lib/decorators";
|
||||
import { INPUT_DELAY } from "discourse/lib/environment";
|
||||
import { extractErrorInfo } from "../lib/ajax-error";
|
||||
|
||||
const ERROR_MODES = ["flash", "popup"];
|
||||
const DEFAULT_ERROR_MODE = "flash";
|
||||
|
||||
export default class AsyncContent extends Component {
|
||||
#debounce = false;
|
||||
|
||||
@cached
|
||||
get data() {
|
||||
const asyncData = this.args.asyncData;
|
||||
const context = this.args.context;
|
||||
|
||||
if (this.isDestroying || this.isDestroyed) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (asyncData instanceof TrackedAsyncData) {
|
||||
return asyncData;
|
||||
}
|
||||
|
||||
let value;
|
||||
|
||||
if (this.#isPromise(asyncData)) {
|
||||
value = asyncData;
|
||||
} else if (typeof asyncData === "function") {
|
||||
value = this.#debounce
|
||||
? new Promise((resolve, reject) => {
|
||||
discourseDebounce(
|
||||
this,
|
||||
this.#resolveAsyncData,
|
||||
asyncData,
|
||||
context,
|
||||
resolve,
|
||||
reject,
|
||||
this.#debounce
|
||||
);
|
||||
})
|
||||
: this.#resolveAsyncData(asyncData, context);
|
||||
}
|
||||
|
||||
if (!this.#isPromise(value)) {
|
||||
throw new Error(
|
||||
`\`<AsyncContent />\` expects @asyncData to be an async function or a promise`
|
||||
);
|
||||
}
|
||||
|
||||
return new TrackedAsyncData(value);
|
||||
}
|
||||
|
||||
get errorMessage() {
|
||||
const errorInfo = extractErrorInfo(this.data.error);
|
||||
return errorInfo.html ? htmlSafe(errorInfo.message) : errorInfo.message;
|
||||
}
|
||||
|
||||
get errorMode() {
|
||||
return this.args.errorMode ?? DEFAULT_ERROR_MODE;
|
||||
}
|
||||
|
||||
@bind
|
||||
verifyParameters({ hasErrorBlock }) {
|
||||
if (hasErrorBlock && this.args.errorMode) {
|
||||
throw `@errorMode cannot be used when a block named "error" is provided`;
|
||||
}
|
||||
|
||||
if (this.errorMode && !ERROR_MODES.includes(this.errorMode)) {
|
||||
throw `@errorMode must be one of \`${ERROR_MODES.join("`, `")}\``;
|
||||
}
|
||||
}
|
||||
|
||||
#isPromise(value) {
|
||||
return value instanceof Promise || value instanceof RsvpPromise;
|
||||
}
|
||||
|
||||
// a stable reference to a function to use the `debounce` method
|
||||
#resolveAsyncData(asyncData, context, resolve, reject) {
|
||||
this.#debounce =
|
||||
this.args.debounce === true ? INPUT_DELAY : this.args.debounce;
|
||||
|
||||
// when a resolve function is provided, we need to resolve the promise once asyncData is done
|
||||
// otherwise, we just call asyncData
|
||||
return resolve
|
||||
? asyncData(context).then(resolve).catch(reject)
|
||||
: asyncData(context);
|
||||
}
|
||||
|
||||
<template>
|
||||
{{this.verifyParameters (hash hasErrorBlock=(has-block "error"))}}
|
||||
{{#if this.data.isPending}}
|
||||
{{#if (has-block "loading")}}
|
||||
{{yield to="loading"}}
|
||||
{{else}}
|
||||
<ConditionalLoadingSpinner @condition={{this.data.isPending}} />
|
||||
{{/if}}
|
||||
{{else if this.data.isResolved}}
|
||||
{{#if this.data.value}}
|
||||
{{yield this.data.value to="content"}}
|
||||
{{else if (has-block "empty")}}
|
||||
{{yield to="empty"}}
|
||||
{{else}}
|
||||
{{yield this.data.value to="content"}}
|
||||
{{/if}}
|
||||
{{else if this.data.isRejected}}
|
||||
{{#if (has-block "error")}}
|
||||
{{yield this.data.error to="error"}}
|
||||
{{else if (eq this.errorMode "flash")}}
|
||||
<FlashMessage role="alert" @flash={{this.errorMessage}} @type="error" />
|
||||
{{else if (eq this.errorMode "popup")}}
|
||||
{{popupAjaxError this.data.error}}
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
</template>
|
||||
}
|
@ -3,21 +3,21 @@ import { tracked } from "@glimmer/tracking";
|
||||
import { fn } from "@ember/helper";
|
||||
import { on } from "@ember/modifier";
|
||||
import { action } from "@ember/object";
|
||||
import { next } from "@ember/runloop";
|
||||
import { isEmpty } from "@ember/utils";
|
||||
import { debounce } from "discourse/lib/decorators";
|
||||
import { eq } from "truth-helpers";
|
||||
import AsyncContent from "discourse/components/async-content";
|
||||
import { searchForTerm } from "discourse/lib/search";
|
||||
import { i18n } from "discourse-i18n";
|
||||
|
||||
export default class ChooseMessage extends Component {
|
||||
@tracked hasSearched = false;
|
||||
@tracked loading = false;
|
||||
@tracked messages;
|
||||
@tracked messageTitle = null;
|
||||
|
||||
@action
|
||||
async search(title) {
|
||||
next(() => this.args.setSelectedTopicId(null)); // clear existing selection
|
||||
|
||||
@debounce(300)
|
||||
async debouncedSearch(title) {
|
||||
if (isEmpty(title)) {
|
||||
this.messages = null;
|
||||
this.loading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
@ -27,54 +27,73 @@ export default class ChooseMessage extends Component {
|
||||
restrictToArchetype: "private_message",
|
||||
});
|
||||
|
||||
this.messages = results?.posts
|
||||
const messages = results?.posts
|
||||
?.mapBy("topic")
|
||||
.filter((topic) => topic.id !== this.args.currentTopicId);
|
||||
|
||||
this.loading = false;
|
||||
if (messages.length === 1) {
|
||||
next(() => this.args.setSelectedTopicId(messages[0]));
|
||||
}
|
||||
|
||||
return messages;
|
||||
}
|
||||
|
||||
@action
|
||||
search(event) {
|
||||
this.hasSearched = true;
|
||||
this.loading = true;
|
||||
this.args.setSelectedTopicId(null);
|
||||
this.debouncedSearch(event.target.value);
|
||||
setSearchTerm(evt) {
|
||||
this.messageTitle = evt.target.value;
|
||||
}
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="choose-message">
|
||||
<label for="choose-message-title">
|
||||
{{i18n "choose_message.title.search"}}
|
||||
</label>
|
||||
|
||||
<input
|
||||
{{on "input" this.search}}
|
||||
{{on "input" this.setSearchTerm}}
|
||||
type="text"
|
||||
placeholder={{i18n "choose_message.title.placeholder"}}
|
||||
id="choose-message-title"
|
||||
/>
|
||||
|
||||
{{#if this.loading}}
|
||||
<p>{{i18n "loading"}}</p>
|
||||
{{else if this.hasSearched}}
|
||||
{{#each this.messages as |message|}}
|
||||
<div class="controls existing-message">
|
||||
<label class="radio">
|
||||
<input
|
||||
{{on "click" (fn @setSelectedTopicId message)}}
|
||||
type="radio"
|
||||
name="choose_message_id"
|
||||
/>
|
||||
<span class="message-title">
|
||||
{{message.title}}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
{{else}}
|
||||
<p>{{i18n "choose_message.none_found"}}</p>
|
||||
{{/each}}
|
||||
{{/if}}
|
||||
<div class="choose-message__search-results">
|
||||
<AsyncContent
|
||||
@asyncData={{this.search}}
|
||||
@context={{this.messageTitle}}
|
||||
@debounce={{true}}
|
||||
>
|
||||
<:loading>
|
||||
{{i18n "loading"}}
|
||||
</:loading>
|
||||
|
||||
<:empty>
|
||||
{{#if this.messageTitle}}
|
||||
{{i18n "choose_message.none_found"}}
|
||||
{{else}}
|
||||
{{! ensure the paragraph has the same height as the loading message to prevent layout shift }}
|
||||
|
||||
{{/if}}
|
||||
</:empty>
|
||||
|
||||
<:content as |messages|>
|
||||
{{#each messages as |message|}}
|
||||
<div class="controls existing-message">
|
||||
<label class="radio">
|
||||
<input
|
||||
{{on "click" (fn @setSelectedTopicId message)}}
|
||||
type="radio"
|
||||
name="choose_message_id"
|
||||
checked={{eq message.id @selectedTopicId}}
|
||||
/>
|
||||
<span class="message-title">
|
||||
{{message.title}}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
{{/each}}
|
||||
</:content>
|
||||
</AsyncContent>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
}
|
||||
|
180
app/assets/javascripts/discourse/app/components/choose-topic.gjs
Normal file
180
app/assets/javascripts/discourse/app/components/choose-topic.gjs
Normal file
@ -0,0 +1,180 @@
|
||||
import Component from "@glimmer/component";
|
||||
import { tracked } from "@glimmer/tracking";
|
||||
import { concat, fn } from "@ember/helper";
|
||||
import { on } from "@ember/modifier";
|
||||
import { action } from "@ember/object";
|
||||
import { next } from "@ember/runloop";
|
||||
import { isEmpty, isPresent } from "@ember/utils";
|
||||
import { eq, or } from "truth-helpers";
|
||||
import AsyncContent from "discourse/components/async-content";
|
||||
import TopicStatus from "discourse/components/topic-status";
|
||||
import boundCategoryLink from "discourse/helpers/bound-category-link";
|
||||
import replaceEmoji from "discourse/helpers/replace-emoji";
|
||||
import { searchForTerm } from "discourse/lib/search";
|
||||
import { i18n } from "discourse-i18n";
|
||||
|
||||
// args:
|
||||
// topicChangedCallback
|
||||
//
|
||||
// optional:
|
||||
// currentTopicId
|
||||
// additionalFilters
|
||||
// label
|
||||
// loadOnInit
|
||||
export default class ChooseTopic extends Component {
|
||||
@tracked topicTitle = null;
|
||||
|
||||
async initialSearch() {
|
||||
const results = await searchForTerm(this.args.additionalFilters);
|
||||
if (!results?.posts?.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
return results.posts
|
||||
.mapBy("topic")
|
||||
.filter((t) => t.id !== this.args.currentTopicId);
|
||||
}
|
||||
|
||||
@action
|
||||
async loadTopics(title) {
|
||||
next(() => this.chooseTopic(null)); // clear existing selection
|
||||
|
||||
// topicTitle is null => initial load
|
||||
if (this.topicTitle === null) {
|
||||
if (this.args.loadOnInit && isPresent(this.args.additionalFilters)) {
|
||||
return await this.initialSearch();
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.isDestroying || this.isDestroyed) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isEmpty(title) && isEmpty(this.args.additionalFilters)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const titleWithFilters = [title, this.args.additionalFilters]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
|
||||
let searchParams;
|
||||
|
||||
if (isPresent(title)) {
|
||||
searchParams = {
|
||||
typeFilter: "topic",
|
||||
restrictToArchetype: "regular",
|
||||
searchForId: true,
|
||||
};
|
||||
}
|
||||
|
||||
const results = await searchForTerm(titleWithFilters, searchParams);
|
||||
|
||||
// search term changed after the request was fired but before we
|
||||
// got a response, ignore results.
|
||||
if (title !== this.topicTitle) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!results?.posts?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const topics = results.posts
|
||||
.mapBy("topic")
|
||||
.filter((t) => t.id !== this.args.currentTopicId);
|
||||
|
||||
if (topics.length === 1) {
|
||||
next(() => this.chooseTopic(topics[0]));
|
||||
}
|
||||
|
||||
return topics;
|
||||
}
|
||||
|
||||
@action
|
||||
async onTopicTitleChange(event) {
|
||||
this.topicTitle = event.target.value;
|
||||
}
|
||||
|
||||
@action
|
||||
ignoreEnter(event) {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
chooseTopic(topic) {
|
||||
this.args.topicChangedCallback(topic);
|
||||
}
|
||||
|
||||
<template>
|
||||
<div class="choose-topic">
|
||||
<label for="choose-topic-title">
|
||||
<span>{{i18n (or @label "choose_topic.title.search")}}</span>
|
||||
</label>
|
||||
|
||||
<input
|
||||
{{on "keydown" this.ignoreEnter}}
|
||||
{{on "input" this.onTopicTitleChange}}
|
||||
type="text"
|
||||
placeholder={{i18n "choose_topic.title.placeholder"}}
|
||||
id="choose-topic-title"
|
||||
/>
|
||||
|
||||
<div class="choose-topic__search-results">
|
||||
<AsyncContent
|
||||
@asyncData={{this.loadTopics}}
|
||||
@context={{this.topicTitle}}
|
||||
@debounce={{true}}
|
||||
>
|
||||
<:loading>
|
||||
{{i18n "loading"}}
|
||||
</:loading>
|
||||
|
||||
<:empty>
|
||||
{{#if this.topicTitle}}
|
||||
{{i18n "choose_topic.none_found"}}
|
||||
{{else}}
|
||||
{{! ensure the paragraph has the same height as the loading message to prevent layout shift }}
|
||||
|
||||
{{/if}}
|
||||
</:empty>
|
||||
|
||||
<:content as |topics|>
|
||||
<div class="choose-topic-list" role="radiogroup">
|
||||
{{#each topics as |t|}}
|
||||
<div class="controls existing-topic">
|
||||
<label class="radio">
|
||||
<input
|
||||
{{on "click" (fn this.chooseTopic t)}}
|
||||
checked={{eq t.id @selectedTopicId}}
|
||||
type="radio"
|
||||
name="choose_topic_id"
|
||||
id={{concat "choose-topic-" t.id}}
|
||||
/>
|
||||
<TopicStatus @topic={{t}} @disableActions={{true}} />
|
||||
<span class="topic-title">
|
||||
{{replaceEmoji t.title}}
|
||||
</span>
|
||||
<span class="topic-categories">
|
||||
{{boundCategoryLink
|
||||
t.category
|
||||
ancestors=t.category.predecessors
|
||||
hideParent=true
|
||||
link=false
|
||||
}}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
{{/each}}
|
||||
</div>
|
||||
</:content>
|
||||
</AsyncContent>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
}
|
@ -1,47 +0,0 @@
|
||||
<div>
|
||||
<label for="choose-topic-title">
|
||||
<span>{{i18n (or @label "choose_topic.title.search")}}</span>
|
||||
</label>
|
||||
|
||||
<input
|
||||
{{on "keydown" this.ignoreEnter}}
|
||||
{{on "input" this.onTopicTitleChange}}
|
||||
type="text"
|
||||
placeholder={{i18n "choose_topic.title.placeholder"}}
|
||||
id="choose-topic-title"
|
||||
/>
|
||||
|
||||
{{#if this.loading}}
|
||||
<p>{{i18n "loading"}}</p>
|
||||
{{else if (not this.topics.length)}}
|
||||
<p>{{i18n "choose_topic.none_found"}}</p>
|
||||
{{else}}
|
||||
<div class="choose-topic-list" role="radiogroup">
|
||||
{{#each this.topics as |t|}}
|
||||
<div class="controls existing-topic">
|
||||
<label class="radio">
|
||||
<Input
|
||||
{{on "click" (fn this.chooseTopic t)}}
|
||||
@checked={{eq t.id this.selectedTopicId}}
|
||||
@type="radio"
|
||||
name="choose_topic_id"
|
||||
id={{concat "choose-topic-" t.id}}
|
||||
/>
|
||||
<TopicStatus @topic={{t}} @disableActions={{true}} />
|
||||
<span class="topic-title">
|
||||
{{replace-emoji t.title}}
|
||||
</span>
|
||||
<span class="topic-categories">
|
||||
{{bound-category-link
|
||||
t.category
|
||||
ancestors=t.category.predecessors
|
||||
hideParent=true
|
||||
link=false
|
||||
}}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
{{/each}}
|
||||
</div>
|
||||
{{/if}}
|
||||
</div>
|
@ -1,121 +0,0 @@
|
||||
import Component from "@glimmer/component";
|
||||
import { tracked } from "@glimmer/tracking";
|
||||
import { action } from "@ember/object";
|
||||
import { isEmpty, isPresent } from "@ember/utils";
|
||||
import { popupAjaxError } from "discourse/lib/ajax-error";
|
||||
import { debounce } from "discourse/lib/decorators";
|
||||
import { INPUT_DELAY } from "discourse/lib/environment";
|
||||
import { searchForTerm } from "discourse/lib/search";
|
||||
|
||||
// args:
|
||||
// topicChangedCallback
|
||||
//
|
||||
// optional:
|
||||
// currentTopicId
|
||||
// additionalFilters
|
||||
// label
|
||||
// loadOnInit
|
||||
export default class ChooseTopic extends Component {
|
||||
@tracked loading = true;
|
||||
@tracked topics;
|
||||
topicTitle;
|
||||
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
|
||||
if (this.args.loadOnInit && isPresent(this.args.additionalFilters)) {
|
||||
this.initialSearch();
|
||||
}
|
||||
}
|
||||
|
||||
async initialSearch() {
|
||||
try {
|
||||
const results = await searchForTerm(this.args.additionalFilters);
|
||||
if (!results?.posts?.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.topics = results.posts
|
||||
.mapBy("topic")
|
||||
.filter((t) => t.id !== this.args.currentTopicId);
|
||||
} catch (e) {
|
||||
popupAjaxError(e);
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
@debounce(INPUT_DELAY)
|
||||
async search(title) {
|
||||
if (this.isDestroying || this.isDestroyed) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isEmpty(title) && isEmpty(this.args.additionalFilters)) {
|
||||
this.topics = null;
|
||||
this.loading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const titleWithFilters = [title, this.args.additionalFilters]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
let searchParams;
|
||||
|
||||
if (isPresent(title)) {
|
||||
searchParams = {
|
||||
typeFilter: "topic",
|
||||
restrictToArchetype: "regular",
|
||||
searchForId: true,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const results = await searchForTerm(titleWithFilters, searchParams);
|
||||
|
||||
// search term changed after the request was fired but before we
|
||||
// got a response, ignore results.
|
||||
if (title !== this.topicTitle) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!results?.posts?.length) {
|
||||
this.topics = null;
|
||||
return;
|
||||
}
|
||||
|
||||
this.topics = results.posts
|
||||
.mapBy("topic")
|
||||
.filter((t) => t.id !== this.args.currentTopicId);
|
||||
|
||||
if (this.topics.length === 1) {
|
||||
this.chooseTopic(this.topics[0]);
|
||||
}
|
||||
} catch (e) {
|
||||
popupAjaxError(e);
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
async onTopicTitleChange(event) {
|
||||
this.topicTitle = event.target.value;
|
||||
this.loading = true;
|
||||
|
||||
await this.search(this.topicTitle);
|
||||
}
|
||||
|
||||
@action
|
||||
ignoreEnter(event) {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
chooseTopic(topic) {
|
||||
this.args.topicChangedCallback(topic);
|
||||
}
|
||||
}
|
@ -71,6 +71,7 @@
|
||||
<ChooseMessage
|
||||
@currentTopicId={{@model.topic.id}}
|
||||
@setSelectedTopicId={{fn (mut this.selectedTopic)}}
|
||||
@selectedTopicId={{this.selectedTopic.id}}
|
||||
/>
|
||||
|
||||
<label>{{i18n "topic.move_to_new_message.participants"}}</label>
|
||||
@ -145,6 +146,7 @@
|
||||
<ChooseTopic
|
||||
@topicChangedCallback={{this.newTopicSelected}}
|
||||
@currentTopicId={{@model.topic.id}}
|
||||
@selectedTopicId={{this.selectedTopic.id}}
|
||||
/>
|
||||
|
||||
{{#if this.selectedTopic}}
|
||||
|
@ -3,7 +3,7 @@ import $ from "jquery";
|
||||
import { getOwnerWithFallback } from "discourse/lib/get-owner";
|
||||
import { i18n } from "discourse-i18n";
|
||||
|
||||
function extractErrorInfo(error, defaultMessage) {
|
||||
export function extractErrorInfo(error, defaultMessage) {
|
||||
if (error instanceof Error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(error.stack);
|
||||
|
@ -94,6 +94,7 @@
|
||||
"discourse-i18n": "workspace:1.0.0",
|
||||
"discourse-markdown-it": "workspace:1.0.0",
|
||||
"discourse-plugins": "workspace:1.0.0",
|
||||
"ember-async-data": "^1.0.3",
|
||||
"ember-auto-import": "^2.10.0",
|
||||
"ember-buffered-proxy": "^2.1.1",
|
||||
"ember-cached-decorator-polyfill": "^1.0.2",
|
||||
|
371
app/assets/javascripts/discourse/tests/integration/components/async-content-test.gjs
Normal file
371
app/assets/javascripts/discourse/tests/integration/components/async-content-test.gjs
Normal file
@ -0,0 +1,371 @@
|
||||
import Component from "@glimmer/component";
|
||||
import { tracked } from "@glimmer/tracking";
|
||||
import { on } from "@ember/modifier";
|
||||
import { action } from "@ember/object";
|
||||
import { click, render, waitFor } from "@ember/test-helpers";
|
||||
import { TrackedAsyncData } from "ember-async-data";
|
||||
import { module, test } from "qunit";
|
||||
import { Promise as RsvpPromise } from "rsvp";
|
||||
import AsyncContent from "discourse/components/async-content";
|
||||
import DialogHolder from "discourse/components/dialog-holder";
|
||||
import { setupRenderingTest } from "discourse/tests/helpers/component-test";
|
||||
|
||||
module("Integration | Component | AsyncContent", function (hooks) {
|
||||
setupRenderingTest(hooks);
|
||||
|
||||
module("@asyncData", function () {
|
||||
test("it accepts a promise", async function (assert) {
|
||||
const promise = Promise.resolve("data");
|
||||
|
||||
await render(<template>
|
||||
<AsyncContent @asyncData={{promise}}>
|
||||
<:content as |data|>
|
||||
<div class="content">{{data}}</div>
|
||||
</:content>
|
||||
</AsyncContent>
|
||||
</template>);
|
||||
|
||||
assert.true(true, "no error is thrown");
|
||||
assert.dom(".content").hasText("data");
|
||||
});
|
||||
|
||||
test("it accepts a function that returns a promise", async function (assert) {
|
||||
const promise = () => Promise.resolve("data");
|
||||
|
||||
await render(<template>
|
||||
<AsyncContent @asyncData={{promise}}>
|
||||
<:content as |data|>
|
||||
<div class="content">{{data}}</div>
|
||||
</:content>
|
||||
</AsyncContent>
|
||||
</template>);
|
||||
|
||||
assert.true(true, "no error is thrown");
|
||||
assert.dom(".content").hasText("data");
|
||||
});
|
||||
|
||||
test("it accepts an RsvpPromise", async function (assert) {
|
||||
const promise = RsvpPromise.resolve("data");
|
||||
|
||||
await render(<template>
|
||||
<AsyncContent @asyncData={{promise}}>
|
||||
<:content as |data|>
|
||||
<div class="content">{{data}}</div>
|
||||
</:content>
|
||||
</AsyncContent>
|
||||
</template>);
|
||||
|
||||
assert.dom(".content").hasText("data");
|
||||
});
|
||||
|
||||
test("it accepts an async function", async function (assert) {
|
||||
const promise = async () => "data";
|
||||
|
||||
await render(<template>
|
||||
<AsyncContent @asyncData={{promise}}>
|
||||
<:content as |data|>
|
||||
<div class="content">{{data}}</div>
|
||||
</:content>
|
||||
</AsyncContent>
|
||||
</template>);
|
||||
|
||||
assert.dom(".content").hasText("data");
|
||||
});
|
||||
|
||||
test("it accepts an instance of TrackedAsyncData", async function (assert) {
|
||||
const promise = new TrackedAsyncData(Promise.resolve("data"));
|
||||
|
||||
await render(<template>
|
||||
<AsyncContent @asyncData={{promise}}>
|
||||
<:content as |data|>
|
||||
<div class="content">{{data}}</div>
|
||||
</:content>
|
||||
</AsyncContent>
|
||||
</template>);
|
||||
|
||||
assert.dom(".content").hasText("data");
|
||||
});
|
||||
});
|
||||
|
||||
module("@context", function () {
|
||||
test("it passes the context to the async function", async function (assert) {
|
||||
const promise = (context) => {
|
||||
assert.strictEqual(context, "correct", "context is passed correctly");
|
||||
return Promise.resolve("data");
|
||||
};
|
||||
|
||||
await render(<template>
|
||||
<AsyncContent @asyncData={{promise}} @context="correct">
|
||||
<:content as |data|>
|
||||
<div class="content">{{data}}</div>
|
||||
</:content>
|
||||
</AsyncContent>
|
||||
</template>);
|
||||
|
||||
assert.dom(".content").hasText("data");
|
||||
});
|
||||
|
||||
test("it updates the async data when the context changes", async function (assert) {
|
||||
await render(
|
||||
class extends Component {
|
||||
@tracked context = "first";
|
||||
|
||||
@action
|
||||
changeContext() {
|
||||
this.context = "second";
|
||||
}
|
||||
|
||||
async load(context) {
|
||||
return context;
|
||||
}
|
||||
|
||||
<template>
|
||||
<button {{on "click" this.changeContext}}>Change Context</button>
|
||||
<AsyncContent @asyncData={{this.load}} @context={{this.context}}>
|
||||
<:content as |data|>
|
||||
<div class="content">{{data}}</div>
|
||||
</:content>
|
||||
</AsyncContent>
|
||||
</template>
|
||||
}
|
||||
);
|
||||
|
||||
assert.dom(".content").hasText("first");
|
||||
|
||||
await click("button");
|
||||
|
||||
assert.dom(".content").hasText("second");
|
||||
});
|
||||
});
|
||||
|
||||
module("<:loading>", function () {
|
||||
test("it displays the spinner when the block is not provided", async function (assert) {
|
||||
let resolvePromise;
|
||||
const promise = new Promise((resolve) => (resolvePromise = resolve));
|
||||
|
||||
const renderPromise = render(<template>
|
||||
<div data-async-content-test>
|
||||
<AsyncContent @asyncData={{promise}}>
|
||||
<:content>
|
||||
<div class="content"></div>
|
||||
</:content>
|
||||
</AsyncContent>
|
||||
</div>
|
||||
</template>);
|
||||
|
||||
// TrackedAsyncData is tangled with Ember's run loop, so we need to wait for the result of the rendering
|
||||
// instead to check the loading state.
|
||||
// Otherwise, the test will timeout waiting for the promise to resolve.
|
||||
await waitFor("[data-async-content-test]");
|
||||
assert.dom(".spinner").exists();
|
||||
|
||||
resolvePromise();
|
||||
await renderPromise;
|
||||
assert.dom(".content").exists();
|
||||
});
|
||||
|
||||
test("it displays the block when provided", async function (assert) {
|
||||
let resolvePromise;
|
||||
const promise = new Promise((resolve) => (resolvePromise = resolve));
|
||||
|
||||
const renderPromise = render(<template>
|
||||
<div data-async-content-test>
|
||||
<AsyncContent @asyncData={{promise}}>
|
||||
<:loading>
|
||||
<div class="loading-provided"></div>
|
||||
</:loading>
|
||||
|
||||
<:content>
|
||||
<div class="content"></div>
|
||||
</:content>
|
||||
</AsyncContent>
|
||||
</div>
|
||||
</template>);
|
||||
|
||||
// TrackedAsyncData is tangled with Ember's run loop, so we need to wait for the result of the rendering
|
||||
// instead to check the loading state.
|
||||
// Otherwise, the test will timeout waiting for the promise to resolve.
|
||||
await waitFor("[data-async-content-test]");
|
||||
assert.dom(".loading-provided").exists();
|
||||
|
||||
resolvePromise();
|
||||
await renderPromise;
|
||||
assert.dom(".content").exists();
|
||||
});
|
||||
});
|
||||
|
||||
module("<:content>", function () {
|
||||
test("it displays the block once the promise is fulfilled", async function (assert) {
|
||||
const promise = Promise.resolve("data returned");
|
||||
|
||||
await render(<template>
|
||||
<AsyncContent @asyncData={{promise}}>
|
||||
<:content as |data|>
|
||||
<div class="content">
|
||||
{{data}}
|
||||
</div>
|
||||
</:content>
|
||||
</AsyncContent>
|
||||
</template>);
|
||||
|
||||
assert.dom(".content").exists();
|
||||
assert.dom(".content").hasText("data returned");
|
||||
});
|
||||
|
||||
test("it does not display the block if the promise fails", async function (assert) {
|
||||
const promise = Promise.reject("error");
|
||||
|
||||
await render(<template>
|
||||
<AsyncContent @asyncData={{promise}}>
|
||||
<:content as |data|>
|
||||
<div class="content">
|
||||
{{data}}
|
||||
</div>
|
||||
</:content>
|
||||
</AsyncContent>
|
||||
</template>);
|
||||
|
||||
assert.dom(".content").doesNotExist();
|
||||
});
|
||||
});
|
||||
|
||||
module("<:empty>", function () {
|
||||
test("it displays the block when the promise is resolved with an empty value", async function (assert) {
|
||||
const promise = Promise.resolve(null);
|
||||
|
||||
await render(<template>
|
||||
<AsyncContent @asyncData={{promise}}>
|
||||
<:empty>
|
||||
<div class="empty">
|
||||
Empty
|
||||
</div>
|
||||
</:empty>
|
||||
</AsyncContent>
|
||||
</template>);
|
||||
|
||||
assert.dom(".empty").exists();
|
||||
});
|
||||
|
||||
test("it does not display the block when the promise is resolved with a value", async function (assert) {
|
||||
const promise = Promise.resolve("data");
|
||||
|
||||
await render(<template>
|
||||
<AsyncContent @asyncData={{promise}}>
|
||||
<:empty>
|
||||
<div class="empty">
|
||||
Empty
|
||||
</div>
|
||||
</:empty>
|
||||
</AsyncContent>
|
||||
</template>);
|
||||
|
||||
assert.dom(".empty").doesNotExist();
|
||||
});
|
||||
|
||||
test("it displays the content block if the the empty block is not provided", async function (assert) {
|
||||
const promise = Promise.resolve(null);
|
||||
|
||||
await render(<template>
|
||||
<AsyncContent @asyncData={{promise}}>
|
||||
<:content>
|
||||
<div class="content">
|
||||
Empty
|
||||
</div>
|
||||
</:content>
|
||||
</AsyncContent>
|
||||
</template>);
|
||||
|
||||
assert.dom(".content").exists();
|
||||
});
|
||||
|
||||
test("it does not display the block if the promise fails", async function (assert) {
|
||||
const promise = Promise.reject("error");
|
||||
|
||||
await render(<template>
|
||||
<AsyncContent @asyncData={{promise}}>
|
||||
<:empty>
|
||||
<div class="empty">
|
||||
Empty
|
||||
</div>
|
||||
</:empty>
|
||||
</AsyncContent>
|
||||
</template>);
|
||||
|
||||
assert.dom(".empty").doesNotExist();
|
||||
});
|
||||
});
|
||||
|
||||
module("<:error>", function () {
|
||||
test("it displays an inline error dialog when the block is not provided", async function (assert) {
|
||||
const promise = Promise.reject("error");
|
||||
|
||||
await render(<template>
|
||||
<AsyncContent @asyncData={{promise}} />
|
||||
</template>);
|
||||
|
||||
assert.dom(".alert-error").exists();
|
||||
assert.dom(".alert-error").hasText("Sorry, an error has occurred.");
|
||||
});
|
||||
|
||||
test("it displays a popup error dialog when the block is not provided", async function (assert) {
|
||||
const promise = Promise.reject("error");
|
||||
|
||||
await render(<template>
|
||||
<AsyncContent @asyncData={{promise}} @errorMode="popup" />
|
||||
<DialogHolder />
|
||||
</template>);
|
||||
|
||||
assert.dom(".dialog-body").exists();
|
||||
assert.dom(".dialog-body").hasText("Sorry, an error has occurred.");
|
||||
});
|
||||
|
||||
test("it displays the block when the promise is rejected", async function (assert) {
|
||||
const promise = Promise.reject("error");
|
||||
|
||||
await render(<template>
|
||||
<AsyncContent @asyncData={{promise}}>
|
||||
<:error as |error|>
|
||||
<div class="error">
|
||||
{{error}}
|
||||
</div>
|
||||
</:error>
|
||||
</AsyncContent>
|
||||
</template>);
|
||||
|
||||
assert.dom(".error").exists();
|
||||
assert.dom(".error").hasText("error");
|
||||
});
|
||||
|
||||
test("it does not display the block when the promise is resolved", async function (assert) {
|
||||
const promise = Promise.resolve("data");
|
||||
|
||||
await render(<template>
|
||||
<AsyncContent @asyncData={{promise}}>
|
||||
<:error as |error|>
|
||||
<div class="error">
|
||||
{{error}}
|
||||
</div>
|
||||
</:error>
|
||||
</AsyncContent>
|
||||
</template>);
|
||||
|
||||
assert.dom(".error").doesNotExist();
|
||||
});
|
||||
|
||||
test("it does not display the block when the promise is resolved with an empty value", async function (assert) {
|
||||
const promise = Promise.resolve(null);
|
||||
|
||||
await render(<template>
|
||||
<AsyncContent @asyncData={{promise}}>
|
||||
<:error as |error|>
|
||||
<div class="error">
|
||||
{{error}}
|
||||
</div>
|
||||
</:error>
|
||||
</AsyncContent>
|
||||
</template>);
|
||||
|
||||
assert.dom(".error").doesNotExist();
|
||||
});
|
||||
});
|
||||
});
|
@ -127,6 +127,13 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.choose-message,
|
||||
.choose-topic {
|
||||
&__search-results {
|
||||
margin: 0 0 1em 0;
|
||||
}
|
||||
}
|
||||
|
||||
.category-chooser {
|
||||
margin-bottom: 9px;
|
||||
}
|
||||
|
@ -70,7 +70,10 @@
|
||||
{{#if this.existingTopic}}
|
||||
<p>{{this.existingTopicInstruction}}</p>
|
||||
<form>
|
||||
<ChooseTopic @topicChangedCallback={{@topicChangedCallback}} />
|
||||
<ChooseTopic
|
||||
@topicChangedCallback={{@topicChangedCallback}}
|
||||
@selectedTopicId={{@selectedTopicId}}
|
||||
/>
|
||||
</form>
|
||||
{{/if}}
|
||||
|
||||
|
@ -115,7 +115,7 @@ export default class ChatModalArchiveChannel extends Component {
|
||||
|
||||
@action
|
||||
newTopicSelected(topic) {
|
||||
this.selectedTopicId = topic.id;
|
||||
this.selectedTopicId = topic?.id;
|
||||
}
|
||||
|
||||
<template>
|
||||
@ -137,6 +137,7 @@ export default class ChatModalArchiveChannel extends Component {
|
||||
@categoryId={{this.categoryId}}
|
||||
@tags={{this.tags}}
|
||||
@topicChangedCallback={{this.newTopicSelected}}
|
||||
@selectedTopicId={{this.selectedTopicId}}
|
||||
@instructionLabels={{this.instructionLabels}}
|
||||
@allowNewMessage={{false}}
|
||||
/>
|
||||
|
16
pnpm-lock.yaml
generated
16
pnpm-lock.yaml
generated
@ -504,6 +504,9 @@ importers:
|
||||
discourse-plugins:
|
||||
specifier: workspace:1.0.0
|
||||
version: link:../discourse-plugins
|
||||
ember-async-data:
|
||||
specifier: ^1.0.3
|
||||
version: 1.0.3(ember-source@5.12.0(patch_hash=xx7mvsb7nmshqkkqhmf45r3hse)(@glimmer/component@1.1.2(@babel/core@7.26.9))(@glint/template@1.4.1-unstable.34c4510)(rsvp@4.8.5)(webpack@5.98.0(@swc/core@1.10.16)(esbuild@0.25.0)))
|
||||
ember-auto-import:
|
||||
specifier: ^2.10.0
|
||||
version: 2.10.0(@glint/template@1.4.1-unstable.34c4510)(webpack@5.98.0(@swc/core@1.10.16)(esbuild@0.25.0))
|
||||
@ -4174,6 +4177,11 @@ packages:
|
||||
electron-to-chromium@1.5.99:
|
||||
resolution: {integrity: sha512-77c/+fCyL2U+aOyqfIFi89wYLBeSTCs55xCZL0oFH0KjqsvSvyh6AdQ+UIl1vgpnQQE6g+/KK8hOIupH6VwPtg==}
|
||||
|
||||
ember-async-data@1.0.3:
|
||||
resolution: {integrity: sha512-54OtoQwNi+/ZvPOVuT4t8fcHR9xL8N7kBydzcZSo6BIEsLYeXPi3+jUR8niWjfjXXhKlJ8EWXR0lTeHleTrxbw==}
|
||||
peerDependencies:
|
||||
ember-source: '>=4.8.4'
|
||||
|
||||
ember-auto-import@2.10.0:
|
||||
resolution: {integrity: sha512-bcBFDYVTFHyqyq8BNvsj6UO3pE6Uqou/cNmee0WaqBgZ+1nQqFz0UE26usrtnFAT+YaFZSkqF2H36QW84k0/cg==}
|
||||
engines: {node: 12.* || 14.* || >= 16}
|
||||
@ -12360,6 +12368,14 @@ snapshots:
|
||||
|
||||
electron-to-chromium@1.5.99: {}
|
||||
|
||||
ember-async-data@1.0.3(ember-source@5.12.0(patch_hash=xx7mvsb7nmshqkkqhmf45r3hse)(@glimmer/component@1.1.2(@babel/core@7.26.9))(@glint/template@1.4.1-unstable.34c4510)(rsvp@4.8.5)(webpack@5.98.0(@swc/core@1.10.16)(esbuild@0.25.0))):
|
||||
dependencies:
|
||||
'@ember/test-waiters': 3.1.0
|
||||
'@embroider/addon-shim': 1.9.0
|
||||
ember-source: 5.12.0(patch_hash=xx7mvsb7nmshqkkqhmf45r3hse)(@glimmer/component@1.1.2(@babel/core@7.26.9))(@glint/template@1.4.1-unstable.34c4510)(rsvp@4.8.5)(webpack@5.98.0(@swc/core@1.10.16)(esbuild@0.25.0))
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
ember-auto-import@2.10.0(@glint/template@1.4.1-unstable.34c4510)(webpack@5.98.0(@swc/core@1.10.16)(esbuild@0.25.0)):
|
||||
dependencies:
|
||||
'@babel/core': 7.26.9(supports-color@8.1.1)
|
||||
|
Reference in New Issue
Block a user