1
0
mirror of https://github.com/discourse/discourse.git synced 2025-03-15 11:01:14 +00:00

DEV: Add the AsyncContent component ()

Co-authored-by: David Taylor <david@taylorhq.com>
This commit is contained in:
Sérgio Saquetim
2025-02-17 18:38:51 -03:00
committed by GitHub
parent 43ececd22d
commit 1a7d2667c4
14 changed files with 801 additions and 249 deletions
app/assets
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 }}
&nbsp;
{{/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>
}

@ -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 }}
&nbsp;
{{/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",

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

@ -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)