DEV: Modernize the post menu from widgets to Glimmer components (#28670)

This commit modernizes the post menu by migrating it from the existing widget-based implementation to Glimmer components. This transition aims to improve the maintainability, performance, and overall developer experience.

It also introduces a new DAG-based transformer API for customizations that aims to be more flexible than the widget base one.

---------

Co-authored-by: David Taylor <david@taylorhq.com>
This commit is contained in:
Sérgio Saquetim
2024-11-11 15:36:08 -03:00
committed by GitHub
parent 5c5ac72488
commit 3019bb577b
50 changed files with 4641 additions and 390 deletions

View File

@@ -13,6 +13,10 @@ const DEPRECATION_WORKFLOW = [
handler: "silence",
matchId: "discourse.fontawesome-6-upgrade",
},
{
handler: "silence",
matchId: "discourse.post-menu-widget-overrides",
},
];
export default DEPRECATION_WORKFLOW;

View File

@@ -14,13 +14,13 @@ export default class AdminPostMenu extends Component {
@service adminPostMenuButtons;
get reviewUrl() {
return `/review?topic_id=${this.args.data.transformedPost.id}&status=all`;
return `/review?topic_id=${this.args.data.post.id}&status=all`;
}
get extraButtons() {
return this.adminPostMenuButtons.callbacks
.map((callback) => {
return callback(this.args.data.transformedPost);
return callback(this.args.data.post);
})
.filter(Boolean);
}
@@ -36,14 +36,14 @@ export default class AdminPostMenu extends Component {
console.error(`Unknown error while attempting \`${actionName}\`:`, error);
}
await this.args.data.scheduleRerender();
await this.args.data.scheduleRerender?.();
}
@action
async extraAction(button) {
await this.args.close();
await button.action(this.args.data.post);
await this.args.data.scheduleRerender();
await this.args.data.scheduleRerender?.();
}
<template>
@@ -59,45 +59,45 @@ export default class AdminPostMenu extends Component {
</dropdown.item>
{{/if}}
{{#if (and this.currentUser.staff (not @data.transformedPost.isWhisper))}}
{{#if (and this.currentUser.staff (not @data.post.isWhisper))}}
<dropdown.item>
<DButton
@label={{if
@data.transformedPost.isModeratorAction
@data.post.isModeratorAction
"post.controls.revert_to_regular"
"post.controls.convert_to_moderator"
}}
@icon="shield-halved"
class={{concatClass
"btn btn-transparent toggle-post-type"
(if @data.transformedPost.isModeratorAction "btn-success")
(if @data.post.isModeratorAction "btn-success")
}}
@action={{fn this.topicAction "togglePostType"}}
/>
</dropdown.item>
{{/if}}
{{#if @data.transformedPost.canEditStaffNotes}}
{{#if @data.post.canEditStaffNotes}}
<dropdown.item>
<DButton
@icon="user-shield"
@label={{if
@data.transformedPost.notice
@data.post.notice
"post.controls.change_post_notice"
"post.controls.add_post_notice"
}}
@title="post.controls.unhide"
class={{concatClass
"btn btn-transparent"
(if @data.transformedPost.notice "change-notice" "add-notice")
(if @data.transformedPost.notice "btn-success")
(if @data.post.notice "change-notice" "add-notice")
(if @data.post.notice "btn-success")
}}
@action={{fn this.topicAction "changeNotice"}}
/>
</dropdown.item>
{{/if}}
{{#if (and this.currentUser.staff @data.transformedPost.hidden)}}
{{#if (and this.currentUser.staff @data.post.hidden)}}
<dropdown.item>
<DButton
@label="post.controls.unhide"
@@ -128,7 +128,7 @@ export default class AdminPostMenu extends Component {
</dropdown.item>
{{/if}}
{{#if (and @data.transformedPost.user_id this.currentUser.staff)}}
{{#if (and @data.post.user_id this.currentUser.staff)}}
{{#if this.siteSettings.enable_badges}}
<dropdown.item>
<DButton
@@ -140,7 +140,7 @@ export default class AdminPostMenu extends Component {
</dropdown.item>
{{/if}}
{{#if @data.transformedPost.locked}}
{{#if @data.post.locked}}
<dropdown.item>
<DButton
@label="post.controls.unlock_post"
@@ -166,7 +166,7 @@ export default class AdminPostMenu extends Component {
{{/if}}
{{/if}}
{{#if @data.transformedPost.canPermanentlyDelete}}
{{#if @data.post.canPermanentlyDelete}}
<dropdown.item>
<DButton
@label="post.controls.permanently_delete"
@@ -177,15 +177,15 @@ export default class AdminPostMenu extends Component {
</dropdown.item>
{{/if}}
{{#if (or @data.transformedPost.canManage @data.transformedPost.canWiki)}}
{{#if @data.transformedPost.wiki}}
{{#if (or @data.post.canManage @data.post.can_wiki)}}
{{#if @data.post.wiki}}
<dropdown.item>
<DButton
@label="post.controls.unwiki"
@icon="far-pen-to-square"
class={{concatClass
"btn btn-transparent wiki wikied"
(if @data.transformedPost.wiki "btn-success")
(if @data.post.wiki "btn-success")
}}
@action={{fn this.topicAction "toggleWiki"}}
/>
@@ -202,7 +202,7 @@ export default class AdminPostMenu extends Component {
{{/if}}
{{/if}}
{{#if @data.transformedPost.canPublishPage}}
{{#if @data.post.canPublishPage}}
<dropdown.item>
<DButton
@label="post.controls.publish_page"
@@ -213,7 +213,7 @@ export default class AdminPostMenu extends Component {
</dropdown.item>
{{/if}}
{{#if @data.transformedPost.canManage}}
{{#if @data.post.canManage}}
<dropdown.item>
<DButton
@label="post.controls.rebake"

View File

@@ -236,6 +236,7 @@ export default class BookmarkMenu extends Component {
<template>
<DMenu
{{didInsert this.setReminderShortcuts}}
...attributes
@identifier="bookmark-menu"
@triggers={{array "click"}}
class={{this.buttonClasses}}

View File

@@ -80,6 +80,15 @@ export default class DButton extends GlimmerComponentWithDeprecatedParentView {
}
}
get computedAriaPressed() {
if (this.args.ariaPressed === true) {
return "true";
}
if (this.args.ariaPressed === false) {
return "false";
}
}
@action
keyDown(e) {
if (this.args.onKeyDown) {
@@ -177,6 +186,7 @@ export default class DButton extends GlimmerComponentWithDeprecatedParentView {
form={{@form}}
aria-controls={{@ariaControls}}
aria-expanded={{this.computedAriaExpanded}}
aria-pressed={{this.computedAriaPressed}}
tabindex={{@tabindex}}
disabled={{this.isDisabled}}
title={{this.computedTitle}}

View File

@@ -0,0 +1,677 @@
import Component from "@glimmer/component";
import { cached, tracked } from "@glimmer/tracking";
import { hash } from "@ember/helper";
import { action } from "@ember/object";
import { getOwner } from "@ember/owner";
import { inject as service } from "@ember/service";
import { isEmpty, isPresent } from "@ember/utils";
import { and, eq } from "truth-helpers";
import AdminPostMenu from "discourse/components/admin-post-menu";
import DeleteTopicDisallowedModal from "discourse/components/modal/delete-topic-disallowed";
import PluginOutlet from "discourse/components/plugin-outlet";
import SmallUserList from "discourse/components/small-user-list";
import UserTip from "discourse/components/user-tip";
import concatClass from "discourse/helpers/concat-class";
import DAG from "discourse/lib/dag";
import { applyMutableValueTransformer } from "discourse/lib/transformer";
import { userPath } from "discourse/lib/url";
import i18n from "discourse-common/helpers/i18n";
import PostMenuButtonConfig from "./menu/button-config";
import PostMenuButtonWrapper from "./menu/button-wrapper";
import PostMenuAdminButton from "./menu/buttons/admin";
import PostMenuBookmarkButton from "./menu/buttons/bookmark";
import PostMenuCopyLinkButton from "./menu/buttons/copy-link";
import PostMenuDeleteButton from "./menu/buttons/delete";
import PostMenuEditButton from "./menu/buttons/edit";
import PostMenuFlagButton from "./menu/buttons/flag";
import PostMenuLikeButton from "./menu/buttons/like";
import PostMenuReadButton from "./menu/buttons/read";
import PostMenuRepliesButton from "./menu/buttons/replies";
import PostMenuReplyButton from "./menu/buttons/reply";
import PostMenuShareButton from "./menu/buttons/share";
import PostMenuShowMoreButton from "./menu/buttons/show-more";
const LIKE_ACTION = 2;
const VIBRATE_DURATION = 5;
const buttonKeys = Object.freeze({
ADMIN: "admin",
BOOKMARK: "bookmark",
COPY_LINK: "copyLink",
DELETE: "delete",
EDIT: "edit",
FLAG: "flag",
LIKE: "like",
READ: "read",
REPLIES: "replies",
REPLY: "reply",
SHARE: "share",
SHOW_MORE: "showMore",
});
const coreButtonComponents = new Map([
[buttonKeys.ADMIN, PostMenuAdminButton],
[buttonKeys.BOOKMARK, PostMenuBookmarkButton],
[buttonKeys.COPY_LINK, PostMenuCopyLinkButton],
[buttonKeys.DELETE, PostMenuDeleteButton],
[buttonKeys.EDIT, PostMenuEditButton],
[buttonKeys.FLAG, PostMenuFlagButton],
[buttonKeys.LIKE, PostMenuLikeButton],
[buttonKeys.READ, PostMenuReadButton],
[buttonKeys.REPLIES, PostMenuRepliesButton],
[buttonKeys.REPLY, PostMenuReplyButton],
[buttonKeys.SHARE, PostMenuShareButton],
[buttonKeys.SHOW_MORE, PostMenuShowMoreButton],
]);
function smallUserAttributes(user) {
return {
template: user.avatar_template,
username: user.username,
post_url: user.post_url,
url: userPath(user.username_lower),
unknown: user.unknown,
};
}
const defaultDagOptions = {
defaultPosition: { before: buttonKeys.SHOW_MORE },
throwErrorOnCycle: false,
};
export default class PostMenu extends Component {
@service appEvents;
@service capabilities;
@service currentUser;
@service keyValueStore;
@service modal;
@service menu;
@service site;
@service siteSettings;
@service store;
@tracked collapsed = true; // TODO (glimmer-post-menu): Some plugins will need a value transformer
@tracked isWhoLikedVisible = false;
@tracked likedUsers = [];
@tracked totalLikedUsers;
@tracked isWhoReadVisible = false;
@tracked readers = [];
@tracked totalReaders;
@cached
get buttonActions() {
return {
copyLink: this.args.copyLink,
deletePost: this.args.deletePost,
editPost: this.args.editPost,
toggleLike: this.toggleLike,
openAdminMenu: this.openAdminMenu,
recoverPost: this.args.recoverPost,
replyToPost: this.args.replyToPost,
setCollapsed: (value) => (this.collapsed = value),
share: this.args.share,
showDeleteTopicModal: this.showDeleteTopicModal,
showFlags: this.args.showFlags,
showMoreActions: this.showMoreActions,
toggleReplies: this.args.toggleReplies,
toggleWhoLiked: this.toggleWhoLiked,
toggleWhoRead: this.toggleWhoRead,
};
}
@cached
get staticMethodsState() {
return Object.freeze({
canCreatePost: this.args.canCreatePost,
collapsed: this.collapsed,
currentUser: this.currentUser,
filteredRepliesView: this.args.filteredRepliesView,
isWhoLikedVisible: this.isWhoLikedVisible,
isWhoReadVisible: this.isWhoReadVisible,
isWikiMode: this.isWikiMode,
repliesShown: this.args.repliesShown,
replyDirectlyBelow:
this.args.nextPost?.reply_to_post_number ===
this.args.post.post_number &&
this.args.post.post_number !== this.args.post.filteredRepliesPostNumber,
showReadIndicator: this.args.showReadIndicator,
suppressReplyDirectlyBelow:
this.siteSettings.suppress_reply_directly_below,
});
}
@cached
get staticMethodsArgs() {
return {
post: this.args.post,
state: this.staticMethodsState,
};
}
@cached
get state() {
return Object.freeze({
...this.staticMethodsState,
collapsedButtons: this.renderableCollapsedButtons,
});
}
@cached
get registeredButtons() {
let addedKeys;
const replacementMap = new WeakMap();
const configuredItems = this.configuredItems;
const configuredPositions = this.configuredPositions;
// it's important to sort the buttons in the order they were configured, so we can feed them in the correct order
// to initialize the DAG because the results are affected by the order of the items were added
const sortedButtons = Array.from(coreButtonComponents.entries()).sort(
([keyA], [keyB]) => {
const indexA = configuredItems.indexOf(keyA);
const indexB = configuredItems.indexOf(keyB);
if (indexA === -1) {
return -1;
}
return indexA - indexB;
}
);
const dag = DAG.from(
Array.from(sortedButtons).map(([key, ButtonComponent]) => {
const configuredIndex = configuredItems.indexOf(key);
const position =
configuredIndex !== -1 ? configuredPositions.get(key) : null;
return [key, ButtonComponent, position];
}),
{
...defaultDagOptions,
// we need to keep track of the buttons that were added by plugins because they won't respect the values in
// the post_menu setting
onAddItem(key) {
addedKeys?.add(key);
},
onDeleteItem(key) {
addedKeys?.delete(key);
},
// when an item is replaced, we want the new button to inherit the properties defined by static methods in the
// original button if they're not defined. To achieve this we keep track of the replacements in a map
onReplaceItem: (key, newComponent, oldComponent) => {
if (newComponent !== oldComponent) {
replacementMap.set(newComponent, oldComponent);
}
},
}
);
// the map is initialized here, to ensure only the buttons manipulated by plugins using the API are tracked
addedKeys = new Set();
// map to keep track of the labels that should be shown for each button if the plugins wants to override the default
const buttonLabels = new Map();
const showMoreButtonPosition = configuredItems.indexOf(
buttonKeys.SHOW_MORE
);
const hiddenButtonKeys = this.configuredItems.filter((key) =>
this.#hiddenItems.includes(key)
);
// the DAG is not resolved now, instead we just use the object for convenience to pass a nice DAG API to be used
// in the value transformer, and extract the data to be used later to resolve the DAG order
const buttonsRegistry = applyMutableValueTransformer(
"post-menu-buttons",
dag,
{
...this.staticMethodsArgs,
buttonLabels: {
hide(key) {
buttonLabels.set(key, false);
},
show(key) {
buttonLabels.set(key, true);
},
default(key) {
return buttonLabels.delete(key);
},
},
buttonKeys,
firstButtonKey: this.configuredItems[0],
lastHiddenButtonKey: hiddenButtonKeys.length
? hiddenButtonKeys[hiddenButtonKeys.length - 1]
: null,
lastItemBeforeMoreItemsButtonKey:
showMoreButtonPosition > 0
? this.configuredItems[showMoreButtonPosition - 1]
: null,
secondLastHiddenButtonKey:
hiddenButtonKeys.length > 1
? hiddenButtonKeys[hiddenButtonKeys.length - 2]
: null,
}
);
return new Map(
buttonsRegistry.entries().map(([key, ButtonComponent, position]) => {
const config = new PostMenuButtonConfig({
key,
Component: ButtonComponent,
apiAdded: addedKeys.has(key), // flag indicating if the button was added using the API
owner: getOwner(this), // to be passed as argument to the static methods
position,
replacementMap,
showLabel: buttonLabels.get(key),
});
return [key, config];
})
);
}
@cached
get configuredItems() {
const list = this.siteSettings.post_menu
.split("|")
.filter(Boolean)
.map((key) => {
// if the post is a wiki, make Edit more prominent
if (this.isWikiMode) {
switch (key) {
case buttonKeys.EDIT:
return buttonKeys.REPLY;
case buttonKeys.REPLY:
return buttonKeys.EDIT;
}
}
return key;
});
if (list.length > 0 && !list.includes(buttonKeys.SHOW_MORE)) {
list.splice(list.length - 1, 0, buttonKeys.SHOW_MORE);
}
return list;
}
@cached
get configuredPositions() {
let referencePosition = "before";
const configuredItems = this.configuredItems;
return new Map(
configuredItems.map((key, index) => {
if (key === buttonKeys.SHOW_MORE) {
referencePosition = "after";
return [key, null];
} else if (
referencePosition === "before" &&
index < configuredItems.length - 1
) {
return [
key,
{
[referencePosition]: [buttonKeys.SHOW_MORE],
},
];
} else if (referencePosition === "after" && index > 0) {
return [
key,
{
[referencePosition]: [buttonKeys.SHOW_MORE],
},
];
} else {
return [key, null];
}
})
);
}
@cached
get extraControls() {
const items = Array.from(this.registeredButtons.values())
.filter(
(button) =>
isPresent(button) && button.extraControls(this.staticMethodsArgs)
)
.map((button) => [button.key, button, button.position]);
return DAG.from(items, defaultDagOptions)
.resolve()
.map(({ value }) => value);
}
@cached
get availableButtons() {
const items = this.configuredItems;
return Array.from(this.registeredButtons.values()).filter(
(button) =>
(button.apiAdded || items.includes(button.key)) &&
!button.extraControls(this.staticMethodsArgs)
);
}
@cached
get availableCollapsedButtons() {
const hiddenItems = this.#hiddenItems;
if (
isEmpty(hiddenItems) ||
!this.collapsed ||
!this.isShowMoreButtonAvailable
) {
return [];
}
const items = this.availableButtons.filter((button) => {
const hidden = button.hidden(this.staticMethodsArgs);
// when the value returned by hidden is explicitly false we ignore the hidden items specified in the
// site setting
if (hidden === false || button.key === buttonKeys.SHOW_MORE) {
return false;
}
if (this.args.post.reviewable_id && button.key === buttonKeys.FLAG) {
return false;
}
return hidden || hiddenItems.includes(button.key);
});
if (items.length <= 1) {
return [];
}
return items;
}
@cached
get renderableCollapsedButtons() {
return this.availableCollapsedButtons.filter((button) =>
button.shouldRender(this.staticMethodsArgs)
);
}
@cached
get visibleButtons() {
const nonCollapsed = this.availableButtons.filter((button) => {
return !this.availableCollapsedButtons.includes(button);
});
return DAG.from(
nonCollapsed.map((button) => [button.key, button, button.position]),
defaultDagOptions
)
.resolve()
.map(({ value }) => value);
}
get repliesButton() {
return this.registeredButtons.get(buttonKeys.REPLIES);
}
get showMoreButton() {
return this.registeredButtons.get(buttonKeys.SHOW_MORE);
}
get remainingLikedUsers() {
return this.totalLikedUsers - this.likedUsers.length;
}
get remainingReaders() {
return this.totalReaders - this.readers.length;
}
get isWikiMode() {
return this.args.post.wiki && this.args.post.can_edit;
}
get isShowMoreButtonAvailable() {
return (
this.availableButtons.some(
(button) => button.key === buttonKeys.SHOW_MORE
) ||
this.extraControls.some((button) => button.key === buttonKeys.SHOW_MORE)
);
}
@action
async toggleLike() {
if (!this.currentUser) {
this.keyValueStore &&
this.keyValueStore.set({
key: "likedPostId",
value: this.args.post.id,
});
this.args.showLogin();
return;
}
if (this.capabilities.userHasBeenActive && this.capabilities.canVibrate) {
navigator.vibrate(VIBRATE_DURATION);
}
await this.args.toggleLike();
if (!this.collapsed) {
await this.#fetchWhoLiked();
}
}
@action
openAdminMenu(_, event) {
this.menu.show(event.target, {
identifier: "admin-post-menu",
component: AdminPostMenu,
modalForMobile: true,
autofocus: true,
data: {
post: this.args.post,
changeNotice: this.args.changeNotice,
changePostOwner: this.args.changePostOwner,
grantBadge: this.args.grantBadge,
lockPost: this.args.lockPost,
permanentlyDeletePost: this.args.permanentlyDeletePost,
rebakePost: this.args.rebakePost,
showPagePublish: this.args.showPagePublish,
togglePostType: this.args.togglePostType,
toggleWiki: this.args.toggleWiki,
unhidePost: this.args.unhidePost,
unlockPost: this.args.unlockPost,
},
});
}
@action
refreshReaders() {
if (this.readers.length) {
return this.#fetchWhoRead();
}
}
@action
showDeleteTopicModal() {
this.modal.show(DeleteTopicDisallowedModal);
}
@action
async showMoreActions() {
this.collapsed = false;
const fetchData = [
!this.isWhoLikedVisible && this.#fetchWhoLiked(),
!this.isWhoReadVisible &&
this.args.showReadIndicator &&
this.#fetchWhoRead(),
].filter(Boolean);
await Promise.all(fetchData);
}
@action
toggleWhoLiked() {
if (this.isWhoLikedVisible) {
this.isWhoLikedVisible = false;
return;
}
this.#fetchWhoLiked();
}
@action
toggleWhoRead() {
if (this.isWhoReadVisible) {
this.isWhoReadVisible = false;
return;
}
this.#fetchWhoRead();
}
@cached
get #hiddenItems() {
const setting = this.siteSettings.post_menu_hidden_items;
if (isEmpty(setting)) {
return [];
}
return setting
.split("|")
.filter(
(itemKey) =>
!this.args.post.bookmarked || itemKey !== buttonKeys.BOOKMARK
);
}
async #fetchWhoLiked() {
const users = await this.store.find("post-action-user", {
id: this.args.post.id,
post_action_type_id: LIKE_ACTION,
});
this.likedUsers = users.map(smallUserAttributes);
this.totalLikedUsers = users.totalRows;
this.isWhoLikedVisible = true;
}
async #fetchWhoRead() {
const users = await this.store.find("post-reader", {
id: this.args.post.id,
});
this.readers = users.map(smallUserAttributes);
this.totalReaders = users.totalRows;
this.isWhoReadVisible = true;
}
<template>
{{! The section tag can't be include while we're still using the widget shim }}
{{! <section class="post-menu-area clearfix"> }}
<PluginOutlet
@name="post-menu"
@outletArgs={{hash post=@post state=this.state}}
>
<nav
class={{concatClass
"post-controls"
"glimmer-post-menu"
(if
(and
(this.showMoreButton.shouldRender
(hash post=this.post state=this.state)
)
this.collapsed
)
"collapsed"
"expanded"
)
(if
this.siteSettings.enable_filtered_replies_view
"replies-button-visible"
)
}}
>
{{! do not include PluginOutlets here, use the PostMenu DAG API instead }}
{{#each this.extraControls key="key" as |extraControl|}}
<PostMenuButtonWrapper
@buttonActions={{this.buttonActions}}
@buttonConfig={{extraControl}}
@post={{@post}}
@state={{this.state}}
/>
{{/each}}
<div class="actions">
{{#each this.visibleButtons key="key" as |button|}}
<PostMenuButtonWrapper
@buttonActions={{this.buttonActions}}
@buttonConfig={{button}}
@post={{@post}}
@state={{this.state}}
/>
{{/each}}
</div>
</nav>
{{#if this.isWhoReadVisible}}
<SmallUserList
class="who-read"
@addSelf={{false}}
@ariaLabel={{i18n
"post.actions.people.sr_post_readers_list_description"
}}
@count={{if
this.remainingReaders
this.remainingReaders
this.totalReaders
}}
@description={{if
this.remainingReaders
"post.actions.people.read_capped"
"post.actions.people.read"
}}
@users={{this.readers}}
/>
{{/if}}
{{#if this.isWhoLikedVisible}}
<SmallUserList
class="who-liked"
@addSelf={{and @post.liked (eq this.remainingLikedUsers 0)}}
@ariaLabel={{i18n
"post.actions.people.sr_post_likers_list_description"
}}
@count={{if
this.remainingLikedUsers
this.remainingLikedUsers
this.totalLikedUsers
}}
@description={{if
this.remainingLikedUsers
"post.actions.people.like_capped"
"post.actions.people.like"
}}
@users={{this.likedUsers}}
/>
{{/if}}
{{#if this.collapsedButtons}}
<UserTip
@id="post_menu"
@triggerSelector=".post-controls .actions .show-more-actions"
@placement="top"
@titleText={{i18n "user_tips.post_menu.title"}}
@contentText={{i18n "user_tips.post_menu.content"}}
/>
{{/if}}
</PluginOutlet>
{{! </section> }}
</template>
}

View File

@@ -0,0 +1,131 @@
import { helperContext } from "discourse-common/lib/helpers";
import { bind } from "discourse-common/utils/decorators";
export default class PostMenuButtonConfig {
#Component;
#apiAdded;
#key;
#owner;
#position;
#replacementMap;
#showLabel;
constructor({
key,
Component,
apiAdded,
owner,
position,
replacementMap,
showLabel,
}) {
this.#Component = Component;
this.#apiAdded = apiAdded;
this.#key = key;
this.#owner = owner;
this.#position = position;
this.#replacementMap = replacementMap;
this.#showLabel = showLabel;
}
get Component() {
return this.#Component;
}
get apiAdded() {
return this.#apiAdded;
}
@bind
hidden(args) {
return this.#staticPropertyWithReplacementFallback({
property: "hidden",
args,
defaultValue: null,
});
}
@bind
delegateShouldRenderToTemplate(args) {
return this.#staticPropertyWithReplacementFallback({
property: "delegateShouldRenderToTemplate",
args,
defaultValue: false,
});
}
@bind
extraControls(args) {
return this.#staticPropertyWithReplacementFallback({
property: "extraControls",
args,
defaultValue: false,
});
}
get key() {
return this.#key;
}
get position() {
return this.#position;
}
@bind
setShowLabel(value) {
this.#showLabel = value;
}
@bind
shouldRender(args) {
return this.#staticPropertyWithReplacementFallback({
property: "shouldRender",
args,
defaultValue: true,
});
}
@bind
showLabel(args) {
return (
this.#showLabel ??
this.#staticPropertyWithReplacementFallback({
property: "showLabel",
args,
defaultValue: null,
})
);
}
#staticPropertyWithReplacementFallback(
{ klass = this.#Component, property, args, defaultValue },
_usedKlasses = new WeakSet()
) {
// fallback to the default value if the klass is not defined, i.e., the button was not replaced
// or if the klass was already used to avoid an infinite recursion in case of a circular reference
if (!klass || _usedKlasses.has(klass)) {
return defaultValue;
}
let value;
if (typeof klass[property] === "undefined") {
// fallback to the replacement map if the property is not defined
return this.#staticPropertyWithReplacementFallback(
{
klass: this.#replacementMap.get(klass) || null, // passing null explicitly to avoid using the default value
property,
args,
defaultValue,
},
_usedKlasses.add(klass)
);
} else if (typeof klass[property] === "function") {
value = klass[property](args, helperContext(), this.#owner);
} else {
value = klass[property];
}
return value;
}
}

View File

@@ -0,0 +1,66 @@
import Component from "@glimmer/component";
import { on } from "@ember/modifier";
import { action } from "@ember/object";
import didInsert from "@ember/render-modifiers/modifiers/did-insert";
import { or } from "truth-helpers";
import { showAlert } from "../../../lib/post-action-feedback";
export default class PostMenuButtonWrapper extends Component {
#element;
get delegateShouldRenderToTemplate() {
return this.args.buttonConfig.delegateShouldRenderToTemplate(this.args);
}
get hidden() {
return this.args.buttonConfig.hidden(this.args);
}
get shouldRender() {
if (this.delegateShouldRenderToTemplate) {
return;
}
return this.args.buttonConfig.shouldRender(this.args);
}
get showLabel() {
return this.args.buttonConfig.showLabel(this.args);
}
@action
setElement(element) {
this.#element = element;
}
@action
sharedBehaviorOnClick(event) {
event.currentTarget?.blur();
}
@action
showFeedback(messageKey) {
if (this.#element) {
showAlert(this.args.post.id, this.args.buttonConfig.key, messageKey, {
actionBtn: this.#element,
});
}
}
<template>
{{#if (or this.shouldRender this.delegateShouldRenderToTemplate)}}
<@buttonConfig.Component
class="btn-flat"
@buttonActions={{@buttonActions}}
@hidden={{this.hidden}}
@post={{@post}}
@shouldRender={{this.shouldRender}}
@showFeedback={{this.showFeedback}}
@showLabel={{this.showLabel}}
@state={{@state}}
{{didInsert this.setElement}}
{{on "click" this.sharedBehaviorOnClick}}
/>
{{/if}}
</template>
}

View File

@@ -0,0 +1,22 @@
import Component from "@glimmer/component";
import DButton from "discourse/components/d-button";
export default class PostMenuAdminButton extends Component {
static shouldRender(args) {
return (
args.post.canManage || args.post.can_wiki || args.post.canEditStaffNotes
);
}
<template>
<DButton
class="post-action-menu__admin show-post-admin-menu"
...attributes
@action={{@buttonActions.openAdminMenu}}
@forwardEvent={{true}}
@icon="wrench"
@label={{if @showLabel "post.controls.admin_action"}}
@title="post.controls.admin"
/>
</template>
}

View File

@@ -0,0 +1,25 @@
import Component from "@glimmer/component";
import { cached } from "@glimmer/tracking";
import { getOwner } from "@ember/owner";
import BookmarkMenu from "discourse/components/bookmark-menu";
import PostBookmarkManager from "discourse/lib/post-bookmark-manager";
export default class PostMenuBookmarkButton extends Component {
static shouldRender(args) {
return !!args.post.canBookmark;
}
@cached
get bookmarkManager() {
return new PostBookmarkManager(getOwner(this), this.args.post);
}
<template>
<BookmarkMenu
class="post-action-menu__bookmark"
...attributes
@bookmarkManager={{this.bookmarkManager}}
@showLabel={{@showLabel}}
/>
</template>
}

View File

@@ -0,0 +1,14 @@
import DButton from "discourse/components/d-button";
const PostMenuCopyLinkButton = <template>
<DButton
class="post-action-menu__copy-link"
...attributes
@action={{@buttonActions.copyLink}}
@icon="d-post-share"
@label={{if @showLabel "post.controls.copy_action"}}
@title="post.controls.copy_title"
/>
</template>;
export default PostMenuCopyLinkButton;

View File

@@ -0,0 +1,133 @@
import Component from "@glimmer/component";
import DButton from "discourse/components/d-button";
export const BUTTON_ACTION_MODE_DELETE = "delete";
export const BUTTON_ACTION_MODE_DELETE_TOPIC = "delete-topic";
export const BUTTON_ACTION_MODE_SHOW_FLAG_DELETE = "show-flag-delete";
export const BUTTON_ACTION_MODE_RECOVER = "recover";
export const BUTTON_ACTION_MODE_RECOVER_TOPIC = "recover-topic";
export const BUTTON_ACTION_MODE_RECOVERING = "recovering";
export const BUTTON_ACTION_MODE_RECOVERING_TOPIC = "recovering-topic";
export default class PostMenuDeleteButton extends Component {
static shouldRender(args) {
return !!PostMenuDeleteButton.modeFor(args.post);
}
static modeFor(post) {
if (post.canRecoverTopic) {
return BUTTON_ACTION_MODE_RECOVER_TOPIC;
} else if (post.canDeleteTopic) {
return BUTTON_ACTION_MODE_DELETE_TOPIC;
} else if (post.canRecover) {
return BUTTON_ACTION_MODE_RECOVER;
} else if (post.canDelete) {
return BUTTON_ACTION_MODE_DELETE;
} else if (post.showFlagDelete) {
return BUTTON_ACTION_MODE_SHOW_FLAG_DELETE;
} else if (post.isRecovering) {
return BUTTON_ACTION_MODE_RECOVERING;
} else if (post.isRecoveringTopic) {
return BUTTON_ACTION_MODE_RECOVERING_TOPIC;
}
}
get className() {
switch (this.#activeMode) {
case BUTTON_ACTION_MODE_RECOVER:
case BUTTON_ACTION_MODE_RECOVER_TOPIC:
case BUTTON_ACTION_MODE_RECOVERING:
case BUTTON_ACTION_MODE_RECOVERING_TOPIC:
return "post-action-menu__recover recover";
default:
return "post-action-menu__delete delete";
}
}
get icon() {
switch (this.#activeMode) {
case BUTTON_ACTION_MODE_RECOVER:
case BUTTON_ACTION_MODE_RECOVER_TOPIC:
case BUTTON_ACTION_MODE_RECOVERING:
case BUTTON_ACTION_MODE_RECOVERING_TOPIC:
return "arrow-rotate-left";
default:
return "trash-can";
}
}
get label() {
switch (this.#activeMode) {
case BUTTON_ACTION_MODE_DELETE:
return "post.controls.delete_action";
case BUTTON_ACTION_MODE_DELETE_TOPIC:
case BUTTON_ACTION_MODE_SHOW_FLAG_DELETE:
return "topic.actions.delete";
case BUTTON_ACTION_MODE_RECOVER:
case BUTTON_ACTION_MODE_RECOVERING:
return "post.controls.undelete_action";
case BUTTON_ACTION_MODE_RECOVER_TOPIC:
case BUTTON_ACTION_MODE_RECOVERING_TOPIC:
return "topic.actions.recover";
}
}
get title() {
switch (this.#activeMode) {
case BUTTON_ACTION_MODE_DELETE:
return "post.controls.delete";
case BUTTON_ACTION_MODE_DELETE_TOPIC:
return "post.controls.delete_topic";
case BUTTON_ACTION_MODE_SHOW_FLAG_DELETE:
return "post.controls.delete_topic_disallowed";
case BUTTON_ACTION_MODE_RECOVER:
case BUTTON_ACTION_MODE_RECOVERING:
return "post.controls.undelete";
case BUTTON_ACTION_MODE_RECOVER_TOPIC:
case BUTTON_ACTION_MODE_RECOVERING_TOPIC:
return "topic.actions.recover";
}
}
get activeAction() {
if (this.args.post.canRecoverTopic) {
return this.args.buttonActions.recoverPost;
} else if (this.args.post.canDeleteTopic) {
return this.args.buttonActions.deletePost;
} else if (this.args.post.canRecover) {
return this.args.buttonActions.recoverPost;
} else if (this.args.post.canDelete) {
return this.args.buttonActions.deletePost;
} else if (this.args.post.showFlagDelete) {
return this.args.buttonActions.showDeleteTopicModal;
}
}
get disabled() {
return !this.activeAction;
}
get #activeMode() {
return this.constructor.modeFor(this.args.post);
}
<template>
<DButton
class={{this.className}}
...attributes
disabled={{this.disabled}}
@action={{this.activeAction}}
@icon={{this.icon}}
@label={{if @showLabel this.label}}
@title={{this.title}}
/>
</template>
}

View File

@@ -0,0 +1,43 @@
import Component from "@glimmer/component";
import { inject as service } from "@ember/service";
import DButton from "discourse/components/d-button";
import concatClass from "discourse/helpers/concat-class";
export default class PostMenuEditButton extends Component {
static hidden(args) {
if (args.state.isWikiMode || (args.post.can_edit && args.post.yours)) {
return false;
}
// returning null here allows collapseByDefault to fallback to the value configured in the settings for the button
return null;
}
static shouldRender(args) {
return args.post.can_edit;
}
@service site;
get showLabel() {
return (
this.args.showLabel ??
(this.site.desktopView && this.args.state.isWikiMode)
);
}
<template>
<DButton
class={{concatClass
"post-action-menu__edit"
"edit"
(if @post.wiki "create")
}}
...attributes
@action={{@buttonActions.editPost}}
@icon={{if @post.wiki "far-edit" "pencil-alt"}}
@label={{if this.showLabel "post.controls.edit_action"}}
@title="post.controls.edit"
/>
</template>
}

View File

@@ -0,0 +1,43 @@
import Component from "@glimmer/component";
import { action } from "@ember/object";
import { gt } from "truth-helpers";
import DButton from "discourse/components/d-button";
import concatClass from "discourse/helpers/concat-class";
import DiscourseURL from "discourse/lib/url";
export default class PostMenuFlagButton extends Component {
static shouldRender(args) {
const { reviewable_id, canFlag, hidden } = args.post;
return reviewable_id || (canFlag && !hidden);
}
@action
navigateToReviewable() {
DiscourseURL.routeTo(`/review/${this.args.post.reviewable_id}`);
}
<template>
<div class="double-button">
{{#if @post.reviewable_id}}
<DButton
class={{concatClass
"button-count"
(if (gt @post.reviewable_score_pending_count 0) "has-pending")
}}
...attributes
@action={{this.navigateToReviewable}}
>
<span>{{@post.reviewable_score_count}}</span>
</DButton>
{{/if}}
<DButton
class="post-action-menu__flag create-flag"
...attributes
@action={{@buttonActions.showFlags}}
@icon="flag"
@label={{if @showLabel "post.controls.flag_action"}}
@title="post.controls.flag"
/>
</div>
</template>
}

View File

@@ -0,0 +1,153 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { action } from "@ember/object";
import { inject as service } from "@ember/service";
import DButton from "discourse/components/d-button";
import concatClass from "discourse/helpers/concat-class";
import icon from "discourse-common/helpers/d-icon";
import i18n from "discourse-common/helpers/i18n";
import discourseLater from "discourse-common/lib/later";
export default class PostMenuLikeButton extends Component {
static shouldRender(args) {
return args.post.showLike || args.post.likeCount > 0;
}
@service currentUser;
@tracked isAnimated = false;
get disabled() {
return this.currentUser && !this.args.post.canToggleLike;
}
get title() {
// If the user has already liked the post and doesn't have permission
// to undo that operation, then indicate via the title that they've liked it
// and disable the button. Otherwise, set the title even if the user
// is anonymous (meaning they don't currently have permission to like);
// this is important for accessibility.
if (this.args.post.liked && !this.args.post.canToggleLike) {
return "post.controls.has_liked";
}
return this.args.post.liked
? "post.controls.undo_like"
: "post.controls.like";
}
@action
async toggleLike() {
this.isAnimated = true;
return new Promise((resolve) => {
discourseLater(async () => {
this.isAnimated = false;
await this.args.buttonActions.toggleLike();
resolve();
}, 400);
});
}
<template>
{{#if @post.showLike}}
<div class="double-button">
<LikeCount
...attributes
@action={{@buttonActions.toggleWhoLiked}}
@state={{@state}}
@post={{@post}}
/>
<DButton
class={{concatClass
"post-action-menu__like"
"toggle-like"
"btn-icon"
(if this.isAnimated "heart-animation")
(if @post.liked "has-like fade-out" "like")
}}
...attributes
data-post-id={{@post.id}}
disabled={{this.disabled}}
@action={{this.toggleLike}}
@icon={{if @post.liked "d-liked" "d-unliked"}}
@label={{if @showLabel "post.controls.like_action"}}
@title={{this.title}}
/>
</div>
{{else}}
<div class="double-button">
<LikeCount
...attributes
@action={{@buttonActions.toggleWhoLiked}}
@state={{@state}}
@post={{@post}}
/>
</div>
{{/if}}
</template>
}
class LikeCount extends Component {
get icon() {
if (!this.args.post.showLike) {
return this.args.post.yours ? "d-liked" : "d-unliked";
}
if (this.args.post.yours) {
return "d-liked";
}
}
get translatedTitle() {
let title;
if (this.args.post.liked) {
title =
this.args.post.likeCount === 1
? "post.has_likes_title_only_you"
: "post.has_likes_title_you";
} else {
title = "post.has_likes_title";
}
return i18n(title, {
count: this.args.post.liked
? this.args.post.likeCount - 1
: this.args.post.likeCount,
});
}
<template>
{{#if @post.likeCount}}
<DButton
class={{concatClass
"post-action-menu__like-count"
"like-count"
"button-count"
"highlight-action"
(if @post.yours "my-likes" "regular-likes")
}}
...attributes
@ariaPressed={{@state.isWhoReadVisible}}
@translatedAriaLabel={{i18n
"post.sr_post_like_count_button"
count=@post.likeCount
}}
@translatedTitle={{this.translatedTitle}}
@action={{@action}}
>
{{@post.likeCount}}
{{!--
When displayed, the icon on the Like Count button is aligned to the right
To get the desired effect will use the {{yield}} in the DButton component to our advantage
introducing manually the icon after the label
--}}
{{#if this.icon}}
{{~icon this.icon~}}
{{/if}}
</DButton>
{{/if}}
</template>
}

View File

@@ -0,0 +1,33 @@
import Component from "@glimmer/component";
import DButton from "discourse/components/d-button";
import i18n from "discourse-common/helpers/i18n";
export default class PostMenuReadButton extends Component {
static shouldRender(args) {
return args.state.showReadIndicator && args.post.readers_count > 0;
}
<template>
<div class="double-button">
<DButton
class="post-action-menu__read read-indicator button-count"
...attributes
@ariaPressed={{@state.isWhoReadVisible}}
@action={{@buttonActions.toggleWhoRead}}
@translatedAriaLabel={{i18n
"post.sr_post_read_count_button"
count=@post.readers_count
}}
@title="post.controls.read_indicator"
>
{{@post.readers_count}}
</DButton>
<DButton
...attributes
@action={{@buttonActions.toggleWhoRead}}
@icon="book-reader"
@title="post.controls.read_indicator"
/>
</div>
</template>
}

View File

@@ -0,0 +1,71 @@
import Component from "@glimmer/component";
import { concat } from "@ember/helper";
import { inject as service } from "@ember/service";
import { and, not } from "truth-helpers";
import DButton from "discourse/components/d-button";
import icon from "discourse-common/helpers/d-icon";
import i18n from "discourse-common/helpers/i18n";
export default class PostMenuRepliesButton extends Component {
static extraControls = true;
static shouldRender(args) {
const replyCount = args.post.reply_count;
if (!replyCount) {
return false;
}
return !(
replyCount === 1 &&
args.state.replyDirectlyBelow &&
args.state.suppressReplyDirectlyBelow
);
}
@service site;
get disabled() {
return !!this.args.post.deleted;
}
get translatedTitle() {
if (!this.args.state.filteredRepliesView) {
return;
}
return this.args.state.repliesShown
? i18n("post.view_all_posts")
: i18n("post.filtered_replies_hint", {
count: this.args.post.reply_count,
});
}
<template>
<DButton
class="show-replies btn-icon-text"
...attributes
disabled={{this.disabled}}
@action={{@buttonActions.toggleReplies}}
@ariaControls={{concat "embedded-posts__bottom--" @post.post_number}}
@ariaExpanded={{and @state.repliesShown (not @state.filteredRepliesView)}}
@ariaPressed={{unless @state.filteredRepliesView @state.repliesShown}}
@translatedAriaLabel={{i18n
"post.sr_expand_replies"
count=@post.reply_count
}}
@translatedLabel={{i18n
(if this.site.mobileView "post.has_replies_count" "post.has_replies")
count=@post.reply_count
}}
@translatedTitle={{this.translatedTitle}}
>
{{!--
The icon on the replies button is aligned to the right
To get the desired effect will use the {{yield}} in the DButton component to our advantage
introducing manually the icon after the label
--}}
{{~icon (if @state.repliesShown "chevron-up" "chevron-down")~}}
</DButton>
</template>
}

View File

@@ -0,0 +1,40 @@
import Component from "@glimmer/component";
import { inject as service } from "@ember/service";
import DButton from "discourse/components/d-button";
import concatClass from "discourse/helpers/concat-class";
import i18n from "discourse-common/helpers/i18n";
export default class PostMenuReplyButton extends Component {
static shouldRender(args) {
return args.state.canCreatePost;
}
@service site;
get showLabel() {
return (
this.args.showLabel ??
(this.site.desktopView && !this.args.state.isWikiMode)
);
}
<template>
<DButton
class={{concatClass
"post-action-menu__reply"
"reply"
(if this.showLabel "create fade-out")
}}
...attributes
@action={{@buttonActions.replyToPost}}
@icon="reply"
@label={{if this.showLabel "topic.reply.title"}}
@title="post.controls.reply"
@translatedAriaLabel={{i18n
"post.sr_reply_to"
post_number=@post.post_number
username=@post.username
}}
/>
</template>
}

View File

@@ -0,0 +1,14 @@
import DButton from "discourse/components/d-button";
const PostMenuShareButton = <template>
<DButton
class="post-action-menu__share share"
...attributes
@action={{@buttonActions.share}}
@icon="d-post-share"
@label={{if @showLabel "post.controls.share_action"}}
@title="post.controls.share"
/>
</template>;
export default PostMenuShareButton;

View File

@@ -0,0 +1,18 @@
import Component from "@glimmer/component";
import DButton from "discourse/components/d-button";
export default class PostMenuShowMoreButton extends Component {
static shouldRender(args) {
return args.state.collapsedButtons.length && args.state.collapsed;
}
<template>
<DButton
class="post-action-menu__show-more show-more-actions"
...attributes
@action={{@buttonActions.showMoreActions}}
@icon="ellipsis-h"
@title="show_more"
/>
</template>
}

View File

@@ -19,10 +19,10 @@ export default class SmallUserList extends Component {
}
get users() {
let users = this.args.data.users;
let users = this.args.users;
if (
this.args.data.addSelf &&
this.args.addSelf &&
!users.some((u) => u.username === this.currentUser.username)
) {
users = users.concat(this.smallUserAtts(this.currentUser));
@@ -38,38 +38,48 @@ export default class SmallUserList extends Component {
}
<template>
{{#each this.users as |user|}}
{{#if user.unknown}}
<div
title={{i18n "post.unknown_user"}}
class="unknown"
role="listitem"
></div>
{{else}}
<a
class="trigger-user-card"
data-user-card={{user.username}}
title={{user.username}}
aria-hidden="false"
role="listitem"
{{#if this.users}}
<div class="clearfix small-user-list" ...attributes>
<span
class="small-user-list-content"
aria-label={{@ariaLabel}}
role="list"
>
{{avatar user.template "tiny"}}
</a>
{{/if}}
{{/each}}
{{#each this.users key="username" as |user|}}
{{#if user.unknown}}
<div
title={{i18n "post.unknown_user"}}
class="unknown"
role="listitem"
></div>
{{else}}
<a
class="trigger-user-card"
data-user-card={{user.username}}
title={{user.username}}
aria-hidden="false"
role="listitem"
>
{{avatar user.template "tiny"}}
</a>
{{/if}}
{{/each}}
{{#if @data.description}}
{{#if this.postUrl}}
<a href={{this.postUrl}}>
<span aria-hidden="true" class="list-description">
{{i18n @data.description count=@data.count}}
</span>
</a>
{{else}}
<span aria-hidden="true" class="list-description">
{{i18n @data.description count=@data.count}}
{{#if @description}}
{{#if this.postUrl}}
<a href={{this.postUrl}}>
<span aria-hidden="true" class="list-description">
{{i18n @description count=@count}}
</span>
</a>
{{else}}
<span aria-hidden="true" class="list-description">
{{i18n @description count=@count}}
</span>
{{/if}}
{{/if}}
</span>
{{/if}}
</div>
{{/if}}
</template>
}

View File

@@ -1,5 +1,6 @@
import { withPluginApi } from "discourse/lib/plugin-api";
import PostBookmarkManager from "discourse/lib/post-bookmark-manager";
import { withSilencedDeprecations } from "discourse-common/lib/deprecated";
export default {
name: "discourse-bookmark-menu",
@@ -9,18 +10,20 @@ export default {
withPluginApi("0.10.1", (api) => {
if (currentUser) {
api.replacePostMenuButton("bookmark", {
name: "bookmark-menu-shim",
shouldRender: () => true,
buildAttrs: (widget) => {
return {
post: widget.findAncestorModel(),
bookmarkManager: new PostBookmarkManager(
container,
widget.findAncestorModel()
),
};
},
withSilencedDeprecations("discourse.post-menu-widget-overrides", () => {
api.replacePostMenuButton("bookmark", {
name: "bookmark-menu-shim",
shouldRender: () => true,
buildAttrs: (widget) => {
return {
post: widget.findAncestorModel(),
bookmarkManager: new PostBookmarkManager(
container,
widget.findAncestorModel()
),
};
},
});
});
}
});

View File

@@ -6,16 +6,73 @@ function ensureArray(val) {
}
export default class DAG {
/**
* Creates a new DAG instance from an iterable of entries.
*
* @param {Iterable<[string, any, Object?]>} entriesLike - An iterable of key-value-position tuples to initialize the DAG.
* @param {Object} [opts] - Optional configuration object.
* @param {Object} [opts.defaultPosition] - Default positioning rules for new items.
* @param {boolean} [opts.throwErrorOnCycle=true] - Flag indicating whether to throw an error when a cycle is detected. Default is true. When false, the default position will be used instead.
* @param {string|string[]} [opts.defaultPosition.before] - A key or array of keys of items which should appear before this one.
* @param {string|string[]} [opts.defaultPosition.after] - A key or array of keys of items which should appear after this one.
* @param {(key: string, value: any, position: {before?: string|string[], after?: string|string[]}) => void} [opts.onAddItem] - Callback function to be called when an item is added.
* @param {(key: string) => void} [opts.onDeleteItem] - Callback function to be called when an item is removed.
* @param {(key: string, newValue: any, oldValue: any, newPosition: {before?: string|string[], after?: string|string[]}, oldPosition: {before?: string|string[], after?: string|string[]}) => void} [opts.onReplaceItem] - Callback function to be called when an item is replaced.
* @param {(key: string, newPosition: {before?: string|string[], after?: string|string[]}, oldPosition: {before?: string|string[], after?: string|string[]}) => void} [opts.onRepositionItem] - Callback function to be called when an item is repositioned.
* @returns {DAG} A new DAG instance.
*/
static from(entriesLike, opts) {
const dag = new this(opts);
for (const [key, value, position] of entriesLike) {
dag.add(key, value, position);
}
return dag;
}
#defaultPosition;
#onAddItem;
#onDeleteItem;
#onReplaceItem;
#onRepositionItem;
#throwErrorOnCycle;
#rawData = new Map();
#dag = new DAGMap();
constructor(args) {
/**
* Creates a new Directed Acyclic Graph (DAG) instance.
*
* @param {Object} [opts] - Optional configuration object.
* @param {Object} [opts.defaultPosition] - Default positioning rules for new items.
* @param {boolean} [opts.throwErrorOnCycle=true] - Flag indicating whether to throw an error when a cycle is detected. When false, the default position will be used instead.
* @param {string|string[]} [opts.defaultPosition.before] - A key or array of keys of items which should appear before this one.
* @param {string|string[]} [opts.defaultPosition.after] - A key or array of keys of items which should appear after this one.
* @param {(key: string, value: any, position: {before?: string|string[], after?: string|string[]}) => void} [opts.onAddItem] - Callback function to be called when an item is added.
* @param {(key: string) => void} [opts.onDeleteItem] - Callback function to be called when an item is removed.
* @param {(key: string, newValue: any, oldValue: any, newPosition: {before?: string|string[], after?: string|string[]}, oldPosition: {before?: string|string[], after?: string|string[]}) => void} [opts.onReplaceItem] - Callback function to be called when an item is replaced.
* @param {(key: string, newPosition: {before?: string|string[], after?: string|string[]}, oldPosition: {before?: string|string[], after?: string|string[]}) => void} [opts.onRepositionItem] - Callback function to be called when an item is repositioned.
*/
constructor(opts) {
// allows for custom default positioning of new items added to the DAG, eg
// new DAG({ defaultPosition: { before: "foo", after: "bar" } });
this.#defaultPosition = args?.defaultPosition || {};
this.#defaultPosition = opts?.defaultPosition || {};
this.#throwErrorOnCycle = opts?.throwErrorOnCycle ?? true;
this.#onAddItem = opts?.onAddItem;
this.#onDeleteItem = opts?.onDeleteItem;
this.#onReplaceItem = opts?.onReplaceItem;
this.#onRepositionItem = opts?.onRepositionItem;
}
/**
* Returns the default position for a given key, excluding the key itself from the before/after arrays.
*
* @param {string} key - The key to get the default position for.
* @returns {Object} The default position object.
* @private
*/
#defaultPositionForKey(key) {
const pos = { ...this.#defaultPosition };
if (ensureArray(pos.before).includes(key)) {
@@ -28,15 +85,20 @@ export default class DAG {
}
/**
* Adds a key/value pair to the map. Can optionally specify before/after position requirements.
* Adds a key/value pair to the DAG map. Can optionally specify before/after position requirements.
*
* @param {string} key The key of the item to be added. Can be referenced by other member's position parameters.
* @param {any} value
* @param {Object} position
* @param {string | string[]} position.before A key or array of keys of items which should appear before this one.
* @param {string | string[]} position.after A key or array of keys of items which should appear after this one.
* @param {string} key - The key of the item to be added. Can be referenced by other member's position parameters.
* @param {any} value - The value of the item to be added.
* @param {Object} [position] - The position object specifying before/after requirements.
* @param {string|string[]} [position.before] - A key or array of keys of items which should appear before this one.
* @param {string|string[]} [position.after] - A key or array of keys of items which should appear after this one.
* @returns {boolean} True if the item was added, false if the key already exists.
*/
add(key, value, position) {
if (this.has(key)) {
return false;
}
position ||= this.#defaultPositionForKey(key);
const { before, after } = position;
this.#rawData.set(key, {
@@ -44,49 +106,88 @@ export default class DAG {
before,
after,
});
this.#dag.add(key, value, before, after);
this.#addHandlingCycles(this.#dag, key, value, before, after);
this.#onAddItem?.(key, value, position);
return true;
}
/**
* Remove an item from the map by key. no-op if the key does not exist.
* Remove an item from the map by key.
*
* @param {string} key The key of the item to be removed.
* @param {string} key - The key of the item to be removed.
* @returns {boolean} True if the item was deleted, false otherwise.
*/
delete(key) {
this.#rawData.delete(key);
const deleted = this.#rawData.delete(key);
this.#refreshDAG();
if (deleted) {
this.#onDeleteItem?.(key);
}
return deleted;
}
/**
* Change the positioning rules of an existing item in the map. Will replace all existing rules. No-op if the key does not exist.
* Replace an existing item in the map.
*
* @param {string} key
* @param {string | string[]} position.before A key or array of keys of items which should appear before this one.
* @param {string | string[]} position.after A key or array of keys of items which should appear after this one.
* @param {string} key - The key of the item to be replaced.
* @param {any} value - The new value of the item.
* @param {Object} position - The new position object specifying before/after requirements.
* @param {string|string[]} [position.before] - A key or array of keys of items which should appear before this one.
* @param {string|string[]} [position.after] - A key or array of keys of items which should appear after this one.
* @returns {boolean} True if the item was replaced, false otherwise.
*/
reposition(key, { before, after }) {
const node = this.#rawData.get(key);
if (node) {
node.before = before;
node.after = after;
replace(key, value, position) {
return this.#replace(key, value, position);
}
/**
* Change the positioning rules of an existing item in the map. Will replace all existing rules.
*
* @param {string} key - The key of the item to reposition.
* @param {Object} position - The new position object specifying before/after requirements.
* @param {string|string[]} [position.before] - A key or array of keys of items which should appear before this one.
* @param {string|string[]} [position.after] - A key or array of keys of items which should appear after this one.
* @returns {boolean} True if the item was repositioned, false otherwise.
*/
reposition(key, position) {
if (!this.has(key) || !position) {
return false;
}
this.#refreshDAG();
const { value } = this.#rawData.get(key);
return this.#replace(key, value, position, { repositionOnly: true });
}
/**
* Check whether an item exists in the map.
* @param {string} key
* @returns {boolean}
*
* @param {string} key - The key to check for existence.
* @returns {boolean} True if the item exists, false otherwise.
*/
has(key) {
return this.#rawData.has(key);
}
/**
* Return the resolved key/value pairs in the map. The order of the pairs is determined by the before/after rules.
* @returns {Array<[key: string, value: any]}>} An array of key/value pairs.
* Returns an array of entries in the DAG. Each entry is a tuple containing the key, value, and position object.
*
* @returns {Array<[string, any, {before?: string|string[], after?: string|string[]}]>} An array of key-value-position tuples.
*/
entries() {
return Array.from(this.#rawData.entries()).map(
([key, { value, before, after }]) => [key, value, { before, after }]
);
}
/**
* Return the resolved key/value pairs in the map. The order of the pairs is determined by the before/after rules.
*
* @returns {Array<{key: string, value: any, position: {before?: string|string[], after?: string|string[]}}>} An array of key/value/position objects.
*/
@bind
resolve() {
@@ -96,20 +197,100 @@ export default class DAG {
// dependencies, for example if an item has a "before: search" dependency, the "search" vertex will be included
// even if it was explicitly excluded from the raw data.
if (this.has(key)) {
result.push({ key, value });
const { before, after } = this.#rawData.get(key);
result.push({ key, value, position: { before, after } });
}
});
return result;
}
/**
* DAGMap doesn't support removing or modifying keys, so we
* need to completely recreate it from the raw data
* Adds a key/value pair to the DAG map while handling potential cycles.
*
* @param {DAGMap} dag - The DAG map instance to add the key/value pair to.
* @param {string} key - The key of the item to be added.
* @param {any} value - The value of the item to be added.
* @param {string|string[]} [before] - A key or array of keys of items which should appear before this one.
* @param {string|string[]} [after] - A key or array of keys of items which should appear after this one.
* @throws {Error} Throws an error if a cycle is detected and `throwErrorOnCycle` is true.
* @private
*/
#addHandlingCycles(dag, key, value, before, after) {
if (this.#throwErrorOnCycle) {
dag.add(key, value, before, after);
} else {
try {
dag.add(key, value, before, after);
} catch (e) {
if (e.message.match(/cycle/i)) {
const { before: newBefore, after: newAfter } =
this.#defaultPositionForKey(key);
// if even the default position causes a cycle, an error will be thrown
dag.add(key, value, newBefore, newAfter);
}
}
}
}
/**
* Replace an existing item in the map.
*
* @param {string} key - The key of the item to be replaced.
* @param {any} value - The new value of the item.
* @param {Object} position - The new position object specifying before/after requirements.
* @param {string|string[]} [position.before] - A key or array of keys of items which should appear before this one.
* @param {string|string[]} [position.after] - A key or array of keys of items which should appear after this one.
* @param {Object} [options] - Additional options.
* @param {boolean} [options.repositionOnly=false] - Whether the replacement is for repositioning only.
* @returns {boolean} True if the item was replaced, false otherwise.
* @private
*/
#replace(
key,
value,
position,
{ repositionOnly } = { repositionOnly: false }
) {
if (!this.has(key)) {
return false;
}
const existingItem = this.#rawData.get(key);
const oldValue = existingItem.value;
const oldPosition = {
before: existingItem.before,
after: existingItem.after,
};
// mutating the existing item keeps the position in the map in case before/after weren't explicitly set
existingItem.value = value;
if (position) {
existingItem.before = position.before;
existingItem.after = position.after;
}
this.#refreshDAG();
if (repositionOnly) {
this.#onRepositionItem?.(key, position, oldPosition);
} else {
this.#onReplaceItem?.(key, value, oldValue, position, oldPosition);
}
return true;
}
/**
* Refreshes the DAG by recreating it from the raw data.
*
* @private
*/
#refreshDAG() {
const newDAG = new DAGMap();
for (const [key, { value, before, after }] of this.#rawData) {
newDAG.add(key, value, before, after);
this.#addHandlingCycles(newDAG, key, value, before, after);
}
this.#dag = newDAG;
}

View File

@@ -164,6 +164,12 @@ import { addImageWrapperButton } from "discourse-markdown-it/features/image-cont
import { CUSTOM_USER_SEARCH_OPTIONS } from "select-kit/components/user-chooser";
import { modifySelectKit } from "select-kit/mixins/plugin-api";
const DEPRECATED_POST_MENU_WIDGETS = [
"post-menu",
"post-user-tip-shim",
"small-user-list",
];
const appliedModificationIds = new WeakMap();
// This helper prevents us from applying the same `modifyClass` over and over in test mode.
@@ -418,9 +424,10 @@ class PluginApi {
* behavior. Notice that this includes the default behavior and if next() is not called in your transformer's callback
* the default behavior will be completely overridden
* @param {*} [behaviorCallback.context] the optional context in which the behavior is being transformed
* @returns {boolean} True if the transformer exists, false otherwise.
*/
registerBehaviorTransformer(transformerName, behaviorCallback) {
_registerTransformer(
return _registerTransformer(
transformerName,
transformerTypes.BEHAVIOR,
behaviorCallback
@@ -485,9 +492,10 @@ class PluginApi {
* mutating the input value, return the same output for the same input and not have any side effects.
* @param {*} valueCallback.value the value to be transformed
* @param {*} [valueCallback.context] the optional context in which the value is being transformed
* @returns {boolean} True if the transformer exists, false otherwise.
*/
registerValueTransformer(transformerName, valueCallback) {
_registerTransformer(
return _registerTransformer(
transformerName,
transformerTypes.VALUE,
valueCallback
@@ -832,6 +840,14 @@ class PluginApi {
* }
**/
addPostMenuButton(name, callback) {
deprecated(
"`api.addPostMenuButton` has been deprecated. Use the value transformer `post-menu-buttons` instead.",
{
since: "v3.4.0.beta3-dev",
id: "discourse.post-menu-widget-overrides",
}
);
apiExtraButtons[name] = callback;
addButton(name, callback);
}
@@ -902,6 +918,14 @@ class PluginApi {
* ```
**/
removePostMenuButton(name, callback) {
deprecated(
"`api.removePostMenuButton` has been deprecated. Use the value transformer `post-menu-buttons` instead.",
{
since: "v3.4.0.beta3-dev",
id: "discourse.post-menu-widget-overrides",
}
);
removeButton(name, callback);
}
@@ -922,6 +946,14 @@ class PluginApi {
* });
**/
replacePostMenuButton(name, widget) {
deprecated(
"`api.replacePostMenuButton` has been deprecated. Use the value transformer `post-menu-buttons` instead.",
{
since: "v3.4.0.beta3-dev",
id: "discourse.post-menu-widget-overrides",
}
);
replaceButton(name, widget);
}
@@ -3345,6 +3377,16 @@ class PluginApi {
// }
// );
// }
if (DEPRECATED_POST_MENU_WIDGETS.includes(widgetName)) {
deprecated(
`The ${widgetName} widget has been deprecated and ${override} is no longer a supported override.`,
{
since: "v3.4.0.beta3-dev",
id: "discourse.post-menu-widget-overrides",
}
);
}
}
}

View File

@@ -35,9 +35,10 @@ export function recentlyCopied(postId, actionClass) {
);
}
export function showAlert(postId, actionClass, messageKey) {
export function showAlert(postId, actionClass, messageKey, opts = {}) {
const postSelector = `article[data-post-id='${postId}']`;
const actionBtn = document.querySelector(`${postSelector} .${actionClass}`);
const actionBtn =
opts.actionBtn || document.querySelector(`${postSelector} .${actionClass}`);
actionBtn?.classList.add("post-action-feedback-button");

View File

@@ -18,7 +18,7 @@ export function transformBasicPost(post) {
deleted: post.get("deleted"),
deleted_at: post.deleted_at,
user_deleted: post.user_deleted,
isDeleted: post.deleted_at || post.user_deleted,
isDeleted: post.deleted,
deletedByAvatarTemplate: null,
deletedByUsername: null,
primary_group_name: post.primary_group_name,
@@ -124,27 +124,32 @@ export default function transformPost(
postAtts.post_type = postType;
postAtts.via_email = post.via_email;
postAtts.isAutoGenerated = post.is_auto_generated;
postAtts.isModeratorAction = postType === postTypes.moderator_action;
postAtts.isWhisper = postType === postTypes.whisper;
postAtts.isModeratorAction = post.isModeratorAction;
postAtts.isWhisper = post.isWhisper;
postAtts.isSmallAction =
postType === postTypes.small_action || post.action_code === "split_topic";
postAtts.canBookmark = !!currentUser;
postAtts.canManage = currentUser && currentUser.get("canManageTopic");
postAtts.canManage = post.canManage;
postAtts.canViewRawEmail = currentUser && currentUser.can_view_raw_email;
postAtts.canArchiveTopic = !!details.can_archive_topic;
postAtts.canCloseTopic = !!details.can_close_topic;
postAtts.canSplitMergeTopic = !!details.can_split_merge_topic;
postAtts.canEditStaffNotes = !!details.can_edit_staff_notes;
postAtts.canEditStaffNotes = post.canEditStaffNotes;
postAtts.canReplyAsNewTopic = !!details.can_reply_as_new_topic;
postAtts.canReviewTopic = !!details.can_review_topic;
postAtts.canPublishPage =
!!details.can_publish_page && post.post_number === 1;
postAtts.canPublishPage = post.canPublishPage;
postAtts.isWarning = topic.is_warning;
postAtts.links = post.get("internalLinks");
// on the glimmer post menu this logic was implemented on the PostMenu component as it depends on
// two post object instances
// if you change the logic replyDirectlyBelow here, while the widget post menu is still around, please
// be sure to also update the logic on the Glimmer PostMenu component
postAtts.replyDirectlyBelow =
nextPost &&
nextPost.reply_to_post_number === post.post_number &&
post.post_number !== filteredRepliesPostNumber;
postAtts.replyDirectlyAbove =
prevPost &&
post.id !== filteredUpwardsPostID &&
@@ -205,9 +210,10 @@ export default function transformPost(
});
}
postAtts.liked = post.liked;
const likeAction = post.likeAction;
if (likeAction) {
postAtts.liked = likeAction.acted;
postAtts.canToggleLike = likeAction.get("canToggle");
postAtts.showLike = postAtts.liked || postAtts.canToggleLike;
postAtts.likeCount = likeAction.count;
@@ -218,12 +224,14 @@ export default function transformPost(
postAtts.showLike = true;
}
postAtts.canDelete = post.canDelete;
postAtts.canDeleteTopic = post.canDeleteTopic;
postAtts.canPermanentlyDelete = post.canPermanentlyDelete;
postAtts.canRecover = post.canRecover;
postAtts.canRecoverTopic = post.canRecoverTopic;
if (postAtts.post_number === 1) {
postAtts.canRecoverTopic = postAtts.isDeleted && details.can_recover;
postAtts.canDeleteTopic = !postAtts.isDeleted && details.can_delete;
postAtts.expandablePost = topic.expandable_first_post;
postAtts.canPermanentlyDelete =
postAtts.isDeleted && details.can_permanently_delete;
// Show a "Flag to delete" message if not staff and you can't
// otherwise delete it.
@@ -234,14 +242,11 @@ export default function transformPost(
currentUser &&
!currentUser.staff;
} else {
postAtts.canRecover = postAtts.isDeleted && postAtts.canRecover;
postAtts.canDelete =
postAtts.canDelete &&
!post.deleted_at &&
currentUser &&
(currentUser.staff || !post.user_deleted);
postAtts.canPermanentlyDelete =
postAtts.isDeleted && post.can_permanently_delete;
}
_additionalAttributes.forEach((a) => (postAtts[a] = post[a]));

View File

@@ -152,6 +152,7 @@ export function _addTransformerName(name, transformerType) {
* @param {string} transformerName the name of the transformer
* @param {string} transformerType the type of the transformer being registered
* @param {function} callback callback that will transform the value.
* @returns {boolean} True if the transformer exists, false otherwise.
*/
export function _registerTransformer(
transformerName,
@@ -183,6 +184,8 @@ export function _registerTransformer(
`${prefix}: transformer "${transformerName}" is unknown and will be ignored. ` +
"Is the name correct? Are you using the correct API for the transformer type?"
);
return false;
}
if (typeof callback !== "function") {
@@ -197,6 +200,8 @@ export function _registerTransformer(
existingTransformers.push(callback);
transformersRegistry.set(normalizedTransformerName, existingTransformers);
return true;
}
export function applyBehaviorTransformer(

View File

@@ -12,4 +12,5 @@ export const VALUE_TRANSFORMERS = Object.freeze([
"home-logo-image-url",
"mentions-class",
"more-topics-tabs",
"post-menu-buttons",
]);

View File

@@ -1,5 +1,6 @@
import { tracked } from "@glimmer/tracking";
import EmberObject, { get } from "@ember/object";
import { and, equal, not, or } from "@ember/object/computed";
import { alias, and, equal, not, or } from "@ember/object/computed";
import { service } from "@ember/service";
import { isEmpty } from "@ember/utils";
import { Promise } from "rsvp";
@@ -104,14 +105,29 @@ export default class Post extends RestModel {
}
@service currentUser;
@service site;
@tracked bookmarked;
@tracked can_delete;
@tracked can_edit;
@tracked can_permanently_delete;
@tracked can_recover;
@tracked deleted_at;
@tracked likeAction;
@tracked post_type;
@tracked user_deleted;
@tracked user_id;
@tracked yours;
customShare = null;
@alias("can_edit") canEdit; // for compatibility with existing code
@equal("trust_level", 0) new_user;
@equal("post_number", 1) firstPost;
@or("deleted_at", "deletedViaTopic") deleted;
@not("deleted") notDeleted;
@propertyEqual("topic.details.created_by.id", "user_id") topicOwner;
@alias("topic.details.created_by.id") topicCreatedById;
// Posts can show up as deleted if the topic is deleted
@and("firstPost", "topic.deleted_at") deletedViaTopic;
@@ -200,6 +216,112 @@ export default class Post extends RestModel {
return fancyTitle(title, this.siteSettings.support_mixed_text_direction);
}
get canBookmark() {
return !!this.currentUser;
}
get canDelete() {
return (
this.can_delete &&
!this.deleted_at &&
this.currentUser &&
(this.currentUser.staff || !this.user_deleted)
);
}
get canDeleteTopic() {
return this.firstPost && !this.deleted && this.topic.details.can_delete;
}
get canEditStaffNotes() {
return !!this.topic.details.can_edit_staff_notes;
}
get canFlag() {
return !this.topic.deleted && !isEmpty(this.flagsAvailable);
}
get canManage() {
return this.currentUser?.canManageTopic;
}
get canPermanentlyDelete() {
return (
this.deleted &&
(this.firstPost
? this.topic.details.can_permanently_delete
: this.can_permanently_delete)
);
}
get canPublishPage() {
return this.firstPost && !!this.topic.details.can_publish_page;
}
get canRecover() {
return this.deleted && this.can_recover;
}
get isRecovering() {
return !this.deleted && this.can_recover;
}
get canRecoverTopic() {
return this.firstPost && this.deleted && this.topic.details.can_recover;
}
get isRecoveringTopic() {
return this.firstPost && !this.deleted && this.topic.details.can_recover;
}
get canToggleLike() {
return !!this.likeAction?.get("canToggle");
}
get filteredRepliesPostNumber() {
return this.topic.get("postStream.filterRepliesToPostNumber");
}
get isWhisper() {
return this.post_type === this.site.post_types.whisper;
}
get isModeratorAction() {
return this.post_type === this.site.post_types.moderator_action;
}
get liked() {
return !!this.likeAction?.get("acted");
}
get likeCount() {
return this.likeAction?.get("count");
}
/**
* Show a "Flag to delete" message if not staff and you can't otherwise delete it.
*/
get showFlagDelete() {
return (
!this.canDelete &&
this.yours &&
this.canFlag &&
this.currentUser &&
!this.currentUser.staff
);
}
get showLike() {
if (
!this.currentUser ||
(this.topic?.get("archived") && this.user_id !== this.currentUser.id)
) {
return true;
}
return this.likeAction && (this.liked || this.canToggleLike);
}
afterUpdate(res) {
if (res.category) {
this.site.updateCategory(res.category);
@@ -277,9 +399,9 @@ export default class Post extends RestModel {
}
/**
Changes the state of the post to be deleted. Does not call the server, that should be
done elsewhere.
**/
Changes the state of the post to be deleted. Does not call the server, that should be
done elsewhere.
**/
setDeletedState(deletedBy) {
let promise;
this.set("oldCooked", this.cooked);
@@ -316,10 +438,10 @@ export default class Post extends RestModel {
}
/**
Changes the state of the post to NOT be deleted. Does not call the server.
This can only be called after setDeletedState was called, but the delete
failed on the server.
**/
Changes the state of the post to NOT be deleted. Does not call the server.
This can only be called after setDeletedState was called, but the delete
failed on the server.
**/
undoDeleteState() {
if (this.oldCooked) {
this.setProperties({
@@ -344,9 +466,9 @@ export default class Post extends RestModel {
}
/**
Updates a post from another's attributes. This will normally happen when a post is loading but
is already found in an identity map.
**/
Updates a post from another's attributes. This will normally happen when a post is loading but
is already found in an identity map.
**/
updateFromPost(otherPost) {
Object.keys(otherPost).forEach((key) => {
let value = otherPost[key],

View File

@@ -1,3 +1,4 @@
import { tracked } from "@glimmer/tracking";
import EmberObject from "@ember/object";
import { service } from "@ember/service";
import { ajax } from "discourse/lib/ajax";
@@ -11,6 +12,13 @@ import RestModel from "discourse/models/rest";
export default class TopicDetails extends RestModel {
@service store;
@tracked can_delete;
@tracked can_edit_staff_notes;
@tracked can_permanently_delete;
@tracked can_publish_page;
@tracked created_by;
@tracked notification_level;
loaded = false;
updateFromJson(details) {

View File

@@ -1,40 +1,23 @@
import { hbs } from "ember-cli-htmlbars";
import RenderGlimmer, {
registerWidgetShim,
} from "discourse/widgets/render-glimmer";
import { createWidget } from "discourse/widgets/widget";
import { registerWidgetShim } from "discourse/widgets/render-glimmer";
createWidget("small-user-list", {
tagName: "div.clearfix.small-user-list",
buildClasses(atts) {
return atts.listClassName;
},
buildAttributes(attrs) {
const attributes = { role: "list" };
if (attrs.ariaLabel) {
attributes["aria-label"] = attrs.ariaLabel;
}
return attributes;
},
html(attrs) {
return [
new RenderGlimmer(
this,
"span.small-user-list-content",
hbs`<SmallUserList @data={{@data.attrs}}/>`,
{
attrs,
}
),
];
},
});
// This shim is nesting everything into a DIV and changing the HTML but only thw two voting plugins
// are using this widget outside of core.
registerWidgetShim(
"small-user-list",
"div",
hbs`
<SmallUserList class={{@data.listClassName}}
@ariaLabel={{@data.ariaLabel}}
@users={{@data.users}}
@addSelf={{@data.addSelf}}
@count={{@data.count}}
@description={{@data.description}}/>`
);
registerWidgetShim(
"actions-summary",
"section.post-actions",
hbs`<ActionsSummary @data={{@data}} /> `
hbs`
<ActionsSummary @data={{@data}} /> `
);

View File

@@ -11,7 +11,9 @@ import {
NO_REMINDER_ICON,
WITH_REMINDER_ICON,
} from "discourse/models/bookmark";
import RenderGlimmer from "discourse/widgets/render-glimmer";
import RenderGlimmer, {
registerWidgetShim,
} from "discourse/widgets/render-glimmer";
import { applyDecorators, createWidget } from "discourse/widgets/widget";
import discourseLater from "discourse-common/lib/later";
import I18n from "discourse-i18n";
@@ -944,3 +946,40 @@ export default createWidget("post-menu", {
}
},
});
// TODO (glimmer-post-menu): Once this widget is removed the `<section>...</section>` tag needs to be added to the PostMenu component
registerWidgetShim(
"glimmer-post-menu",
"section.post-menu-area.clearfix",
hbs`
<Post::Menu
@canCreatePost={{@data.canCreatePost}}
@filteredRepliesView={{@data.filteredRepliesView}}
@nextPost={{@data.nextPost}}
@post={{@data.post}}
@prevPost={{@data.prevPost}}
@repliesShown={{@data.repliesShown}}
@showReadIndicator={{@data.showReadIndicator}}
@changeNotice={{@data.changeNotice}}
@changePostOwner={{@data.changePostOwner}}
@copyLink={{@data.copyLink}}
@deletePost={{@data.deletePost}}
@editPost={{@data.editPost}}
@grantBadge={{@data.grantBadge}}
@lockPost={{@data.lockPost}}
@permanentlyDeletePost={{@data.permanentlyDeletePost}}
@rebakePost={{@data.rebakePost}}
@recoverPost={{@data.recoverPost}}
@replyToPost={{@data.replyToPost}}
@share={{@data.share}}
@showFlags={{@data.showFlags}}
@showLogin={{@data.showLogin}}
@showPagePublish={{@data.showPagePublish}}
@toggleLike={{@data.toggleLike}}
@togglePostType={{@data.togglePostType}}
@toggleReplies={{@data.toggleReplies}}
@toggleWiki={{@data.toggleWiki}}
@unhidePost={{@data.unhidePost}}
@unlockPost={{@data.unlockPost}}
/>`
);

View File

@@ -13,6 +13,7 @@ import { iconNode } from "discourse-common/lib/icon-library";
import I18n from "discourse-i18n";
let transformCallbacks = null;
export function postTransformCallbacks(transformed) {
if (transformCallbacks === null) {
return;
@@ -22,6 +23,7 @@ export function postTransformCallbacks(transformed) {
transformCallbacks[i].call(this, transformed);
}
}
export function addPostTransformCallback(callback) {
transformCallbacks = transformCallbacks || [];
transformCallbacks.push(callback);
@@ -223,6 +225,8 @@ export default createWidget("post-stream", {
nextPost
);
transformed.canCreatePost = attrs.canCreatePost;
transformed.prevPost = prevPost;
transformed.nextPost = nextPost;
transformed.mobileView = mobileView;
if (transformed.canManage || transformed.canSplitMergeTopic) {
@@ -258,7 +262,8 @@ export default createWidget("post-stream", {
new RenderGlimmer(
this,
"div.time-gap.small-action",
hbs`<TimeGap @daysSince={{@data.daysSince}} />`,
hbs`
<TimeGap @daysSince={{@data.daysSince}} />`,
{ daysSince }
)
);
@@ -277,6 +282,9 @@ export default createWidget("post-stream", {
);
} else {
transformed.showReadIndicator = attrs.showReadIndicator;
// The following properties will have to be untangled from the transformed model when
// converting this widget to a Glimmer component:
// canCreatePost, showReadIndicator, prevPost, nextPost
result.push(this.attach("post", transformed, { model: post }));
}

View File

@@ -12,6 +12,7 @@ import {
prioritizeNameFallback,
prioritizeNameInUx,
} from "discourse/lib/settings";
import { consolePrefix } from "discourse/lib/source-identifier";
import { transformBasicPost } from "discourse/lib/transform-post";
import DiscourseURL from "discourse/lib/url";
import { clipboardCopy, formatUsername } from "discourse/lib/utilities";
@@ -24,6 +25,7 @@ import RenderGlimmer from "discourse/widgets/render-glimmer";
import { applyDecorators, createWidget } from "discourse/widgets/widget";
import { isTesting } from "discourse-common/config/environment";
import { avatarUrl, translateSize } from "discourse-common/lib/avatar-utils";
import { registerDeprecationHandler } from "discourse-common/lib/deprecated";
import getURL, {
getAbsoluteURL,
getURLWithCDN,
@@ -41,6 +43,19 @@ function transformWithCallbacks(post, topicUrl, store) {
return transformed;
}
let postMenuWidgetExtensionsAdded = null;
let postMenuConsoleWarningLogged = false;
registerDeprecationHandler((_, opts) => {
if (opts?.id === "discourse.post-menu-widget-overrides") {
if (!postMenuWidgetExtensionsAdded) {
postMenuWidgetExtensionsAdded = new Set();
}
postMenuWidgetExtensionsAdded.add(consolePrefix().slice(1, -1));
}
});
export function avatarImg(wanted, attrs) {
const size = translateSize(wanted);
const url = avatarUrl(attrs.template, size);
@@ -532,7 +547,94 @@ createWidget("post-contents", {
filteredRepliesShown: state.filteredRepliesShown,
},
};
result.push(this.attach("post-menu", attrs, extraState));
if (
this.siteSettings.glimmer_post_menu_mode === "enabled" ||
((this.siteSettings.glimmer_post_menu_mode === "auto" ||
this.currentUser?.use_auto_glimmer_post_menu) &&
!postMenuWidgetExtensionsAdded)
) {
if (!postMenuConsoleWarningLogged) {
if (postMenuWidgetExtensionsAdded) {
postMenuConsoleWarningLogged = true;
// eslint-disable-next-line no-console
console.warn(
[
"Using the new 'glimmer' post menu, even though there are themes and/or plugins using deprecated APIs (glimmer_post_menu_mode = enabled).\n" +
"The following plugins and/or themes are using deprecated APIs, their post menu customizations are broken and may cause your site to not work properly:",
...Array.from(postMenuWidgetExtensionsAdded).sort(),
// TODO (glimmer-post-menu): add link to meta topic here when the roadmap for the update is announced
].join("\n- ")
);
} else if (this.currentUser?.use_auto_glimmer_post_menu) {
// TODO (glimmer-post-menu): remove this else if block when removing the site setting glimmer_post_menu_groups
postMenuConsoleWarningLogged = true;
// eslint-disable-next-line no-console
console.log("✅ Using the new 'glimmer' post menu!");
}
}
const filteredRepliesView =
this.siteSettings.enable_filtered_replies_view;
result.push(
this.attach("glimmer-post-menu", {
canCreatePost: attrs.canCreatePost,
filteredRepliesView,
nextPost: attrs.nextPost,
post: this.findAncestorModel(),
prevPost: attrs.prevPost,
repliesShown: filteredRepliesView
? extraState.state.filteredRepliesShown
: extraState.state.repliesShown,
showReadIndicator: attrs.showReadIndicator,
changeNotice: () => this.sendWidgetAction("changeNotice"), // this action comes from the post stream
changePostOwner: () => this.sendWidgetAction("changePostOwner"), // this action comes from the post stream
copyLink: () => this.sendWidgetAction("copyLink"),
deletePost: () => this.sendWidgetAction("deletePost"), // this action comes from the post stream
editPost: () => this.sendWidgetAction("editPost"), // this action comes from the post stream
grantBadge: () => this.sendWidgetAction("grantBadge"), // this action comes from the post stream
lockPost: () => this.sendWidgetAction("lockPost"), // this action comes from the post stream
permanentlyDeletePost: () =>
this.sendWidgetAction("permanentlyDeletePost"),
rebakePost: () => this.sendWidgetAction("rebakePost"), // this action comes from the post stream
recoverPost: () => this.sendWidgetAction("recoverPost"), // this action comes from the post stream
replyToPost: () => this.sendWidgetAction("replyToPost"), // this action comes from the post stream
share: () => this.sendWidgetAction("share"),
showFlags: () => this.sendWidgetAction("showFlags"), // this action comes from the post stream
showLogin: () => this.sendWidgetAction("showLogin"), // this action comes from application route
showPagePublish: () => this.sendWidgetAction("showPagePublish"), // this action comes from the post stream
toggleLike: () => this.sendWidgetAction("toggleLike"),
togglePostType: () => this.sendWidgetAction("togglePostType"), // this action comes from the post stream
toggleReplies: filteredRepliesView
? () => this.sendWidgetAction("toggleFilteredRepliesView")
: () => this.sendWidgetAction("toggleRepliesBelow"),
toggleWiki: () => this.sendWidgetAction("toggleWiki"), // this action comes from the post stream
unhidePost: () => this.sendWidgetAction("unhidePost"), // this action comes from the post stream
unlockPost: () => this.sendWidgetAction("unlockPost"), // this action comes from the post stream
})
);
} else {
if (
(this.siteSettings.glimmer_post_menu_mode !== "disabled" ||
this.currentUser?.use_auto_glimmer_post_menu) &&
postMenuWidgetExtensionsAdded &&
!postMenuConsoleWarningLogged
) {
postMenuConsoleWarningLogged = true;
// eslint-disable-next-line no-console
console.warn(
[
"Using the legacy 'widget' post menu because the following plugins and/or themes are using deprecated APIs:",
...Array.from(postMenuWidgetExtensionsAdded).sort(),
// TODO (glimmer-post-menu): add link to meta topic here when the roadmap for the update is announced
].join("\n- ")
);
}
result.push(this.attach("post-menu", attrs, extraState));
}
const repliesBelow = state.repliesBelow;
if (repliesBelow.length) {
@@ -942,6 +1044,7 @@ createWidget("post-article", {
});
let addPostClassesCallbacks = null;
export function addPostClassesCallback(callback) {
addPostClassesCallbacks = addPostClassesCallbacks || [];
addPostClassesCallbacks.push(callback);
@@ -1025,15 +1128,15 @@ export default createWidget("post", {
return [this.attach("post-article", attrs)];
},
toggleLike() {
async toggleLike() {
const post = this.model;
const likeAction = post.get("likeAction");
if (likeAction && likeAction.get("canToggle")) {
return likeAction.togglePromise(post).then((result) => {
this.appEvents.trigger("page:like-toggled", post, likeAction);
return this._warnIfClose(result);
});
const result = await likeAction.togglePromise(post);
this.appEvents.trigger("page:like-toggled", post, likeAction);
return this._warnIfClose(result);
}
},

View File

@@ -24,10 +24,10 @@ acceptance("Post controls", function () {
.hasAria("pressed", "true", "show likes button is now pressed");
assert
.dom("#post_2 .small-user-list.who-liked")
.dom("#post_2 .small-user-list.who-liked .small-user-list-content")
.hasAttribute("role", "list", "likes container has list role");
assert
.dom("#post_2 .small-user-list.who-liked")
.dom("#post_2 .small-user-list.who-liked .small-user-list-content")
.hasAria(
"label",
I18n.t("post.actions.people.sr_post_likers_list_description"),

View File

@@ -6,6 +6,7 @@ import { withPluginApi } from "discourse/lib/plugin-api";
import { setupRenderingTest } from "discourse/tests/helpers/component-test";
import { resetPostMenuExtraButtons } from "discourse/widgets/post-menu";
import { createWidget } from "discourse/widgets/widget";
import { withSilencedDeprecations } from "discourse-common/lib/deprecated";
module("Integration | Component | Widget | post-menu", function (hooks) {
setupRenderingTest(hooks);
@@ -17,18 +18,21 @@ module("Integration | Component | Widget | post-menu", function (hooks) {
test("add extra button", async function (assert) {
this.set("args", {});
withPluginApi("0.14.0", (api) => {
api.addPostMenuButton("coffee", () => {
return {
action: "drinkCoffee",
icon: "mug-saucer",
className: "hot-coffee",
title: "coffee.title",
position: "first",
};
withSilencedDeprecations("discourse.post-menu-widget-overrides", () => {
api.addPostMenuButton("coffee", () => {
return {
action: "drinkCoffee",
icon: "mug-saucer",
className: "hot-coffee",
title: "coffee.title",
position: "first",
};
});
});
});
await render(hbs`<MountWidget @widget="post-menu" @args={{this.args}} />`);
await render(hbs`
<MountWidget @widget="post-menu" @args={{this.args}} />`);
assert
.dom(".actions .extra-buttons .hot-coffee")
@@ -41,25 +45,27 @@ module("Integration | Component | Widget | post-menu", function (hooks) {
let testPost = null;
withPluginApi("0.14.0", (api) => {
api.addPostMenuButton("coffee", () => {
return {
action: ({ post, showFeedback }) => {
testPost = post;
showFeedback("coffee.drink");
},
icon: "mug-saucer",
className: "hot-coffee",
title: "coffee.title",
position: "first",
actionParam: { id: 123 }, // hack for testing
};
withSilencedDeprecations("discourse.post-menu-widget-overrides", () => {
api.addPostMenuButton("coffee", () => {
return {
action: ({ post, showFeedback }) => {
testPost = post;
showFeedback("coffee.drink");
},
icon: "mug-saucer",
className: "hot-coffee",
title: "coffee.title",
position: "first",
actionParam: { id: 123 }, // hack for testing
};
});
});
});
await render(hbs`
<article data-post-id="123">
<MountWidget @widget="post-menu" @args={{this.args}} />
</article>`);
<article data-post-id="123">
<MountWidget @widget="post-menu" @args={{this.args}} />
</article>`);
await click(".hot-coffee");
@@ -75,12 +81,15 @@ module("Integration | Component | Widget | post-menu", function (hooks) {
this.set("args", { canCreatePost: true, canRemoveReply: true });
withPluginApi("0.14.0", (api) => {
api.removePostMenuButton("reply", (attrs) => {
return attrs.canRemoveReply;
withSilencedDeprecations("discourse.post-menu-widget-overrides", () => {
api.removePostMenuButton("reply", (attrs) => {
return attrs.canRemoveReply;
});
});
});
await render(hbs`<MountWidget @widget="post-menu" @args={{this.args}} />`);
await render(hbs`
<MountWidget @widget="post-menu" @args={{this.args}} />`);
assert.dom(".actions .reply").doesNotExist("removes reply button");
});
@@ -89,12 +98,15 @@ module("Integration | Component | Widget | post-menu", function (hooks) {
this.set("args", { canCreatePost: true, canRemoveReply: false });
withPluginApi("0.14.0", (api) => {
api.removePostMenuButton("reply", (attrs) => {
return attrs.canRemoveReply;
withSilencedDeprecations("discourse.post-menu-widget-overrides", () => {
api.removePostMenuButton("reply", (attrs) => {
return attrs.canRemoveReply;
});
});
});
await render(hbs`<MountWidget @widget="post-menu" @args={{this.args}} />`);
await render(hbs`
<MountWidget @widget="post-menu" @args={{this.args}} />`);
assert.dom(".actions .reply").exists("does not remove reply button");
});
@@ -103,10 +115,13 @@ module("Integration | Component | Widget | post-menu", function (hooks) {
this.set("args", { canCreatePost: true });
withPluginApi("0.14.0", (api) => {
api.removePostMenuButton("reply");
withSilencedDeprecations("discourse.post-menu-widget-overrides", () => {
api.removePostMenuButton("reply");
});
});
await render(hbs`<MountWidget @widget="post-menu" @args={{this.args}} />`);
await render(hbs`
<MountWidget @widget="post-menu" @args={{this.args}} />`);
assert.dom(".actions .reply").doesNotExist("removes reply button");
});
@@ -115,11 +130,14 @@ module("Integration | Component | Widget | post-menu", function (hooks) {
this.set("args", {});
withPluginApi("0.14.0", (api) => {
api.removePostMenuButton("reply", () => true);
api.removePostMenuButton("reply", () => false);
withSilencedDeprecations("discourse.post-menu-widget-overrides", () => {
api.removePostMenuButton("reply", () => true);
api.removePostMenuButton("reply", () => false);
});
});
await render(hbs`<MountWidget @widget="post-menu" @args={{this.args}} />`);
await render(hbs`
<MountWidget @widget="post-menu" @args={{this.args}} />`);
assert.dom(".actions .reply").doesNotExist("removes reply button");
});
@@ -134,16 +152,19 @@ module("Integration | Component | Widget | post-menu", function (hooks) {
this.set("args", { id: 1, canCreatePost: true });
withPluginApi("0.14.0", (api) => {
api.replacePostMenuButton("reply", {
name: "post-menu-replacement",
buildAttrs: (widget) => {
return widget.attrs;
},
shouldRender: (widget) => widget.attrs.id === 1, // true!
withSilencedDeprecations("discourse.post-menu-widget-overrides", () => {
api.replacePostMenuButton("reply", {
name: "post-menu-replacement",
buildAttrs: (widget) => {
return widget.attrs;
},
shouldRender: (widget) => widget.attrs.id === 1, // true!
});
});
});
await render(hbs`<MountWidget @widget="post-menu" @args={{this.args}} />`);
await render(hbs`
<MountWidget @widget="post-menu" @args={{this.args}} />`);
assert.dom("h1.post-menu-replacement").exists("replacement is rendered");
assert
@@ -155,16 +176,19 @@ module("Integration | Component | Widget | post-menu", function (hooks) {
this.set("args", { id: 1, canCreatePost: true, canRemoveReply: false });
withPluginApi("0.14.0", (api) => {
api.replacePostMenuButton("reply", {
name: "post-menu-replacement",
buildAttrs: (widget) => {
return widget.attrs;
},
shouldRender: (widget) => widget.attrs.id === 102323948, // false!
withSilencedDeprecations("discourse.post-menu-widget-overrides", () => {
api.replacePostMenuButton("reply", {
name: "post-menu-replacement",
buildAttrs: (widget) => {
return widget.attrs;
},
shouldRender: (widget) => widget.attrs.id === 102323948, // false!
});
});
});
await render(hbs`<MountWidget @widget="post-menu" @args={{this.args}} />`);
await render(hbs`
<MountWidget @widget="post-menu" @args={{this.args}} />`);
assert
.dom("h1.post-menu-replacement")

View File

@@ -5,6 +5,7 @@ import { module, test } from "qunit";
import { withPluginApi } from "discourse/lib/plugin-api";
import { setupRenderingTest } from "discourse/tests/helpers/component-test";
import { resetPostMenuExtraButtons } from "discourse/widgets/post-menu";
import { withSilencedDeprecations } from "discourse-common/lib/deprecated";
function postStreamTest(name, attrs) {
test(name, async function (assert) {
@@ -30,15 +31,17 @@ module("Integration | Component | Widget | post-stream", function (hooks) {
postStreamTest("extensibility", {
posts() {
withPluginApi("0.14.0", (api) => {
api.addPostMenuButton("coffee", (transformedPost) => {
lastTransformedPost = transformedPost;
return {
action: "drinkCoffee",
icon: "mug-saucer",
className: "hot-coffee",
title: "coffee.title",
position: "first",
};
withSilencedDeprecations("discourse.post-menu-widget-overrides", () => {
api.addPostMenuButton("coffee", (transformedPost) => {
lastTransformedPost = transformedPost;
return {
action: "drinkCoffee",
icon: "mug-saucer",
className: "hot-coffee",
title: "coffee.title",
position: "first",
};
});
});
});

View File

@@ -0,0 +1,809 @@
import EmberObject from "@ember/object";
import { getOwner } from "@ember/owner";
import { click, render, triggerEvent } from "@ember/test-helpers";
import { hbs } from "ember-cli-htmlbars";
import { module, test } from "qunit";
import { setupRenderingTest } from "discourse/tests/helpers/component-test";
import {
count,
exists,
query,
queryAll,
} from "discourse/tests/helpers/qunit-helpers";
import I18n from "discourse-i18n";
module(
"Integration | Component | Widget | post with glimmer-post-menu",
function (hooks) {
setupRenderingTest(hooks);
hooks.beforeEach(function () {
this.siteSettings.glimmer_post_menu_mode = "enabled";
this.siteSettings.post_menu_hidden_items = "";
const store = getOwner(this).lookup("service:store");
const topic = store.createRecord("topic", { id: 123 });
const post = store.createRecord("post", {
id: 1,
post_number: 1,
topic,
like_count: 3,
actions_summary: [{ id: 2, count: 1, hidden: false, can_act: true }],
});
this.set("post", post);
this.set("args", {});
});
test("basic elements", async function (assert) {
const store = getOwner(this).lookup("service:store");
const topic = store.createRecord("topic", {
id: 123,
archetype: "regular",
});
this.set("args", { shareUrl: "/example", post_number: 1, topic });
await render(hbs`
<MountWidget @widget="post"
@model={{this.post}}
@args={{this.args}} />`);
assert.ok(exists(".names"), "includes poster name");
assert.ok(exists("a.post-date"), "includes post date");
});
test("post - links", async function (assert) {
this.set("args", {
cooked:
"<a href='http://link1.example.com/'>first link</a> and <a href='http://link2.example.com/?some=query'>second link</a>",
linkCounts: [
{ url: "http://link1.example.com/", clicks: 1, internal: true },
{ url: "http://link2.example.com/", clicks: 2, internal: true },
],
});
await render(
hbs`
<MountWidget @widget="post-contents" @model={{this.post}} @args={{this.args}} />`
);
assert.strictEqual(
queryAll("a[data-clicks='1']")[0].getAttribute("data-clicks"),
"1"
);
assert.strictEqual(
queryAll("a[data-clicks='2']")[0].getAttribute("data-clicks"),
"2"
);
});
test("post - onebox links", async function (assert) {
this.set("args", {
cooked: `
<p><a href="https://example.com">Other URL</a></p>
<aside class="onebox twitterstatus" data-onebox-src="https://twitter.com/codinghorror">
<header class="source">
<a href="https://twitter.com/codinghorror" target="_blank" rel="noopener">twitter.com</a>
</header>
<article class="onebox-body">
<h4><a href="https://twitter.com/codinghorror" target="_blank" rel="noopener">Jeff Atwood</a></h4>
<div class="twitter-screen-name"><a href="https://twitter.com/codinghorror" target="_blank" rel="noopener">@codinghorror</a></div>
</article>
</aside>`,
linkCounts: [
{ url: "https://example.com", clicks: 1 },
{ url: "https://twitter.com/codinghorror", clicks: 2 },
],
});
await render(
hbs`
<MountWidget @widget="post-contents" @model={{this.post}} @args={{this.args}} />`
);
assert.strictEqual(
queryAll("a[data-clicks='1']")[0].getAttribute("data-clicks"),
"1",
"First link has correct data attribute and content"
);
assert.strictEqual(
queryAll("a[data-clicks='2']")[0].getAttribute("data-clicks"),
"2",
"Second link has correct data attribute and content"
);
});
test("wiki", async function (assert) {
this.set("args", { wiki: true, version: 2, canViewEditHistory: true });
this.set("showHistory", () => (this.historyShown = true));
await render(hbs`
<MountWidget @widget="post" @model={{this.post}} @args={{this.args}}
@showHistory={{this.showHistory}} />
`);
await click(".post-info .wiki");
assert.ok(
this.historyShown,
"clicking the wiki icon displays the post history"
);
});
test("wiki without revision", async function (assert) {
this.set("args", { wiki: true, version: 1, canViewEditHistory: true });
this.set("editPost", () => (this.editPostCalled = true));
await render(hbs`
<MountWidget @widget="post" @model={{this.post}} @args={{this.args}}
@editPost={{this.editPost}} />
`);
await click(".post-info .wiki");
assert.ok(this.editPostCalled, "clicking wiki icon edits the post");
});
test("via-email", async function (assert) {
this.set("args", { via_email: true, canViewRawEmail: true });
this.set("showRawEmail", () => (this.rawEmailShown = true));
await render(
hbs`
<MountWidget @widget="post" @model={{this.post}} @args={{this.args}} @showRawEmail={{this.showRawEmail}} />`
);
await click(".post-info.via-email");
assert.ok(
this.rawEmailShown,
"clicking the envelope shows the raw email"
);
});
test("via-email without permission", async function (assert) {
this.set("args", { via_email: true, canViewRawEmail: false });
this.set("showRawEmail", () => (this.rawEmailShown = true));
await render(
hbs`
<MountWidget @widget="post" @model={{this.post}} @args={{this.args}} @showRawEmail={{this.showRawEmail}} />`
);
await click(".post-info.via-email");
assert.ok(
!this.rawEmailShown,
"clicking the envelope doesn't show the raw email"
);
});
test("history", async function (assert) {
this.set("args", { version: 3, canViewEditHistory: true });
this.set("showHistory", () => (this.historyShown = true));
await render(
hbs`
<MountWidget @widget="post" @model={{this.post}} @args={{this.args}} @showHistory={{this.showHistory}} />`
);
await click(".post-info.edits button");
assert.ok(this.historyShown, "clicking the pencil shows the history");
});
test("history without view permission", async function (assert) {
this.set("args", { version: 3, canViewEditHistory: false });
this.set("showHistory", () => (this.historyShown = true));
await render(
hbs`
<MountWidget @widget="post" @model={{this.post}} @args={{this.args}} @showHistory={{this.showHistory}} />`
);
await click(".post-info.edits");
assert.ok(
!this.historyShown,
`clicking the pencil doesn't show the history`
);
});
test("whisper", async function (assert) {
this.set("args", { isWhisper: true });
await render(hbs`
<MountWidget @widget="post" @model={{this.post}} @args={{this.args}} />`);
assert.strictEqual(count(".topic-post.whisper"), 1);
assert.strictEqual(count(".post-info.whisper"), 1);
});
test(`read indicator`, async function (assert) {
this.set("args", { read: true });
await render(hbs`
<MountWidget @widget="post" @model={{this.post}} @args={{this.args}} />`);
assert.ok(exists(".read-state.read"));
});
test(`unread indicator`, async function (assert) {
this.set("args", { read: false });
await render(hbs`
<MountWidget @widget="post" @model={{this.post}} @args={{this.args}} />`);
assert.ok(exists(".read-state"));
});
test("reply directly above (suppressed)", async function (assert) {
this.set("args", {
replyToUsername: "eviltrout",
replyToAvatarTemplate: "/images/avatar.png",
replyDirectlyAbove: true,
});
await render(hbs`
<MountWidget @widget="post" @model={{this.post}} @args={{this.args}} />`);
assert.ok(!exists("a.reply-to-tab"), "hides the tab");
assert.ok(!exists(".avoid-tab"), "doesn't have the avoid tab class");
});
test("reply a few posts above (suppressed)", async function (assert) {
this.set("args", {
replyToUsername: "eviltrout",
replyToAvatarTemplate: "/images/avatar.png",
replyDirectlyAbove: false,
});
await render(hbs`
<MountWidget @widget="post" @model={{this.post}} @args={{this.args}} />`);
assert.ok(exists("a.reply-to-tab"), "shows the tab");
assert.strictEqual(count(".avoid-tab"), 1, "has the avoid tab class");
});
test("reply directly above", async function (assert) {
this.set("args", {
replyToUsername: "eviltrout",
replyToAvatarTemplate: "/images/avatar.png",
replyDirectlyAbove: true,
});
this.siteSettings.suppress_reply_directly_above = false;
await render(hbs`
<MountWidget @widget="post" @model={{this.post}} @args={{this.args}} />`);
assert.strictEqual(count(".avoid-tab"), 1, "has the avoid tab class");
await click("a.reply-to-tab");
assert.strictEqual(count("section.embedded-posts.top .cooked"), 1);
assert.strictEqual(count("section.embedded-posts .d-icon-arrow-up"), 1);
});
test("cooked content hidden", async function (assert) {
this.set("args", { cooked_hidden: true, canSeeHiddenPost: true });
this.set("expandHidden", () => (this.unhidden = true));
await render(
hbs`
<MountWidget @widget="post" @model={{this.post}} @args={{this.args}} @expandHidden={{this.expandHidden}} />`
);
await click(".topic-body .expand-hidden");
assert.ok(this.unhidden, "triggers the action");
});
test(`cooked content hidden - can't view hidden post`, async function (assert) {
this.set("args", { cooked_hidden: true, canSeeHiddenPost: false });
this.set("expandHidden", () => (this.unhidden = true));
await render(
hbs`
<MountWidget @widget="post" @model={{this.post}} @args={{this.args}} @expandHidden={{this.expandHidden}} />`
);
assert.ok(
!exists(".topic-body .expand-hidden"),
"button is not displayed"
);
});
test("expand first post", async function (assert) {
this.set("args", { expandablePost: true });
await render(
hbs`
<MountWidget @widget="post" @model={{this.post}} @args={{this.args}} />`
);
await click(".topic-body .expand-post");
assert.ok(!exists(".expand-post"), "button is gone");
});
test("can't show admin menu when you can't manage", async function (assert) {
this.set("args", { canManage: false });
await render(hbs`
<MountWidget @widget="post" @model={{this.post}} @args={{this.args}} />`);
assert.ok(!exists(".post-menu-area .show-post-admin-menu"));
});
test("show admin menu", async function (assert) {
this.currentUser.admin = true;
await render(
hbs`
<MountWidget @widget="post" @model={{this.post}} @args={{this.args}} />
<DMenus/>`
);
assert
.dom("[data-content][data-identifier='admin-post-menu']")
.doesNotExist();
await click(".post-menu-area .show-post-admin-menu");
assert.dom("[data-content][data-identifier='admin-post-menu']").exists();
await triggerEvent(".post-menu-area", "pointerdown");
assert
.dom("[data-content][data-identifier='admin-post-menu']")
.doesNotExist("clicking outside clears the popup");
});
test("permanently delete topic", async function (assert) {
this.currentUser.set("admin", true);
const store = getOwner(this).lookup("service:store");
const topic = store.createRecord("topic", {
id: 123,
details: { can_permanently_delete: true },
});
const post = store.createRecord("post", {
id: 1,
post_number: 1,
deleted_at: new Date().toISOString(),
topic,
});
this.set("args", { canManage: true });
this.set("post", post);
this.set("permanentlyDeletePost", () => (this.deleted = true));
await render(
hbs`
<MountWidget @widget="post" @model={{this.post}} @args={{this.args}}
@permanentlyDeletePost={{this.permanentlyDeletePost}} />
<DMenus/>`
);
await click(".post-menu-area .show-post-admin-menu");
await click(
"[data-content][data-identifier='admin-post-menu'] .permanently-delete"
);
assert.ok(this.deleted);
assert
.dom("[data-content][data-identifier='admin-post-menu']")
.doesNotExist("also hides the menu");
});
test("permanently delete post", async function (assert) {
this.currentUser.set("admin", true);
const store = getOwner(this).lookup("service:store");
const topic = store.createRecord("topic", {
id: 123,
});
const post = store.createRecord("post", {
id: 1,
post_number: 2,
deleted_at: new Date().toISOString(),
can_permanently_delete: true,
topic,
});
this.set("args", { canManage: true });
this.set("post", post);
this.set("permanentlyDeletePost", () => (this.deleted = true));
await render(hbs`
<MountWidget @widget="post" @model={{this.post}} @args={{this.args}}
@permanentlyDeletePost={{this.permanentlyDeletePost}} />
<DMenus/>
`);
await click(".post-menu-area .show-post-admin-menu");
await click(
"[data-content][data-identifier='admin-post-menu'] .permanently-delete"
);
assert.ok(this.deleted);
assert
.dom("[data-content][data-identifier='admin-post-menu']")
.doesNotExist("also hides the menu");
});
test("toggle moderator post", async function (assert) {
this.currentUser.set("moderator", true);
const store = getOwner(this).lookup("service:store");
const topic = store.createRecord("topic", {
id: 123,
});
const post = store.createRecord("post", {
id: 1,
post_number: 2,
deleted_at: new Date().toISOString(),
can_permanently_delete: true,
topic,
});
this.set("args", { canManage: true });
this.set("post", post);
this.set("togglePostType", () => (this.toggled = true));
await render(hbs`
<MountWidget @widget="post" @model={{this.post}} @args={{this.args}}
@togglePostType={{this.togglePostType}} />
<DMenus/>`);
await click(".post-menu-area .show-post-admin-menu");
await click(
"[data-content][data-identifier='admin-post-menu'] .toggle-post-type"
);
assert.ok(this.toggled);
assert
.dom("[data-content][data-identifier='admin-post-menu']")
.doesNotExist("also hides the menu");
});
test("rebake post", async function (assert) {
this.currentUser.moderator = true;
const store = getOwner(this).lookup("service:store");
const topic = store.createRecord("topic", { id: 123 });
const post = store.createRecord("post", {
id: 1,
post_number: 1,
topic,
});
this.set("args", { canManage: true });
this.set("post", post);
this.set("rebakePost", () => (this.baked = true));
await render(hbs`
<MountWidget @widget="post" @model={{this.post}} @args={{this.args}}
@rebakePost={{this.rebakePost}} />
<DMenus/>`);
await click(".post-menu-area .show-post-admin-menu");
await click(
"[data-content][data-identifier='admin-post-menu'] .rebuild-html"
);
assert.ok(this.baked);
assert
.dom("[data-content][data-identifier='admin-post-menu']")
.doesNotExist("also hides the menu");
});
test("unhide post", async function (assert) {
let unhidden;
this.currentUser.admin = true;
this.post.hidden = true;
this.set("args", { canManage: true });
this.set("unhidePost", () => (unhidden = true));
await render(hbs`
<MountWidget @widget="post" @model={{this.post}} @args={{this.args}}
@unhidePost={{this.unhidePost}} />
<DMenus/>
`);
await click(".post-menu-area .show-post-admin-menu");
await click(
"[data-content][data-identifier='admin-post-menu'] .unhide-post"
);
assert.ok(unhidden);
assert
.dom("[data-content][data-identifier='admin-post-menu']")
.doesNotExist("also hides the menu");
});
test("change owner", async function (assert) {
this.currentUser.admin = true;
const store = getOwner(this).lookup("service:store");
const topic = store.createRecord("topic", { id: 123 });
const post = store.createRecord("post", {
id: 1,
post_number: 1,
hidden: true,
topic,
});
this.set("args", { canManage: true });
this.set("post", post);
this.set("changePostOwner", () => (this.owned = true));
await render(hbs`
<MountWidget @widget="post" @model={{this.post}} @args={{this.args}}
@changePostOwner={{this.changePostOwner}} />
<DMenus/>
`);
await click(".post-menu-area .show-post-admin-menu");
await click(
"[data-content][data-identifier='admin-post-menu'] .change-owner"
);
assert.ok(this.owned);
assert
.dom("[data-content][data-identifier='admin-post-menu']")
.doesNotExist("also hides the menu");
});
test("shows the topic map when setting the 'topicMap' attribute", async function (assert) {
const store = getOwner(this).lookup("service:store");
const topic = store.createRecord("topic", { id: 123 });
this.set("args", { topic, post_number: 1, topicMap: true });
await render(hbs`
<MountWidget @widget="post" @model={{this.post}} @args={{this.args}} />`);
assert.dom(".topic-map").exists();
});
test("shows the topic map when no replies", async function (assert) {
this.siteSettings.show_topic_map_in_topics_without_replies = true;
const store = getOwner(this).lookup("service:store");
const topic = store.createRecord("topic", {
id: 123,
archetype: "regular",
});
this.set("args", { topic, post_number: 1 });
await render(hbs`
<MountWidget @widget="post" @model={{this.post}} @args={{this.args}} />`);
assert.dom(".topic-map").exists();
});
test("topic map - few participants", async function (assert) {
const store = getOwner(this).lookup("service:store");
const topic = store.createRecord("topic", {
id: 123,
posts_count: 10,
participant_count: 2,
archetype: "regular",
});
topic.details.set("participants", [
{ username: "eviltrout" },
{ username: "codinghorror" },
]);
this.set("args", {
topic,
post_number: 1,
});
await render(hbs`
<MountWidget @widget="post" @model={{this.post}} @args={{this.args}} />`);
assert.dom(".topic-map__users-trigger").doesNotExist();
assert.dom(".topic-map__users-list a.poster").exists({ count: 2 });
});
test("topic map - participants", async function (assert) {
const store = getOwner(this).lookup("service:store");
const topic = store.createRecord("topic", {
id: 123,
posts_count: 10,
participant_count: 6,
archetype: "regular",
});
topic.postStream.setProperties({ userFilters: ["sam", "codinghorror"] });
topic.details.set("participants", [
{ username: "eviltrout" },
{ username: "codinghorror" },
{ username: "sam" },
{ username: "zogstrip" },
{ username: "joffreyjaffeux" },
{ username: "david" },
]);
this.set("args", {
topic,
post_number: 1,
});
await render(hbs`
<MountWidget @widget="post" @model={{this.post}} @args={{this.args}} />`);
assert.dom(".topic-map__users-list a.poster").exists({ count: 5 });
await click(".topic-map__users-trigger");
assert
.dom(".topic-map__users-content .topic-map__users-list a.poster")
.exists({ count: 6 });
});
test("topic map - links", async function (assert) {
const store = getOwner(this).lookup("service:store");
const topic = store.createRecord("topic", {
id: 123,
posts_count: 2,
archetype: "regular",
});
topic.details.set("links", [
{ url: "http://link1.example.com", clicks: 0 },
{ url: "http://link2.example.com", clicks: 0 },
{ url: "http://link3.example.com", clicks: 0 },
{ url: "http://link4.example.com", clicks: 0 },
{ url: "http://link5.example.com", clicks: 0 },
{ url: "http://link6.example.com", clicks: 0 },
]);
this.set("args", { topic, post_number: 1 });
await render(hbs`
<MountWidget @widget="post" @model={{this.post}} @args={{this.args}} />`);
assert.dom(".topic-map").exists({ count: 1 });
assert.dom(".topic-map__links-content").doesNotExist();
await click(".topic-map__links-trigger");
assert.dom(".topic-map__links-content").exists({ count: 1 });
assert.dom(".topic-map__links-content .topic-link").exists({ count: 5 });
await click(".link-summary");
assert.dom(".topic-map__links-content .topic-link").exists({ count: 6 });
});
test("topic map - no top reply summary", async function (assert) {
const store = getOwner(this).lookup("service:store");
const topic = store.createRecord("topic", {
id: 123,
archetype: "regular",
posts_count: 2,
});
this.set("args", { topic, post_number: 1 });
await render(hbs`
<MountWidget @widget="post" @model={{this.post}} @args={{this.args}} />`);
assert.dom(".topic-map").exists();
assert.dom(".summarization-button .top-replies").doesNotExist();
});
test("topic map - has top replies summary", async function (assert) {
const store = getOwner(this).lookup("service:store");
const topic = store.createRecord("topic", {
id: 123,
archetype: "regular",
posts_count: 2,
has_summary: true,
});
this.set("args", { topic, post_number: 1 });
await render(hbs`
<MountWidget @widget="post" @model={{this.post}} @args={{this.args}} />`);
assert.dom(".summarization-button .top-replies").exists({ count: 1 });
});
test("pm map", async function (assert) {
const store = getOwner(this).lookup("service:store");
const topic = store.createRecord("topic", {
id: 123,
archetype: "private_message",
});
topic.details.set("allowed_users", [
EmberObject.create({ username: "eviltrout" }),
]);
this.set("args", {
topic,
post_number: 1,
});
await render(hbs`
<MountWidget @widget="post" @model={{this.post}} @args={{this.args}} />`);
assert.dom(".topic-map__private-message-map").exists({ count: 1 });
assert.dom(".topic-map__private-message-map .user").exists({ count: 1 });
});
test("post notice - with username", async function (assert) {
const twoDaysAgo = new Date();
twoDaysAgo.setDate(twoDaysAgo.getDate() - 2);
this.siteSettings.display_name_on_posts = false;
this.siteSettings.prioritize_username_in_ux = true;
this.siteSettings.old_post_notice_days = 14;
this.set("args", {
username: "codinghorror",
name: "Jeff",
created_at: new Date(),
notice: {
type: "returning_user",
lastPostedAt: twoDaysAgo,
},
});
await render(hbs`
<MountWidget @widget="post" @model={{this.post}} @args={{this.args}} />`);
assert.strictEqual(
query(".post-notice.returning-user:not(.old)").innerText.trim(),
I18n.t("post.notice.returning_user", {
user: "codinghorror",
time: "2 days ago",
})
);
});
test("post notice - with name", async function (assert) {
this.siteSettings.display_name_on_posts = true;
this.siteSettings.prioritize_username_in_ux = false;
this.siteSettings.old_post_notice_days = 14;
this.set("args", {
username: "codinghorror",
name: "Jeff",
created_at: new Date(2019, 0, 1),
notice: { type: "new_user" },
});
await render(hbs`
<MountWidget @widget="post" @model={{this.post}} @args={{this.args}} />`);
assert.strictEqual(
query(".post-notice.old.new-user").innerText.trim(),
I18n.t("post.notice.new_user", { user: "Jeff", time: "Jan '10" })
);
});
test("show group request in post", async function (assert) {
this.set("args", {
username: "foo",
requestedGroupName: "testGroup",
});
await render(hbs`
<MountWidget @widget="post" @model={{this.post}} @args={{this.args}} />`);
const link = query(".group-request a");
assert.strictEqual(
link.innerText.trim(),
I18n.t("groups.requests.handle")
);
assert.strictEqual(
link.getAttribute("href"),
"/g/testGroup/requests?filter=foo"
);
});
test("shows user status if enabled in site settings", async function (assert) {
this.siteSettings.enable_user_status = true;
const status = {
emoji: "tooth",
description: "off to dentist",
};
const store = getOwner(this).lookup("service:store");
const user = store.createRecord("user", { status });
this.set("args", { user });
await render(hbs`
<MountWidget @widget="post" @model={{this.post}} @args={{this.args}} />`);
assert.ok(exists(".user-status-message"));
});
test("doesn't show user status if disabled in site settings", async function (assert) {
this.siteSettings.enable_user_status = false;
const status = {
emoji: "tooth",
description: "off to dentist",
};
const store = getOwner(this).lookup("service:store");
const user = store.createRecord("user", { status });
this.set("args", { user });
await render(hbs`
<MountWidget @widget="post" @model={{this.post}} @args={{this.args}} />`);
assert.notOk(exists(".user-status-message"));
});
}
);

View File

@@ -1,3 +1,5 @@
// deprecated in favor of ./post-test-with-glimmer-post-menu.js
import EmberObject from "@ember/object";
import { getOwner } from "@ember/owner";
import { click, render, triggerEvent } from "@ember/test-helpers";
@@ -10,6 +12,10 @@ import I18n from "discourse-i18n";
module("Integration | Component | Widget | post", function (hooks) {
setupRenderingTest(hooks);
hooks.beforeEach(function () {
this.siteSettings.glimmer_post_menu_mode = "disabled";
});
test("basic elements", async function (assert) {
const store = getOwner(this).lookup("service:store");
const topic = store.createRecord("topic", {
@@ -19,7 +25,8 @@ module("Integration | Component | Widget | post", function (hooks) {
this.set("args", { shareUrl: "/example", post_number: 1, topic });
await render(hbs`<MountWidget @widget="post" @args={{this.args}} />`);
await render(hbs`
<MountWidget @widget="post" @args={{this.args}} />`);
assert.dom(".names").exists("includes poster name");
assert.dom("a.post-date").exists("includes post date");
@@ -36,7 +43,8 @@ module("Integration | Component | Widget | post", function (hooks) {
});
await render(
hbs`<MountWidget @widget="post-contents" @args={{this.args}} />`
hbs`
<MountWidget @widget="post-contents" @args={{this.args}} />`
);
assert.dom("a[data-clicks='1']").hasAttribute("data-clicks", "1");
@@ -64,7 +72,8 @@ module("Integration | Component | Widget | post", function (hooks) {
});
await render(
hbs`<MountWidget @widget="post-contents" @args={{this.args}} />`
hbs`
<MountWidget @widget="post-contents" @args={{this.args}} />`
);
assert
@@ -114,9 +123,10 @@ module("Integration | Component | Widget | post", function (hooks) {
this.set("args", { via_email: true, canViewRawEmail: true });
this.set("showRawEmail", () => (this.rawEmailShown = true));
await render(hbs`
<MountWidget @widget="post" @args={{this.args}} @showRawEmail={{this.showRawEmail}} />
`);
await render(
hbs`
<MountWidget @widget="post" @args={{this.args}} @showRawEmail={{this.showRawEmail}} />`
);
await click(".post-info.via-email");
assert.ok(this.rawEmailShown, "clicking the envelope shows the raw email");
@@ -126,9 +136,10 @@ module("Integration | Component | Widget | post", function (hooks) {
this.set("args", { via_email: true, canViewRawEmail: false });
this.set("showRawEmail", () => (this.rawEmailShown = true));
await render(hbs`
<MountWidget @widget="post" @args={{this.args}} @showRawEmail={{this.showRawEmail}} />
`);
await render(
hbs`
<MountWidget @widget="post" @args={{this.args}} @showRawEmail={{this.showRawEmail}} />`
);
await click(".post-info.via-email");
assert.ok(
@@ -141,9 +152,10 @@ module("Integration | Component | Widget | post", function (hooks) {
this.set("args", { version: 3, canViewEditHistory: true });
this.set("showHistory", () => (this.historyShown = true));
await render(hbs`
<MountWidget @widget="post" @args={{this.args}} @showHistory={{this.showHistory}} />
`);
await render(
hbs`
<MountWidget @widget="post" @args={{this.args}} @showHistory={{this.showHistory}} />`
);
await click(".post-info.edits button");
assert.ok(this.historyShown, "clicking the pencil shows the history");
@@ -153,9 +165,10 @@ module("Integration | Component | Widget | post", function (hooks) {
this.set("args", { version: 3, canViewEditHistory: false });
this.set("showHistory", () => (this.historyShown = true));
await render(hbs`
<MountWidget @widget="post" @args={{this.args}} @showHistory={{this.showHistory}} />
`);
await render(
hbs`
<MountWidget @widget="post" @args={{this.args}} @showHistory={{this.showHistory}} />`
);
await click(".post-info.edits");
assert.ok(
@@ -167,12 +180,14 @@ module("Integration | Component | Widget | post", function (hooks) {
test("whisper", async function (assert) {
this.set("args", { isWhisper: true });
await render(hbs`<MountWidget @widget="post" @args={{this.args}} />`);
await render(hbs`
<MountWidget @widget="post" @args={{this.args}} />`);
assert.dom(".topic-post.whisper").exists();
assert.dom(".post-info.whisper").exists();
});
// glimmer-post-menu: deprecated in favor of spec/system/post_menu_spec.rb
test("like count button", async function (assert) {
const store = getOwner(this).lookup("service:store");
const topic = store.createRecord("topic", { id: 123 });
@@ -187,7 +202,8 @@ module("Integration | Component | Widget | post", function (hooks) {
this.set("args", { likeCount: 1 });
await render(
hbs`<MountWidget @widget="post" @model={{this.post}} @args={{this.args}} />`
hbs`
<MountWidget @widget="post" @model={{this.post}} @args={{this.args}} />`
);
assert.dom("button.like-count").exists();
@@ -204,35 +220,42 @@ module("Integration | Component | Widget | post", function (hooks) {
assert.dom(".who-liked a.trigger-user-card").doesNotExist();
});
// glimmer-post-menu: deprecated in favor of spec/system/post_menu_spec.rb
test("like count with no likes", async function (assert) {
this.set("args", { likeCount: 0 });
await render(
hbs`<MountWidget @widget="post" @model={{this.post}} @args={{this.args}} />`
hbs`
<MountWidget @widget="post" @model={{this.post}} @args={{this.args}} />`
);
assert.dom("button.like-count").doesNotExist();
});
// glimmer-post-menu: deprecated in favor of spec/system/post_menu_spec.rb
test("share button", async function (assert) {
this.siteSettings.post_menu += "|share";
this.set("args", { shareUrl: "http://share-me.example.com" });
await render(hbs`<MountWidget @widget="post" @args={{this.args}} />`);
await render(hbs`
<MountWidget @widget="post" @args={{this.args}} />`);
assert.dom(".actions button.share").exists("renders a share button");
});
// glimmer-post-menu: deprecated in favor of spec/system/post_menu_spec.rb
test("copy link button", async function (assert) {
this.set("args", { shareUrl: "http://share-me.example.com" });
await render(hbs`<MountWidget @widget="post" @args={{this.args}} />`);
await render(hbs`
<MountWidget @widget="post" @args={{this.args}} />`);
assert
.dom(".actions button.post-action-menu__copy-link")
.exists("it renders a copy link button");
});
// glimmer-post-menu: deprecated in favor of spec/system/post_menu_spec.rb
test("liking", async function (assert) {
const args = { showLike: true, canToggleLike: true, id: 5 };
this.set("args", args);
@@ -241,9 +264,10 @@ module("Integration | Component | Widget | post", function (hooks) {
args.likeCount = args.liked ? 1 : 0;
});
await render(hbs`
<MountWidget @widget="post-menu" @args={{this.args}} @toggleLike={{this.toggleLike}} />
`);
await render(
hbs`
<MountWidget @widget="post-menu" @args={{this.args}} @toggleLike={{this.toggleLike}} />`
);
assert.dom(".actions button.like").exists();
assert.dom(".actions button.like-count").doesNotExist();
@@ -259,15 +283,17 @@ module("Integration | Component | Widget | post", function (hooks) {
assert.dom(".actions button.like-count").doesNotExist();
});
// glimmer-post-menu: deprecated in favor of spec/system/post_menu_spec.rb
test("anon liking", async function (assert) {
this.owner.unregister("service:current-user");
const args = { showLike: true };
this.set("args", args);
this.set("showLogin", () => (this.loginShown = true));
await render(hbs`
<MountWidget @widget="post-menu" @args={{this.args}} @showLogin={{this.showLogin}} />
`);
await render(
hbs`
<MountWidget @widget="post-menu" @args={{this.args}} @showLogin={{this.showLogin}} />`
);
assert.dom(".actions button.like").exists();
assert.dom(".actions button.like-count").doesNotExist();
@@ -284,58 +310,55 @@ module("Integration | Component | Widget | post", function (hooks) {
assert.ok(this.loginShown);
});
// glimmer-post-menu: deprecated in favor of spec/system/post_menu_spec.rb
test("edit button", async function (assert) {
this.set("args", { canEdit: true });
this.set("editPost", () => (this.editPostCalled = true));
await render(hbs`
<MountWidget @widget="post" @args={{this.args}} @editPost={{this.editPost}} />
`);
await render(
hbs`
<MountWidget @widget="post" @args={{this.args}} @editPost={{this.editPost}} />`
);
await click("button.edit");
assert.ok(this.editPostCalled, "it triggered the edit action");
});
// glimmer-post-menu: deprecated in favor of spec/system/post_menu_spec.rb
test(`edit button - can't edit`, async function (assert) {
this.set("args", { canEdit: false });
await render(hbs`<MountWidget @widget="post" @args={{this.args}} />`);
await render(hbs`
<MountWidget @widget="post" @args={{this.args}} />`);
assert.dom("button.edit").doesNotExist("button is not displayed");
});
test("recover button", async function (assert) {
this.set("args", { canDelete: true });
this.set("deletePost", () => (this.deletePostCalled = true));
await render(hbs`
<MountWidget @widget="post" @args={{this.args}} @deletePost={{this.deletePost}} />
`);
await click("button.delete");
assert.ok(this.deletePostCalled, "it triggered the delete action");
});
// glimmer-post-menu: deprecated in favor of spec/system/post_menu_spec.rb
test("delete topic button", async function (assert) {
this.set("args", { canDeleteTopic: true });
this.set("deletePost", () => (this.deletePostCalled = true));
await render(hbs`
<MountWidget @widget="post" @args={{this.args}} @deletePost={{this.deletePost}} />
`);
await render(
hbs`
<MountWidget @widget="post" @args={{this.args}} @deletePost={{this.deletePost}} />`
);
await click("button.delete");
assert.ok(this.deletePostCalled, "it triggered the delete action");
});
// glimmer-post-menu: deprecated in favor of spec/system/post_menu_spec.rb
test(`delete topic button - can't delete`, async function (assert) {
this.set("args", { canDeleteTopic: false });
await render(hbs`<MountWidget @widget="post" @args={{this.args}} />`);
await render(hbs`
<MountWidget @widget="post" @args={{this.args}} />`);
assert.dom("button.delete").doesNotExist("button is not displayed");
});
// glimmer-post-menu: deprecated in favor of spec/system/post_menu_spec.rb
test(`delete topic button - can't delete when topic author without permission`, async function (assert) {
this.set("args", {
canDeleteTopic: false,
@@ -343,7 +366,8 @@ module("Integration | Component | Widget | post", function (hooks) {
canFlag: true,
});
await render(hbs`<MountWidget @widget="post" @args={{this.args}} />`);
await render(hbs`
<MountWidget @widget="post" @args={{this.args}} />`);
await click(".show-more-actions");
@@ -358,47 +382,56 @@ module("Integration | Component | Widget | post", function (hooks) {
);
});
// glimmer-post-menu: deprecated in favor of spec/system/post_menu_spec.rb
test("recover topic button", async function (assert) {
this.set("args", { canRecoverTopic: true });
this.set("recoverPost", () => (this.recovered = true));
await render(hbs`
<MountWidget @widget="post" @args={{this.args}} @recoverPost={{this.recoverPost}} />
`);
await render(
hbs`
<MountWidget @widget="post" @args={{this.args}} @recoverPost={{this.recoverPost}} />`
);
await click("button.recover");
assert.ok(this.recovered);
});
// glimmer-post-menu: deprecated in favor of spec/system/post_menu_spec.rb
test(`recover topic button - can't recover`, async function (assert) {
this.set("args", { canRecoverTopic: false });
await render(hbs`<MountWidget @widget="post" @args={{this.args}} />`);
await render(hbs`
<MountWidget @widget="post" @args={{this.args}} />`);
assert.dom("button.recover").doesNotExist("button is not displayed");
});
// glimmer-post-menu: deprecated in favor of spec/system/post_menu_spec.rb
test("delete post button", async function (assert) {
this.set("args", { canDelete: true, canFlag: true });
this.set("deletePost", () => (this.deletePostCalled = true));
await render(hbs`
<MountWidget @widget="post" @args={{this.args}} @deletePost={{this.deletePost}} />
`);
await render(
hbs`
<MountWidget @widget="post" @args={{this.args}} @deletePost={{this.deletePost}} />`
);
await click(".show-more-actions");
await click("button.delete");
assert.ok(this.deletePostCalled, "it triggered the delete action");
});
// glimmer-post-menu: deprecated in favor of spec/system/post_menu_spec.rb
test(`delete post button - can't delete`, async function (assert) {
this.set("args", { canDelete: false });
await render(hbs`<MountWidget @widget="post" @args={{this.args}} />`);
await render(hbs`
<MountWidget @widget="post" @args={{this.args}} />`);
assert.dom("button.delete").doesNotExist("button is not displayed");
});
// glimmer-post-menu: deprecated in favor of spec/system/post_menu_spec.rb
test(`delete post button - can't delete, can't flag`, async function (assert) {
this.set("args", {
canDeleteTopic: false,
@@ -406,7 +439,8 @@ module("Integration | Component | Widget | post", function (hooks) {
canFlag: false,
});
await render(hbs`<MountWidget @widget="post" @args={{this.args}} />`);
await render(hbs`
<MountWidget @widget="post" @args={{this.args}} />`);
assert.dom("button.delete").doesNotExist("delete button is not displayed");
assert
@@ -414,22 +448,26 @@ module("Integration | Component | Widget | post", function (hooks) {
.doesNotExist("flag button is not displayed");
});
// glimmer-post-menu: deprecated in favor of spec/system/post_menu_spec.rb
test("recover post button", async function (assert) {
this.set("args", { canRecover: true });
this.set("recoverPost", () => (this.recovered = true));
await render(hbs`
<MountWidget @widget="post" @args={{this.args}} @recoverPost={{this.recoverPost}} />
`);
await render(
hbs`
<MountWidget @widget="post" @args={{this.args}} @recoverPost={{this.recoverPost}} />`
);
await click("button.recover");
assert.ok(this.recovered);
});
// glimmer-post-menu: deprecated in favor of spec/system/post_menu_spec.rb
test(`recover post button - can't recover`, async function (assert) {
this.set("args", { canRecover: false });
await render(hbs`<MountWidget @widget="post" @args={{this.args}} />`);
await render(hbs`
<MountWidget @widget="post" @args={{this.args}} />`);
assert.dom("button.recover").doesNotExist("button is not displayed");
});
@@ -438,9 +476,10 @@ module("Integration | Component | Widget | post", function (hooks) {
this.set("args", { canFlag: true });
this.set("showFlags", () => (this.flagsShown = true));
await render(hbs`
<MountWidget @widget="post" @args={{this.args}} @showFlags={{this.showFlags}} />
`);
await render(
hbs`
<MountWidget @widget="post" @args={{this.args}} @showFlags={{this.showFlags}} />`
);
assert.dom("button.create-flag").exists();
@@ -451,7 +490,8 @@ module("Integration | Component | Widget | post", function (hooks) {
test(`flagging: can't flag`, async function (assert) {
this.set("args", { canFlag: false });
await render(hbs`<MountWidget @widget="post" @args={{this.args}} />`);
await render(hbs`
<MountWidget @widget="post" @args={{this.args}} />`);
assert.dom("button.create-flag").doesNotExist();
});
@@ -459,7 +499,8 @@ module("Integration | Component | Widget | post", function (hooks) {
test(`flagging: can't flag when post is hidden`, async function (assert) {
this.set("args", { canFlag: true, hidden: true });
await render(hbs`<MountWidget @widget="post" @args={{this.args}} />`);
await render(hbs`
<MountWidget @widget="post" @args={{this.args}} />`);
assert.dom("button.create-flag").doesNotExist();
});
@@ -467,7 +508,8 @@ module("Integration | Component | Widget | post", function (hooks) {
test(`read indicator`, async function (assert) {
this.set("args", { read: true });
await render(hbs`<MountWidget @widget="post" @args={{this.args}} />`);
await render(hbs`
<MountWidget @widget="post" @args={{this.args}} />`);
assert.dom(".read-state.read").exists();
});
@@ -475,7 +517,8 @@ module("Integration | Component | Widget | post", function (hooks) {
test(`unread indicator`, async function (assert) {
this.set("args", { read: false });
await render(hbs`<MountWidget @widget="post" @args={{this.args}} />`);
await render(hbs`
<MountWidget @widget="post" @args={{this.args}} />`);
assert.dom(".read-state").exists();
});
@@ -487,7 +530,8 @@ module("Integration | Component | Widget | post", function (hooks) {
replyDirectlyAbove: true,
});
await render(hbs`<MountWidget @widget="post" @args={{this.args}} />`);
await render(hbs`
<MountWidget @widget="post" @args={{this.args}} />`);
assert.dom("a.reply-to-tab").doesNotExist("hides the tab");
assert.dom(".avoid-tab").doesNotExist("doesn't have the avoid tab class");
@@ -500,7 +544,8 @@ module("Integration | Component | Widget | post", function (hooks) {
replyDirectlyAbove: false,
});
await render(hbs`<MountWidget @widget="post" @args={{this.args}} />`);
await render(hbs`
<MountWidget @widget="post" @args={{this.args}} />`);
assert.dom("a.reply-to-tab").exists("shows the tab");
assert.dom(".avoid-tab").exists("has the avoid tab class");
@@ -514,7 +559,8 @@ module("Integration | Component | Widget | post", function (hooks) {
});
this.siteSettings.suppress_reply_directly_above = false;
await render(hbs`<MountWidget @widget="post" @args={{this.args}} />`);
await render(hbs`
<MountWidget @widget="post" @args={{this.args}} />`);
assert.dom(".avoid-tab").exists("has the avoid tab class");
await click("a.reply-to-tab");
@@ -526,9 +572,10 @@ module("Integration | Component | Widget | post", function (hooks) {
this.set("args", { cooked_hidden: true, canSeeHiddenPost: true });
this.set("expandHidden", () => (this.unhidden = true));
await render(hbs`
<MountWidget @widget="post" @args={{this.args}} @expandHidden={{this.expandHidden}} />
`);
await render(
hbs`
<MountWidget @widget="post" @args={{this.args}} @expandHidden={{this.expandHidden}} />`
);
await click(".topic-body .expand-hidden");
assert.ok(this.unhidden, "triggers the action");
@@ -538,9 +585,10 @@ module("Integration | Component | Widget | post", function (hooks) {
this.set("args", { cooked_hidden: true, canSeeHiddenPost: false });
this.set("expandHidden", () => (this.unhidden = true));
await render(hbs`
<MountWidget @widget="post" @args={{this.args}} @expandHidden={{this.expandHidden}} />
`);
await render(
hbs`
<MountWidget @widget="post" @args={{this.args}} @expandHidden={{this.expandHidden}} />`
);
assert
.dom(".topic-body .expand-hidden")
@@ -553,31 +601,36 @@ module("Integration | Component | Widget | post", function (hooks) {
this.set("post", store.createRecord("post", { id: 1234 }));
await render(
hbs`<MountWidget @widget="post" @model={{this.post}} @args={{this.args}} />`
hbs`
<MountWidget @widget="post" @model={{this.post}} @args={{this.args}} />`
);
await click(".topic-body .expand-post");
assert.dom(".expand-post").doesNotExist("button is gone");
});
// glimmer-post-menu: deprecated in favor of spec/system/post_menu_spec.rb
test("can't bookmark", async function (assert) {
this.set("args", { canBookmark: false });
await render(hbs`<MountWidget @widget="post" @args={{this.args}} />`);
await render(hbs`
<MountWidget @widget="post" @args={{this.args}} />`);
assert.dom("button.bookmark").doesNotExist();
assert.dom("button.bookmarked").doesNotExist();
});
// glimmer-post-menu: deprecated in favor of spec/system/post_menu_spec.rb
test("bookmark", async function (assert) {
const args = { canBookmark: true };
this.set("args", args);
this.set("toggleBookmark", () => (args.bookmarked = true));
await render(hbs`
<MountWidget @widget="post" @args={{this.args}} @toggleBookmark={{this.toggleBookmark}} />
`);
await render(
hbs`
<MountWidget @widget="post" @args={{this.args}} @toggleBookmark={{this.toggleBookmark}} />`
);
assert.dom(".post-menu-area .bookmark").exists();
assert.dom("button.bookmarked").doesNotExist();
@@ -586,7 +639,8 @@ module("Integration | Component | Widget | post", function (hooks) {
test("can't show admin menu when you can't manage", async function (assert) {
this.set("args", { canManage: false });
await render(hbs`<MountWidget @widget="post" @args={{this.args}} />`);
await render(hbs`
<MountWidget @widget="post" @args={{this.args}} />`);
assert.dom(".post-menu-area .show-post-admin-menu").doesNotExist();
});
@@ -595,14 +649,15 @@ module("Integration | Component | Widget | post", function (hooks) {
this.set("args", { canManage: true });
await render(
hbs`<MountWidget @widget="post" @args={{this.args}} /><DMenus />`
hbs`
<MountWidget @widget="post" @args={{this.args}} />
<DMenus/>`
);
assert
.dom("[data-content][data-identifier='admin-post-menu']")
.doesNotExist();
await click(".post-menu-area .show-post-admin-menu");
assert.dom("[data-content][data-identifier='admin-post-menu']").exists();
await triggerEvent(".post-menu-area", "pointerdown");
@@ -612,11 +667,28 @@ module("Integration | Component | Widget | post", function (hooks) {
});
test("permanently delete topic", async function (assert) {
this.set("args", { canManage: true, canPermanentlyDelete: true });
this.currentUser.set("admin", true);
const store = getOwner(this).lookup("service:store");
const topic = store.createRecord("topic", {
id: 123,
details: { can_permanently_delete: true },
});
const post = store.createRecord("post", {
id: 1,
post_number: 1,
deleted_at: new Date().toISOString(),
topic,
});
this.set("args", { canManage: true });
this.set("post", post);
this.set("permanentlyDeletePost", () => (this.deleted = true));
await render(
hbs`<MountWidget @widget="post" @args={{this.args}} @permanentlyDeletePost={{this.permanentlyDeletePost}} /><DMenus />`
hbs`
<MountWidget @widget="post" @args={{this.args}} @model={{this.post}}
@permanentlyDeletePost={{this.permanentlyDeletePost}} />
<DMenus/>`
);
await click(".post-menu-area .show-post-admin-menu");
@@ -630,12 +702,27 @@ module("Integration | Component | Widget | post", function (hooks) {
});
test("permanently delete post", async function (assert) {
this.set("args", { canManage: true, canPermanentlyDelete: true });
this.currentUser.set("admin", true);
const store = getOwner(this).lookup("service:store");
const topic = store.createRecord("topic", {
id: 123,
});
const post = store.createRecord("post", {
id: 1,
post_number: 2,
deleted_at: new Date().toISOString(),
can_permanently_delete: true,
topic,
});
this.set("args", { canManage: true });
this.set("post", post);
this.set("permanentlyDeletePost", () => (this.deleted = true));
await render(hbs`
<MountWidget @widget="post" @args={{this.args}} @permanentlyDeletePost={{this.permanentlyDeletePost}} />
<DMenus />
<MountWidget @widget="post" @args={{this.args}} @model={{this.post}}
@permanentlyDeletePost={{this.permanentlyDeletePost}} />
<DMenus/>
`);
await click(".post-menu-area .show-post-admin-menu");
@@ -651,13 +738,26 @@ module("Integration | Component | Widget | post", function (hooks) {
test("toggle moderator post", async function (assert) {
this.currentUser.set("moderator", true);
const store = getOwner(this).lookup("service:store");
const topic = store.createRecord("topic", {
id: 123,
});
const post = store.createRecord("post", {
id: 1,
post_number: 2,
deleted_at: new Date().toISOString(),
can_permanently_delete: true,
topic,
});
this.set("args", { canManage: true });
this.set("post", post);
this.set("togglePostType", () => (this.toggled = true));
await render(hbs`
<MountWidget @widget="post" @args={{this.args}} @togglePostType={{this.togglePostType}} />
<DMenus />
`);
<MountWidget @widget="post" @args={{this.args}} @model={{this.post}} @togglePostType={{this.togglePostType}} />
<DMenus/>`);
await click(".post-menu-area .show-post-admin-menu");
await click(
@@ -671,13 +771,22 @@ module("Integration | Component | Widget | post", function (hooks) {
});
test("rebake post", async function (assert) {
this.currentUser.moderator = true;
const store = getOwner(this).lookup("service:store");
const topic = store.createRecord("topic", { id: 123 });
const post = store.createRecord("post", {
id: 1,
post_number: 1,
topic,
});
this.set("args", { canManage: true });
this.set("post", post);
this.set("rebakePost", () => (this.baked = true));
await render(hbs`
<MountWidget @widget="post" @args={{this.args}} @rebakePost={{this.rebakePost}} />
<DMenus />
`);
<MountWidget @widget="post" @args={{this.args}} @model={{this.post}} @rebakePost={{this.rebakePost}} />
<DMenus/>`);
await click(".post-menu-area .show-post-admin-menu");
await click(
@@ -692,12 +801,22 @@ module("Integration | Component | Widget | post", function (hooks) {
test("unhide post", async function (assert) {
let unhidden;
this.currentUser.admin = true;
this.set("args", { canManage: true, hidden: true });
const store = getOwner(this).lookup("service:store");
const topic = store.createRecord("topic", { id: 123 });
const post = store.createRecord("post", {
id: 1,
post_number: 1,
hidden: true,
topic,
});
this.set("args", { canManage: true });
this.set("post", post);
this.set("unhidePost", () => (unhidden = true));
await render(hbs`
<MountWidget @widget="post" @args={{this.args}} @unhidePost={{this.unhidePost}} />
<DMenus />
<MountWidget @widget="post" @args={{this.args}} @model={{this.post}} @unhidePost={{this.unhidePost}} />
<DMenus/>
`);
await click(".post-menu-area .show-post-admin-menu");
@@ -715,12 +834,22 @@ module("Integration | Component | Widget | post", function (hooks) {
test("change owner", async function (assert) {
this.currentUser.admin = true;
const store = getOwner(this).lookup("service:store");
const topic = store.createRecord("topic", { id: 123 });
const post = store.createRecord("post", {
id: 1,
post_number: 1,
hidden: true,
topic,
});
this.set("args", { canManage: true });
this.set("post", post);
this.set("changePostOwner", () => (this.owned = true));
await render(hbs`
<MountWidget @widget="post" @args={{this.args}} @changePostOwner={{this.changePostOwner}} />
<DMenus />
<MountWidget @widget="post" @args={{this.args}} @model={{this.post}} @changePostOwner={{this.changePostOwner}} />
<DMenus/>
`);
await click(".post-menu-area .show-post-admin-menu");
@@ -733,6 +862,7 @@ module("Integration | Component | Widget | post", function (hooks) {
.doesNotExist("also hides the menu");
});
// glimmer-post-menu: deprecated in favor of spec/system/post_menu_spec.rb
test("reply", async function (assert) {
this.set("args", { canCreatePost: true });
this.set("replyToPost", () => (this.replied = true));
@@ -745,45 +875,55 @@ module("Integration | Component | Widget | post", function (hooks) {
assert.ok(this.replied);
});
// glimmer-post-menu: deprecated in favor of spec/system/post_menu_spec.rb
test("reply - without permissions", async function (assert) {
this.set("args", { canCreatePost: false });
await render(hbs`<MountWidget @widget="post" @args={{this.args}} />`);
await render(hbs`
<MountWidget @widget="post" @args={{this.args}} />`);
assert.dom(".post-controls .create").doesNotExist();
});
// glimmer-post-menu: deprecated in favor of spec/system/post_menu_spec.rb
test("replies - no replies", async function (assert) {
this.set("args", { replyCount: 0 });
await render(hbs`<MountWidget @widget="post" @args={{this.args}} />`);
await render(hbs`
<MountWidget @widget="post" @args={{this.args}} />`);
assert.dom("button.show-replies").doesNotExist();
});
// glimmer-post-menu: deprecated in favor of spec/system/post_menu_spec.rb
test("replies - multiple replies", async function (assert) {
this.siteSettings.suppress_reply_directly_below = true;
this.set("args", { replyCount: 2, replyDirectlyBelow: true });
await render(hbs`<MountWidget @widget="post" @args={{this.args}} />`);
await render(hbs`
<MountWidget @widget="post" @args={{this.args}} />`);
assert.dom("button.show-replies").exists();
});
// glimmer-post-menu: deprecated in favor of spec/system/post_menu_spec.rb
test("replies - one below, suppressed", async function (assert) {
this.siteSettings.suppress_reply_directly_below = true;
this.set("args", { replyCount: 1, replyDirectlyBelow: true });
await render(hbs`<MountWidget @widget="post" @args={{this.args}} />`);
await render(hbs`
<MountWidget @widget="post" @args={{this.args}} />`);
assert.dom("button.show-replies").doesNotExist();
});
// glimmer-post-menu: deprecated in favor of spec/system/post_menu_spec.rb
test("replies - one below, not suppressed", async function (assert) {
this.siteSettings.suppress_reply_directly_below = false;
this.set("args", { id: 6654, replyCount: 1, replyDirectlyBelow: true });
await render(hbs`<MountWidget @widget="post" @args={{this.args}} />`);
await render(hbs`
<MountWidget @widget="post" @args={{this.args}} />`);
await click("button.show-replies");
assert.dom("section.embedded-posts.bottom .cooked").exists();
@@ -795,7 +935,8 @@ module("Integration | Component | Widget | post", function (hooks) {
const topic = store.createRecord("topic", { id: 123 });
this.set("args", { topic, post_number: 1, topicMap: true });
await render(hbs`<MountWidget @widget="post" @args={{this.args}} />`);
await render(hbs`
<MountWidget @widget="post" @args={{this.args}} />`);
assert.dom(".topic-map").exists();
});
@@ -810,7 +951,8 @@ module("Integration | Component | Widget | post", function (hooks) {
});
this.set("args", { topic, post_number: 1 });
await render(hbs`<MountWidget @widget="post" @args={{this.args}} />`);
await render(hbs`
<MountWidget @widget="post" @args={{this.args}} />`);
assert.dom(".topic-map").exists();
});
@@ -832,7 +974,8 @@ module("Integration | Component | Widget | post", function (hooks) {
post_number: 1,
});
await render(hbs`<MountWidget @widget="post" @args={{this.args}} />`);
await render(hbs`
<MountWidget @widget="post" @args={{this.args}} />`);
assert.dom(".topic-map__users-trigger").doesNotExist();
assert.dom(".topic-map__users-list a.poster").exists({ count: 2 });
});
@@ -860,7 +1003,8 @@ module("Integration | Component | Widget | post", function (hooks) {
post_number: 1,
});
await render(hbs`<MountWidget @widget="post" @args={{this.args}} />`);
await render(hbs`
<MountWidget @widget="post" @args={{this.args}} />`);
assert.dom(".topic-map__users-list a.poster").exists({ count: 5 });
await click(".topic-map__users-trigger");
@@ -886,7 +1030,8 @@ module("Integration | Component | Widget | post", function (hooks) {
]);
this.set("args", { topic, post_number: 1 });
await render(hbs`<MountWidget @widget="post" @args={{this.args}} />`);
await render(hbs`
<MountWidget @widget="post" @args={{this.args}} />`);
assert.dom(".topic-map").exists({ count: 1 });
assert.dom(".topic-map__links-content").doesNotExist();
@@ -906,7 +1051,8 @@ module("Integration | Component | Widget | post", function (hooks) {
});
this.set("args", { topic, post_number: 1 });
await render(hbs`<MountWidget @widget="post" @args={{this.args}} />`);
await render(hbs`
<MountWidget @widget="post" @args={{this.args}} />`);
assert.dom(".topic-map").exists();
assert.dom(".summarization-button .top-replies").doesNotExist();
@@ -922,7 +1068,8 @@ module("Integration | Component | Widget | post", function (hooks) {
});
this.set("args", { topic, post_number: 1 });
await render(hbs`<MountWidget @widget="post" @args={{this.args}} />`);
await render(hbs`
<MountWidget @widget="post" @args={{this.args}} />`);
assert.dom(".summarization-button .top-replies").exists({ count: 1 });
});
@@ -941,7 +1088,8 @@ module("Integration | Component | Widget | post", function (hooks) {
post_number: 1,
});
await render(hbs`<MountWidget @widget="post" @args={{this.args}} />`);
await render(hbs`
<MountWidget @widget="post" @args={{this.args}} />`);
assert.dom(".topic-map__private-message-map").exists({ count: 1 });
assert.dom(".topic-map__private-message-map .user").exists({ count: 1 });
@@ -963,7 +1111,8 @@ module("Integration | Component | Widget | post", function (hooks) {
},
});
await render(hbs`<MountWidget @widget="post" @args={{this.args}} />`);
await render(hbs`
<MountWidget @widget="post" @args={{this.args}} />`);
assert.dom(".post-notice.returning-user:not(.old)").hasText(
I18n.t("post.notice.returning_user", {
@@ -984,7 +1133,8 @@ module("Integration | Component | Widget | post", function (hooks) {
notice: { type: "new_user" },
});
await render(hbs`<MountWidget @widget="post" @args={{this.args}} />`);
await render(hbs`
<MountWidget @widget="post" @args={{this.args}} />`);
assert
.dom(".post-notice.old.new-user")
@@ -999,7 +1149,8 @@ module("Integration | Component | Widget | post", function (hooks) {
requestedGroupName: "testGroup",
});
await render(hbs`<MountWidget @widget="post" @args={{this.args}} />`);
await render(hbs`
<MountWidget @widget="post" @args={{this.args}} />`);
const link = query(".group-request a");
assert.strictEqual(link.innerText.trim(), I18n.t("groups.requests.handle"));
@@ -1018,7 +1169,8 @@ module("Integration | Component | Widget | post", function (hooks) {
const user = store.createRecord("user", { status });
this.set("args", { user });
await render(hbs`<MountWidget @widget="post" @args={{this.args}} />`);
await render(hbs`
<MountWidget @widget="post" @args={{this.args}} />`);
assert.dom(".user-status-message").exists();
});
@@ -1033,7 +1185,8 @@ module("Integration | Component | Widget | post", function (hooks) {
const user = store.createRecord("user", { status });
this.set("args", { user });
await render(hbs`<MountWidget @widget="post" @args={{this.args}} />`);
await render(hbs`
<MountWidget @widget="post" @args={{this.args}} />`);
assert.dom(".user-status-message").doesNotExist();
});

View File

@@ -7,6 +7,23 @@ module("Unit | Lib | DAG", function (hooks) {
let dag;
test("DAG.from should create a DAG instance from the provided entries", function (assert) {
dag = DAG.from([
["key1", "value1", { after: "key2" }],
["key2", "value2", { before: "key3" }],
["key3", "value3", { before: "key1" }],
]);
assert.ok(dag.has("key1"));
assert.ok(dag.has("key2"));
assert.ok(dag.has("key3"));
const resolved = dag.resolve();
const keys = resolved.map((entry) => entry.key);
assert.deepEqual(keys, ["key2", "key3", "key1"]);
});
test("should add items to the map", function (assert) {
dag = new DAG();
dag.add("key1", "value1");
@@ -16,6 +33,58 @@ module("Unit | Lib | DAG", function (hooks) {
assert.ok(dag.has("key1"));
assert.ok(dag.has("key2"));
assert.ok(dag.has("key3"));
// adding a new item
assert.ok(
dag.add("key4", "value4"),
"adding an item returns true when the item is added"
);
assert.ok(dag.has("key4"));
// adding an item that already exists
assert.notOk(
dag.add("key1", "value1"),
"adding an item returns false when the item already exists"
);
});
test("should not throw an error when throwErrorOnCycle is false when adding an item creates a cycle", function (assert) {
dag = new DAG({
throwErrorOnCycle: false,
defaultPosition: { before: "key3" },
});
dag.add("key1", "value1", { after: "key2" });
dag.add("key2", "value2", { after: "key3" });
// This would normally cause a cycle if throwErrorOnCycle was true
dag.add("key3", "value3", { after: "key1" });
assert.ok(dag.has("key1"));
assert.ok(dag.has("key2"));
assert.ok(dag.has("key3"));
const resolved = dag.resolve();
const keys = resolved.map((entry) => entry.key);
// Check that the default position was used to avoid the cycle
assert.deepEqual(keys, ["key3", "key2", "key1"]);
});
test("should call the method specified for onAddItem callback when an item is added", function (assert) {
let called = 0;
dag = new DAG({
onAddItem: () => {
called++;
},
});
dag.add("key1", "value1");
assert.equal(called, 1, "the callback was called");
// it doesn't call the callback when the item already exists
dag.add("key1", "value1");
assert.equal(called, 1, "the callback was not called");
});
test("should remove an item from the map", function (assert) {
@@ -24,11 +93,80 @@ module("Unit | Lib | DAG", function (hooks) {
dag.add("key2", "value2");
dag.add("key3", "value3");
dag.delete("key2");
let removed = dag.delete("key2");
assert.ok(dag.has("key1"));
assert.false(dag.has("key2"));
assert.ok(dag.has("key3"));
assert.ok(removed, "delete returns true when the item is removed");
removed = dag.delete("key2");
assert.notOk(removed, "delete returns false when the item doesn't exist");
});
test("should call the method specified for onDeleteItem callback when an item is removed", function (assert) {
let called = 0;
dag = new DAG({
onDeleteItem: () => {
called++;
},
});
dag.add("key1", "value1");
dag.delete("key1");
assert.equal(called, 1, "the callback was called");
// it doesn't call the callback when the item doesn't exist
dag.delete("key1");
assert.equal(called, 1, "the callback was not called");
});
test("should replace the value from an item in the map", function (assert) {
dag = new DAG();
dag.add("key1", "value1");
dag.add("key2", "value2");
dag.add("key3", "value3");
// simply replacing the value
let replaced = dag.replace("key2", "replaced-value2");
assert.deepEqual(
dag.resolve().map((entry) => entry.value),
["value1", "replaced-value2", "value3"],
"replace allows simply replacing the value"
);
assert.ok(replaced, "replace returns true when the item is replaced");
// also changing the position
dag.replace("key2", "replaced-value2-again", { before: "key1" });
assert.deepEqual(
dag.resolve().map((entry) => entry.value),
["replaced-value2-again", "value1", "value3"],
"replace also allows changing the position"
);
// replacing an item that doesn't exist
replaced = dag.replace("key4", "replaced-value4");
assert.notOk(replaced, "replace returns false when the item doesn't exist");
});
test("should call the method specified for onReplaceItem callback when an item is replaced", function (assert) {
let called = 0;
dag = new DAG({
onReplaceItem: () => {
called++;
},
});
dag.add("key1", "value1");
dag.replace("key1", "replaced-value1");
assert.equal(called, 1, "the callback was called");
// it doesn't call the callback when the item doesn't exist
dag.replace("key2", "replaced-value2");
assert.equal(called, 1, "the callback was not called");
});
test("should reposition an item in the map", function (assert) {
@@ -37,12 +175,66 @@ module("Unit | Lib | DAG", function (hooks) {
dag.add("key2", "value2");
dag.add("key3", "value3");
dag.reposition("key3", { before: "key1" });
let repositioned = dag.reposition("key3", { before: "key1" });
assert.ok(
repositioned,
"reposition returns true when the item is repositioned"
);
const resolved = dag.resolve();
const keys = resolved.map((pair) => pair.key);
const keys = resolved.map((entry) => entry.key);
assert.deepEqual(keys, ["key3", "key1", "key2"]);
// repositioning an item that doesn't exist
repositioned = dag.reposition("key4", { before: "key1" });
assert.notOk(
repositioned,
"reposition returns false when the item doesn't exist"
);
});
test("should call the method specified for onRepositionItem callback when an item is repositioned", function (assert) {
let called = 0;
dag = new DAG({
onRepositionItem: () => {
called++;
},
});
dag.add("key1", "value1");
dag.reposition("key1", { before: "key2" });
assert.equal(called, 1, "the callback was called");
// it doesn't call the callback when the item doesn't exist
dag.reposition("key2", { before: "key1" });
assert.equal(called, 1, "the callback was not called");
});
test("should return the entries in the map", function (assert) {
const entries = [
["key1", "value1", { after: "key2" }],
["key2", "value2", { before: "key3" }],
["key3", "value3", { before: "key1" }],
];
dag = DAG.from(entries);
const dagEntries = dag.entries();
entries.forEach((entry, index) => {
assert.equal(dagEntries[index][0], entry[0], "the key is correct");
assert.equal(dagEntries[index][1], entry[1], "the value is correct");
assert.equal(
dagEntries[index][2]["before"],
entry[2]["before"],
"the before position is correct"
);
assert.equal(
dagEntries[index][2]["after"],
entry[2]["after"],
"the after position is correct"
);
});
});
test("should resolve the map in the correct order", function (assert) {
@@ -52,7 +244,7 @@ module("Unit | Lib | DAG", function (hooks) {
dag.add("key3", "value3");
const resolved = dag.resolve();
const keys = resolved.map((pair) => pair.key);
const keys = resolved.map((entry) => entry.key);
assert.deepEqual(keys, ["key1", "key2", "key3"]);
});
@@ -65,9 +257,20 @@ module("Unit | Lib | DAG", function (hooks) {
dag.add("key4", "value4");
const resolved = dag.resolve();
const keys = resolved.map((pair) => pair.key);
const keys = resolved.map((entry) => entry.key);
assert.deepEqual(keys, ["key1", "key2", "key4", "key3"]);
// it also returns the positioning data for each entry
assert.deepEqual(
resolved.map((entry) => entry.position),
[
{ before: undefined, after: undefined }, // {} from key1
{ before: undefined, after: "key1" }, // from key2
{ before: "key3", after: "key2" }, // from the defaultPosition applied to key4
{ before: undefined, after: "key2" }, // from key3
]
);
});
test("should resolve only existing keys", function (assert) {
@@ -79,7 +282,7 @@ module("Unit | Lib | DAG", function (hooks) {
dag.delete("key1");
const resolved = dag.resolve();
const keys = resolved.map((pair) => pair.key);
const keys = resolved.map((entry) => entry.key);
assert.deepEqual(keys, ["key2", "key3"]);
});

View File

@@ -122,7 +122,11 @@ module("Unit | Utility | transformers", function (hooks) {
test("warns if transformer is unknown", function (assert) {
withPluginApi("1.34.0", (api) => {
api.registerValueTransformer("whatever", () => "foo");
const result = api.registerValueTransformer("whatever", () => "foo");
assert.notOk(
result,
"registerValueTransformer returns false if the transformer name does not exist"
);
// testing warning about core transformers
assert.strictEqual(
@@ -138,7 +142,7 @@ module("Unit | Utility | transformers", function (hooks) {
assert.throws(
() =>
withPluginApi("1.34.0", (api) => {
api.registerValueTransformer("whatever", "foo");
api.registerValueTransformer("home-logo-href", "foo");
}),
/api.registerValueTransformer requires the callback argument to be a function/
);
@@ -160,7 +164,14 @@ module("Unit | Utility | transformers", function (hooks) {
"value did not change. transformer is not registered yet"
);
api.registerValueTransformer("test-transformer", () => true);
const result = api.registerValueTransformer(
"test-transformer",
() => true
);
assert.ok(
result,
"registerValueTransformer returns true if the transformer was registered"
);
assert.strictEqual(
transformerWasRegistered("test-transformer"),
@@ -656,9 +667,13 @@ module("Unit | Utility | transformers", function (hooks) {
);
});
test("warns if transformer is unknown", function (assert) {
test("warns if transformer is unknown ans returns false", function (assert) {
withPluginApi("1.35.0", (api) => {
api.registerBehaviorTransformer("whatever", () => "foo");
const result = api.registerBehaviorTransformer("whatever", () => "foo");
assert.notOk(
result,
"registerBehaviorTransformer returns false if the transformer name does not exist"
);
// testing warning about core transformers
assert.strictEqual(
@@ -674,7 +689,10 @@ module("Unit | Utility | transformers", function (hooks) {
assert.throws(
() =>
withPluginApi("1.35.0", (api) => {
api.registerBehaviorTransformer("whatever", "foo");
api.registerBehaviorTransformer(
"discovery-topic-list-load-more",
"foo"
);
}),
/api.registerBehaviorTransformer requires the callback argument to be a function/
);
@@ -707,8 +725,13 @@ module("Unit | Utility | transformers", function (hooks) {
"value was set by the default callback. transformer is not registered yet"
);
api.registerBehaviorTransformer("test-transformer", ({ context }) =>
context.setValue("TRANSFORMED_CALLBACK")
const result = api.registerBehaviorTransformer(
"test-transformer",
({ context }) => context.setValue("TRANSFORMED_CALLBACK")
);
assert.ok(
result,
"registerBehaviorTransformer returns true if the transformer was registered"
);
transformerWasRegistered("test-transformer");

View File

@@ -20,6 +20,7 @@
border-radius: 50%;
position: relative;
overflow: hidden;
&:before {
@media (prefers-reduced-motion: no-preference) {
animation: placeHolderShimmer 4s linear infinite forwards;
@@ -53,6 +54,7 @@
.names {
flex: 1 1 auto;
overflow: hidden;
span.first {
display: flex;
align-items: baseline;
@@ -65,19 +67,23 @@
display: inline-block;
@include ellipsis;
vertical-align: middle;
a {
color: var(--primary-high-or-secondary-low);
outline-offset: -1px;
}
}
.fa {
font-size: var(--font-down-1);
color: var(--primary-med-or-secondary-med);
}
.svg-icon-title {
margin-left: 3px;
margin-right: 0px;
}
.new_user a,
.user-title,
.user-title a {
@@ -103,9 +109,11 @@
h6 {
margin: 2rem 0 0.67rem;
line-height: var(--line-height-medium);
a.anchor {
opacity: 0;
transition: opacity 0.25s;
&:before {
content: svg-uri(
'<svg xmlns="http://www.w3.org/2000/svg" width="16px" height="16px" viewBox="0 0 512 512" fill="#{$primary-medium}"><path d="M326.612 185.391c59.747 59.809 58.927 155.698.36 214.59-.11.12-.24.25-.36.37l-67.2 67.2c-59.27 59.27-155.699 59.262-214.96 0-59.27-59.26-59.27-155.7 0-214.96l37.106-37.106c9.84-9.84 26.786-3.3 27.294 10.606.648 17.722 3.826 35.527 9.69 52.721 1.986 5.822.567 12.262-3.783 16.612l-13.087 13.087c-28.026 28.026-28.905 73.66-1.155 101.96 28.024 28.579 74.086 28.749 102.325.51l67.2-67.19c28.191-28.191 28.073-73.757 0-101.83-3.701-3.694-7.429-6.564-10.341-8.569a16.037 16.037 0 0 1-6.947-12.606c-.396-10.567 3.348-21.456 11.698-29.806l21.054-21.055c5.521-5.521 14.182-6.199 20.584-1.731a152.482 152.482 0 0 1 20.522 17.197zM467.547 44.449c-59.261-59.262-155.69-59.27-214.96 0l-67.2 67.2c-.12.12-.25.25-.36.37-58.566 58.892-59.387 154.781.36 214.59a152.454 152.454 0 0 0 20.521 17.196c6.402 4.468 15.064 3.789 20.584-1.731l21.054-21.055c8.35-8.35 12.094-19.239 11.698-29.806a16.037 16.037 0 0 0-6.947-12.606c-2.912-2.005-6.64-4.875-10.341-8.569-28.073-28.073-28.191-73.639 0-101.83l67.2-67.19c28.239-28.239 74.3-28.069 102.325.51 27.75 28.3 26.872 73.934-1.155 101.96l-13.087 13.087c-4.35 4.35-5.769 10.79-3.783 16.612 5.864 17.194 9.042 34.999 9.69 52.721.509 13.906 17.454 20.446 27.294 10.606l37.106-37.106c59.271-59.259 59.271-155.699.001-214.959z"></path></svg>'
@@ -162,29 +170,37 @@
background-color: var(--success-low);
text-decoration: underline;
}
del {
background-color: var(--danger-low);
text-decoration: line-through;
}
mark {
background-color: var(--highlight);
}
// Prevents users from breaking posts with tag nesting
big {
font-size: 1.5rem;
}
small {
font-size: 0.75rem;
}
small small {
font-size: 0.75em;
}
big big {
font-size: 1em;
}
sub sub sub {
bottom: 0;
}
sup sup sup {
top: 0;
}
@@ -195,6 +211,7 @@
video {
max-width: 100%;
}
sup sup {
top: 0;
}
@@ -220,9 +237,11 @@
.regular > .cooked {
background-color: var(--highlight-bg);
}
.clearfix > .topic-meta-data > .names {
span.user-title {
color: var(--primary-high-or-secondary-low);
a {
background-color: var(--highlight-bg);
padding-left: 4px;
@@ -248,6 +267,7 @@
.user-status-message {
display: flex;
img.emoji {
width: 1em;
height: 1em;
@@ -260,6 +280,7 @@ nav.post-controls {
display: flex;
align-items: center;
justify-content: space-between;
color: var(--primary-low-mid);
.actions {
@@ -273,11 +294,13 @@ nav.post-controls {
flex: 0 1 auto;
align-items: center;
white-space: nowrap;
button {
// It looks really confusing when one half a double button has an inner shadow on click.
&:active {
box-shadow: none;
}
&.my-likes,
&.read-indicator,
&.regular-likes {
@@ -287,22 +310,26 @@ nav.post-controls {
padding-left: 0.45em;
}
}
&.has-like {
// Like button after I've liked
.d-icon {
color: var(--love);
}
}
&[disabled] {
// Disabled like button
cursor: not-allowed;
}
&.button-count {
// Like count button
height: 100%; // sometimes the font might be shorter than the icon
&:not(.my-likes) {
padding-right: 0;
}
+ .toggle-like {
// Like button when like count is present
padding-left: 0.45em;
@@ -310,12 +337,15 @@ nav.post-controls {
}
}
}
a,
button {
color: var(--primary-low-mid-or-secondary-high);
.d-icon {
opacity: 1;
}
display: inline-flex;
}
@@ -326,39 +356,51 @@ nav.post-controls {
vertical-align: top;
background: transparent;
border: none;
.d-icon {
// this avoids an issue where hovering off the icon
// removes the .d-hover class from the button prematurely
pointer-events: none;
}
&.d-hover,
&:hover,
&:focus,
&:active {
outline: none;
background: var(--primary-low);
color: var(--primary);
}
&.hidden {
display: none;
}
&.admin {
position: relative;
}
// TODO (glimmer-post-menu): Go over the the d-hover style and remove the unnecessary ones when glimmer-post-menu replaces the widget version
&.delete.d-hover,
&.delete:hover,
&.delete:active,
&.delete:focus {
background: var(--danger);
color: var(--secondary);
.d-icon {
color: var(--secondary);
}
}
&.bookmarked .d-icon {
color: var(--tertiary);
}
&.create {
margin-right: 0;
color: var(--primary-high-or-secondary-low);
.d-icon {
color: var(--primary-high-or-secondary-low);
}
@@ -371,15 +413,18 @@ nav.post-controls {
font-size: inherit;
padding: 10px;
color: var(--primary-medium);
&:hover,
&:focus {
outline: none;
color: var(--primary);
background: var(--primary-low);
}
.d-icon {
font-size: var(--font-down-1);
}
.d-button-label + .d-icon {
margin-left: 0.45em;
margin-right: 0;
@@ -391,6 +436,7 @@ nav.post-controls {
.regular > .cooked {
background-color: var(--danger-low-mid);
}
.topic-meta-data:not(.embedded-reply) {
color: var(--danger);
@@ -403,36 +449,44 @@ nav.post-controls {
color: currentColor;
}
}
nav.post-controls {
color: var(--danger);
.show-replies,
button.reply.create {
color: var(--danger);
.d-icon {
color: var(--danger);
}
}
.widget-button {
&:hover {
color: currentColor;
background: var(--danger-low);
.d-icon {
color: currentColor;
}
}
&[disabled]:hover {
background-color: transparent;
cursor: not-allowed;
}
&.fade-out {
opacity: 1;
}
}
.d-icon {
color: var(--danger);
}
}
.post-action {
color: var(--danger);
}
@@ -450,7 +504,9 @@ nav.post-controls {
}
}
.has-like .d-icon.heart-animation {
// .d-icon.heart-animation is the widget animation while .toggle-like.heart-animation is the glimmer-post-menu's
.has-like .d-icon.heart-animation,
.toggle-like.heart-animation .d-icon {
@media (prefers-reduced-motion: no-preference) {
animation: heartBump 0.4s;
}
@@ -480,20 +536,24 @@ aside.quote {
.avatar {
margin-right: 0.5em;
}
img {
margin-top: -0.26em;
}
@include unselectable;
}
// blockquote is docked within aside for content
blockquote {
margin-top: 0;
.expanded-quote {
overflow: hidden;
@media (prefers-reduced-motion: no-preference) {
animation: slideout 1s ease-in-out;
}
&.icon-only {
text-align: center;
font-size: var(--font-up-4);
@@ -528,6 +588,7 @@ aside.quote {
.post-action {
color: var(--primary-medium);
.undo-action,
.act-action {
margin-left: 5px;
@@ -570,6 +631,7 @@ aside.quote {
float: right;
display: flex;
align-items: center;
a {
margin-left: 0.3em;
}
@@ -660,11 +722,13 @@ aside.quote {
padding: 0;
overflow: hidden;
}
.quote-share-label + .quote-share-buttons {
max-width: 10em;
opacity: 1;
transition: opacity 0.3s ease-in-out;
}
// this pseudo element creates a transition buffer zone
// without it, the width change on hover can cause transition jitter
// the width is roughly wide enough to cover long translations of "share"
@@ -712,6 +776,7 @@ aside.quote {
right: -6px;
}
}
.topic-avatar .avatar-flair,
.avatar-flair-preview .avatar-flair,
.collapsed-info .user-profile-avatar .avatar-flair,
@@ -720,6 +785,7 @@ aside.quote {
background-size: 20px 20px;
width: 20px;
height: 20px;
&.rounded {
background-size: 18px 18px;
border-radius: 12px;
@@ -729,11 +795,13 @@ aside.quote {
right: -8px;
}
}
.user-card-avatar .avatar-flair,
.user-profile-avatar .avatar-flair {
background-size: 40px 40px;
width: 40px;
height: 40px;
&.rounded {
background-size: 30px 30px;
border-radius: 24px;
@@ -742,6 +810,7 @@ aside.quote {
bottom: -2px;
right: -4px;
}
.fa {
font-size: var(--font-up-4);
}
@@ -758,6 +827,7 @@ aside.quote {
display: none;
}
}
.group-request {
border-top: 1px solid var(--primary-low);
padding-top: 0.5em;
@@ -789,6 +859,7 @@ aside.quote {
&.post-date {
margin-right: 0;
}
&.via-email,
&.whisper,
&.post-locked {
@@ -805,32 +876,39 @@ aside.quote {
&.via-email {
color: var(--primary-low-mid-or-secondary-high);
}
&.raw-email {
cursor: pointer;
}
&.edits {
.widget-button {
display: flex;
align-items: center;
.d-button-label {
order: 0;
padding-right: 0.25em;
color: var(--primary-med-or-secondary-med);
}
.d-icon {
order: 1;
color: var(--primary-med-or-secondary-med);
}
.discourse-no-touch & {
&:hover {
.d-button-label {
color: var(--primary-high);
}
.d-icon {
color: var(--primary-high);
}
}
}
&:focus {
@include default-focus;
background: transparent;
@@ -841,6 +919,7 @@ aside.quote {
pre {
max-height: 2000px;
code {
word-wrap: normal;
display: block;
@@ -882,6 +961,7 @@ pre {
&.action-complete {
cursor: auto;
.d-icon {
color: var(--tertiary);
}
@@ -924,6 +1004,7 @@ kbd {
blockquote > *:first-child {
margin-top: 0 !important;
}
blockquote > *:last-child {
margin-bottom: 0 !important;
}
@@ -941,6 +1022,7 @@ blockquote > *:last-child {
.who-liked,
.who-read {
transition: height 0.5s;
a {
margin: 0 0.25em 0.5em 0;
display: inline-block;
@@ -993,6 +1075,7 @@ blockquote > *:last-child {
width: var(--topic-avatar-width);
justify-content: center;
height: auto;
.d-icon {
font-size: var(--font-up-3);
width: var(--topic-avatar-width);
@@ -1037,6 +1120,7 @@ blockquote > *:last-child {
word-break: break-word;
min-width: 0; // Allows content like oneboxes to shrink
color: var(--primary);
p {
margin-bottom: 0;
}
@@ -1045,6 +1129,7 @@ blockquote > *:last-child {
button {
background: transparent;
font-size: var(--font-down-1);
.d-icon {
color: var(--primary-500);
}
@@ -1053,6 +1138,7 @@ blockquote > *:last-child {
&:hover,
&:focus {
background: var(--primary-200);
.d-icon {
color: var(--primary);
}
@@ -1074,6 +1160,7 @@ blockquote > *:last-child {
var(--topic-body-width) + var(--topic-avatar-width) +
(var(--topic-body-width-padding) * 2)
);
.topic-post-visited-message {
background-color: var(--secondary);
padding: 0 0.5em;
@@ -1086,6 +1173,7 @@ blockquote > *:last-child {
.post-info.whisper {
margin-left: 0.5em;
}
.topic-body {
.cooked {
font-style: italic;
@@ -1210,6 +1298,7 @@ span.mention {
border-color: var(--tertiary);
}
}
.topic-body {
.contents:after {
display: none;
@@ -1217,14 +1306,17 @@ span.mention {
}
}
}
article.boxed {
position: relative;
.select-posts {
position: absolute;
right: 7em;
z-index: z("dropdown");
top: 0.5em;
height: 100px;
button {
margin-left: 8px;
background-color: var(--primary-low);
@@ -1255,20 +1347,25 @@ span.mention {
@include breakpoint(mobile-extra-large) {
padding: 1.5em 1.5em 0.25em;
}
h3 {
margin-bottom: 0.5em;
}
a {
text-decoration: underline;
}
.buttons {
display: flex;
flex-wrap: wrap;
align-items: center;
margin: 1.5em 0 1em;
> * {
margin-bottom: 0.5em;
}
.btn {
margin-right: 0.5em;
white-space: nowrap;
@@ -1284,6 +1381,7 @@ span.mention {
background: var(--primary-medium);
}
}
@include breakpoint(mobile-extra-large) {
&.btn-primary {
margin-right: 100%;
@@ -1306,6 +1404,7 @@ span.mention {
);
padding: var(--topic-body-width-padding);
padding-left: 0;
&.old {
background-color: unset;
color: var(--primary-medium);
@@ -1344,11 +1443,14 @@ html.anon #topic-footer-buttons {
details {
margin-right: 0.54em;
}
> * {
margin-bottom: 0.5em; // all immediate children should have a bottom margin in case of wrapping
}
.topic-admin-menu-button-container {
display: inline-flex;
.topic-admin-menu-button {
display: flex; // to make this button match siblings behavior, all of its parents need to be flex
}
@@ -1374,6 +1476,7 @@ html.anon #topic-footer-buttons {
width: 100%;
overflow: hidden;
}
.name {
@include ellipsis;
}
@@ -1423,6 +1526,7 @@ iframe {
.filtered-avatar {
margin: 0 0.5em;
+ .names {
flex: inherit;
}
@@ -1458,6 +1562,7 @@ iframe {
display: flex;
justify-content: flex-end;
align-items: flex-end;
> .d-icon {
cursor: pointer;
padding: 0.5em;
@@ -1523,8 +1628,10 @@ iframe {
.d-modal__container {
width: auto;
}
.d-modal__body {
padding-top: 0;
thead {
position: sticky;
top: 0;
@@ -1542,6 +1649,7 @@ html.discourse-no-touch .fullscreen-table-wrapper:hover {
border-radius: 5px;
box-shadow: 0 2px 5px 0 rgba(var(--always-black-rgb), 0.1),
0 2px 10px 0 rgba(var(--always-black-rgb), 0.1);
.open-popup-link {
opacity: 100%;
}
@@ -1554,6 +1662,7 @@ html.discourse-no-touch .fullscreen-table-wrapper:hover {
color: var(--tertiary-medium);
right: 0;
font-size: 0.571em;
&.read {
visibility: hidden;
opacity: 0;

View File

@@ -12,23 +12,28 @@
.topic-body {
padding: 0;
&:first-of-type {
border-top: none;
}
.reply-to-tab {
z-index: z("base") + 1;
color: var(--primary-med-or-secondary-med);
}
.actions .fade-out {
.discourse-no-touch & {
opacity: 0.7;
transition: background 0.25s, opacity 0.7s ease-in-out;
animation: none; // replaces jsuites animation on .fade-out
}
.discourse-touch & {
opacity: 1;
}
}
&:hover .actions .fade-out,
.selected .actions .fade-out {
opacity: 1;
@@ -48,54 +53,71 @@ nav.post-controls {
// for consistency, try to control spacing by editing these variables
--control-margin: 0.33em;
--control-icon-space: 0.33em;
.actions {
button {
margin-left: var(--control-margin);
&.btn-icon-text,
&.create {
margin-left: calc(var(--control-margin) * 1.52);
.d-icon {
margin-right: var(--control-icon-space);
}
}
}
// Some buttons can be doubled up, like likes or flags
.double-button {
margin-left: var(--control-margin);
&:hover {
button {
background: var(--primary-low);
color: var(--primary-medium);
}
}
button {
margin-left: 0;
margin-right: 0;
&.like {
// Like button with 0 likes
&.d-hover {
&.d-hover,
&:hover {
background: var(--love-low);
.d-icon {
color: var(--love);
}
}
}
&.has-like {
// Like button after I've liked
&.d-hover {
&.d-hover,
&:hover {
background: var(--primary-low);
.d-icon {
color: var(--primary-medium);
}
}
}
&.button-count {
// Like count button
&.d-hover {
&.d-hover,
&:hover {
color: var(--primary);
}
+ .toggle-like {
// Like button when like count is present
&.d-hover {
&.d-hover,
&:hover {
background: var(--primary-low);
}
}
@@ -103,27 +125,34 @@ nav.post-controls {
}
}
}
.show-replies {
display: flex;
align-items: center;
margin-left: 0;
border-radius: var(--d-button-border-radius);
.topic-post & {
margin-right: 0.5em;
}
white-space: nowrap;
.d-icon {
margin-inline: var(--control-icon-space);
margin-left: 0;
}
&[aria-expanded="true"] {
background: var(--primary-low);
color: var(--primary-high);
box-shadow: 0px 0px 0px 1px var(--primary-300);
z-index: 1;
.d-icon {
color: var(--primary-high);
}
&:hover,
&:focus {
background: var(--primary-300);
@@ -147,6 +176,7 @@ pre.codeblock-buttons:hover {
.fullscreen-cmd {
opacity: 0.7;
visibility: visible;
&:hover {
opacity: 1;
}
@@ -159,22 +189,28 @@ pre.codeblock-buttons:hover {
h3 {
margin: 10px 0;
}
border: 1px solid var(--primary-low);
.topic-body {
box-sizing: border-box;
width: calc(100% - 70px); // [100% - .topic-avatar width]
// WARNING: overflow hide is required for quoted / embedded images
// which expect "normal" post width, but expansions are narrower
overflow: hidden;
} // this is covered by .topic-body .regular on a normal post
}
// this is covered by .topic-body .regular on a normal post
// but no such class structure exists for an embedded, expanded post
.cooked {
margin-top: 15px;
}
.topic-avatar {
padding-left: 25px;
padding-top: 15px;
}
.collapse-down,
.collapse-up {
position: absolute;
@@ -186,10 +222,12 @@ pre.codeblock-buttons:hover {
.d-icon {
color: currentColor;
}
.discourse-no-touch & {
&:hover {
background: var(--primary-low);
color: var(--primary-high);
.d-icon {
color: currentColor;
}
@@ -203,10 +241,13 @@ pre.codeblock-buttons:hover {
max-width: calc(100% - 66px);
margin-bottom: 30px;
border: none;
> div {
position: relative;
&:last-of-type {
margin-bottom: 0;
.row {
// Main reply line
&:before {
@@ -220,6 +261,7 @@ pre.codeblock-buttons:hover {
}
}
}
.row {
padding-bottom: 0.5em;
// Main reply line
@@ -232,28 +274,36 @@ pre.codeblock-buttons:hover {
background: var(--primary-300);
left: 32px;
}
.topic-avatar {
border-top: none;
padding-left: 9px;
position: relative;
}
.topic-body {
border-top: none;
padding-bottom: 2.5em;
.topic-meta-data {
position: unset;
.post-link-arrow {
position: absolute;
bottom: 0.75em;
.archetype-private_message & {
bottom: 0;
}
.post-info.arrow {
display: block;
margin-right: 0;
.d-icon {
margin-left: 0;
}
&:hover,
&:focus {
color: var(--primary-high);
@@ -261,17 +311,20 @@ pre.codeblock-buttons:hover {
}
}
}
.cooked {
margin-top: 0.25em;
padding-top: 0.5em;
}
}
}
&.hidden {
display: block;
opacity: 0;
}
}
.collapse-up {
transform: translate(-50%, -164%);
background: var(--primary-low);
@@ -281,12 +334,15 @@ pre.codeblock-buttons:hover {
left: 32px;
bottom: -3em;
z-index: 1;
.archetype-private_message & {
display: flex;
}
.d-icon {
transform: scale(0.871);
}
.discourse-no-touch & {
&:hover,
&:focus {
@@ -295,6 +351,7 @@ pre.codeblock-buttons:hover {
}
}
}
.load-more-replies {
font-size: var(--font-down-1);
position: absolute;
@@ -312,10 +369,12 @@ pre.codeblock-buttons:hover {
&.top {
margin-left: 0px;
border: none;
.collapse-down {
transform: translate(17%, 230%);
z-index: 1;
}
width: calc(
var(--topic-body-width) + (var(--topic-body-width-padding) * 2) +
var(--topic-avatar-width) - (var(--topic-avatar-width) + 2px)
@@ -325,11 +384,14 @@ pre.codeblock-buttons:hover {
.topic-avatar {
border-top: none;
}
.topic-avatar {
padding-left: 0;
}
.topic-body {
overflow: visible;
&::before {
content: "";
position: absolute;
@@ -342,31 +404,39 @@ pre.codeblock-buttons:hover {
}
}
}
&.top.topic-body {
padding: 0;
}
.post-date {
color: var(--primary-med-or-secondary-high);
}
.d-icon-arrow-up,
.d-icon-arrow-down {
margin-left: 5px;
}
.reply:first-of-type .row {
border-top: none;
}
.topic-meta-data {
position: relative;
}
.topic-meta-data h5 {
position: absolute;
z-index: z("base");
font-size: var(--font-down-1);
a {
font-weight: bold;
color: var(--primary-low-mid-or-secondary-high);
}
}
.arrow {
color: var(--primary-med-or-secondary-high);
}
@@ -376,6 +446,7 @@ pre.codeblock-buttons:hover {
.relative-date {
margin-left: 5px;
}
.avatar {
margin-right: 2px;
}
@@ -403,6 +474,7 @@ pre.codeblock-buttons:hover {
.bookmark.bookmarked .d-icon-discourse-bookmark-clock {
color: var(--tertiary);
}
.feature-on-profile.featured-on-profile .d-icon-id-card {
color: var(--tertiary);
}
@@ -426,6 +498,7 @@ video {
.video {
// Height determined by aspect-ratio
max-height: 500px;
> video {
max-height: unset;
}
@@ -481,6 +554,7 @@ blockquote {
background-color: var(--primary-very-low);
}
}
aside {
.quote,
.title,
@@ -490,6 +564,7 @@ blockquote {
background: var(--primary-very-low);
border-left: 5px solid var(--primary-low);
}
aside.quote > blockquote,
aside.quote > .title {
border-left: 0;
@@ -514,18 +589,22 @@ blockquote {
position: relative;
border-top: 1px solid var(--primary-low);
padding: 0.8em 0 0 0;
.topic-meta-data {
padding: 0 var(--topic-body-width-padding) 0.25em
var(--topic-body-width-padding);
}
.cooked {
padding: 1em var(--topic-body-width-padding) 0.25em
var(--topic-body-width-padding);
}
.group-request {
padding: 0.5em var(--topic-body-width-padding) 0
var(--topic-body-width-padding);
}
a.expand-hidden {
padding-left: var(--topic-body-width-padding);
}
@@ -599,29 +678,36 @@ blockquote {
margin-left: 330px;
left: 50%;
}
button {
width: 100%;
margin: 4px auto;
display: inline-block;
text-align: left;
}
&.hidden {
display: none;
}
.controls {
margin-top: 10px;
}
p {
font-size: var(--font-down-1);
margin: 0 0 10px 0;
}
p.cancel {
margin: 10px 0 0 0;
}
h3 {
font-size: var(--font-up-4);
color: var(--primary);
margin-bottom: 5px;
.d-icon {
margin-right: 7px;
}

View File

@@ -34,7 +34,8 @@ html.discourse-no-touch {
color: var(--secondary);
}
}
.btn-flat.delete.d-hover {
.btn-flat.delete.d-hover,
.btn-flat.delete:hover {
background: var(--danger);
}
@@ -218,6 +219,7 @@ html {
}
button {
&.d-hover,
&:hover,
&:focus,
&:active {
background: var(--primary-medium);
@@ -241,7 +243,8 @@ html {
.d-icon {
color: var(--tertiary);
}
&.d-hover {
&.d-hover,
&:hover {
.d-icon {
color: var(--tertiary-medium);
}

View File

@@ -76,6 +76,7 @@ class CurrentUserSerializer < BasicUserSerializer
:use_admin_sidebar,
:can_view_raw_email,
:use_glimmer_topic_list?,
:use_auto_glimmer_post_menu?,
:login_method,
:has_unseen_features
@@ -326,6 +327,10 @@ class CurrentUserSerializer < BasicUserSerializer
scope.user.in_any_groups?(SiteSetting.experimental_glimmer_topic_list_groups_map)
end
def use_auto_glimmer_post_menu?
scope.user.in_any_groups?(SiteSetting.glimmer_post_menu_groups_map)
end
def do_not_disturb_channel_position
MessageBus.last_id("/do-not-disturb/#{object.id}")
end

View File

@@ -3846,17 +3846,24 @@ en:
controls:
reply: "begin composing a reply to this post"
like_action: "Like"
like: "like this post"
has_liked: "you've liked this post"
read_indicator: "members who read this post"
undo_like_action: "Undo like"
undo_like: "undo like"
edit: "edit this post"
edit_action: "Edit"
edit_anonymous: "Sorry, but you need to be logged in to edit this post."
flag_action: "Flag"
flag: "privately flag this post for attention or send a private notification about it"
delete_action: "Delete post"
delete: "delete this post"
undelete_action: "Undelete post"
undelete: "undelete this post"
share_action: "Share"
share: "share a link to this post"
copy_action: "Copy link"
copy_title: "copy a link to this post to clipboard"
link_copied: "Link copied!"
more: "More"
@@ -3870,6 +3877,7 @@ en:
other: "Yes, and all %{count} replies"
just_the_post: "No, just this post"
admin: "post admin actions"
admin_action: "Admin"
permanently_delete: "Permanently Delete"
permanently_delete_confirmation: "Are you sure you permanently want to delete this post? You will not be able to recover it."
wiki: "Make Wiki"

View File

@@ -1877,7 +1877,7 @@ en:
markdown_linkify_tlds: "List of top level domains that get automatically treated as links"
markdown_typographer_quotation_marks: "List of double and single quotes replacement pairs"
post_undo_action_window_mins: "Number of minutes users are allowed to undo recent actions on a post (like, flag, etc)."
must_approve_users: "All new users must wait for moderator or admin approval before being allowed to log in. (Note: enabling this setting also removes the \"arrive at topic\" invite option)"
must_approve_users: 'All new users must wait for moderator or admin approval before being allowed to log in. (Note: enabling this setting also removes the "arrive at topic" invite option)'
invite_code: "User must type this code to be allowed account registration, ignored when empty (case-insensitive)"
approve_suspect_users: "Add suspicious users to the review queue. Suspicious users have entered a bio/website but have no reading activity."
review_every_post: "Send every new post to the review queue for moderation. Posts are still published immediately and are visible to all users. WARNING! Not recommended for high-traffic sites due to the potential volume of posts needing review."
@@ -2725,6 +2725,8 @@ en:
experimental_topics_filter: "Enables the experimental topics filter route at /filter"
enable_experimental_lightbox: "Replace the default image lightbox with the revamped design."
experimental_glimmer_topic_list_groups: "Enable the new 'glimmer' topic list implementation. This implementation is under active development, and is not intended for production use. Do not develop themes/plugins against it until the implementation is finalized and announced."
glimmer_post_menu_mode: "Control whether the new 'glimmer' post menu implementation is used. 'auto' will enable automatically once all your themes and plugins are ready. This implementation is under active development, and is not intended for production use. Do not develop themes/plugins against it until the implementation is finalized and announced."
glimmer_post_menu_groups: "Enable the new 'glimmer' post menu implementation in 'auto' mode for the specified user groups. This implementation is under active development, and is not intended for production use. Do not develop themes/plugins against it until the implementation is finalized and announced."
experimental_form_templates: "Enable the form templates feature. <b>After enabled,</b> manage the templates at <a href='%{base_path}/admin/customize/form-templates'>Customize / Templates</a>."
admin_sidebar_enabled_groups: "Enable sidebar navigation for the admin UI for the specified groups, which replaces the top-level admin navigation buttons."
lazy_load_categories_groups: "Lazy load category information only for users of these groups. This improves performance on sites with many categories."

View File

@@ -3304,6 +3304,22 @@ experimental:
default: ""
allow_any: false
refresh: true
glimmer_post_menu_mode:
client: true
hidden: true
type: enum
choices:
- disabled
- auto
- enabled
default: disabled
glimmer_post_menu_groups:
client: true
type: group_list
list_type: compact
default: ""
allow_any: false
refresh: true
enable_experimental_lightbox:
default: false
client: true

View File

@@ -7,6 +7,7 @@ module PageObjects
include RSpec::Matchers
BODY_SELECTOR = ""
MODAL_SELECTOR = ""
def header
find(".d-modal__header")
@@ -49,11 +50,11 @@ module PageObjects
end
def open?
has_css?(".modal.d-modal")
has_css?(".modal.d-modal#{MODAL_SELECTOR}")
end
def closed?
has_no_css?(".modal.d-modal")
has_no_css?(".modal.d-modal#{MODAL_SELECTOR}")
end
end
end

View File

@@ -49,10 +49,26 @@ module PageObjects
post_by_number(post).has_content? post.raw
end
def has_deleted_post?(post)
has_css?(".topic-post.deleted:has(#post_#{post.post_number})")
end
def has_no_deleted_post?(post)
has_no_css?(".topic-post.deleted:has(#post_#{post.post_number})")
end
def has_post_number?(number)
has_css?("#post_#{number}")
end
def has_replies_expanded?(post)
within_post(post) { has_css?(".embedded-posts") }
end
def has_replies_collapsed?(post)
within_post(post) { has_no_css?(".embedded-posts") }
end
def post_by_number(post_or_number, wait: Capybara.default_max_wait_time)
post_or_number = post_or_number.is_a?(Post) ? post_or_number.post_number : post_or_number
find(".topic-post:not(.staged) #post_#{post_or_number}", wait: wait)
@@ -79,22 +95,62 @@ module PageObjects
end
def click_post_action_button(post, button)
case button
when :bookmark
within_post(post) { find(".bookmark").click }
when :reply
within_post(post) { find(".post-controls .reply").click }
when :flag
within_post(post) { find(".post-controls .create-flag").click }
when :copy_link
within_post(post) { find(".post-controls .post-action-menu__copy-link").click }
when :edit
within_post(post) { find(".post-controls .edit").click }
find_post_action_button(post, button).click
end
def find_post_action_buttons(post)
within_post(post) { find(".post-controls .actions") }
end
def find_post_action_button(post, button)
button_selector = selector_for_post_action_button(button)
within_post(post) { find(button_selector) }
end
def has_post_action_button?(post, button)
button_selector = selector_for_post_action_button(button)
within_post(post) { has_css?(button_selector) }
end
def has_no_post_action_button?(post, button)
button_selector = selector_for_post_action_button(button)
within_post(post) { has_no_css?(button_selector) }
end
def has_who_liked_on_post?(post, count: nil)
if count
return within_post(post) { has_css?(".who-liked a.trigger-user-card", count: count) }
end
within_post(post) { has_css?(".who-liked") }
end
def has_no_who_liked_on_post?(post)
within_post(post) { has_no_css?(".who-liked") }
end
def has_who_read_on_post?(post, count: nil)
if count
return within_post(post) { has_css?(".who-read a.trigger-user-card", count: count) }
end
within_post(post) { has_css?(".who-read") }
end
def has_no_who_read_on_post?(post)
within_post(post) { has_no_css?(".who-read") }
end
def expand_post_admin_actions(post)
within_post(post) { find(".show-post-admin-menu").click }
click_post_action_button(post, :admin)
end
def has_post_admin_menu?()
has_css?("[data-content][data-identifier='admin-post-menu']")
end
def has_no_post_admin_menu?()
has_no_css?("[data-content][data-identifier='admin-post-menu']")
end
def click_post_admin_action_button(post, button)
@@ -258,6 +314,42 @@ module PageObjects
within("#topic-footer-buttons") { yield }
end
def selector_for_post_action_button(button)
# TODO (glimmer-post-menu): Replace the selector with the BEM format ones once the glimmer-post-menu replaces the widget post menu
case button
when :admin
".post-controls .show-post-admin-menu"
when :bookmark
".post-controls .bookmark"
when :copy_link, :copyLink
".post-controls .post-action-menu__copy-link"
when :delete
".post-controls .delete"
when :edit
".post-controls .edit"
when :flag
".post-controls .create-flag"
when :like
".post-controls .toggle-like"
when :like_count
".post-controls .like-count"
when :read
".post-controls .read-indicator"
when :recover
".post-controls .recover"
when :replies
".post-controls .show-replies"
when :reply
".post-controls .reply"
when :share
".post-controls .share"
when :show_more
".post-controls .show-more-actions"
else
raise "Unknown post menu button type: #{button}"
end
end
def is_post_bookmarked(post, bookmarked:, with_reminder: false)
within_post(post) do
css_class = ".bookmark.bookmarked#{with_reminder ? ".with-reminder" : ""}"

View File

@@ -1,23 +1,728 @@
# frozen_string_literal: true
describe "Post menu", type: :system do
fab!(:current_user) { Fabricate(:user) }
fab!(:topic)
fab!(:post) { Fabricate(:post, topic: topic) }
fab!(:admin) { Fabricate(:admin, refresh_auto_groups: true) }
fab!(:user) { Fabricate(:user, refresh_auto_groups: true) }
fab!(:topic, reload: true)
fab!(:post, reload: true) { Fabricate(:post, topic: topic, reads: 5, like_count: 6) }
fab!(:post2, reload: true) { Fabricate(:post, user: user, topic: topic, like_count: 0) }
let(:composer) { PageObjects::Components::Composer.new }
let(:topic_page) { PageObjects::Pages::Topic.new }
let(:flag_modal) { PageObjects::Modals::Flag.new }
let(:login_modal) { PageObjects::Modals::Login.new }
let(:modal) { PageObjects::Modals::Base.new }
before { sign_in(current_user) }
%w[enabled disabled].each do |value|
before do
SiteSetting.glimmer_post_menu_mode = value
SiteSetting.post_menu = "like|copyLink|share|flag|edit|bookmark|delete|admin|reply"
SiteSetting.post_menu_hidden_items = "flag|bookmark|edit|delete|admin"
end
describe "copy link" do
let(:cdp) { PageObjects::CDP.new }
context "when the Glimmer post menu is #{value}" do
describe "general rendering" do
before { sign_in(admin) }
before { cdp.allow_clipboard }
it "renders the expected buttons according to what is specified in the `post_menu`/`post_menu_hidden_items` settings" do
[
{
# skip the read button because we're not viewing a PM
post_menu: "like|copyLink|share|flag|edit|bookmark|delete|admin|reply",
post_menu_hidden_items: "flag|bookmark|edit|delete|admin",
},
{
post_menu: "like|copyLink|edit|bookmark|delete|admin|reply",
post_menu_hidden_items: "flag|admin|edit",
},
].each do |scenario|
scenario => { post_menu:, post_menu_hidden_items: }
it "copies the absolute link to the post when clicked" do
topic_page.visit_topic(post.topic)
topic_page.click_post_action_button(post, :copy_link)
cdp.clipboard_has_text?(post.full_url(share_url: true) + "?u=#{current_user.username}")
SiteSetting.post_menu = post_menu
SiteSetting.post_menu_hidden_items = post_menu_hidden_items
topic_page.visit_topic(post.topic)
available_buttons = %w[admin bookmark copyLink delete edit flag like read reply share]
expected_buttons = SiteSetting.post_menu.split("|")
hidden_buttons = SiteSetting.post_menu_hidden_items.split("|")
visible_buttons = expected_buttons - hidden_buttons
visible_buttons.each do |button|
expect(topic_page).to have_post_action_button(post, button.to_sym)
end
hidden_buttons.each do |button|
expect(topic_page).to have_no_post_action_button(post, button.to_sym)
end
# expand the items and check again if all expected buttons are visible now
topic_page.expand_post_actions(post)
expected_buttons.each do |button|
expect(topic_page).to have_post_action_button(post, button.to_sym)
end
# check if the buttons are in the correct order
node_elements = topic_page.find_post_action_buttons(post).all("*")
button_node_positions =
expected_buttons.map do |button|
node_elements.find_index(topic_page.find_post_action_button(post, button.to_sym))
end
expect(button_node_positions).to eq(button_node_positions.compact.sort)
# verify that buttons that weren't specified int the post_menu setting weren't rendered
(available_buttons - expected_buttons).each do |button|
expect(topic_page).to have_no_post_action_button(post, button.to_sym)
end
end
end
end
describe "admin" do
before do
SiteSetting.edit_wiki_post_allowed_groups = Group::AUTO_GROUPS[:trust_level_0]
SiteSetting.post_menu_hidden_items = ""
end
it "displays the admin button when the user can manage the post" do
# do not display the edit button when unlogged
topic_page.visit_topic(post.topic)
expect(topic_page).to have_no_post_action_button(post, :admin)
expect(topic_page).to have_no_post_action_button(post2, :admin)
# display the admin button for all the posts when a moderator is logged
sign_in(Fabricate(:moderator))
topic_page.visit_topic(post.topic)
expect(topic_page).to have_post_action_button(post, :admin)
expect(topic_page).to have_post_action_button(post2, :admin)
# display the admin button for the all the posts when an admin is logged
sign_in(admin)
topic_page.visit_topic(post.topic)
expect(topic_page).to have_post_action_button(post, :admin)
expect(topic_page).to have_post_action_button(post2, :admin)
end
it "displays the admin button when the user can wiki the post / edit staff notices" do
# display the admin button when the user can wiki
sign_in(Fabricate(:trust_level_4))
topic_page.visit_topic(post.topic)
expect(topic_page).to have_post_action_button(post, :admin)
expect(topic_page).to have_post_action_button(post2, :admin)
# display the admin button when the user can wiki
SiteSetting.self_wiki_allowed_groups = Group::AUTO_GROUPS[:trust_level_0]
sign_in(user)
topic_page.visit_topic(post.topic)
expect(topic_page).to have_no_post_action_button(post, :admin)
expect(topic_page).to have_post_action_button(post2, :admin)
end
it "works as expected" do
sign_in(admin)
topic_page.visit_topic(post.topic)
expect(topic_page).to have_no_post_admin_menu
topic_page.click_post_action_button(post, :admin)
expect(topic_page).to have_post_admin_menu
end
end
describe "bookmark" do
before { SiteSetting.post_menu_hidden_items = "" }
it "does not display the bookmark button when the user is anonymous" do
topic_page.visit_topic(post.topic)
expect(topic_page).to have_no_post_action_button(post, :bookmark)
end
it "works as expected" do
sign_in(user)
topic_page.visit_topic(post.topic)
expect(topic_page).to have_post_action_button(post, :bookmark)
bookmark_button = topic_page.find_post_action_button(post, :bookmark)
expect(bookmark_button[:class].split("\s")).not_to include("bookmarked")
topic_page.click_post_action_button(post, :bookmark)
expect(topic_page).to have_post_bookmarked(post)
end
end
describe "copy link" do
let(:cdp) { PageObjects::CDP.new }
before do
sign_in(user)
cdp.allow_clipboard
end
it "copies the absolute link to the post when clicked" do
topic_page.visit_topic(post.topic)
topic_page.click_post_action_button(post, :copy_link)
cdp.clipboard_has_text?(post.full_url(share_url: true) + "?u=#{user.username}")
end
end
describe "delete / recover" do
before { SiteSetting.post_menu_hidden_items = "" }
it "displays the delete button only when the user can delete the post" do
# do not display the edit button when unlogged
topic_page.visit_topic(post.topic)
expect(topic_page).to have_no_post_action_button(post, :delete)
expect(topic_page).to have_no_post_action_button(post2, :delete)
# display the delete button only for the post that `user` can delete
sign_in(user)
topic_page.visit_topic(post.topic)
expect(topic_page).to have_no_post_action_button(post, :delete)
expect(topic_page).to have_post_action_button(post2, :delete)
# display the delete button for the all the posts because an admin is logged
sign_in(admin)
topic_page.visit_topic(post.topic)
expect(topic_page).to have_post_action_button(post, :delete)
expect(topic_page).to have_post_action_button(post2, :delete)
end
it "displays the recover button only when the user can recover the post" do
PostDestroyer.new(user, post2).destroy
# do not display the edit button when unlogged
topic_page.visit_topic(post.topic)
expect(topic_page).to have_no_post_action_button(post, :recover)
expect(topic_page).to have_no_post_action_button(post2, :recover)
# do not display the recover button because when `user` deletes teh post. it's just marked for deletion
# `user` cannot recover it.
sign_in(user)
topic_page.visit_topic(post.topic)
expect(topic_page).to have_no_post_action_button(post, :recover)
expect(topic_page).to have_no_post_action_button(post2, :recover)
# display the recover button for the deleted post because an admin is logged
PostDestroyer.new(admin, post).destroy
sign_in(admin)
topic_page.visit_topic(post.topic)
expect(topic_page).to have_post_action_button(post, :recover)
end
it "deletes a topic" do
sign_in(admin)
topic_page.visit_topic(post.topic)
expect(topic_page).to have_no_deleted_post(post)
topic_page.click_post_action_button(post, :delete)
expect(topic_page).to have_deleted_post(post)
end
it "deletes a post" do
sign_in(admin)
topic_page.visit_topic(post.topic)
expect(topic_page).to have_no_deleted_post(post2)
topic_page.click_post_action_button(post2, :delete)
expect(topic_page).to have_deleted_post(post2)
end
it "shows a flag to delete message when the user is the author but can't delete it without permission" do
other_topic = Fabricate(:topic)
other_p1 = Fabricate(:post, topic: other_topic, user: user)
other_p2 = Fabricate(:post, topic: other_topic, user: Fabricate(:user))
sign_in(user)
topic_page.visit_topic(other_topic)
expect(topic_page).to have_post_action_button(other_p1, :delete)
expect(topic_page).to have_post_action_button(other_p1, :flag)
topic_page.click_post_action_button(other_p1, :delete)
expect(topic_page).to have_no_deleted_post(other_p1)
expect(modal).to be_open
expect(modal).to have_content(I18n.t("js.post.controls.delete_topic_disallowed_modal"))
end
it "recovers a topic" do
PostDestroyer.new(admin, post).destroy
sign_in(admin)
topic_page.visit_topic(post.topic)
expect(topic_page).to have_deleted_post(post)
topic_page.click_post_action_button(post, :recover)
expect(topic_page).to have_no_deleted_post(post)
end
it "recovers a post" do
sign_in(admin)
topic_page.visit_topic(post.topic)
expect(topic_page).to have_no_deleted_post(post2)
topic_page.click_post_action_button(post2, :delete)
expect(topic_page).to have_deleted_post(post2)
topic_page.click_post_action_button(post2, :recover)
expect(topic_page).to have_no_deleted_post(post2)
end
end
describe "edit" do
before do
SiteSetting.edit_post_allowed_groups = Group::AUTO_GROUPS[:trust_level_0]
SiteSetting.edit_wiki_post_allowed_groups = Group::AUTO_GROUPS[:trust_level_0]
SiteSetting.post_menu_hidden_items = ""
end
it "displays the edit button only when the user can edit the post" do
# do not display the edit button when unlogged
topic_page.visit_topic(post.topic)
expect(topic_page).to have_no_post_action_button(post, :edit)
expect(topic_page).to have_no_post_action_button(post2, :edit)
# display the edit button only for the post that `user` can edit
sign_in(user)
topic_page.visit_topic(post.topic)
expect(topic_page).to have_no_post_action_button(post, :edit)
expect(topic_page).to have_post_action_button(post2, :edit)
# display the edit button for the all the posts because an admin is logged
sign_in(admin)
topic_page.visit_topic(post.topic)
expect(topic_page).to have_post_action_button(post, :edit)
expect(topic_page).to have_post_action_button(post2, :edit)
end
it "displays the edit button properly when the topic is a wiki" do
wiki_topic = Fabricate(:topic)
wiki_post = Fabricate(:post, topic: wiki_topic, user: Fabricate(:user), wiki: true)
non_wiki_post = Fabricate(:post, topic: wiki_topic, user: Fabricate(:user), wiki: false)
# do not display the edit button when unlogged
topic_page.visit_topic(wiki_post.topic)
expect(topic_page).to have_no_post_action_button(wiki_post, :edit)
expect(topic_page).to have_no_post_action_button(non_wiki_post, :edit)
# display the edit button only for the post that `user` can edit
sign_in(user)
topic_page.visit_topic(wiki_post.topic)
expect(topic_page).to have_post_action_button(wiki_post, :edit)
expect(topic_page).to have_no_post_action_button(non_wiki_post, :edit)
end
it "works as expected" do
sign_in(user)
topic_page.visit_topic(post2.topic)
expect(composer).to be_closed
topic_page.click_post_action_button(post2, :edit)
expect(composer).to be_opened
expect(composer).to have_content(post2.raw)
end
end
describe "flag" do
before { SiteSetting.post_menu_hidden_items = "" }
it "displays the flag button only when the user can flag the post" do
# do not display the edit button when unlogged
topic_page.visit_topic(post.topic)
expect(topic_page).to have_no_post_action_button(post, :flag)
expect(topic_page).to have_no_post_action_button(post2, :flag)
# display the flag button only for the post that `user` can flag
sign_in(user)
topic_page.visit_topic(post.topic)
expect(topic_page).to have_post_action_button(post, :flag)
expect(topic_page).to have_post_action_button(post2, :flag)
# display the flag button for the all the posts because an admin is logged
sign_in(admin)
topic_page.visit_topic(post.topic)
expect(topic_page).to have_post_action_button(post, :flag)
expect(topic_page).to have_post_action_button(post2, :flag)
end
it "works as expected" do
sign_in(user)
topic_page.visit_topic(post2.topic)
expect(flag_modal).to be_closed
topic_page.click_post_action_button(post2, :flag)
expect(flag_modal).to be_open
end
end
describe "like" do
it "toggles liking a post" do
unliked_post = Fabricate(:post, topic: topic, like_count: 0)
sign_in(user)
topic_page.visit_topic(unliked_post.topic)
expect(topic_page).to have_post_action_button(unliked_post, :like)
like_button = topic_page.find_post_action_button(unliked_post, :like)
expect(like_button[:class].split("\s")).to include("like")
expect(topic_page).to have_no_post_action_button(unliked_post, :like_count)
# toggle the like on
topic_page.click_post_action_button(unliked_post, :like)
# we need this because the find_post_action_button will target the like button on or off
try_until_success do
like_button = topic_page.find_post_action_button(unliked_post, :like)
expect(like_button[:class].split("\s")).to include("has-like")
end
like_count_button = topic_page.find_post_action_button(unliked_post, :like_count)
expect(like_count_button).to have_content(1)
# toggle the like off
topic_page.click_post_action_button(unliked_post, :like)
# we need this because the find_post_action_button will target the like button on or off
try_until_success do
like_button = topic_page.find_post_action_button(unliked_post, :like)
expect(like_button[:class].split("\s")).to include("like")
end
expect(topic_page).to have_no_post_action_button(unliked_post, :like_count)
end
it "displays the login dialog when the user is anonymous" do
topic_page.visit_topic(post2.topic)
expect(topic_page).to have_post_action_button(post2, :like)
like_button = topic_page.find_post_action_button(post2, :like)
expect(like_button[:title]).to eq(I18n.t("js.post.controls.like"))
expect(topic_page).to have_no_post_action_button(post2, :like_count)
# clicking on the like button should display the login modal
topic_page.click_post_action_button(post2, :like)
expect(login_modal).to be_open
end
it "renders the like count as expected" do
topic_page.visit_topic(post.topic)
# renders the like count when the it's not zero
like_count_button = topic_page.find_post_action_button(post, :like_count)
expect(like_count_button).to have_content(post.like_count)
# does not render the like count when it's zero
expect(topic_page).to have_no_post_action_button(post2, :like_count)
end
it "toggles the users who liked when clicking on the like count" do
PostActionCreator.like(user, post)
PostActionCreator.like(admin, post)
topic_page.visit_topic(post.topic)
expect(topic_page).to have_no_who_liked_on_post(post)
# toggle users who liked on
topic_page.click_post_action_button(post, :like_count)
expect(topic_page).to have_who_liked_on_post(post, count: 2)
# toggle users who liked off
topic_page.click_post_action_button(post, :like_count)
expect(topic_page).to have_no_who_liked_on_post(post)
end
end
describe "read" do
fab!(:group) { Fabricate(:group, publish_read_state: true) }
fab!(:group_user) { Fabricate(:group_user, group: group, user: user) }
fab!(:group_user2) { Fabricate(:group_user, group: group, user: Fabricate(:user)) }
fab!(:pm) { Fabricate(:private_message_topic, allowed_groups: [group]) }
fab!(:pm_post1) { Fabricate(:post, topic: pm, user: user, reads: 2, created_at: 1.day.ago) }
fab!(:pm_post2) { Fabricate(:post, topic: pm, user: group_user2.user, reads: 0) }
before do
SiteSetting.post_menu = "read|like|copyLink|share|flag|edit|bookmark|delete|admin|reply"
sign_in(user)
end
it "shows the read indicator when expected" do
topic_page.visit_topic(pm)
# it shows the read indicator on group pms where publish_read_state = true
# when the post has reads > 0
expect(topic_page).to have_post_action_button(pm_post1, :read)
read_button = topic_page.find_post_action_button(pm_post1, :read)
expect(read_button).to have_content(1)
# don't show when the post has reads = 0
expect(topic_page).to have_no_post_action_button(pm_post2, :read)
# dont't show on regular posts
topic_page.visit_topic(post.topic)
expect(topic_page).to have_no_post_action_button(post, :read)
expect(topic_page).to have_no_post_action_button(post2, :read)
end
it "toggles the users who read when clicking on the read button" do
TopicUser.update_last_read(user, pm.id, 1, 1, 1)
TopicUser.update_last_read(admin, pm.id, 2, 1, 1)
topic_page.visit_topic(pm)
expect(topic_page).to have_no_who_read_on_post(pm_post1)
# toggle users who read on
topic_page.click_post_action_button(pm_post1, :read)
expect(topic_page).to have_who_read_on_post(pm_post1, count: 1)
# toggle users who read off
topic_page.click_post_action_button(pm_post1, :read)
expect(topic_page).to have_no_who_read_on_post(pm_post1)
end
end
describe "replies" do
fab!(:reply_to_post) do
PostCreator.new(
Fabricate(:user),
raw: "Just a reply to the OP",
topic_id: topic.id,
reply_to_post_number: post.post_number,
).create
end
fab!(:post_with_reply, reload: true) do
PostCreator.new(topic.user, raw: Fabricate.build(:post).raw, topic_id: topic.id).create
end
fab!(:reply_to_post_with_reply) do
PostCreator.new(
Fabricate(:user),
raw: "A reply directly below the post",
topic_id: topic.id,
reply_to_post_number: post_with_reply.post_number,
).create
end
it "doesn't display the replies button when there are no replies" do
topic_page.visit_topic(post.topic)
expect(topic_page).to have_no_post_action_button(post2, :replies)
end
it "is disabled when the post is deleted" do
PostDestroyer.new(admin, post).destroy
sign_in(admin)
topic_page.visit_topic(post.topic)
expect(topic_page).to have_post_action_button(post, :replies)
replies_button = topic_page.find_post_action_button(post, :replies)
expect(replies_button[:disabled]).to eq("true")
end
it "displays the replies when clicked" do
topic_page.visit_topic(post.topic)
expect(topic_page).to have_replies_collapsed(post)
topic_page.click_post_action_button(post, :replies)
expect(topic_page).to have_replies_expanded(post)
replies_button = topic_page.find_post_action_button(post, :replies)
expect(replies_button[:ariaExpanded]).to eq("true")
expect(replies_button[:ariaPressed]).to eq("true")
end
it "is displayed correctly when the reply is directly below" do
# it is displayed when there is only one reply directly below and the setting `suppress_reply_directly_below`
# is disabled
SiteSetting.suppress_reply_directly_below = false
topic_page.visit_topic(post_with_reply.topic)
expect(topic_page).to have_post_action_button(post_with_reply, :replies)
# it is not displayed when there is only one reply directly below and the setting
# `suppress_reply_directly_below` is enabled
SiteSetting.suppress_reply_directly_below = true
topic_page.visit_topic(post_with_reply.topic)
expect(topic_page).to have_no_post_action_button(post_with_reply, :replies)
# it is displayed when there is more than one reply directly below
PostCreator.new(
Fabricate(:user),
raw: "A reply directly below the post",
topic_id: topic.id,
reply_to_post_number: post_with_reply.post_number,
).create
topic_page.visit_topic(post_with_reply.topic)
expect(topic_page).to have_post_action_button(post_with_reply, :replies)
end
end
describe "reply" do
before { SiteSetting.post_menu_hidden_items = "" }
it "displays the reply button only when the user can reply the post" do
# do not display the reply button when unlogged
topic_page.visit_topic(post.topic)
expect(topic_page).to have_no_post_action_button(post, :reply)
expect(topic_page).to have_no_post_action_button(post2, :reply)
# display the reply button when the user can reply to the post
sign_in(user)
topic_page.visit_topic(post.topic)
expect(topic_page).to have_post_action_button(post, :reply)
expect(topic_page).to have_post_action_button(post2, :reply)
end
it "displays the reply button less prominently when the topic is a wiki" do
wiki_topic = Fabricate(:topic)
wiki_post = Fabricate(:post, topic: wiki_topic, user: Fabricate(:user), wiki: true)
non_wiki_post = Fabricate(:post, topic: wiki_topic, user: Fabricate(:user), wiki: false)
# do not display the reply button when unlogged
topic_page.visit_topic(wiki_post.topic)
expect(topic_page).to have_no_post_action_button(wiki_post, :reply)
expect(topic_page).to have_no_post_action_button(non_wiki_post, :reply)
# display the reply button only for the post that `user` can reply
sign_in(user)
topic_page.visit_topic(wiki_post.topic)
expect(topic_page).to have_post_action_button(wiki_post, :reply)
expect(topic_page).to have_post_action_button(non_wiki_post, :reply)
# display the reply button less prominently for the wiki post, i.e. it's not a create button
# and the label is not displayed
wiki_post_reply_button = topic_page.find_post_action_button(wiki_post, :reply)
expect(wiki_post_reply_button[:class].split("\s")).not_to include("create")
expect(wiki_post_reply_button).to have_no_content(I18n.t("js.topic.reply.title"))
# display the reply as a create button for the non-wiki post and the label should be displayed
non_wiki_post_reply_button = topic_page.find_post_action_button(non_wiki_post, :reply)
expect(non_wiki_post_reply_button[:class].split("\s")).to include("create")
expect(non_wiki_post_reply_button).to have_content(I18n.t("js.topic.reply.title"))
end
it "works as expected" do
sign_in(user)
topic_page.visit_topic(post.topic)
expect(composer).to be_closed
topic_page.click_post_action_button(post, :reply)
expect(composer).to be_opened
expect(composer).to have_content("")
end
end
describe "share" do
it "works as expected" do
topic_page.visit_topic(post.topic)
expect(topic_page).to have_post_action_button(post, :share)
topic_page.click_post_action_button(post, :share)
expect(page).to have_css(".d-modal.share-topic-modal")
end
end
describe "show more" do
before do
sign_in(admin)
SiteSetting.post_menu = "like|copyLink|share|flag|edit|bookmark|delete|admin|reply"
SiteSetting.post_menu_hidden_items = "flag|bookmark|edit|delete|admin"
end
it "is not displayed when `post_menu_hidden_items` is empty" do
SiteSetting.post_menu_hidden_items = ""
topic_page.visit_topic(post.topic)
expect(topic_page).to have_no_post_action_button(post, :show_more)
end
it "is not displayed when there is only one hidden button" do
SiteSetting.post_menu_hidden_items = "admin"
topic_page.visit_topic(post.topic)
expect(topic_page).to have_no_post_action_button(post, :show_more)
end
it "works as expected" do
topic_page.visit_topic(post.topic)
expect(topic_page).to have_no_post_action_button(post, :flag)
expect(topic_page).to have_no_post_action_button(post, :bookmark)
expect(topic_page).to have_no_post_action_button(post, :edit)
expect(topic_page).to have_no_post_action_button(post, :delete)
expect(topic_page).to have_no_post_action_button(post, :admin)
expect(topic_page).to have_post_action_button(post, :show_more)
topic_page.click_post_action_button(post, :show_more)
expect(topic_page).to have_post_action_button(post, :flag)
expect(topic_page).to have_post_action_button(post, :bookmark)
expect(topic_page).to have_post_action_button(post, :edit)
expect(topic_page).to have_post_action_button(post, :delete)
expect(topic_page).to have_post_action_button(post, :admin)
end
end
end
end
end