Allow joining several hashtags in a single column (#8904)
* Nascent tag menu on frontend * Hook up frontend to search * Tag intersection backend first pass * Update yarnlock * WIP * Fix for tags not searching correctly * Make radio buttons function * Simplify radio buttons with modeOption * Better naming * Rearrange options * Add all/any/none functionality on backend * Small PR cleanup * Move to service from scope * Small cleanup, add proper service tests * Don't use send with user input :D * Set appropriate column header * Handle auto updating timeline * Fix up toggle function * Use tag value correctly * A bit more correct to use 'self' rather than 'all' in status scope * Fix some style issues * Fix more code style issues * Style select dropdown more better * Only use to_id'ed value to ensure no SQL injection * Revamp frontend to allow for multiple selects * Update backend / col header to account for more flexible tagging * Update brakeman ignore * Codeclimate suggestions * Fix presenter tag_url * Implement initial PR feedback * Handle additional tag streaming * CodeClimate tweakmaster
parent
bb5558de62
commit
4c03e05a4e
@ -0,0 +1,102 @@ |
||||
import React from 'react'; |
||||
import PropTypes from 'prop-types'; |
||||
import ImmutablePropTypes from 'react-immutable-proptypes'; |
||||
import { injectIntl, FormattedMessage } from 'react-intl'; |
||||
import Toggle from 'react-toggle'; |
||||
import AsyncSelect from 'react-select/lib/Async'; |
||||
|
||||
@injectIntl |
||||
export default class ColumnSettings extends React.PureComponent { |
||||
|
||||
static propTypes = { |
||||
settings: ImmutablePropTypes.map.isRequired, |
||||
onChange: PropTypes.func.isRequired, |
||||
onLoad: PropTypes.func.isRequired, |
||||
intl: PropTypes.object.isRequired, |
||||
}; |
||||
|
||||
state = { |
||||
open: this.hasTags(), |
||||
}; |
||||
|
||||
hasTags () { |
||||
return ['all', 'any', 'none'].map(mode => this.tags(mode).length > 0).includes(true); |
||||
} |
||||
|
||||
tags (mode) { |
||||
let tags = this.props.settings.getIn(['tags', mode]) || []; |
||||
if (tags.toJSON) { |
||||
return tags.toJSON(); |
||||
} else { |
||||
return tags; |
||||
} |
||||
}; |
||||
|
||||
onSelect = (mode) => { |
||||
return (value) => { |
||||
this.props.onChange(['tags', mode], value); |
||||
}; |
||||
}; |
||||
|
||||
onToggle = () => { |
||||
if (this.state.open && this.hasTags()) { |
||||
this.props.onChange('tags', {}); |
||||
} |
||||
this.setState({ open: !this.state.open }); |
||||
}; |
||||
|
||||
modeSelect (mode) { |
||||
return ( |
||||
<div className='column-settings__section'> |
||||
{this.modeLabel(mode)} |
||||
<AsyncSelect |
||||
isMulti |
||||
autoFocus |
||||
value={this.tags(mode)} |
||||
settings={this.props.settings} |
||||
settingPath={['tags', mode]} |
||||
onChange={this.onSelect(mode)} |
||||
loadOptions={this.props.onLoad} |
||||
classNamePrefix='column-settings__hashtag-select' |
||||
name='tags' |
||||
/> |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
modeLabel (mode) { |
||||
switch(mode) { |
||||
case 'any': return <FormattedMessage id='hashtag.column_settings.tag_mode.any' defaultMessage='Any of these' />; |
||||
case 'all': return <FormattedMessage id='hashtag.column_settings.tag_mode.all' defaultMessage='All of these' />; |
||||
case 'none': return <FormattedMessage id='hashtag.column_settings.tag_mode.none' defaultMessage='None of these' />; |
||||
} |
||||
return ''; |
||||
}; |
||||
|
||||
render () { |
||||
return ( |
||||
<div> |
||||
<div className='column-settings__row'> |
||||
<div className='setting-toggle'> |
||||
<Toggle |
||||
id='hashtag.column_settings.tag_toggle' |
||||
onChange={this.onToggle} |
||||
checked={this.state.open} |
||||
/> |
||||
<span className='setting-toggle__label'> |
||||
<FormattedMessage id='hashtag.column_settings.tag_toggle' defaultMessage='Include additional tags in this column' /> |
||||
</span> |
||||
</div> |
||||
</div> |
||||
{this.state.open && |
||||
<div className='column-settings__hashtags'> |
||||
{this.modeSelect('any')} |
||||
{this.modeSelect('all')} |
||||
{this.modeSelect('none')} |
||||
</div> |
||||
} |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
} |
@ -0,0 +1,31 @@ |
||||
import { connect } from 'react-redux'; |
||||
import ColumnSettings from '../components/column_settings'; |
||||
import { changeColumnParams } from '../../../actions/columns'; |
||||
import api from '../../../api'; |
||||
|
||||
const mapStateToProps = (state, { columnId }) => { |
||||
const columns = state.getIn(['settings', 'columns']); |
||||
const index = columns.findIndex(c => c.get('uuid') === columnId); |
||||
|
||||
if (!(columnId && index >= 0)) { |
||||
return {}; |
||||
} |
||||
|
||||
return { settings: columns.get(index).get('params') }; |
||||
}; |
||||
|
||||
const mapDispatchToProps = (dispatch, { columnId }) => ({ |
||||
onChange (key, value) { |
||||
dispatch(changeColumnParams(columnId, key, value)); |
||||
}, |
||||
|
||||
onLoad (value) { |
||||
return api().get('/api/v2/search', { params: { q: value } }).then(response => { |
||||
return (response.data.hashtags || []).map((tag) => { |
||||
return { value: tag.name, label: `#${tag.name}` }; |
||||
}); |
||||
}); |
||||
}, |
||||
}); |
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(ColumnSettings); |
@ -0,0 +1,21 @@ |
||||
# frozen_string_literal: true |
||||
|
||||
class HashtagQueryService < BaseService |
||||
def call(tag, params, account = nil, local = false) |
||||
any = tags_for(params[:any]) |
||||
all = tags_for(params[:all]) |
||||
none = tags_for(params[:none]) |
||||
|
||||
@query = Status.as_tag_timeline(tag, account, local) |
||||
.tagged_with_all(all) |
||||
.tagged_with_none(none) |
||||
@query = @query.distinct.or(self.class.new.call(any, params.except(:any), account, local).distinct) if any |
||||
@query |
||||
end |
||||
|
||||
private |
||||
|
||||
def tags_for(tags) |
||||
Tag.where(name: tags.map(&:downcase)) if tags.presence |
||||
end |
||||
end |
@ -0,0 +1,60 @@ |
||||
require 'rails_helper' |
||||
|
||||
describe HashtagQueryService, type: :service do |
||||
describe '.call' do |
||||
let(:account) { Fabricate(:account) } |
||||
let(:tag1) { Fabricate(:tag) } |
||||
let(:tag2) { Fabricate(:tag) } |
||||
let!(:status1) { Fabricate(:status, tags: [tag1]) } |
||||
let!(:status2) { Fabricate(:status, tags: [tag2]) } |
||||
let!(:both) { Fabricate(:status, tags: [tag1, tag2]) } |
||||
|
||||
it 'can add tags in "any" mode' do |
||||
results = subject.call(tag1, { any: [tag2.name] }) |
||||
expect(results).to include status1 |
||||
expect(results).to include status2 |
||||
expect(results).to include both |
||||
end |
||||
|
||||
it 'can remove tags in "all" mode' do |
||||
results = subject.call(tag1, { all: [tag2.name] }) |
||||
expect(results).to_not include status1 |
||||
expect(results).to_not include status2 |
||||
expect(results).to include both |
||||
end |
||||
|
||||
it 'can remove tags in "none" mode' do |
||||
results = subject.call(tag1, { none: [tag2.name] }) |
||||
expect(results).to include status1 |
||||
expect(results).to_not include status2 |
||||
expect(results).to_not include both |
||||
end |
||||
|
||||
it 'ignores an invalid mode' do |
||||
results = subject.call(tag1, { wark: [tag2.name] }) |
||||
expect(results).to include status1 |
||||
expect(results).to_not include status2 |
||||
expect(results).to include both |
||||
end |
||||
|
||||
it 'handles being passed non existant tag names' do |
||||
results = subject.call(tag1, { any: ['wark'] }) |
||||
expect(results).to include status1 |
||||
expect(results).to_not include status2 |
||||
expect(results).to include both |
||||
end |
||||
|
||||
it 'can restrict to an account' do |
||||
BlockService.new.call(account, status1.account) |
||||
results = subject.call(tag1, { none: [tag2.name] }, account) |
||||
expect(results).to_not include status1 |
||||
end |
||||
|
||||
it 'can restrict to local' do |
||||
status1.account.update(domain: 'example.com') |
||||
status1.update(local: false, uri: 'example.com/toot') |
||||
results = subject.call(tag1, { any: [tag2.name] }, nil, true) |
||||
expect(results).to_not include status1 |
||||
end |
||||
end |
||||
end |
Loading…
Reference in new issue