diff --git a/app/assets/javascripts/discourse/app/components/group-post.hbs b/app/assets/javascripts/discourse/app/components/group-post.hbs deleted file mode 100644 index 089163a2e6a..00000000000 --- a/app/assets/javascripts/discourse/app/components/group-post.hbs +++ /dev/null @@ -1,48 +0,0 @@ -
- - {{avatar - this.post.user - imageSize="large" - extraClasses="actor" - ignoreTitle="true" - }} - - -
- - - {{#if this.post.user}} -
- {{this.name}} - {{#if this.post.user.title}}{{this.post.user.title}}{{/if}} - -
- {{/if}} -
- - - {{format-date this.post.created_at leaveAgo="true"}} -
- -
- {{#if this.post.expandedExcerpt}} - {{html-safe this.post.expandedExcerpt}} - {{else}} - {{html-safe this.post.excerpt}} - {{/if}} -
\ No newline at end of file diff --git a/app/assets/javascripts/discourse/app/components/group-post.js b/app/assets/javascripts/discourse/app/components/group-post.js deleted file mode 100644 index 7a6419f2106..00000000000 --- a/app/assets/javascripts/discourse/app/components/group-post.js +++ /dev/null @@ -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 }); - } -} diff --git a/app/assets/javascripts/discourse/app/components/post-list/index.gjs b/app/assets/javascripts/discourse/app/components/post-list/index.gjs new file mode 100644 index 00000000000..0735faf18aa --- /dev/null +++ b/app/assets/javascripts/discourse/app/components/post-list/index.gjs @@ -0,0 +1,85 @@ +/** + * A component that renders a list of posts + * + * @component PostList + * + * @args {Array} 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: + * ``` + * + * ``` + */ +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; + } + } + } + + +} diff --git a/app/assets/javascripts/discourse/app/components/post-list/item/details.gjs b/app/assets/javascripts/discourse/app/components/post-list/item/details.gjs new file mode 100644 index 00000000000..6e4b1a21c03 --- /dev/null +++ b/app/assets/javascripts/discourse/app/components/post-list/item/details.gjs @@ -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; + } + + +} diff --git a/app/assets/javascripts/discourse/app/components/post-list/item/index.gjs b/app/assets/javascripts/discourse/app/components/post-list/item/index.gjs new file mode 100644 index 00000000000..3577a04bcba --- /dev/null +++ b/app/assets/javascripts/discourse/app/components/post-list/item/index.gjs @@ -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}`; + } + } + + +} diff --git a/app/assets/javascripts/discourse/app/controllers/group-activity-posts.js b/app/assets/javascripts/discourse/app/controllers/group-activity-posts.js index 1f069fda2e2..6840f9db09c 100644 --- a/app/assets/javascripts/discourse/app/controllers/group-activity-posts.js +++ b/app/assets/javascripts/discourse/app/controllers/group-activity-posts.js @@ -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); } } diff --git a/app/assets/javascripts/discourse/app/templates/group-activity-posts.hbs b/app/assets/javascripts/discourse/app/templates/group-activity-posts.hbs index 3555e1d8b13..886a0ac86a5 100644 --- a/app/assets/javascripts/discourse/app/templates/group-activity-posts.hbs +++ b/app/assets/javascripts/discourse/app/templates/group-activity-posts.hbs @@ -1,14 +1,5 @@ -{{#if this.canLoadMore}} - {{hide-application-footer}} -{{/if}} - - -
- {{#each this.model as |post|}} - - {{else}} -
{{i18n this.emptyText}}
- {{/each}} -
- -
\ No newline at end of file + \ No newline at end of file diff --git a/app/assets/javascripts/discourse/tests/acceptance/group-test.js b/app/assets/javascripts/discourse/tests/acceptance/group-test.js index 7ed919e7ade..8990250d54c 100644 --- a/app/assets/javascripts/discourse/tests/acceptance/group-test.js +++ b/app/assets/javascripts/discourse/tests/acceptance/group-test.js @@ -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", diff --git a/app/assets/javascripts/discourse/tests/fixtures/post-list.js b/app/assets/javascripts/discourse/tests/fixtures/post-list.js new file mode 100644 index 00000000000..5d4d8435528 --- /dev/null +++ b/app/assets/javascripts/discourse/tests/fixtures/post-list.js @@ -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: + "

I am really enjoying having a dog. My dog is so cute. He is a toy poodle, and he loves to play fetch.

He also loves to go outside to the dog park, eat treats, and take naps.

", + excerpt: "

I am really enjoying having a dog. My dog is so cute. He is a toy poodle...

", + }, + { + 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: + "

I am really enjoying having a cat. My cat is so cute. She loves to cuddle.

She also loves to go wander the neighbourhood.

", + excerpt: "

I am really enjoying having a cat. My cat is so cute...

", + }, +]; + +export default postModel; diff --git a/app/assets/javascripts/discourse/tests/integration/components/post-list-test.gjs b/app/assets/javascripts/discourse/tests/integration/components/post-list-test.gjs new file mode 100644 index 00000000000..0e29a89fd67 --- /dev/null +++ b/app/assets/javascripts/discourse/tests/integration/components/post-list-test.gjs @@ -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(); + 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(); + 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(); + assert + .dom(".post-list-item__details .title a") + .hasAttribute("aria-label", titleAriaLabel); + }); + + test("@emptyText", async function (assert) { + const posts = []; + await render(); + assert.dom(".post-list__empty-text").hasText("My custom empty text"); + }); +}); diff --git a/app/assets/stylesheets/common/components/_index.scss b/app/assets/stylesheets/common/components/_index.scss index 369bb253959..d8eb9592fb6 100644 --- a/app/assets/stylesheets/common/components/_index.scss +++ b/app/assets/stylesheets/common/components/_index.scss @@ -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"; diff --git a/app/assets/stylesheets/common/components/post-list.scss b/app/assets/stylesheets/common/components/post-list.scss new file mode 100644 index 00000000000..b980e1f853f --- /dev/null +++ b/app/assets/stylesheets/common/components/post-list.scss @@ -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; + } +} diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index 1b14eaca09a..c75fd068a25 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -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 %{count} 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" diff --git a/plugins/styleguide/assets/javascripts/discourse/components/sections/organisms/01-post-list.gjs b/plugins/styleguide/assets/javascripts/discourse/components/sections/organisms/01-post-list.gjs new file mode 100644 index 00000000000..84ba156932f --- /dev/null +++ b/plugins/styleguide/assets/javascripts/discourse/components/sections/organisms/01-post-list.gjs @@ -0,0 +1,22 @@ +import PostList from "discourse/components/post-list"; +import { i18n } from "discourse-i18n"; +import StyleguideExample from "../../styleguide-example"; + +const StyleguidePostList = ; + +export default StyleguidePostList; diff --git a/plugins/styleguide/assets/javascripts/discourse/components/sections/organisms/01-topic-map.hbs b/plugins/styleguide/assets/javascripts/discourse/components/sections/organisms/02-topic-map.hbs similarity index 100% rename from plugins/styleguide/assets/javascripts/discourse/components/sections/organisms/01-topic-map.hbs rename to plugins/styleguide/assets/javascripts/discourse/components/sections/organisms/02-topic-map.hbs diff --git a/plugins/styleguide/assets/javascripts/discourse/lib/dummy-data.js b/plugins/styleguide/assets/javascripts/discourse/lib/dummy-data.js index 8230b2741d5..5ebfdd33380 100644 --- a/plugins/styleguide/assets/javascripts/discourse/lib/dummy-data.js +++ b/plugins/styleguide/assets/javascripts/discourse/lib/dummy-data.js @@ -153,14 +153,29 @@ export function createData(store) {

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.

`; + const excerpt = + "

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.

