From fafd1ef658439b1f90a92f5f2eeaeeee1ad748c3 Mon Sep 17 00:00:00 2001 From: mgabdev <> Date: Mon, 14 Sep 2020 17:12:45 -0500 Subject: [PATCH] Added pages and routes for Groups by tag and category MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit • Added: - pages and routes for Groups by tag and category --- app/controllers/api/v1/groups_controller.rb | 22 +++- app/javascript/gabsocial/actions/groups.js | 74 +++++++++++++ .../gabsocial/features/group_category.js | 103 ++++++++++++++++++ .../gabsocial/features/group_tag.js | 103 ++++++++++++++++++ .../gabsocial/features/groups_collection.js | 6 +- app/javascript/gabsocial/features/ui/ui.js | 4 + .../features/ui/util/async_components.js | 3 +- .../gabsocial/reducers/group_lists.js | 35 ++++++ app/javascript/gabsocial/utils/unslugify.js | 7 ++ config/routes.rb | 3 + 10 files changed, 355 insertions(+), 5 deletions(-) create mode 100644 app/javascript/gabsocial/features/group_category.js create mode 100644 app/javascript/gabsocial/features/group_tag.js create mode 100644 app/javascript/gabsocial/utils/unslugify.js diff --git a/app/controllers/api/v1/groups_controller.rb b/app/controllers/api/v1/groups_controller.rb index fd94db75..d35b02ff 100644 --- a/app/controllers/api/v1/groups_controller.rb +++ b/app/controllers/api/v1/groups_controller.rb @@ -7,7 +7,7 @@ class Api::V1::GroupsController < Api::BaseController before_action -> { doorkeeper_authorize! :write, :'write:groups' }, except: [:index, :show] before_action :require_user!, except: [:index, :show] - before_action :set_group, except: [:index, :create] + before_action :set_group, except: [:index, :create, :by_category, :by_tag] def index case current_tab @@ -34,6 +34,26 @@ class Api::V1::GroupsController < Api::BaseController render json: @groups, each_serializer: REST::GroupSerializer end + def by_category + if !current_user + render json: { error: 'This method requires an authenticated user' }, status: 422 + end + + @groups = [] + + render json: @groups, each_serializer: REST::GroupSerializer + end + + def by_tag + if !current_user + render json: { error: 'This method requires an authenticated user' }, status: 422 + end + + @groups = [] + + render json: @groups, each_serializer: REST::GroupSerializer + end + def current_tab tab = 'featured' tab = params[:tab] if ['featured', 'member', 'admin', 'new'].include? params[:tab] diff --git a/app/javascript/gabsocial/actions/groups.js b/app/javascript/gabsocial/actions/groups.js index 63ffdc39..2237721b 100644 --- a/app/javascript/gabsocial/actions/groups.js +++ b/app/javascript/gabsocial/actions/groups.js @@ -90,6 +90,14 @@ export const GROUP_UNPIN_STATUS_REQUEST = 'GROUP_UNPIN_STATUS_REQUEST' export const GROUP_UNPIN_STATUS_SUCCESS = 'GROUP_UNPIN_STATUS_SUCCESS' export const GROUP_UNPIN_STATUS_FAIL = 'GROUP_UNPIN_STATUS_FAIL' +export const GROUPS_BY_CATEGORY_FETCH_REQUEST = 'GROUPS_BY_CATEGORY_FETCH_REQUEST' +export const GROUPS_BY_CATEGORY_FETCH_SUCCESS = 'GROUPS_BY_CATEGORY_FETCH_SUCCESS' +export const GROUPS_BY_CATEGORY_FETCH_FAIL = 'GROUPS_BY_CATEGORY_FETCH_FAIL' + +export const GROUPS_BY_TAG_FETCH_REQUEST = 'GROUPS_BY_TAG_FETCH_REQUEST' +export const GROUPS_BY_TAG_FETCH_SUCCESS = 'GROUPS_BY_TAG_FETCH_SUCCESS' +export const GROUPS_BY_TAG_FETCH_FAIL = 'GROUPS_BY_TAG_FETCH_FAIL' + export const GROUP_TIMELINE_SORT = 'GROUP_TIMELINE_SORT' export const GROUP_TIMELINE_TOP_SORT = 'GROUP_TIMELINE_TOP_SORT' @@ -210,6 +218,72 @@ export const fetchGroupsFail = (error, tab) => ({ tab, }); +export const fetchGroupsByCategory = (category) => (dispatch, getState) => { + // Don't refetch or fetch when loading + const isLoading = getState().getIn(['group_lists', 'by_category', category, 'isLoading'], false) + + if (isLoading) return + + dispatch(fetchGroupsByCategoryRequest(category)) + + api(getState).get(`/api/v1/groups/_/category/${category}`) + .then(({ data }) => { + dispatch(fetchGroupsByCategorySuccess(data, category)) + dispatch(fetchGroupRelationships(data.map(item => item.id))) + }) + .catch((err) => dispatch(fetchGroupsByCategoryFail(err, category))) +} + +export const fetchGroupsByCategoryRequest = (category) => ({ + type: GROUPS_BY_CATEGORY_FETCH_REQUEST, + category, +}) + +export const fetchGroupsByCategorySuccess = (groups, category) => ({ + type: GROUPS_BY_CATEGORY_FETCH_SUCCESS, + groups, + category, +}) + +export const fetchGroupsByCategoryFail = (error, category) => ({ + type: GROUPS_BY_CATEGORY_FETCH_FAIL, + error, + category, +}) + +export const fetchGroupsByTag = (tag) => (dispatch, getState) => { + // Don't refetch or fetch when loading + const isLoading = getState().getIn(['group_lists', 'by_tag', tag, 'isLoading'], false) + + if (isLoading) return + + dispatch(fetchGroupsByTagRequest(tag)) + + api(getState).get(`/api/v1/groups/_/tag/${tag}`) + .then(({ data }) => { + dispatch(fetchGroupsByTagSuccess(data, tag)) + dispatch(fetchGroupRelationships(data.map(item => item.id))) + }) + .catch((err) => dispatch(fetchGroupsByTagFail(err, tag))) +} + +export const fetchGroupsByTagRequest = (tag) => ({ + type: GROUPS_BY_TAG_FETCH_REQUEST, + tag, +}) + +export const fetchGroupsByTagSuccess = (groups, tag) => ({ + type: GROUPS_BY_TAG_FETCH_SUCCESS, + groups, + tag, +}) + +export const fetchGroupsByTagFail = (error, tag) => ({ + type: GROUPS_BY_TAG_FETCH_FAIL, + error, + tag, +}) + export function joinGroup(id) { return (dispatch, getState) => { if (!me) return; diff --git a/app/javascript/gabsocial/features/group_category.js b/app/javascript/gabsocial/features/group_category.js new file mode 100644 index 00000000..c3ebe871 --- /dev/null +++ b/app/javascript/gabsocial/features/group_category.js @@ -0,0 +1,103 @@ +import React from 'react' +import PropTypes from 'prop-types' +import ImmutablePropTypes from 'react-immutable-proptypes' +import ImmutablePureComponent from 'react-immutable-pure-component' +import { connect } from 'react-redux' +import { FormattedMessage } from 'react-intl' +import slugify from '../utils/slugify' +import unslugify from '../utils/unslugify' +import { fetchGroupsByCategory } from '../actions/groups' +import Block from '../components/block' +import ColumnIndicator from '../components/column_indicator' +import Heading from '../components/heading' +import GroupListItem from '../components/group_list_item' + +class GroupCategory extends ImmutablePureComponent { + + state = { + category: this.props.params.sluggedCategory, + } + + componentDidUpdate(prevProps) { + if (this.props.params.sluggedCategory !== prevProps.params.sluggedCategory) { + this.handleLoad(this.props.params.sluggedCategory) + } + } + + componentDidMount() { + this.handleLoad(this.props.params.sluggedCategory) + } + + handleLoad = (sluggedCategory) => { + const category = unslugify(sluggedCategory) + this.setState({ category }) + this.props.onFetchGroupsByCategory(category) + } + + render() { + const { + isFetched, + isLoading, + groupIds, + } = this.props + const { category } = this.state + + let errorMessage + if (!groupIds || (isFetched && groupIds.size === 0)) { + errorMessage = } /> + } else if (isLoading && groupIds.size === 0) { + errorMessage = + } + + return ( + + + + + Groups by category: {category} + + + + + { + !errorMessage && + groupIds.map((groupId, i) => ( + + )) + } + { !!errorMessage && errorMessage} + + + ) + } + +} + +const mapStateToProps = (state, { params: { sluggedCategory } }) => { + const cleanSluggedCategory = slugify(sluggedCategory) + + return { + groupIds: state.getIn(['group_lists', 'by_category', cleanSluggedCategory, 'items']), + isFetched: state.getIn(['group_lists', 'by_category', cleanSluggedCategory, 'isFetched']), + isLoading: state.getIn(['group_lists', 'by_category', cleanSluggedCategory, 'isLoading']), + } +} + +const mapDispatchToProps = (dispatch) => ({ + onFetchGroupsByCategory: (category) => dispatch(fetchGroupsByCategory(category)), +}) + +GroupCategory.propTypes = { + groupIds: ImmutablePropTypes.list, + isFetched: PropTypes.bool.isRequired, + isLoading: PropTypes.bool.isRequired, + onFetchGroupsByCategory: PropTypes.func.isRequired, + sluggedCategory: PropTypes.string.isRequired, +} + +export default connect(mapStateToProps, mapDispatchToProps)(GroupCategory) \ No newline at end of file diff --git a/app/javascript/gabsocial/features/group_tag.js b/app/javascript/gabsocial/features/group_tag.js new file mode 100644 index 00000000..1564fcb7 --- /dev/null +++ b/app/javascript/gabsocial/features/group_tag.js @@ -0,0 +1,103 @@ +import React from 'react' +import PropTypes from 'prop-types' +import ImmutablePropTypes from 'react-immutable-proptypes' +import ImmutablePureComponent from 'react-immutable-pure-component' +import { connect } from 'react-redux' +import { FormattedMessage } from 'react-intl' +import slugify from '../utils/slugify' +import unslugify from '../utils/unslugify' +import { fetchGroupsByTag } from '../actions/groups' +import Block from '../components/block' +import ColumnIndicator from '../components/column_indicator' +import Heading from '../components/heading' +import GroupListItem from '../components/group_list_item' + +class GroupTag extends ImmutablePureComponent { + + state = { + tag: this.props.params.sluggedTag, + } + + componentDidUpdate(prevProps) { + if (this.props.params.sluggedTag !== prevProps.params.sluggedTag) { + this.handleLoad(this.props.params.sluggedTag) + } + } + + componentDidMount() { + this.handleLoad(this.props.params.sluggedTag) + } + + handleLoad = (sluggedTag) => { + const tag = unslugify(sluggedTag) + this.setState({ tag }) + this.props.onFetchGroupsByTag(tag) + } + + render() { + const { + isFetched, + isLoading, + groupIds, + } = this.props + const { tag } = this.state + + let errorMessage + if (!groupIds || (isFetched && groupIds.size === 0)) { + errorMessage = } /> + } else if (isLoading && groupIds.size === 0) { + errorMessage = + } + + return ( + + + + + Groups by tag: {tag} + + + + + { + !errorMessage && + groupIds.map((groupId, i) => ( + + )) + } + { !!errorMessage && errorMessage} + + + ) + } + +} + +const mapStateToProps = (state, { params: { sluggedTag } }) => { + const cleanSluggedTag = slugify(sluggedTag) + + return { + groupIds: state.getIn(['group_lists', 'by_tag', cleanSluggedTag, 'items']), + isFetched: state.getIn(['group_lists', 'by_tag', cleanSluggedTag, 'isFetched']), + isLoading: state.getIn(['group_lists', 'by_tag', cleanSluggedTag, 'isLoading']), + } +} + +const mapDispatchToProps = (dispatch) => ({ + onFetchGroupsByTag: (tag) => dispatch(fetchGroupsByTag(tag)), +}) + +GroupTag.propTypes = { + groupIds: ImmutablePropTypes.list, + isFetched: PropTypes.bool.isRequired, + isLoading: PropTypes.bool.isRequired, + onFetchGroupsByTag: PropTypes.func.isRequired, + sluggedTag: PropTypes.string.isRequired, +} + +export default connect(mapStateToProps, mapDispatchToProps)(GroupTag) \ No newline at end of file diff --git a/app/javascript/gabsocial/features/groups_collection.js b/app/javascript/gabsocial/features/groups_collection.js index 909de512..1c9840b5 100644 --- a/app/javascript/gabsocial/features/groups_collection.js +++ b/app/javascript/gabsocial/features/groups_collection.js @@ -42,10 +42,10 @@ class GroupsCollection extends ImmutablePureComponent { isFetched, } = this.props - if (isLoading && groupIds.size === 0) { - return - } else if (isFetched && groupIds.size === 0) { + if (!groupIds || (isFetched && groupIds.size === 0)) { return + } else if (isLoading && groupIds.size === 0) { + return } const isAddable = ['featured', 'new'].indexOf(activeTab) > -1 diff --git a/app/javascript/gabsocial/features/ui/ui.js b/app/javascript/gabsocial/features/ui/ui.js index ab8ee042..0e3683a2 100644 --- a/app/javascript/gabsocial/features/ui/ui.js +++ b/app/javascript/gabsocial/features/ui/ui.js @@ -70,6 +70,8 @@ import { GroupRemovedAccounts, GroupTimeline, GroupsCategories, + GroupCategory, + GroupTag, HashtagTimeline, HomeTimeline, Investors, @@ -197,6 +199,8 @@ class SwitchingArea extends React.PureComponent { + + diff --git a/app/javascript/gabsocial/features/ui/util/async_components.js b/app/javascript/gabsocial/features/ui/util/async_components.js index c7e2dd67..910c5365 100644 --- a/app/javascript/gabsocial/features/ui/util/async_components.js +++ b/app/javascript/gabsocial/features/ui/util/async_components.js @@ -47,8 +47,9 @@ export function GroupRemovedAccounts() { return import(/* webpackChunkName: "fea export function GroupTimeline() { return import(/* webpackChunkName: "features/group_timeline" */'../../group_timeline') } export function GroupTimelineSortOptionsPopover() { return import(/* webpackChunkName: "components/group_timeline_sort_options_popover" */'../../../components/popover/group_timeline_sort_options_popover') } export function GroupTimelineSortTopOptionsPopover() { return import(/* webpackChunkName: "components/group_timeline_sort_top_options_popover" */'../../../components/popover/group_timeline_sort_top_options_popover') } -// export function GroupCategory() { return import(/* webpackChunkName: "features/group_category" */'../../group_category') } export function GroupsCategories() { return import(/* webpackChunkName: "features/groups_categories" */'../../groups_categories') } +export function GroupCategory() { return import(/* webpackChunkName: "features/group_category" */'../../group_category') } +export function GroupTag() { return import(/* webpackChunkName: "features/group_tag" */'../../group_tag') } export function HashtagTimeline() { return import(/* webpackChunkName: "features/hashtag_timeline" */'../../hashtag_timeline') } export function HashtagTimelineSettingsModal() { return import(/* webpackChunkName: "components/hashtag_timeline_settings_modal" */'../../../components/modal/hashtag_timeline_settings_modal') } export function HomeTimeline() { return import(/* webpackChunkName: "features/home_timeline" */'../../home_timeline') } diff --git a/app/javascript/gabsocial/reducers/group_lists.js b/app/javascript/gabsocial/reducers/group_lists.js index a2bd338d..40cf12bb 100644 --- a/app/javascript/gabsocial/reducers/group_lists.js +++ b/app/javascript/gabsocial/reducers/group_lists.js @@ -10,12 +10,19 @@ import { GROUP_CHECK_PASSWORD_REQUEST, GROUP_CHECK_PASSWORD_SUCCESS, GROUP_CHECK_PASSWORD_FAIL, + GROUPS_BY_CATEGORY_FETCH_REQUEST, + GROUPS_BY_CATEGORY_FETCH_SUCCESS, + GROUPS_BY_CATEGORY_FETCH_FAIL, + GROUPS_BY_TAG_FETCH_REQUEST, + GROUPS_BY_TAG_FETCH_SUCCESS, + GROUPS_BY_TAG_FETCH_FAIL, } from '../actions/groups' import { GROUP_TIMELINE_SORTING_TYPE_TOP, GROUP_TIMELINE_SORTING_TYPE_NEWEST, GROUP_TIMELINE_SORTING_TYPE_TOP_OPTION_TODAY, } from '../constants' +import slugify from '../utils/slugify' const tabs = ['new', 'featured', 'member', 'admin'] @@ -43,6 +50,8 @@ const initialState = ImmutableMap({ isLoading: false, items: ImmutableList(), }), + by_category: ImmutableMap(), + by_tag: ImmutableMap(), }) export default function groupLists(state = initialState, action) { @@ -111,7 +120,33 @@ export default function groupLists(state = initialState, action) { mutable.setIn(['passwordCheck', 'isLoading'], false) }) + case GROUPS_BY_CATEGORY_FETCH_REQUEST: + return state.setIn(['by_category', slugify(action.category), 'isLoading'], true) + case GROUPS_BY_CATEGORY_FETCH_SUCCESS: + return state.setIn(['by_category', slugify(action.category)], ImmutableMap({ + items: ImmutableList(action.groups.map(item => item.id)), + isLoading: false, + })) + case GROUPS_BY_CATEGORY_FETCH_FAIL: + return state.setIn(['by_category', slugify(action.category)], ImmutableMap({ + items: ImmutableList(), + isLoading: false, + })) + + case GROUPS_BY_TAG_FETCH_REQUEST: + return state.setIn(['by_tag', slugify(action.tag), 'isLoading'], true) + case GROUPS_BY_TAG_FETCH_SUCCESS: + return state.setIn(['by_tag', slugify(action.tag)], ImmutableMap({ + items: ImmutableList(action.groups.map(item => item.id)), + isLoading: false, + })) + case GROUPS_BY_TAG_FETCH_FAIL: + return state.setIn(['by_tag', slugify(action.tag)], ImmutableMap({ + items: ImmutableList(), + isLoading: false, + })) default: return state } } + diff --git a/app/javascript/gabsocial/utils/unslugify.js b/app/javascript/gabsocial/utils/unslugify.js new file mode 100644 index 00000000..180cbe8b --- /dev/null +++ b/app/javascript/gabsocial/utils/unslugify.js @@ -0,0 +1,7 @@ +// https://github.com/danny-wood/unslugify/blob/master/index.js +export default function unslugify(text) { + const result = `${text}`.replace(/\-/g, " "); + return result.replace(/\w\S*/g, function (txt) { + return txt.charAt(0).toUpperCase() + txt.substr(1).toLowerCase(); + }); +} diff --git a/config/routes.rb b/config/routes.rb index 718c72c5..c4bf84c3 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -437,6 +437,9 @@ Rails.application.routes.draw do post '/statuses/:status_id/approve', to: 'groups#approve_status' end + get '/category/:category', to: 'groups#by_category' + get '/tag/:tag', to: 'groups#by_tag' + resources :relationships, only: :index, controller: 'groups/relationships' resource :accounts, only: [:show, :create, :update, :destroy], controller: 'groups/accounts' resource :removed_accounts, only: [:show, :create, :destroy], controller: 'groups/removed_accounts'