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