* 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
@ -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 |