粘性用户卡片内容主题

我创建了一个更丰富的用户卡片内容主题,并添加了一些自定义用户字段。我希望让卡片保持固定,并添加一个关闭按钮,类似于“创建新主题”组件的实现方式。

我这样理解是否正确:我需要覆盖 user-card-contents.js 文件,以阻止关闭元素的调用?我能否将这部分内容打包到主题中?

谢谢!

嘿,Pete。欢迎来到 Meta :wave:

简短的回答是:可以。如果你的更改仅影响前端,那么可以在主题或组件中完成。

能否请你详细说明一下你说的“sticky”是什么意思?如果你是指希望它随内容滚动但保持在同一位置,那么这需要通过 CSS 来实现。另外,这项更改是否打算同时应用于桌面端和移动端?

能否请你具体描述一下你指的是哪个调用?(例如点击时、滚动时等)

关闭按钮的 HTML 结构需要添加到用户卡片的 Handlebars 模板中。处理按钮点击事件的逻辑则需要添加到组件的 .js 文件中。

可能不需要完全覆盖,有一些钩子(hooks)可以用来覆盖类中的特定方法。如果你能更详细地描述一下你想实现的功能,我可以分享更多关于这方面的信息。

谢谢你的欢迎,Joe!

我的目标是在桌面端阻止 card-contents-base.js 混入中的事件处理器 clickOutsideEventName 关闭卡片。相反,我希望强制用户点击按钮来关闭它。对于移动端,我可能需要采用不同的处理方式。

我已经让这个 Handlebars 模板运行起来了,接下来要解决的是 .js 部分的问题 :slight_smile:

TL;DR I think this is what you’re after

Theme JS

api.modifyClass("component:user-card-contents", {
  didInsertElement() {
    this._super(...arguments);
    $("html").off(this.clickOutsideEventName);
  },
  actions: {
    closeCard() {
      this._close();
    }
  }
});

and then add this to your template somewhere

{{d-button
  class="btn-flat"
  action=(action "closeCard")
  icon="times"
}}

The long version

You probably already know most of this since you’ve already worked on your theme but I will try to keep it a little bit more detailed for the broader audience.

The first thing I’d do is to search either locally or on Github. On Github you’d get something like this. In most cases, a search term will have more than one result and you’d either have to be more specific or manually scan the result to find something close to what you want.

So, now we end up on this file

discourse/app/assets/javascripts/discourse/app/mixins/card-contents-base.js at e5dc843185feb268c277bb0ee4db9666d6452783 · discourse/discourse · GitHub

This file is a Mixin. Why do I mention this? Because you need to be aware that mixins can be shared in a number of different places. In this case, it’s used for both user cards and group cards. So, the changes you make here will affect both.

If you search in the file, you’ll find that clickOutsideEventName is first defined here

discourse/app/assets/javascripts/discourse/app/mixins/card-contents-base.js at e5dc843185feb268c277bb0ee4db9666d6452783 · discourse/discourse · GitHub

then passed on here as a property

discourse/app/assets/javascripts/discourse/app/mixins/card-contents-base.js at e5dc843185feb268c277bb0ee4db9666d6452783 · discourse/discourse · GitHub

and then finally consumed here

discourse/app/assets/javascripts/discourse/app/mixins/card-contents-base.js at e5dc843185feb268c277bb0ee4db9666d6452783 · discourse/discourse · GitHub

Cool, but what does all of that mean? Well if you look at where all of this code is added, you’ll notice that it’s inside didInsertElement

discourse/app/assets/javascripts/discourse/app/mixins/card-contents-base.js at e5dc843185feb268c277bb0ee4db9666d6452783 · discourse/discourse · GitHub

The Ember guides state

Ember guarantees that, by the time didInsertElement() is called:

  1. The component’s element has been both created and inserted into the DOM.
  2. The component’s element is accessible via the component’s this.element property.

Why do we need this? Because we need a different mousedown handler for user cards and group cards. If we go back a bit, you’ll now notice that the id of the element is used in the clickOutsideEventName

