Commit b4c4b48a authored by Bob Van Landuyt's avatar Bob Van Landuyt

Allow users to set a status

This can be done trough the API for the current user, or on the
profile page.
parent 812bfb15
...@@ -100,7 +100,8 @@ class ProfilesController < Profiles::ApplicationController ...@@ -100,7 +100,8 @@ class ProfilesController < Profiles::ApplicationController
:website_url, :website_url,
:organization, :organization,
:preferred_language, :preferred_language,
:private_profile :private_profile,
status: [:emoji, :message]
) )
end end
end end
...@@ -141,6 +141,8 @@ class User < ActiveRecord::Base ...@@ -141,6 +141,8 @@ class User < ActiveRecord::Base
has_many :term_agreements has_many :term_agreements
belongs_to :accepted_term, class_name: 'ApplicationSetting::Term' belongs_to :accepted_term, class_name: 'ApplicationSetting::Term'
has_one :status, class_name: 'UserStatus'
# #
# Validations # Validations
# #
......
...@@ -9,5 +9,5 @@ class UserStatus < ActiveRecord::Base ...@@ -9,5 +9,5 @@ class UserStatus < ActiveRecord::Base
validates :emoji, inclusion: { in: Gitlab::Emoji.emojis_names } validates :emoji, inclusion: { in: Gitlab::Emoji.emojis_names }
validates :message, length: { maximum: 100 }, allow_blank: true validates :message, length: { maximum: 100 }, allow_blank: true
cache_markdown_field :message, pipeline: :single_line cache_markdown_field :message, pipeline: :emoji
end end
...@@ -16,6 +16,7 @@ class UserPolicy < BasePolicy ...@@ -16,6 +16,7 @@ class UserPolicy < BasePolicy
rule { ~subject_ghost & (user_is_self | admin) }.policy do rule { ~subject_ghost & (user_is_self | admin) }.policy do
enable :destroy_user enable :destroy_user
enable :update_user enable :update_user
enable :update_user_status
end end
rule { default }.enable :read_user_profile rule { default }.enable :read_user_profile
......
# frozen_string_literal: true
module Users
class SetStatusService
include Gitlab::Allowable
attr_reader :current_user, :target_user, :params
def initialize(current_user, params)
@current_user, @params = current_user, params.dup
@target_user = params.delete(:user) || current_user
end
def execute
return false unless can?(current_user, :update_user_status, target_user)
if params[:emoji].present? || params[:message].present?
set_status
else
remove_status
end
end
private
def set_status
user_status.update(params)
end
def remove_status
UserStatus.delete(target_user.id)
end
def user_status
target_user.status || target_user.build_status
end
end
end
...@@ -7,6 +7,7 @@ module Users ...@@ -7,6 +7,7 @@ module Users
def initialize(current_user, params = {}) def initialize(current_user, params = {})
@current_user = current_user @current_user = current_user
@user = params.delete(:user) @user = params.delete(:user)
@status_params = params.delete(:status)
@params = params.dup @params = params.dup
end end
...@@ -17,10 +18,11 @@ module Users ...@@ -17,10 +18,11 @@ module Users
assign_attributes(&block) assign_attributes(&block)
if @user.save(validate: validate) if @user.save(validate: validate) && update_status
notify_success(user_exists) notify_success(user_exists)
else else
error(@user.errors.full_messages.uniq.join('. ')) messages = @user.errors.full_messages + Array(@user.status&.errors&.full_messages)
error(messages.uniq.join('. '))
end end
end end
...@@ -34,6 +36,12 @@ module Users ...@@ -34,6 +36,12 @@ module Users
private private
def update_status
return true unless @status_params
Users::SetStatusService.new(current_user, @status_params.merge(user: @user)).execute
end
def notify_success(user_exists) def notify_success(user_exists)
notify_new_user(@user, nil) unless user_exists notify_new_user(@user, nil) unless user_exists
......
...@@ -31,6 +31,16 @@ ...@@ -31,6 +31,16 @@
%hr %hr
= link_to _('Remove avatar'), profile_avatar_path, data: { confirm: _('Avatar will be removed. Are you sure?') }, method: :delete, class: 'btn btn-danger btn-inverted' = link_to _('Remove avatar'), profile_avatar_path, data: { confirm: _('Avatar will be removed. Are you sure?') }, method: :delete, class: 'btn btn-danger btn-inverted'
%hr %hr
.row
.col-lg-4.profile-settings-sidebar
%h4.prepend-top-0= s_("User|Current Status")
%p= _("This emoji and message will appear on your profile and throughout the interface.")
.col-lg-8
.row
= f.fields_for :status, @user.status do |status_form|
= status_form.text_field :emoji
= status_form.text_field :message, maxlength: 100
%hr
.row .row
.col-lg-4.profile-settings-sidebar .col-lg-4.profile-settings-sidebar
%h4.prepend-top-0 %h4.prepend-top-0
......
...@@ -440,6 +440,67 @@ GET /user ...@@ -440,6 +440,67 @@ GET /user
} }
``` ```
## User status
Get the status of the currently signed in user.
```
GET /user/status
```
```json
{
"emoji":"coffee",
"message":"I crave coffee"
}
```
## Get the status of a user
Get the status of a user.
```
GET /users/:id_or_username/status
```
```json
{
"emoji":"coffee",
"message":"I crave coffee"
}
```
Parameters:
| Attribute | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `id_or_username` | string | yes | The id or username of the user to get a status of |
## Set user status
Set the status of the current user.
```
PUT /user/status
```
```json
{
"emoji":"coffee",
"message":"I crave coffee"
}
```
Parameters:
| Attribute | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `emoji` | string | no | The name of the emoji to use as status, if omitted `speech_balloon` is used. Emoji name can be one of the specified names in the [Gemojione index][gemojione-index]. |
| `message` | string | no | The message to set as a status |
When both parameters are empty, the status will be cleared.
## List user projects ## List user projects
Please refer to the [List of user projects ](projects.md#list-user-projects). Please refer to the [List of user projects ](projects.md#list-user-projects).
...@@ -1167,3 +1228,5 @@ Example response: ...@@ -1167,3 +1228,5 @@ Example response:
``` ```
Please note that `last_activity_at` is deprecated, please use `last_activity_on`. Please note that `last_activity_at` is deprecated, please use `last_activity_on`.
[gemojione-index]: https://github.com/jonathanwiesel/gemojione/blob/master/config/index.json
...@@ -62,6 +62,11 @@ module API ...@@ -62,6 +62,11 @@ module API
expose :admin?, as: :is_admin expose :admin?, as: :is_admin
end end
class UserStatus < Grape::Entity
expose :emoji
expose :message
end
class Email < Grape::Entity class Email < Grape::Entity
expose :id, :email expose :id, :email
end end
......
...@@ -121,6 +121,17 @@ module API ...@@ -121,6 +121,17 @@ module API
present user, opts present user, opts
end end
desc "Get the status of a user"
params do
requires :id_or_username, type: String, desc: 'The ID or username of the user'
end
get ":id_or_username/status" do
user = find_user(params[:id_or_username])
not_found!('User') unless user && can?(current_user, :read_user, user)
present user.status || {}, with: Entities::UserStatus
end
desc 'Create a user. Available only for admins.' do desc 'Create a user. Available only for admins.' do
success Entities::UserPublic success Entities::UserPublic
end end
...@@ -740,6 +751,30 @@ module API ...@@ -740,6 +751,30 @@ module API
present paginate(activities), with: Entities::UserActivity present paginate(activities), with: Entities::UserActivity
end end
desc 'Set the status of the current user' do
success Entities::UserStatus
end
params do
optional :emoji, type: String, desc: "The emoji to set on the status"
optional :message, type: String, desc: "The status message to set"
end
put "status" do
forbidden! unless can?(current_user, :update_user_status, current_user)
if ::Users::SetStatusService.new(current_user, declared_params).execute
present current_user.status, with: Entities::UserStatus
else
render_validation_error!(current_user.status)
end
end
desc 'get the status of the current user' do
success Entities::UserStatus
end
get 'status' do
present current_user.status || {}, with: Entities::UserStatus
end
end end
end end
end end
# frozen_string_literal: true
module Banzai
module Pipeline
class EmojiPipeline < BasePipeline
# These filters will only perform sanitization of the content, preventing
# XSS, and replace emoji.
def self.filters
@filters ||= FilterArray[
Filter::HtmlEntityFilter,
Filter::SanitizationFilter,
Filter::EmojiFilter
]
end
end
end
end
...@@ -8,8 +8,6 @@ msgid "" ...@@ -8,8 +8,6 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: gitlab 1.0.0\n" "Project-Id-Version: gitlab 1.0.0\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2018-07-10 16:02-0700\n"
"PO-Revision-Date: 2018-07-10 16:02-0700\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n" "Language-Team: LANGUAGE <LL@li.org>\n"
"Language: \n" "Language: \n"
...@@ -5149,6 +5147,9 @@ msgstr "" ...@@ -5149,6 +5147,9 @@ msgstr ""
msgid "This directory" msgid "This directory"
msgstr "" msgstr ""
msgid "This emoji and message will appear on your profile and throughout the interface."
msgstr ""
msgid "This group" msgid "This group"
msgstr "" msgstr ""
...@@ -5593,6 +5594,9 @@ msgstr "" ...@@ -5593,6 +5594,9 @@ msgstr ""
msgid "Users" msgid "Users"
msgstr "" msgstr ""
msgid "User|Current Status"
msgstr ""
msgid "Variables" msgid "Variables"
msgstr "" msgstr ""
......
...@@ -78,6 +78,15 @@ describe ProfilesController, :request_store do ...@@ -78,6 +78,15 @@ describe ProfilesController, :request_store do
expect(ldap_user.name).not_to eq('John') expect(ldap_user.name).not_to eq('John')
expect(ldap_user.location).to eq('City, Country') expect(ldap_user.location).to eq('City, Country')
end end
it 'allows setting a user status' do
sign_in(user)
put :update, user: { status: { message: 'Working hard!' } }
expect(user.reload.status.message).to eq('Working hard!')
expect(response).to have_gitlab_http_status(302)
end
end end
describe 'PUT update_username' do describe 'PUT update_username' do
......
# frozen_string_literal: true
FactoryBot.define do
factory :user_status do
user
emoji 'coffee'
message 'I crave coffee'
end
end
# frozen_string_literal: true
require 'spec_helper'
describe Banzai::Pipeline::EmojiPipeline do
def parse(text)
described_class.to_html(text, {})
end
it 'replaces emoji' do
expected_result = "Hello world #{Gitlab::Emoji.gl_emoji_tag('100')}"
expect(parse('Hello world :100:')).to eq(expected_result)
end
it 'filters out HTML tags' do
expected_result = "Hello &lt;b&gt;world&lt;/b&gt; #{Gitlab::Emoji.gl_emoji_tag('100')}"
expect(parse('Hello <b>world</b> :100:')).to eq(expected_result)
end
end
...@@ -20,6 +20,7 @@ describe User do ...@@ -20,6 +20,7 @@ describe User do
describe 'associations' do describe 'associations' do
it { is_expected.to have_one(:namespace) } it { is_expected.to have_one(:namespace) }
it { is_expected.to have_one(:status) }
it { is_expected.to have_many(:snippets).dependent(:destroy) } it { is_expected.to have_many(:snippets).dependent(:destroy) }
it { is_expected.to have_many(:members) } it { is_expected.to have_many(:members) }
it { is_expected.to have_many(:project_members) } it { is_expected.to have_many(:project_members) }
......
# frozen_string_literal: true
require 'spec_helper'
describe UserStatus do
it { is_expected.to validate_presence_of(:user) }
it { is_expected.to allow_value('smirk').for(:emoji) }
it { is_expected.not_to allow_value('hello world').for(:emoji) }
it { is_expected.not_to allow_value('').for(:emoji) }
it { is_expected.to validate_length_of(:message).is_at_most(100) }
it { is_expected.to allow_value('').for(:message) }
it 'is expected to be deleted when the user is deleted' do
status = create(:user_status)
expect { status.user.destroy }.to change { described_class.count }.from(1).to(0)
end
end
...@@ -35,6 +35,10 @@ describe UserPolicy do ...@@ -35,6 +35,10 @@ describe UserPolicy do
end end
end end
describe "updating a user's status" do
it_behaves_like 'changing a user', :update_user_status
end
describe "destroying a user" do describe "destroying a user" do
it_behaves_like 'changing a user', :destroy_user it_behaves_like 'changing a user', :destroy_user
end end
......
...@@ -13,6 +13,26 @@ describe API::Users do ...@@ -13,6 +13,26 @@ describe API::Users do
let(:not_existing_pat_id) { (PersonalAccessToken.maximum('id') || 0 ) + 10 } let(:not_existing_pat_id) { (PersonalAccessToken.maximum('id') || 0 ) + 10 }
let(:private_user) { create(:user, private_profile: true) } let(:private_user) { create(:user, private_profile: true) }
shared_examples 'rendering user status' do
it 'returns the status if there was one' do
create(:user_status, user: user)
get api(path, user)
expect(response).to have_gitlab_http_status(:success)
expect(json_response['message']).to be_present
expect(json_response['emoji']).to be_present
end
it 'returns an empty response if there was no status' do
get api(path, user)
expect(response).to have_gitlab_http_status(:success)
expect(json_response['message']).to be_nil
expect(json_response['emoji']).to be_nil
end
end
describe 'GET /users' do describe 'GET /users' do
context "when unauthenticated" do context "when unauthenticated" do
it "returns authorization error when the `username` parameter is not passed" do it "returns authorization error when the `username` parameter is not passed" do
...@@ -310,6 +330,20 @@ describe API::Users do ...@@ -310,6 +330,20 @@ describe API::Users do
end end
end end
describe 'GET /users/:id_or_username/status' do
context 'when finding the user by id' do
it_behaves_like 'rendering user status' do
let(:path) { "/users/#{user.id}/status" }
end
end
context 'when finding the user by username' do
it_behaves_like 'rendering user status' do
let(:path) { "/users/#{user.username}/status" }
end
end
end
describe "POST /users" do describe "POST /users" do
before do before do
admin admin
...@@ -1774,6 +1808,34 @@ describe API::Users do ...@@ -1774,6 +1808,34 @@ describe API::Users do
end end
end end
describe 'GET /user/status' do
let(:path) { '/user/status' }
it_behaves_like 'rendering user status'
end
describe 'PUT /user/status' do
it 'saves the status' do
put api('/user/status', user), { emoji: 'smirk', message: 'hello world' }
expect(response).to have_gitlab_http_status(:success)
expect(json_response['emoji']).to eq('smirk')
end
it 'renders errors when the status was invalid' do
put api('/user/status', user), { emoji: 'does not exist', message: 'hello world' }
expect(response).to have_gitlab_http_status(400)
expect(json_response['message']['emoji']).to be_present
end
it 'deletes the status when passing empty values' do
put api('/user/status', user)
expect(response).to have_gitlab_http_status(:success)
expect(user.reload.status).to be_nil
end
end
describe 'GET /users/:user_id/impersonation_tokens' do describe 'GET /users/:user_id/impersonation_tokens' do
let!(:active_personal_access_token) { create(:personal_access_token, user: user) } let!(:active_personal_access_token) { create(:personal_access_token, user: user) }
let!(:revoked_personal_access_token) { create(:personal_access_token, :revoked, user: user) } let!(:revoked_personal_access_token) { create(:personal_access_token, :revoked, user: user) }
......
# frozen_string_literal: true
require 'spec_helper'
describe Users::SetStatusService do
let(:current_user) { create(:user) }
subject(:service) { described_class.new(current_user, params) }
describe '#execute' do
context 'when when params are set' do
let(:params) { { emoji: 'taurus', message: 'a random status' } }
it 'creates a status' do
service.execute
expect(current_user.status.emoji).to eq('taurus')
expect(current_user.status.message).to eq('a random status')
end
it 'updates a status if it already existed' do
create(:user_status, user: current_user)
expect { service.execute }.not_to change { UserStatus.count }
expect(current_user.status.message).to eq('a random status')
end
context 'for another user' do
let(:target_user) { create(:user) }
let(:params) do
{ emoji: 'taurus', message: 'a random status', user: target_user }
end
context 'the current user is admin' do
let(:current_user) { create(:admin) }
it 'changes the status when the current user is allowed to do that' do
expect { service.execute }.to change { target_user.status }
end
end
it 'does not update the status if the current user is not allowed' do
expect { service.execute }.not_to change { target_user.status }
end
end
end
context 'without params' do
let(:params) { {} }
it 'deletes the status' do
status = create(:user_status, user: current_user)
expect { service.execute }
.to change { current_user.reload.status }.from(status).to(nil)
end
end
end
end
...@@ -30,6 +30,27 @@ describe Users::UpdateService do ...@@ -30,6 +30,27 @@ describe Users::UpdateService do
expect(result[:message]).to eq('Username has already been taken') expect(result[:message]).to eq('Username has already been taken')
end end
it 'updates the status if status params were given' do
update_user(user, status: { message: "On a call" })
expect(user.status.message).to eq("On a call")
end
it 'does not delete the status if no status param was passed' do
create(:user_status, user: user, message: 'Busy!')
update_user(user, name: 'New name')
expect(user.status.message).to eq('Busy!')
end
it 'includes status error messages' do
result = update_user(user, status: { emoji: "Moo!" })
expect(result[:status]).to eq(:error)
expect(result[:message]).to eq("Emoji is not included in the list")
end
def update_user(user, opts) def update_user(user, opts)
described_class.new(user, opts.merge(user: user)).execute described_class.new(user, opts.merge(user: user)).execute
end end
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment