diff --git a/app/controllers/api/base_controller.rb b/app/controllers/api/base_controller.rb index bf7deac5cfc857..b478b9dc56c66b 100644 --- a/app/controllers/api/base_controller.rb +++ b/app/controllers/api/base_controller.rb @@ -3,6 +3,7 @@ class Api::BaseController < ApplicationController DEFAULT_STATUSES_LIMIT = 20 DEFAULT_ACCOUNTS_LIMIT = 40 + DEFAULT_TAGS_LIMIT = 40 include Api::RateLimitHeaders include Api::AccessTokenTrackingConcern diff --git a/app/controllers/api/v1/lists/tags_controller.rb b/app/controllers/api/v1/lists/tags_controller.rb new file mode 100644 index 00000000000000..e0ab70a4f7d927 --- /dev/null +++ b/app/controllers/api/v1/lists/tags_controller.rb @@ -0,0 +1,100 @@ +# frozen_string_literal: true + +class Api::V1::Lists::TagsController < Api::BaseController + before_action -> { doorkeeper_authorize! :read, :'read:lists' }, only: [:show] + before_action -> { doorkeeper_authorize! :write, :'write:lists' }, except: [:show] + + before_action :require_user! + before_action :set_list + + after_action :insert_pagination_headers, only: :show + + def show + @tags = load_tags + render json: @tags, each_serializer: REST::TagSerializer + end + + def create + ApplicationRecord.transaction do + list_tags.each do |tag| + @list.tags << tag + end + end + @tags = load_tags + render json: @tags, each_serializer: REST::TagSerializer + end + + def destroy + ListTag.where(list: @list, tag_id: tag_ids).destroy_all + render_empty + end + + private + + def set_list + @list = List.where(account: current_account).find(params[:list_id]) + end + + def load_tags + if unlimited? + @list.tags.all + else + @list.tags.paginate_by_max_id(limit_param(DEFAULT_TAGS_LIMIT), params[:max_id], params[:since_id]) + end + end + + def list_tags + names = tag_ids.grep_v(/\A[0-9]+\Z/) + ids = tag_ids.grep(/\A[0-9]+\Z/) + existing_by_name = Tag.where(name: names.map { |n| Tag.normalize(n) }).select(:id, :name) + ids.push(*existing_by_name.map(&:id)) + not_existing_by_name = names.reject { |n| existing_by_name.any? { |e| e.name == Tag.normalize(n) } } + created = Tag.find_or_create_by_names(not_existing_by_name) + ids.push(*created.map(&:id)) + Tag.find(ids) + end + + def tag_ids + Array(resource_params[:tag_ids]) + end + + def resource_params + params.permit(tag_ids: []) + end + + def insert_pagination_headers + set_pagination_headers(next_path, prev_path) + end + + def next_path + return if unlimited? + + api_v1_list_tags_url pagination_params(max_id: pagination_max_id) if records_continue? + end + + def prev_path + return if unlimited? + + api_v1_list_tags_url pagination_params(since_id: pagination_since_id) unless @tags.empty? + end + + def pagination_max_id + @tags.last.id + end + + def pagination_since_id + @tags.first.id + end + + def records_continue? + @tags.size == limit_param(DEFAULT_TAGS_LIMIT) + end + + def pagination_params(core_params) + params.slice(:limit).permit(:limit).merge(core_params) + end + + def unlimited? + params[:limit] == '0' + end +end diff --git a/app/javascript/flavours/glitch/actions/importer/index.js b/app/javascript/flavours/glitch/actions/importer/index.js index 5fbc9bb5bbb64a..6b1b0b35141ec8 100644 --- a/app/javascript/flavours/glitch/actions/importer/index.js +++ b/app/javascript/flavours/glitch/actions/importer/index.js @@ -6,6 +6,7 @@ export const STATUS_IMPORT = 'STATUS_IMPORT'; export const STATUSES_IMPORT = 'STATUSES_IMPORT'; export const POLLS_IMPORT = 'POLLS_IMPORT'; export const FILTERS_IMPORT = 'FILTERS_IMPORT'; +export const TAGS_IMPORT = 'TAGS_IMPORT'; function pushUnique(array, object) { if (array.every(element => element.id !== object.id)) { @@ -29,6 +30,10 @@ export function importPolls(polls) { return { type: POLLS_IMPORT, polls }; } +export function importTags(tags) { + return { type: TAGS_IMPORT, tags }; +} + export function importFetchedAccount(account) { return importFetchedAccounts([account]); } @@ -49,6 +54,17 @@ export function importFetchedAccounts(accounts) { return importAccounts({ accounts: normalAccounts }); } +export function importFetchedTags(tags) { + return (dispatch) => { + const uniqueTags = []; + function processTag(tag) { + pushUnique(uniqueTags, tag); + } + tags.forEach(processTag); + dispatch(importTags(uniqueTags)); + }; +} + export function importFetchedStatus(status) { return importFetchedStatuses([status]); } diff --git a/app/javascript/flavours/glitch/actions/lists.js b/app/javascript/flavours/glitch/actions/lists.js index b0789cd426450a..a986bbca2f0a86 100644 --- a/app/javascript/flavours/glitch/actions/lists.js +++ b/app/javascript/flavours/glitch/actions/lists.js @@ -1,7 +1,7 @@ import api from '../api'; import { showAlertForError } from './alerts'; -import { importFetchedAccounts } from './importer'; +import { importFetchedAccounts, importFetchedTags } from './importer'; export const LIST_FETCH_REQUEST = 'LIST_FETCH_REQUEST'; export const LIST_FETCH_SUCCESS = 'LIST_FETCH_SUCCESS'; @@ -31,6 +31,10 @@ export const LIST_ACCOUNTS_FETCH_REQUEST = 'LIST_ACCOUNTS_FETCH_REQUEST'; export const LIST_ACCOUNTS_FETCH_SUCCESS = 'LIST_ACCOUNTS_FETCH_SUCCESS'; export const LIST_ACCOUNTS_FETCH_FAIL = 'LIST_ACCOUNTS_FETCH_FAIL'; +export const LIST_TAGS_FETCH_REQUEST = 'LIST_TAGS_FETCH_REQUEST'; +export const LIST_TAGS_FETCH_SUCCESS = 'LIST_TAGS_FETCH_SUCCESS'; +export const LIST_TAGS_FETCH_FAIL = 'LIST_TAGS_FETCH_FAIL'; + export const LIST_EDITOR_SUGGESTIONS_CHANGE = 'LIST_EDITOR_SUGGESTIONS_CHANGE'; export const LIST_EDITOR_SUGGESTIONS_READY = 'LIST_EDITOR_SUGGESTIONS_READY'; export const LIST_EDITOR_SUGGESTIONS_CLEAR = 'LIST_EDITOR_SUGGESTIONS_CLEAR'; @@ -118,6 +122,7 @@ export const setupListEditor = listId => (dispatch, getState) => { }); dispatch(fetchListAccounts(listId)); + dispatch(fetchListTags(listId)); }; export const changeListEditorTitle = value => ({ @@ -234,6 +239,33 @@ export const fetchListAccountsFail = (id, error) => ({ error, }); +export const fetchListTags = listId => (dispatch, getState) => { + dispatch(fetchListTagsRequest(listId)); + + api(getState).get(`/api/v1/lists/${listId}/tags`, { params: { limit: 0 } }).then(({ data }) => { + dispatch(importFetchedTags(data)); + dispatch(fetchListTagsSuccess(listId, data)); + }).catch(err => dispatch(fetchListTagsFail(listId, err))); +}; + +export const fetchListTagsFail = (id, error) => ({ + type: LIST_TAGS_FETCH_FAIL, + id, + error, +}); + +export const fetchListTagsRequest = id => ({ + type: LIST_TAGS_FETCH_REQUEST, + id, +}); + +export const fetchListTagsSuccess = (id, tags, next) => ({ + type: LIST_TAGS_FETCH_SUCCESS, + id, + tags, + next, +}); + export const fetchListSuggestions = q => (dispatch, getState) => { const params = { q, @@ -263,65 +295,84 @@ export const changeListSuggestions = value => ({ value, }); -export const addToListEditor = accountId => (dispatch, getState) => { - dispatch(addToList(getState().getIn(['listEditor', 'listId']), accountId)); +export const addToListEditor = (id, type) => (dispatch, getState) => { + dispatch(addToList(getState().getIn(['listEditor', 'listId']), id, type)); }; -export const addToList = (listId, accountId) => (dispatch, getState) => { - dispatch(addToListRequest(listId, accountId)); - - api(getState).post(`/api/v1/lists/${listId}/accounts`, { account_ids: [accountId] }) - .then(() => dispatch(addToListSuccess(listId, accountId))) - .catch(err => dispatch(addToListFail(listId, accountId, err))); +export const addToList = (listId, id, type) => (dispatch, getState) => { + dispatch(addToListRequest(listId, id, type)); + + if ('tags' === type) { + api(getState).post(`/api/v1/lists/${listId}/tags`, { tag_ids: [id] }) + .then((data) => dispatch(addToListSuccess(listId, id, type, data))) + .catch(err => dispatch(addToListFail(listId, id, type, err))); + } else { + api(getState).post(`/api/v1/lists/${listId}/accounts`, { account_ids: [id] }) + .then(() => dispatch(addToListSuccess(listId, id, type))) + .catch(err => dispatch(addToListFail(listId, id, type, err))); + } }; -export const addToListRequest = (listId, accountId) => ({ +export const addToListRequest = (listId, id, type) => ({ type: LIST_EDITOR_ADD_REQUEST, + addType: type, listId, - accountId, + id, }); -export const addToListSuccess = (listId, accountId) => ({ +export const addToListSuccess = (listId, id, type, data) => ({ type: LIST_EDITOR_ADD_SUCCESS, + addType: type, listId, - accountId, + id, + data, }); -export const addToListFail = (listId, accountId, error) => ({ +export const addToListFail = (listId, id, type, error) => ({ type: LIST_EDITOR_ADD_FAIL, + addType: type, listId, - accountId, + id, error, }); -export const removeFromListEditor = accountId => (dispatch, getState) => { - dispatch(removeFromList(getState().getIn(['listEditor', 'listId']), accountId)); +export const removeFromListEditor = (id, type) => (dispatch, getState) => { + dispatch(removeFromList(getState().getIn(['listEditor', 'listId']), id, type)); }; -export const removeFromList = (listId, accountId) => (dispatch, getState) => { - dispatch(removeFromListRequest(listId, accountId)); +export const removeFromList = (listId, id, type) => (dispatch, getState) => { + dispatch(removeFromListRequest(listId, id, type)); - api(getState).delete(`/api/v1/lists/${listId}/accounts`, { params: { account_ids: [accountId] } }) - .then(() => dispatch(removeFromListSuccess(listId, accountId))) - .catch(err => dispatch(removeFromListFail(listId, accountId, err))); + if ('tags' === type) { + api(getState).delete(`/api/v1/lists/${listId}/tags`, { params: { tag_ids: [id] } }) + .then(() => dispatch(removeFromListSuccess(listId, id, type))) + .catch(err => dispatch(removeFromListFail(listId, id, type, err))); + } else { + api(getState).delete(`/api/v1/lists/${listId}/accounts`, { params: { account_ids: [id] } }) + .then(() => dispatch(removeFromListSuccess(listId, id, type))) + .catch(err => dispatch(removeFromListFail(listId, id, type, err))); + } }; -export const removeFromListRequest = (listId, accountId) => ({ +export const removeFromListRequest = (listId, id, type) => ({ type: LIST_EDITOR_REMOVE_REQUEST, + removeType: type, listId, - accountId, + id, }); -export const removeFromListSuccess = (listId, accountId) => ({ +export const removeFromListSuccess = (listId, id, type) => ({ type: LIST_EDITOR_REMOVE_SUCCESS, + removeType: type, listId, - accountId, + id, }); -export const removeFromListFail = (listId, accountId, error) => ({ +export const removeFromListFail = (listId, id, type, error) => ({ type: LIST_EDITOR_REMOVE_FAIL, + removeType: type, listId, - accountId, + id, error, }); diff --git a/app/javascript/flavours/glitch/features/list_editor/components/account.jsx b/app/javascript/flavours/glitch/features/list_editor/components/account.jsx index 77d32af80a25a2..29a1ddb151714a 100644 --- a/app/javascript/flavours/glitch/features/list_editor/components/account.jsx +++ b/app/javascript/flavours/glitch/features/list_editor/components/account.jsx @@ -32,8 +32,8 @@ const makeMapStateToProps = () => { }; const mapDispatchToProps = (dispatch, { accountId }) => ({ - onRemove: () => dispatch(removeFromListEditor(accountId)), - onAdd: () => dispatch(addToListEditor(accountId)), + onRemove: () => dispatch(removeFromListEditor(accountId, 'accounts')), + onAdd: () => dispatch(addToListEditor(accountId, 'accounts')), }); class Account extends ImmutablePureComponent { diff --git a/app/javascript/flavours/glitch/features/list_editor/components/add_tag.jsx b/app/javascript/flavours/glitch/features/list_editor/components/add_tag.jsx new file mode 100644 index 00000000000000..1bd4a9e965ced8 --- /dev/null +++ b/app/javascript/flavours/glitch/features/list_editor/components/add_tag.jsx @@ -0,0 +1,77 @@ +import PropTypes from 'prop-types'; +import { PureComponent } from 'react'; + +import { defineMessages, injectIntl } from 'react-intl'; + +import classNames from 'classnames'; + +import { connect } from 'react-redux'; + +import CancelIcon from '@/material-icons/400-24px/cancel.svg?react'; +import TagIcon from '@/material-icons/400-24px/tag.svg?react'; +import { Icon } from 'flavours/glitch/components/icon'; + +import { addToListEditor, changeListSuggestions } from '../../../actions/lists'; + +const messages = defineMessages({ + addtag: { id: 'lists.addtag', defaultMessage: 'Enter a tag you\'d like to follow' }, +}); + +const mapStateToProps = state => ({ + value: state.getIn(['listEditor', 'suggestions', 'value']), +}); + +const mapDispatchToProps = dispatch => ({ + onSubmit: value => dispatch(addToListEditor(value, 'tags')), + onChange: value => dispatch(changeListSuggestions(value)), +}); + +class AddTag extends PureComponent { + + static propTypes = { + intl: PropTypes.object.isRequired, + value: PropTypes.string.isRequired, + onSubmit: PropTypes.func.isRequired, + onChange: PropTypes.func.isRequired, + }; + + handleChange = e => { + //this.props.value = e.target.value; + this.props.onChange(e.target.value); + }; + + handleKeyUp = e => { + if (e.keyCode === 13) { + this.props.onSubmit(this.props.value); + } + }; + + render() { + const { value, intl } = this.props; + const hasValue = value.length > 0; + + return ( +