Which like we discussed above is then passed on as a property.

Now, let’s move on to how all of this relates to what you’re doing.

You’re trying to prevent the cards from being closed when the user clicks outside of them. So, let’s try to find out a way to do that. If you recall, we discussed how clickOutsideEventName eventually gets consumed here

In a nutshell this adds a mousedown handler to the HTML element when a card is inserted (on the first page view). We then check the target of the mousedown event. If the target is somewhere in the card, we bail. If it’s outside of the card, we close it by calling this._close()

_close() is defined here

discourse/app/assets/javascripts/discourse/app/mixins/card-contents-base.js at e5dc843185feb268c277bb0ee4db9666d6452783 · discourse/discourse · GitHub

And this is what you’ll need to call when you add your close button - but we’ll get back to that later.

Now, the goal is to remove this mousedown handler if you want clicks outside the card not to close it. So how do we do this? Well, we’ll need to modify didInsertElement() and here’s how that can be done.

Discourse themes have access to the plugin API, which contains many methods that you can use. There’s a bit more details about the most commonly used ones here

If you remember, the file we’re working with is a Mixin. A Mixin is an Ember class. So, the method we’re going to use is modifyClass

https://github.com/discourse/discourse/blob/master/app/assets/javascripts/discourse/app/lib/plugin-api.js#L121-L124

When you use modifyClass you can either add, modify or completely override a class method. We’ll focus on modifying a method since that’s what you want to do.

We want to modify didInsertElement() so we can do something like this

api.modifyClass('mixin:card-contents-base', {
  didInsertElement() {
    console.log("foo");
  }
});

and try it out…

well that didn’t work.

why is that? Well it turns out that the modifyClass method doesn’t currently support modifying Mixins (as far as I’ve tested) I will make a note to figure out why that’s the case and check if we can fix that. For now though, let’s get back to what you want to do.

Well, we can’t modify Mixins, so I guess we’re stuck, yeah? No. Let’s dig a little bit deeper.

Like I mentioned before, that Mixin is used by both the user user-card-contents and group-card-contents Ember components (again, because Mixins are designed to make code reusable)

So, let’s look at the user-card-contents component here

discourse/app/assets/javascripts/discourse/app/components/user-card-contents.js at e5dc843185feb268c277bb0ee4db9666d6452783 · discourse/discourse · GitHub

If you read carefully, you’ll notice that we first import the Mixin we discussed above here

and then create a new Ember component and pass the Mixin to it.

What does that mean? it means that didInsertElement() for user-card-contents is actually inherited from the Mixin. The same can be said about group-card-contents.

Where does that leave us? Well, there’s good news and bad news. The good news is that if you want to make changes to the user-card-contents without affecting group-card-contents then you can! The bad news is that if you want your changes to apply to both, then you’ll have to duplicate some code.

Let’s go back to modifyClass and try again with user-card-contents. So something like this:

api.modifyClass('component:user-card-contents', {
  didInsertElement() {
    console.log("foo");
  }
});

and see what happens…

voalá :tada:

The change is registered and we can see it in the console, but we’re not quite there yet.

So, now that we can modify didInsertElement(), let’s try to get back to what you’re trying to do. If you remember, the mousedown handler is defined in didInsertElement here

discourse/app/assets/javascripts/discourse/app/mixins/card-contents-base.js at e5dc843185feb268c277bb0ee4db9666d6452783 · discourse/discourse · GitHub

So what can we do about it? Well, it’s a simple as this

$("html").off(clickOutsideEventName)

Will that work though? Nope. If you do this

api.modifyClass('component:user-card-contents', {
  didInsertElement() {
    $("html").off(clickOutsideEventName)
  }
});

You’ll end up with something broken. Why is that? because that’s not a modification of the method. That’s a full override of the core didInsertElement() method for that component. So, none of the code in core is actually applied if you do this.

How do we fix this? Well it turns out that Ember has a thing called this._super(...arguments)

