Add user notes on accounts (#14148)
* Add UserNote model * Add UI for user notes * Put comment in relationships entity * Add API to create user notes * Copy user notes to new account when receiving a Move activity * Address some of the review remarks * Replace modal by inline edition * Please CodeClimate * Button design changes * Change design again * Cancel note edition when pressing Escape * Fixes * Tweak design again * Move “Add note” item, and allow users to add notes to themselves * Rename UserNote into AccountNote, rename “comment” Relationship attribute to “note”master
parent
ce9ae9aa50
commit
65506bac3f
@ -0,0 +1,30 @@ |
||||
# frozen_string_literal: true |
||||
|
||||
class Api::V1::Accounts::NotesController < Api::BaseController |
||||
include Authorization |
||||
|
||||
before_action -> { doorkeeper_authorize! :write, :'write:accounts' } |
||||
before_action :require_user! |
||||
before_action :set_account |
||||
|
||||
def create |
||||
if params[:comment].blank? |
||||
AccountNote.find_by(account: current_account, target_account: @account)&.destroy |
||||
else |
||||
@note = AccountNote.find_or_initialize_by(account: current_account, target_account: @account) |
||||
@note.comment = params[:comment] |
||||
@note.save! if @note.changed? |
||||
end |
||||
render json: @account, serializer: REST::RelationshipSerializer, relationships: relationships_presenter |
||||
end |
||||
|
||||
private |
||||
|
||||
def set_account |
||||
@account = Account.find(params[:account_id]) |
||||
end |
||||
|
||||
def relationships_presenter |
||||
AccountRelationshipsPresenter.new([@account.id], current_user.account_id) |
||||
end |
||||
end |
@ -0,0 +1,69 @@ |
||||
import api from '../api'; |
||||
|
||||
export const ACCOUNT_NOTE_SUBMIT_REQUEST = 'ACCOUNT_NOTE_SUBMIT_REQUEST'; |
||||
export const ACCOUNT_NOTE_SUBMIT_SUCCESS = 'ACCOUNT_NOTE_SUBMIT_SUCCESS'; |
||||
export const ACCOUNT_NOTE_SUBMIT_FAIL = 'ACCOUNT_NOTE_SUBMIT_FAIL'; |
||||
|
||||
export const ACCOUNT_NOTE_INIT_EDIT = 'ACCOUNT_NOTE_INIT_EDIT'; |
||||
export const ACCOUNT_NOTE_CANCEL = 'ACCOUNT_NOTE_CANCEL'; |
||||
|
||||
export const ACCOUNT_NOTE_CHANGE_COMMENT = 'ACCOUNT_NOTE_CHANGE_COMMENT'; |
||||
|
||||
export function submitAccountNote() { |
||||
return (dispatch, getState) => { |
||||
dispatch(submitAccountNoteRequest()); |
||||
|
||||
const id = getState().getIn(['account_notes', 'edit', 'account_id']); |
||||
|
||||
api(getState).post(`/api/v1/accounts/${id}/note`, { |
||||
comment: getState().getIn(['account_notes', 'edit', 'comment']), |
||||
}).then(response => { |
||||
dispatch(submitAccountNoteSuccess(response.data)); |
||||
}).catch(error => dispatch(submitAccountNoteFail(error))); |
||||
}; |
||||
}; |
||||
|
||||
export function submitAccountNoteRequest() { |
||||
return { |
||||
type: ACCOUNT_NOTE_SUBMIT_REQUEST, |
||||
}; |
||||
}; |
||||
|
||||
export function submitAccountNoteSuccess(relationship) { |
||||
return { |
||||
type: ACCOUNT_NOTE_SUBMIT_SUCCESS, |
||||
relationship, |
||||
}; |
||||
}; |
||||
|
||||
export function submitAccountNoteFail(error) { |
||||
return { |
||||
type: ACCOUNT_NOTE_SUBMIT_FAIL, |
||||
error, |
||||
}; |
||||
}; |
||||
|
||||
export function initEditAccountNote(account) { |
||||
return (dispatch, getState) => { |
||||
const comment = getState().getIn(['relationships', account.get('id'), 'note']); |
||||
|
||||
dispatch({ |
||||
type: ACCOUNT_NOTE_INIT_EDIT, |
||||
account, |
||||
comment, |
||||
}); |
||||
}; |
||||
}; |
||||
|
||||
export function cancelAccountNote() { |
||||
return { |
||||
type: ACCOUNT_NOTE_CANCEL, |
||||
}; |
||||
}; |
||||
|
||||
export function changeAccountNoteComment(comment) { |
||||
return { |
||||
type: ACCOUNT_NOTE_CHANGE_COMMENT, |
||||
comment, |
||||
}; |
||||
}; |
@ -0,0 +1,103 @@ |
||||
import React from 'react'; |
||||
import ImmutablePropTypes from 'react-immutable-proptypes'; |
||||
import PropTypes from 'prop-types'; |
||||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; |
||||
import ImmutablePureComponent from 'react-immutable-pure-component'; |
||||
import Icon from 'mastodon/components/icon'; |
||||
import Textarea from 'react-textarea-autosize'; |
||||
|
||||
const messages = defineMessages({ |
||||
placeholder: { id: 'account_note.placeholder', defaultMessage: 'No comment provided' }, |
||||
}); |
||||
|
||||
export default @injectIntl |
||||
class Header extends ImmutablePureComponent { |
||||
|
||||
static propTypes = { |
||||
account: ImmutablePropTypes.map.isRequired, |
||||
isEditing: PropTypes.bool, |
||||
isSubmitting: PropTypes.bool, |
||||
accountNote: PropTypes.string, |
||||
onEditAccountNote: PropTypes.func.isRequired, |
||||
onCancelAccountNote: PropTypes.func.isRequired, |
||||
onSaveAccountNote: PropTypes.func.isRequired, |
||||
onChangeAccountNote: PropTypes.func.isRequired, |
||||
intl: PropTypes.object.isRequired, |
||||
}; |
||||
|
||||
handleChangeAccountNote = (e) => { |
||||
this.props.onChangeAccountNote(e.target.value); |
||||
}; |
||||
|
||||
componentWillUnmount () { |
||||
if (this.props.isEditing) { |
||||
this.props.onCancelAccountNote(); |
||||
} |
||||
} |
||||
|
||||
handleKeyDown = e => { |
||||
if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) { |
||||
this.props.onSaveAccountNote(); |
||||
} else if (e.keyCode === 27) { |
||||
this.props.onCancelAccountNote(); |
||||
} |
||||
} |
||||
|
||||
render () { |
||||
const { account, accountNote, isEditing, isSubmitting, intl } = this.props; |
||||
|
||||
if (!account || (!accountNote && !isEditing)) { |
||||
return null; |
||||
} |
||||
|
||||
let action_buttons = null; |
||||
if (isEditing) { |
||||
action_buttons = ( |
||||
<div className='account__header__account-note__buttons'> |
||||
<button className='text-btn' tabIndex='0' onClick={this.props.onCancelAccountNote} disabled={isSubmitting}> |
||||
<Icon id='times' size={15} /> <FormattedMessage id='account_note.cancel' defaultMessage='Cancel' /> |
||||
</button> |
||||
<div className='flex-spacer' /> |
||||
<button className='text-btn' tabIndex='0' onClick={this.props.onSaveAccountNote} disabled={isSubmitting}> |
||||
<Icon id='check' size={15} /> <FormattedMessage id='account_note.save' defaultMessage='Save' /> |
||||
</button> |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
let note_container = null; |
||||
if (isEditing) { |
||||
note_container = ( |
||||
<Textarea |
||||
className='account__header__account-note__content' |
||||
disabled={isSubmitting} |
||||
placeholder={intl.formatMessage(messages.placeholder)} |
||||
value={accountNote} |
||||
onChange={this.handleChangeAccountNote} |
||||
onKeyDown={this.handleKeyDown} |
||||
autoFocus |
||||
/> |
||||
); |
||||
} else { |
||||
note_container = (<div className='account__header__account-note__content'>{accountNote}</div>); |
||||
} |
||||
|
||||
return ( |
||||
<div className='account__header__account-note'> |
||||
<div className='account__header__account-note__header'> |
||||
<strong><FormattedMessage id='account.account_note_header' defaultMessage='Your note for @{name}' values={{ name: account.get('username') }} /></strong> |
||||
{!isEditing && ( |
||||
<div> |
||||
<button className='text-btn' tabIndex='0' onClick={this.props.onEditAccountNote} disabled={isSubmitting}> |
||||
<Icon id='pencil' size={15} /> <FormattedMessage id='account_note.edit' defaultMessage='Edit' /> |
||||
</button> |
||||
</div> |
||||
)} |
||||
</div> |
||||
{note_container} |
||||
{action_buttons} |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
} |
@ -0,0 +1,34 @@ |
||||
import { connect } from 'react-redux'; |
||||
import { changeAccountNoteComment, submitAccountNote, initEditAccountNote, cancelAccountNote } from 'mastodon/actions/account_notes'; |
||||
import AccountNote from '../components/account_note'; |
||||
|
||||
const mapStateToProps = (state, { account }) => { |
||||
const isEditing = state.getIn(['account_notes', 'edit', 'account_id']) === account.get('id'); |
||||
|
||||
return { |
||||
isSubmitting: state.getIn(['account_notes', 'edit', 'isSubmitting']), |
||||
accountNote: isEditing ? state.getIn(['account_notes', 'edit', 'comment']) : account.getIn(['relationship', 'note']), |
||||
isEditing, |
||||
}; |
||||
}; |
||||
|
||||
const mapDispatchToProps = (dispatch, { account }) => ({ |
||||
|
||||
onEditAccountNote() { |
||||
dispatch(initEditAccountNote(account)); |
||||
}, |
||||
|
||||
onSaveAccountNote() { |
||||
dispatch(submitAccountNote()); |
||||
}, |
||||
|
||||
onCancelAccountNote() { |
||||
dispatch(cancelAccountNote()); |
||||
}, |
||||
|
||||
onChangeAccountNote(comment) { |
||||
dispatch(changeAccountNoteComment(comment)); |
||||
}, |
||||
}); |
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(AccountNote); |
@ -0,0 +1,44 @@ |
||||
import { Map as ImmutableMap } from 'immutable'; |
||||
|
||||
import { |
||||
ACCOUNT_NOTE_INIT_EDIT, |
||||
ACCOUNT_NOTE_CANCEL, |
||||
ACCOUNT_NOTE_CHANGE_COMMENT, |
||||
ACCOUNT_NOTE_SUBMIT_REQUEST, |
||||
ACCOUNT_NOTE_SUBMIT_FAIL, |
||||
ACCOUNT_NOTE_SUBMIT_SUCCESS, |
||||
} from '../actions/account_notes'; |
||||
|
||||
const initialState = ImmutableMap({ |
||||
edit: ImmutableMap({ |
||||
isSubmitting: false, |
||||
account_id: null, |
||||
comment: null, |
||||
}), |
||||
}); |
||||
|
||||
export default function account_notes(state = initialState, action) { |
||||
switch (action.type) { |
||||
case ACCOUNT_NOTE_INIT_EDIT: |
||||
return state.withMutations((state) => { |
||||
state.setIn(['edit', 'isSubmitting'], false); |
||||
state.setIn(['edit', 'account_id'], action.account.get('id')); |
||||
state.setIn(['edit', 'comment'], action.comment); |
||||
}); |
||||
case ACCOUNT_NOTE_CHANGE_COMMENT: |
||||
return state.setIn(['edit', 'comment'], action.comment); |
||||
case ACCOUNT_NOTE_SUBMIT_REQUEST: |
||||
return state.setIn(['edit', 'isSubmitting'], true); |
||||
case ACCOUNT_NOTE_SUBMIT_FAIL: |
||||
return state.setIn(['edit', 'isSubmitting'], false); |
||||
case ACCOUNT_NOTE_SUBMIT_SUCCESS: |
||||
case ACCOUNT_NOTE_CANCEL: |
||||
return state.withMutations((state) => { |
||||
state.setIn(['edit', 'isSubmitting'], false); |
||||
state.setIn(['edit', 'account_id'], null); |
||||
state.setIn(['edit', 'comment'], null); |
||||
}); |
||||
default: |
||||
return state; |
||||
} |
||||
} |
@ -0,0 +1,20 @@ |
||||
# frozen_string_literal: true |
||||
# == Schema Information |
||||
# |
||||
# Table name: account_notes |
||||
# |
||||
# id :bigint(8) not null, primary key |
||||
# account_id :bigint(8) |
||||
# target_account_id :bigint(8) |
||||
# comment :text not null |
||||
# created_at :datetime not null |
||||
# updated_at :datetime not null |
||||
# |
||||
class AccountNote < ApplicationRecord |
||||
include RelationshipCacheable |
||||
|
||||
belongs_to :account |
||||
belongs_to :target_account, class_name: 'Account' |
||||
|
||||
validates :account_id, uniqueness: { scope: :target_account_id } |
||||
end |
@ -0,0 +1,13 @@ |
||||
class CreateAccountNotes < ActiveRecord::Migration[5.2] |
||||
def change |
||||
create_table :account_notes do |t| |
||||
t.references :account, foreign_key: { on_delete: :cascade }, index: false |
||||
t.references :target_account, foreign_key: { to_table: :accounts, on_delete: :cascade } |
||||
t.text :comment, null: false |
||||
t.index [:account_id, :target_account_id], unique: true |
||||
|
||||
t.timestamps |
||||
end |
||||
end |
||||
end |
||||
|
@ -0,0 +1,5 @@ |
||||
Fabricator(:account_note) do |
||||
account |
||||
target_account { Fabricate(:account) } |
||||
comment "User note text" |
||||
end |
Loading…
Reference in new issue