"; + 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, diff --git a/plugins/styleguide/assets/javascripts/discourse/lib/styleguide.js b/plugins/styleguide/assets/javascripts/discourse/lib/styleguide.js index 40d83a84309..1f337cdafd2 100644 --- a/plugins/styleguide/assets/javascripts/discourse/lib/styleguide.js +++ b/plugins/styleguide/assets/javascripts/discourse/lib/styleguide.js @@ -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", diff --git a/plugins/styleguide/config/locales/client.en.yml b/plugins/styleguide/config/locales/client.en.yml index fd6a7b7d2c9..dd93bfdc82e 100644 --- a/plugins/styleguide/config/locales/client.en.yml +++ b/plugins/styleguide/config/locales/client.en.yml @@ -70,6 +70,10 @@ en: title: "Topic Notifications" post: title: "Post" + post_list: + title: "Post List" + empty_example: " empty state" + populated_example: " populated state" topic_map: title: "Topic Map" site_header: diff --git a/plugins/styleguide/spec/system/smoke_test_spec.rb b/plugins/styleguide/spec/system/smoke_test_spec.rb index e27573321ca..00bd9a4f628 100644 --- a/plugins/styleguide/spec/system/smoke_test_spec.rb +++ b/plugins/styleguide/spec/system/smoke_test_spec.rb @@ -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" }, diff --git a/spec/system/page_objects/pages/group_activity_posts.rb b/spec/system/page_objects/pages/group_activity_posts.rb index 58e2ffda576..d2cb9943c72 100644 --- a/spec/system/page_objects/pages/group_activity_posts.rb +++ b/spec/system/page_objects/pages/group_activity_posts.rb @@ -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