DEV: Chat service object initial implementation (#19814)

This is a combined work of Martin Brennan, Loïc Guitaut, and Joffrey Jaffeux.

---

This commit implements a base service object when working in chat. The documentation is available at https://discourse.github.io/discourse/chat/backend/Chat/Service.html

Generating documentation has been made as part of this commit with a bigger goal in mind of generally making it easier to dive into the chat project.

Working with services generally involves 3 parts:

- The service object itself, which is a series of steps where few of them are specialized (model, transaction, policy)

```ruby
class UpdateAge
  include Chat::Service::Base

  model :user, :fetch_user
  policy :can_see_user
  contract
  step :update_age

  class Contract
    attribute :age, :integer
  end

  def fetch_user(user_id:, **)
    User.find_by(id: user_id)
  end

  def can_see_user(guardian:, **)
    guardian.can_see_user(user)
  end

  def update_age(age:, **)
    user.update!(age: age)
  end
end
```

- The `with_service` controller helper, handling success and failure of the service within a service and making easy to return proper response to it from the controller

```ruby
def update
  with_service(UpdateAge) do
    on_success { render_serialized(result.user, BasicUserSerializer, root: "user") }
  end
end
```

- Rspec matchers and steps inspector, improving the dev experience while creating specs for a service

```ruby
RSpec.describe(UpdateAge) do
  subject(:result) do
    described_class.call(guardian: guardian, user_id: user.id, age: age)
  end

  fab!(:user) { Fabricate(:user) }
  fab!(:current_user) { Fabricate(:admin) }

  let(:guardian) { Guardian.new(current_user) }
  let(:age) { 1 }

   it { expect(user.reload.age).to eq(age) }
end
```

Note in case of unexpected failure in your spec, the output will give all the relevant information:

```
  1) UpdateAge when no channel_id is given is expected to fail to find a model named 'user'
     Failure/Error: it { is_expected.to fail_to_find_a_model(:user) }

       Expected model 'foo' (key: 'result.model.user') was not found in the result object.

       [1/4] [model] 'user' 
       [2/4] [policy] 'can_see_user'
       [3/4] [contract] 'default'
       [4/4] [step] 'update_age'

       /Users/joffreyjaffeux/Code/pr-discourse/plugins/chat/app/services/update_age.rb:32:in `fetch_user': missing keyword: :user_id (ArgumentError)
       	from /Users/joffreyjaffeux/Code/pr-discourse/plugins/chat/app/services/base.rb:202:in `instance_exec'
       	from /Users/joffreyjaffeux/Code/pr-discourse/plugins/chat/app/services/base.rb:202:in `call'
       	from /Users/joffreyjaffeux/Code/pr-discourse/plugins/chat/app/services/base.rb:219:in `call'
       	from /Users/joffreyjaffeux/Code/pr-discourse/plugins/chat/app/services/base.rb:417:in `block in run!'
       	from /Users/joffreyjaffeux/Code/pr-discourse/plugins/chat/app/services/base.rb:417:in `each'
       	from /Users/joffreyjaffeux/Code/pr-discourse/plugins/chat/app/services/base.rb:417:in `run!'
       	from /Users/joffreyjaffeux/Code/pr-discourse/plugins/chat/app/services/base.rb:411:in `run'
       	from <internal:kernel>:90:in `tap'
       	from /Users/joffreyjaffeux/Code/pr-discourse/plugins/chat/app/services/base.rb:302:in `call'
       	from /Users/joffreyjaffeux/Code/pr-discourse/plugins/chat/spec/services/update_age_spec.rb:15:in `block (3 levels) in <main>'
```
This commit is contained in:
Martin Brennan
2023-02-13 22:09:57 +10:00
committed by GitHub
parent 81a4d75f06
commit 60ad836313
85 changed files with 14567 additions and 932 deletions

View File

@ -1,5 +1,3 @@
/** @module Collection */
import { ajax } from "discourse/lib/ajax";
import { tracked } from "@glimmer/tracking";
import { bind } from "discourse-common/utils/decorators";
@ -7,19 +5,12 @@ import { Promise } from "rsvp";
/**
* Handles a paginated API response.
*
* @class
*/
export default class Collection {
@tracked items = [];
@tracked meta = {};
@tracked loading = false;
/**
* Create a Collection instance
* @param {string} resourceURL - the API endpoint to call
* @param {callback} handler - anonymous function used to handle the response
*/
constructor(resourceURL, handler) {
this._resourceURL = resourceURL;
this._handler = handler;

View File

@ -5,6 +5,66 @@ import {
} from "discourse/plugins/chat/discourse/components/chat-message";
import { registerChatComposerButton } from "discourse/plugins/chat/discourse/lib/chat-composer-buttons";
/**
* Class exposing the javascript API available to plugins and themes.
* @class PluginApi
*/
/**
* Callback used to decorate a chat message
*
* @callback PluginApi~decorateChatMessageCallback
* @param {ChatMessage} chatMessage - model
* @param {HTMLElement} messageContainer - DOM node
* @param {ChatChannel} chatChannel - model
*/
/**
* Decorate a chat message
*
* @memberof PluginApi
* @instance
* @function decorateChatMessage
* @param {PluginApi~decorateChatMessageCallback} decorator
* @example
*
* api.decorateChatMessage((chatMessage, messageContainer) => {
* messageContainer.dataset.foo = chatMessage.id;
* });
*/
/**
* Register a button in the chat composer
*
* @memberof PluginApi
* @instance
* @function registerChatComposerButton
* @param {Object} options
* @param {number} options.id - The id of the button
* @param {function} options.action - An action name or an anonymous function called when the button is pressed, eg: "onFooClicked" or `() => { console.log("clicked") }`
* @param {string} options.icon - A valid font awesome icon name, eg: "far fa-image"
* @param {string} options.label - Text displayed on the button, a translatable key, eg: "foo.bar"
* @param {string} options.translatedLabel - Text displayed on the button, a string, eg: "Add gifs"
* @param {string} [options.position] - Can be "inline" or "dropdown", defaults to "inline"
* @param {string} [options.title] - Title attribute of the button, a translatable key, eg: "foo.bar"
* @param {string} [options.translatedTitle] - Title attribute of the button, a string, eg: "Add gifs"
* @param {string} [options.ariaLabel] - aria-label attribute of the button, a translatable key, eg: "foo.bar"
* @param {string} [options.translatedAriaLabel] - aria-label attribute of the button, a string, eg: "Add gifs"
* @param {string} [options.classNames] - Additional names to add to the button’s class attribute, eg: ["foo", "bar"]
* @param {boolean} [options.displayed] - Hide or show the button
* @param {boolean} [options.disabled] - Sets the disabled attribute on the button
* @param {number} [options.priority] - An integer defining the order of the buttons, higher comes first, eg: `700`
* @param {Array.<string>} [options.dependentKeys] - List of property names which should trigger a refresh of the buttons when changed, eg: `["foo.bar", "bar.baz"]`
* @example
*
* api.registerChatComposerButton({
* id: "foo",
* displayed() {
* return this.site.mobileView && this.canAttachUploads;
* }
* });
*/
export default {
name: "chat-plugin-api",
after: "inject-discourse-objects",

View File

@ -14,7 +14,8 @@ export default function withChatChannel(extendedClass) {
this.controllerFor("chat-channel").set("targetMessageId", null);
this.chat.activeChannel = model;
let { messageId } = this.paramsFor(this.routeName);
let { messageId, channelTitle } = this.paramsFor(this.routeName);
// messageId query param backwards-compatibility
if (messageId) {
this.router.replaceWith(
@ -24,7 +25,6 @@ export default function withChatChannel(extendedClass) {
);
}
const { channelTitle } = this.paramsFor("chat.channel");
if (channelTitle && channelTitle !== model.slugifiedTitle) {
const nearMessageParams = this.paramsFor("chat.channel.near-message");
if (nearMessageParams.messageId) {

View File

@ -1,5 +1,3 @@
/** @module ChatApi */
import Service, { inject as service } from "@ember/service";
import { ajax } from "discourse/lib/ajax";
import UserChatChannelMembership from "discourse/plugins/chat/discourse/models/user-chat-channel-membership";
@ -8,7 +6,7 @@ import Collection from "../lib/collection";
/**
* Chat API service. Provides methods to interact with the chat API.
*
* @class
* @module ChatApi
* @implements {@ember/service}
*/
export default class ChatApi extends Service {
@ -31,7 +29,7 @@ export default class ChatApi extends Service {
/**
* List all accessible category channels of the current user.
* @returns {module:Collection}
* @returns {Collection}
*
* @example
*
@ -70,17 +68,14 @@ export default class ChatApi extends Service {
/**
* Destroys a channel.
* @param {number} channelId - The ID of the channel.
* @param {string} channelName - The name of the channel to be destroyed, used as confirmation.
* @returns {Promise}
*
* @example
*
* this.chatApi.destroyChannel(1, "foo").then(() => { ... })
* this.chatApi.destroyChannel(1).then(() => { ... })
*/
destroyChannel(channelId, channelName) {
return this.#deleteRequest(`/channels/${channelId}`, {
channel: { name_confirmation: channelName },
});
destroyChannel(channelId) {
return this.#deleteRequest(`/channels/${channelId}`);
}
/**
@ -174,7 +169,7 @@ export default class ChatApi extends Service {
/**
* Lists members of a channel.
* @param {number} channelId - The ID of the channel.
* @returns {module:Collection}
* @returns {Collection}
*/
listChannelMemberships(channelId) {
return new Collection(