What does that do? it allows you to append or prepend code in addition to what the class method already has. For example, if you do this

api.modifyClass('component:user-card-contents', {
  didInsertElement() {
    // code you want to add
    $("html").off(clickOutsideEventName);
    // code from core
    this._super(...arguments);
  }
});

then the code you want to add will run before anything else here

discourse/app/assets/javascripts/discourse/app/mixins/card-contents-base.js at e5dc843185feb268c277bb0ee4db9666d6452783 · discourse/discourse · GitHub

However, if you do this…

api.modifyClass('component:user-card-contents', {
  didInsertElement() {
    // code from core
    this._super(...arguments);
    // code you want to add
    $("html").off(clickOutsideEventName);
  }
});

then your code will run after everything here runs

discourse/app/assets/javascripts/discourse/app/mixins/card-contents-base.js at e5dc843185feb268c277bb0ee4db9666d6452783 · discourse/discourse · GitHub

This is a great way to keep your theme resilient to changes in core since everything in core runs first, then your code runs after that. So, let’s try this again and see what happens.

api.modifyClass('component:user-card-contents', {
  didInsertElement() {
    // code from core
    this._super(...arguments);
    // code you want to add
    $("html").off(clickOutsideEventName);
  }
});

and…

Why is this happening? it’s because of different code context. In the Ember component file clickOutsideEventName is already defined by the time it’s consumed. Your theme is in a different file, so clickOutsideEventName is not defined there.

How do we fix it? Remember this?

clickOutsideEventName is a property of the component, so if you use this.clickOutsideEventName then it should work. Let’s try that.

api.modifyClass('component:user-card-contents', {
  didInsertElement() {
    // code from core
    this._super(...arguments);
    // code you want to add
    $("html").off(this.clickOutsideEventName);
  },
});

And indeed it works :tada:

The user cards now open without any errors and clicking anywhere outside doesn’t do anything.

You can do the exact same thing for group cards like so

api.modifyClass('component:group-card-contents', {
  didInsertElement() {
    // code from core
    this._super(...arguments);
    // code you want to add
    $("html").off(this.clickOutsideEventName);
  },
});

The only thing left is to hookup that close button to the _close() method we discussed earlier and there are three steps to this.

  1. add a closeCard action (you can name whatever you want)
  2. add a button to the user card template
  3. call that action when the button is clicked.

I’ll stick with the user cards for simplicity. So, we add this (along with what we already discussed)

api.modifyClass("component:user-card-contents", {
  didInsertElement() {
    this._super(...arguments);
    $("html").off(this.clickOutsideEventName);
  },
  // new stuff 
  actions: {
    closeCard() {
      this._close();
    }
  }
});

all that does is that it calls the core _close() method whenever the closeCard custom action is triggered.

Next we need to add a button to the user card template or something like this

{{d-button
  class="btn-flat"
  action=(action "closeCard")
  icon="times"
}}

My rough results look like this

And of course you can do something similar for group cards like I mentioned above.

I’ll leave the group card and mobile implementation as exercise for you since you’d essentially use the exact same concepts we discussed above if you want to make changes to those but please let me know if you run into any problems.

非常感谢,这真是一个出色的教程!我真的很感激。我之前并没有意识到插件 API 的存在。

上面有一点并不那么显而易见:需要将主题 JS 的更改包裹在 <script> 标签中,并将其放入插件的 common/head_tag.html 文件中:

<script type="text/discourse-plugin" version="0.2">
</script>

只是出于好奇,标签中的版本号在这里重要吗?另外,是否总是最好将这些内容放在 head_tag.html 而不是 header.html 中,还是说这其实并不重要?

谢谢!

@Johani 如果您愿意,我可以为此另开一个线程,但我正在尝试追踪客户端的 user-card 控制器,该控制器:

  • 在首次点击时加载用户卡片
  • 并在第二次点击时重定向到用户摘要

我的目标是移除第二次点击的功能。谢谢!