DEV: Reusable post-list component (#30312)

This update adds a  _new_ `<PostList />` component, along with it's child components (`<PostListItem/>` and `<PostListItemDetails />`). This new generic component can be used to show a list of posts.

It can be used like so:
```js
/**
 * A component that renders a list of posts
 *
 * @component PostList
 *
 * @args {Array<Object>} posts - The array of post objects to display
 * @args {Function} fetchMorePosts - A function that fetches more posts. Must return a Promise that resolves to an array of new posts.
 * @args {String} emptyText (optional) - Custom text to display when there are no posts
 * @args {String|Array} additionalItemClasses (optional) - Additional classes to add to each post list item
 * @args {String} titleAriaLabel (optional) - Custom Aria label for the post title
 * 
*/
```
```hbs
<PostList
    @posts={{this.posts}}
    @fetchMorePosts={{this.loadMorePosts}}
    @emptyText={{i18n "custom_identifier.empty"}}
    @additionalItemClasses="custom-class"
 />
```
This commit is contained in:
Keegan George 2024-12-20 02:20:25 +09:00 committed by GitHub
parent 6cd964306f
commit d886c55f63
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 520 additions and 159 deletions

View File

@ -1,48 +0,0 @@
<div class="user-stream-item__header info">
<a
href={{this.userUrl}}
data-user-card={{this.post.user.username}}
class="avatar-link"
>
{{avatar
this.post.user
imageSize="large"
extraClasses="actor"
ignoreTitle="true"
}}
</a>
<div class="user-stream-item__details">
<div class="stream-topic-title">
<span class="title">
<a href={{this.postUrl}} aria-label={{this.titleAriaLabel}}>
{{html-safe this.post.topic.fancyTitle}}
</a>
</span>
</div>
<div class="group-post-category">{{category-link this.post.category}}</div>
{{#if this.post.user}}
<div class="group-member-info names">
<span class="name">{{this.name}}</span>
{{#if this.post.user.title}}<span
class="user-title"
>{{this.post.user.title}}</span>{{/if}}
<PluginOutlet
@name="group-post-additional-member-info"
@outletArgs={{hash user=this.post.user}}
/>
</div>
{{/if}}
</div>
<ExpandPost @item={{this.post}} />
<span class="time">{{format-date this.post.created_at leaveAgo="true"}}</span>
</div>
<div class="excerpt">
{{#if this.post.expandedExcerpt}}
{{html-safe this.post.expandedExcerpt}}
{{else}}
{{html-safe this.post.excerpt}}
{{/if}}
</div>

View File

@ -1,49 +0,0 @@
import Component from "@ember/component";
import { classNameBindings } from "@ember-decorators/component";
import { propertyEqual } from "discourse/lib/computed";
import { prioritizeNameInUx } from "discourse/lib/settings";
import { userPath } from "discourse/lib/url";
import getURL from "discourse-common/lib/get-url";
import discourseComputed from "discourse-common/utils/decorators";
import { i18n } from "discourse-i18n";
@classNameBindings(
":user-stream-item",
":item",
"moderatorAction",
"primaryGroup"
)
export default class GroupPost extends Component {
@propertyEqual("post.post_type", "site.post_types.moderator_action")
moderatorAction;
@discourseComputed("post.url")
postUrl(url) {
return getURL(url);
}
@discourseComputed("post.user")
name(postUser) {
if (prioritizeNameInUx(postUser.name)) {
return postUser.name;
}
return postUser.username;
}
@discourseComputed("post.user")
primaryGroup(postUser) {
if (postUser.primary_group_name) {
return `group-${postUser.primary_group_name}`;
}
}
@discourseComputed("post.user.username")
userUrl(username) {
return userPath(username.toLowerCase());
}
@discourseComputed("post.title", "post.post_number")
titleAriaLabel(title, postNumber) {
return i18n("groups.aria_post_number", { postNumber, title });
}
}

View File

@ -0,0 +1,85 @@
/**
* A component that renders a list of posts
*
* @component PostList
*
* @args {Array<Object>} posts - The array of post objects to display
* @args {Function} fetchMorePosts - A function that fetches more posts. Must return a Promise that resolves to an array of new posts.
* @args {String} emptyText (optional) - Custom text to display when there are no posts
* @args {String|Array} additionalItemClasses (optional) - Additional classes to add to each post list item
* @args {String} titleAriaLabel (optional) - Custom Aria label for the post title
*
* @template Usage Example:
* ```
* <PostList
* @posts={{this.posts}}
* @fetchMorePosts={{this.loadMorePosts}}
* @emptyText={{i18n "custom_identifier.empty"}}
* @additionalItemClasses="custom-class"
* />
* ```
*/
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { action } from "@ember/object";
import ConditionalLoadingSpinner from "discourse/components/conditional-loading-spinner";
import LoadMore from "discourse/components/load-more";
import PostListItem from "discourse/components/post-list/item";
import hideApplicationFooter from "discourse/helpers/hide-application-footer";
import { popupAjaxError } from "discourse/lib/ajax-error";
import { i18n } from "discourse-i18n";
export default class PostList extends Component {
@tracked loading = false;
@tracked canLoadMore = true;
@tracked emptyText = this.args.emptyText || i18n("post_list.empty");
@action
async loadMore() {
if (
!this.canLoadMore ||
this.loading ||
this.args.fetchMorePosts === undefined
) {
return;
}
this.loading = true;
const posts = this.args.posts;
if (posts && posts.length) {
try {
const newPosts = await this.args.fetchMorePosts();
this.args.posts.addObjects(newPosts);
if (newPosts.length === 0) {
this.canLoadMore = false;
}
} catch (error) {
popupAjaxError(error);
} finally {
this.loading = false;
}
}
}
<template>
{{#if this.canLoadMore}}
{{hideApplicationFooter}}
{{/if}}
<LoadMore @selector=".post-list-item" @action={{this.loadMore}}>
<div class="post-list">
{{#each @posts as |post|}}
<PostListItem
@post={{post}}
@additionalItemClasses={{@additionalItemClasses}}
@titleAriaLabel={{@titleAriaLabel}}
/>
{{else}}
<div class="post-list__empty-text">{{this.emptyText}}</div>
{{/each}}
</div>
<ConditionalLoadingSpinner @condition={{this.loading}} />
</LoadMore>
</template>
}

View File

@ -0,0 +1,68 @@
import Component from "@glimmer/component";
import { hash } from "@ember/helper";
import { htmlSafe } from "@ember/template";
import PluginOutlet from "discourse/components/plugin-outlet";
import categoryLink from "discourse/helpers/category-link";
import { prioritizeNameInUx } from "discourse/lib/settings";
import getURL from "discourse-common/lib/get-url";
import { i18n } from "discourse-i18n";
export default class PostListItemDetails extends Component {
get titleAriaLabel() {
return (
this.args.titleAriaLabel ||
i18n("post_list.aria_post_number", {
title: this.args.post.title,
postNumber: this.args.post.post_number,
})
);
}
get posterName() {
if (prioritizeNameInUx(this.args.post.user.name)) {
return this.args.post.user.name;
}
return this.args.post.user.username;
}
<template>
<div class="post-list-item__details">
<div class="stream-topic-title">
<span class="title">
<a
href={{getURL @post.url}}
aria-label={{this.titleAriaLabel}}
>{{htmlSafe @post.topic.fancyTitle}}</a>
</span>
</div>
<div class="stream-post-category">
{{categoryLink @post.category}}
</div>
{{#if @post.user}}
<div class="post-member-info names">
<span class="name">{{this.posterName}}</span>
{{#if @post.user.title}}
<span class="user-title">{{@post.user.title}}</span>
{{/if}}
<PluginOutlet
@name="post-list-additional-member-info"
@outletArgs={{hash user=@post.user}}
/>
{{!
Deprecated Outlet:
Please use: "post-list-additional-member-info" instead
}}
<PluginOutlet
@name="group-post-additional-member-info"
@outletArgs={{hash user=@post.user}}
/>
</div>
{{/if}}
</div>
</template>
}

View File

@ -0,0 +1,66 @@
import Component from "@glimmer/component";
import { service } from "@ember/service";
import { htmlSafe } from "@ember/template";
import ExpandPost from "discourse/components/expand-post";
import PostListItemDetails from "discourse/components/post-list/item/details";
import avatar from "discourse/helpers/avatar";
import concatClass from "discourse/helpers/concat-class";
import formatDate from "discourse/helpers/format-date";
import { userPath } from "discourse/lib/url";
export default class PostListItem extends Component {
@service site;
get moderatorActionClass() {
return this.args.post.post_type === this.site.post_types.moderator_action
? "moderator-action"
: "";
}
get primaryGroupClass() {
if (this.args.post.user && this.args.post.user.primary_group_name) {
return `group-${this.args.post.user.primary_group_name}`;
}
}
<template>
<div
class="post-list-item
{{concatClass
this.moderatorActionClass
this.primaryGroupClass
@additionalItemClasses
}}"
>
<div class="post-list-item__header info">
<a
href={{userPath @post.user.username}}
data-user-card={{@post.user.username}}
class="avatar-link"
>
{{avatar
@post.user
imageSize="large"
extraClasses="actor"
ignoreTitle="true"
}}
</a>
<PostListItemDetails
@post={{@post}}
@titleAriaLabel={{@titleAriaLabel}}
/>
<ExpandPost @item={{@post}} />
<div class="time">{{formatDate @post.created_at leaveAgo="true"}}</div>
</div>
<div class="excerpt">
{{#if @post.expandedExcerpt}}
{{htmlSafe @post.expandedExcerpt}}
{{else}}
{{htmlSafe @post.excerpt}}
{{/if}}
</div>
</div>
</template>
}

View File

@ -1,45 +1,18 @@
import Controller, { inject as controller } from "@ember/controller";
import { action } from "@ember/object";
import { fmt } from "discourse/lib/computed";
export default class GroupActivityPostsController extends Controller {
@controller group;
@controller groupActivity;
@controller application;
@fmt("type", "groups.empty.%@") emptyText;
canLoadMore = true;
loading = false;
@action
loadMore() {
if (!this.canLoadMore) {
return;
}
if (this.loading) {
return;
}
this.set("loading", true);
async fetchMorePosts() {
const posts = this.model;
if (posts && posts.length) {
const before = posts[posts.length - 1].get("created_at");
const group = this.get("group.model");
const before = posts[posts.length - 1].created_at;
const group = this.group.model;
const categoryId = this.groupActivity.category_id;
const opts = { before, type: this.type, categoryId };
let categoryId = this.get("groupActivity.category_id");
const opts = { before, type: this.type, categoryId };
group
.findPosts(opts)
.then((newPosts) => {
posts.addObjects(newPosts);
if (newPosts.length === 0) {
this.set("canLoadMore", false);
}
})
.finally(() => {
this.set("loading", false);
});
}
return await group.findPosts(opts);
}
}

View File

@ -1,14 +1,5 @@
{{#if this.canLoadMore}}
{{hide-application-footer}}
{{/if}}
<LoadMore @selector=".user-stream-item" @action={{action "loadMore"}}>
<div class="user-stream">
{{#each this.model as |post|}}
<GroupPost @post={{post}} />
{{else}}
<div>{{i18n this.emptyText}}</div>
{{/each}}
</div>
<ConditionalLoadingSpinner @condition={{this.loading}} />
</LoadMore>
<PostList
@posts={{this.model}}
@fetchMorePosts={{this.fetchMorePosts}}
@emptyText={{i18n "groups.empty.posts"}}
/>

View File

@ -29,7 +29,7 @@ acceptance("Group - Anonymous", function (needs) {
await click(".nav-pills li a[title='Activity']");
assert.dom(".user-stream-item").exists("lists stream items");
assert.dom(".post-list-item").exists("lists stream items");
await click(".activity-nav li a[href='/g/discourse/activity/topics']");
@ -38,14 +38,14 @@ acceptance("Group - Anonymous", function (needs) {
await click(".activity-nav li a[href='/g/discourse/activity/mentions']");
assert.dom(".user-stream-item").exists("lists stream items");
assert.dom(".post-list-item").exists("lists stream items");
assert
.dom(".nav-pills li a[title='Edit Group']")
.doesNotExist("does not show messages tab if user is not admin");
assert
.dom(".nav-pills li a[title='Logs']")
.doesNotExist("does not show Logs tab if user is not admin");
assert.dom(".user-stream-item").exists("lists stream items");
assert.dom(".post-list-item").exists("lists stream items");
const groupDropdown = selectKit(".group-dropdown");
await groupDropdown.expand();
@ -268,7 +268,7 @@ acceptance("Group - Authenticated", function (needs) {
await visit("/g/discourse/activity/posts");
assert
.dom(".user-stream-item a.avatar-link")
.dom(".post-list-item a.avatar-link")
.hasAttribute(
"href",
"/u/awesomerobot",

View File

@ -0,0 +1,42 @@
const postModel = [
{
id: 1,
title: "My dog is so cute",
created_at: "2024-03-15T18:45:38.720Z",
category: {
id: 1,
name: "Pets",
color: "f00",
},
user: {
id: 1,
username: "uwe_keim",
name: "Uwe Keim",
avatar_template: "/user_avatar/meta.discourse.org/uwe_keim/{size}/5697.png"
},
cooked:
"<p>I am really enjoying having a dog. My dog is so cute. He is a toy poodle, and he loves to play fetch.</p><p>He also loves to go outside to the dog park, eat treats, and take naps.</p>",
excerpt: "<p>I am really enjoying having a dog. My dog is so cute. He is a toy poodle...</p>",
},
{
id: 2,
title: "My cat is adorable",
created_at: "2024-03-16T18:45:38.720Z",
category: {
id: 1,
name: "Pets",
color: "f00",
},
user: {
id: 1,
username: "uwe_keim",
name: "Uwe Keim",
avatar_template: "/user_avatar/meta.discourse.org/uwe_keim/{size}/5697.png"
},
cooked:
"<p>I am really enjoying having a cat. My cat is so cute. She loves to cuddle.</p><p>She also loves to go wander the neighbourhood.</p>",
excerpt: "<p>I am really enjoying having a cat. My cat is so cute...</p>",
},
];
export default postModel;

View File

@ -0,0 +1,46 @@
import { render } from "@ember/test-helpers";
import { module, test } from "qunit";
import PostList from "discourse/components/post-list";
import { setupRenderingTest } from "discourse/tests/helpers/component-test";
import postModel from "../../fixtures/post-list";
module("Integration | Component | PostList | Index", function (hooks) {
setupRenderingTest(hooks);
test("@posts", async function (assert) {
const posts = postModel;
await render(<template><PostList @posts={{posts}} /></template>);
assert.dom(".post-list").exists();
assert.dom(".post-list__empty-text").doesNotExist();
assert.dom(".post-list-item").exists({ count: 2 });
});
test("@additionalItemClasses", async function (assert) {
const posts = postModel;
const additionalClasses = ["first-class", "second-class"];
await render(<template>
<PostList @posts={{posts}} @additionalItemClasses={{additionalClasses}} />
</template>);
assert.dom(".post-list-item").hasClass("first-class");
assert.dom(".post-list-item").hasClass("second-class");
});
test("@titleAriaLabel", async function (assert) {
const posts = postModel;
const titleAriaLabel = "My custom aria title label";
await render(<template>
<PostList @posts={{posts}} @titleAriaLabel={{titleAriaLabel}} />
</template>);
assert
.dom(".post-list-item__details .title a")
.hasAttribute("aria-label", titleAriaLabel);
});
test("@emptyText", async function (assert) {
const posts = [];
await render(<template>
<PostList @posts={{posts}} @emptyText="My custom empty text" />
</template>);
assert.dom(".post-list__empty-text").hasText("My custom empty text");
});
});

View File

@ -54,6 +54,7 @@
@import "user-status-picker";
@import "user-stream-item";
@import "user-stream";
@import "post-list";
@import "widget-dropdown";
@import "welcome-header";
@import "notifications-tracking";

View File

@ -0,0 +1,81 @@
.post-list {
margin: 0;
.post-list-item {
background: var(--d-content-background, var(--secondary));
border-bottom: 1px solid var(--primary-low);
padding: 1em 0.53em;
list-style: none;
&.moderator-action {
background-color: var(--highlight-bg);
}
&.deleted {
background-color: var(--danger-low-mid);
}
&.hidden {
display: block;
opacity: 0.4;
}
&__header {
display: flex;
align-items: flex-start;
}
&__details {
flex-grow: 1;
min-width: 0;
.badge-category__wrapper {
width: 100%;
}
}
.stream-topic-title {
overflow-wrap: anywhere;
}
.relative-date {
line-height: var(--line-height-small);
color: var(--primary-medium);
font-size: var(--font-down-2);
padding-top: 5px;
}
}
.avatar-link {
margin-right: 0.5em;
}
.name {
font-size: var(--font-0);
max-width: 400px;
@include ellipsis;
}
.excerpt {
margin: 1em 0 0 0;
font-size: var(--font-0);
word-wrap: break-word;
color: var(--primary);
&:empty {
display: none;
}
details.disabled {
color: var(--primary-medium);
}
.emoji.only-emoji {
// oversized emoji break excerpt layout
// so we match inline emoji size
width: 20px;
height: 20px;
margin: 0;
}
}
.post-member-info {
display: flex;
}
}

View File

@ -1008,13 +1008,13 @@ en:
public_admission: "Allow users to join the group freely (Requires publicly visible group)"
public_exit: "Allow users to leave the group freely"
empty:
posts: "There are no posts by members of this group."
members: "There are no members in this group."
requests: "There are no membership requests for this group."
mentions: "There are no mentions of this group."
messages: "There are no messages for this group."
topics: "There are no topics by members of this group."
logs: "There are no logs for this group."
posts: "There are no posts by members of this group"
members: "There are no members in this group"
requests: "There are no membership requests for this group"
mentions: "There are no mentions of this group"
messages: "There are no messages for this group"
topics: "There are no topics by members of this group"
logs: "There are no logs for this group"
add: "Add"
join: "Join"
leave: "Leave"
@ -3747,6 +3747,10 @@ en:
other: "You have selected <b>%{count}</b> posts."
deleted_by_author_simple: "(topic deleted by author)"
post_list:
empty: "There are no posts"
aria_post_number: "%{title} - post #%{postNumber}"
post:
confirm_delete: "Are you sure you want to delete this post?"
quote_reply: "Quote"

View File

@ -0,0 +1,22 @@
import PostList from "discourse/components/post-list";
import { i18n } from "discourse-i18n";
import StyleguideExample from "../../styleguide-example";
const StyleguidePostList = <template>
<StyleguideExample
@title={{i18n "styleguide.sections.post_list.empty_example"}}
>
<PostList @posts="" @additionalItemClasses="styleguide-post-list-item" />
</StyleguideExample>
<StyleguideExample
@title={{i18n "styleguide.sections.post_list.populated_example"}}
>
<PostList
@posts={{@dummy.postList}}
@additionalItemClasses="styleguide-post-list-item"
/>
</StyleguideExample>
</template>;
export default StyleguidePostList;

View File

@ -153,14 +153,29 @@ export function createData(store) {
<p>Case everti equidem ius ea, ubique veritus vim id. Eros omnium conclusionemque qui te, usu error alienum imperdiet ut, ex ius meis adipisci. Libris reprehendunt eos ex, mea at nisl suavitate. Altera virtute democritum pro cu, melius latine in ius.</p>`;
const excerpt =
"<p>Lorem ipsum dolor sit amet, et nec quis viderer prompta, ex omnium ponderum insolens eos, sed discere invenire principes in. Fuisset constituto per ad. Est no scripta propriae facilisis, viderer impedit deserunt in mel. Quot debet facilisis ne vix, nam in detracto tacimates.</p>";
const transformedPost = {
id: 1234,
topic,
user: {
avatar_template: user.avatar_template,
id: user.id,
username: user.username,
name: user.name,
},
name: user.name,
username: user.username,
avatar_template: user.avatar_template,
category: {
id: categories[0].id,
name: categories[0].name,
color: categories[0].color,
},
created_at: "2024-11-13T21:12:37.835Z",
cooked,
excerpt,
post_number: 1,
post_type: 1,
updated_at: moment().subtract(2, "days"),
@ -262,8 +277,64 @@ export function createData(store) {
const postModel = store.createRecord("post", {
transformedPost,
});
postModel.set("topic", store.createRecord("topic", transformedPost.topic));
const postList = [
transformedPost,
{
id: 145,
topic: pinnedTopic,
created_at: "2024-03-15T18:45:38.720Z",
category: {
id: categories[2].id,
color: categories[2].color,
name: categories[2].name,
},
user: {
avatar_template: user.avatar_template,
id: user.id,
username: user.username,
name: user.name,
},
excerpt,
},
{
id: 144,
topic: archivedTopic,
created_at: "2024-02-15T18:45:38.720Z",
category: {
id: categories[1].id,
color: categories[1].color,
name: categories[1].name,
},
user: {
avatar_template: user.avatar_template,
id: user.id,
username: user.username,
name: user.name,
},
excerpt,
},
{
id: 143,
topic: closedTopic,
created_at: "2024-01-15T18:45:38.720Z",
category: {
id: categories[0].id,
color: categories[0].color,
name: categories[0].name,
},
user: {
avatar_template: user.avatar_template,
id: user.id,
username: user.username,
name: user.name,
},
excerpt,
},
];
_data = {
options: [
{ id: 1, name: "Orange" },
@ -316,6 +387,7 @@ export function createData(store) {
transformedPost,
postModel,
postList,
user,

View File

@ -25,7 +25,8 @@ import topicListItem from "../components/sections/molecules/topic-list-item";
import topicNotifications from "../components/sections/molecules/topic-notifications";
import topicTimerInfo from "../components/sections/molecules/topic-timer-info";
import post from "../components/sections/organisms/00-post";
import topicMap from "../components/sections/organisms/01-topic-map";
import postList from "../components/sections/organisms/01-post-list";
import topicMap from "../components/sections/organisms/02-topic-map";
import topicFooterButtons from "../components/sections/organisms/03-topic-footer-buttons";
import topicList from "../components/sections/organisms/04-topic-list";
import basicTopicList from "../components/sections/organisms/basic-topic-list";
@ -84,7 +85,8 @@ const SECTIONS = [
},
{ component: topicTimerInfo, category: "molecules", id: "topic-timer-info" },
{ component: post, category: "organisms", id: "post", priority: 0 },
{ component: topicMap, category: "organisms", id: "topic-map", priority: 1 },
{ component: postList, category: "organisms", id: "post-list", priority: 1 },
{ component: topicMap, category: "organisms", id: "topic-map", priority: 2 },
{
component: topicFooterButtons,
category: "organisms",

View File

@ -70,6 +70,10 @@ en:
title: "Topic Notifications"
post:
title: "Post"
post_list:
title: "Post List"
empty_example: "<PostList /> empty state"
populated_example: "<PostList /> populated state"
topic_map:
title: "Topic Map"
site_header:

View File

@ -40,6 +40,7 @@ RSpec.describe "Styleguide Smoke Test", type: :system do
],
"ORGANISMS" => [
{ href: "/organisms/post", title: "Post" },
{ href: "/organisms/post-list", title: "Post List" },
{ href: "/organisms/topic-map", title: "Topic Map" },
{ href: "/organisms/topic-footer-buttons", title: "Topic Footer Buttons" },
{ href: "/organisms/topic-list", title: "Topic List" },

View File

@ -9,12 +9,12 @@ module PageObjects
end
def has_user_stream_item?(count:)
has_css?(".user-stream-item", count: count)
has_css?(".post-list-item", count: count)
end
def scroll_to_last_item
page.execute_script <<~JS
document.querySelector('.user-stream-item:last-of-type').scrollIntoView(true);
document.querySelector('.post-list-item:last-of-type').scrollIntoView(true);
JS
end
end