diff --git a/app/assets/stylesheets/common/_custom_texts.scss b/app/assets/stylesheets/common/_custom_texts.scss index c65d3796..165c1f17 100644 --- a/app/assets/stylesheets/common/_custom_texts.scss +++ b/app/assets/stylesheets/common/_custom_texts.scss @@ -60,4 +60,7 @@ .descriptionText { @extend .text-muted; + + max-height: 48px; + overflow-y: hidden; } \ No newline at end of file diff --git a/app/controllers/posts_controller.rb b/app/controllers/posts_controller.rb index 1dc7c7f1..5f68d420 100644 --- a/app/controllers/posts_controller.rb +++ b/app/controllers/posts_controller.rb @@ -18,7 +18,7 @@ class PostsController < ApplicationController .group('posts.id') .where(board_id: params[:board_id] || Board.first.id) .search_by_name_or_description(params[:search]) - .order('hotness DESC') + .order_by(params[:sort_by]) .page(params[:page]) # apply post status filter if present diff --git a/app/javascript/actions/Post/requestPosts.ts b/app/javascript/actions/Post/requestPosts.ts index cf300dc1..cefc2534 100644 --- a/app/javascript/actions/Post/requestPosts.ts +++ b/app/javascript/actions/Post/requestPosts.ts @@ -4,6 +4,7 @@ import { ThunkAction } from 'redux-thunk'; import IPostJSON from '../../interfaces/json/IPost'; import { State } from '../../reducers/rootReducer'; +import { SortByFilterValues } from '../changeFilters'; export const POSTS_REQUEST_START = 'POSTS_REQUEST_START'; interface PostsRequestStartAction { @@ -49,6 +50,7 @@ export const requestPosts = ( page: number, searchQuery: string, postStatusIds: Array, + sortBy: SortByFilterValues, ): ThunkAction> => async (dispatch) => { dispatch(postsRequestStart()); @@ -65,6 +67,7 @@ export const requestPosts = ( if (i !== postStatusIds.length-1) params += '&'; } } + if (sortBy) params += `&sort_by=${sortBy}`; const response = await fetch(`/posts?${params}`); const json = await response.json(); diff --git a/app/javascript/actions/changeFilters.ts b/app/javascript/actions/changeFilters.ts index 5a26917e..8980555b 100644 --- a/app/javascript/actions/changeFilters.ts +++ b/app/javascript/actions/changeFilters.ts @@ -10,6 +10,13 @@ interface SetPostStatusFilterAction { postStatusId: number; } +export const SET_SORT_BY_FILTER = 'SET_SORT_BY_FILTER'; +export type SortByFilterValues = 'trending' | 'newest' | 'most_voted' | 'oldest'; +interface SetSortByFilterAction { + type: typeof SET_SORT_BY_FILTER; + sortBy: SortByFilterValues; +} + export const setSearchFilter = (searchQuery: string): SetSearchFilterAction => ({ type: SET_SEARCH_FILTER, @@ -21,7 +28,13 @@ export const setPostStatusFilter = (postStatusId: number): SetPostStatusFilterAc postStatusId, }); +export const setSortByFilter = (sortBy: SortByFilterValues): SetSortByFilterAction => ({ + type: SET_SORT_BY_FILTER, + sortBy, +}); + export type ChangeFiltersActionTypes = SetSearchFilterAction | - SetPostStatusFilterAction; \ No newline at end of file + SetPostStatusFilterAction | + SetSortByFilterAction; \ No newline at end of file diff --git a/app/javascript/components/Board/BoardP.tsx b/app/javascript/components/Board/BoardP.tsx index 0e7379ff..be85bd9d 100644 --- a/app/javascript/components/Board/BoardP.tsx +++ b/app/javascript/components/Board/BoardP.tsx @@ -11,6 +11,8 @@ import ITenantSetting from '../../interfaces/ITenantSetting'; import { PostsState } from '../../reducers/postsReducer'; import { PostStatusesState } from '../../reducers/postStatusesReducer'; +import SortByFilter from './SortByFilter'; +import { SortByFilterValues } from '../../actions/changeFilters'; interface Props { board: IBoard; @@ -26,17 +28,19 @@ interface Props { page?: number, searchQuery?: string, postStatusIds?: Array, + sortBy?: SortByFilterValues, ): void; requestPostStatuses(): void; handleSearchFilterChange(searchQuery: string): void; handlePostStatusFilterChange(postStatusId: number): void; + handleSortByFilterChange(sortBy: SortByFilterValues): void; } class BoardP extends React.Component { searchFilterTimeoutId: ReturnType; componentDidMount() { - this.props.requestPosts(this.props.board.id); + this.props.requestPosts(this.props.board.id, 1, '', null, this.props.posts.filters.sortBy); this.props.requestPostStatuses(); } @@ -47,6 +51,9 @@ class BoardP extends React.Component { const { postStatusIds } = this.props.posts.filters; const prevPostStatusIds = prevProps.posts.filters.postStatusIds; + const { sortBy } = this.props.posts.filters; + const prevSortBy = prevProps.posts.filters.sortBy; + // search filter changed if (searchQuery !== prevSearchQuery) { if (this.searchFilterTimeoutId) clearInterval(this.searchFilterTimeoutId); @@ -60,6 +67,11 @@ class BoardP extends React.Component { if (postStatusIds.length !== prevPostStatusIds.length) { this.props.requestPosts(this.props.board.id, 1, searchQuery, postStatusIds); } + + // sort by filter changed + if (sortBy !== prevSortBy) { + this.props.requestPosts(this.props.board.id, 1, searchQuery, postStatusIds, sortBy); + } } render() { @@ -75,6 +87,7 @@ class BoardP extends React.Component { requestPosts, handleSearchFilterChange, handlePostStatusFilterChange, + handleSortByFilterChange, } = this.props; const { filters } = posts; @@ -90,6 +103,13 @@ class BoardP extends React.Component { searchQuery={filters.searchQuery} handleChange={handleSearchFilterChange} /> + { + isPowerUser && + handleSortByFilterChange(sortBy)} + /> + } {title} - {description?.slice(0, 120)} + {description}
diff --git a/app/javascript/components/Board/SortByFilter.tsx b/app/javascript/components/Board/SortByFilter.tsx new file mode 100644 index 00000000..712d1c81 --- /dev/null +++ b/app/javascript/components/Board/SortByFilter.tsx @@ -0,0 +1,28 @@ +import * as React from 'react'; +import I18n from 'i18n-js'; + +import SidebarBox from '../common/SidebarBox'; +import { SortByFilterValues } from '../../actions/changeFilters'; + +interface Props { + sortBy: SortByFilterValues; + handleChange(newSortBy: SortByFilterValues): void; +} + +const SortByFilter = ({ sortBy, handleChange }: Props) => ( + + + +); + +export default SortByFilter; \ No newline at end of file diff --git a/app/javascript/components/common/Sidebar.tsx b/app/javascript/components/common/Sidebar.tsx index 820f1101..1989bcc4 100644 --- a/app/javascript/components/common/Sidebar.tsx +++ b/app/javascript/components/common/Sidebar.tsx @@ -1,13 +1,41 @@ import * as React from 'react'; +import { useEffect, useState } from 'react'; +import StickyBox from 'react-sticky-box'; + +// Sidebar uses react-sticky-box to handle sidebar higher than screen height +// However, in mobile view we fallback to a normal div with class 'sidebar' (we don't want the sticky behaviour on mobile) +// Note: using react-sticky-box v1.0.2 as >v2.0 returns jsx-runtime error (https://github.com/codecks-io/react-sticky-box/issues/87) interface Props { children: React.ReactNode; } -const Sidebar = ({ children }: Props) => ( -
- {children} -
-); +const BOOTSTRAP_BREAKPOINT_SM = 768; + +const Sidebar = ({ children }: Props) => { + const [isMobile, setIsMobile] = useState(window.innerWidth < BOOTSTRAP_BREAKPOINT_SM); + + useEffect(() => { + const handleResize = () => { + setIsMobile(window.innerWidth < BOOTSTRAP_BREAKPOINT_SM); + }; + + window.addEventListener('resize', handleResize); + return () => { + window.removeEventListener('resize', handleResize); + }; + }, []); + + return ( + isMobile ? +
+ {children} +
+ : + + {children} + + ); +}; export default Sidebar; \ No newline at end of file diff --git a/app/javascript/containers/Board.tsx b/app/javascript/containers/Board.tsx index 277ecfb6..7fccc019 100644 --- a/app/javascript/containers/Board.tsx +++ b/app/javascript/containers/Board.tsx @@ -5,6 +5,8 @@ import { requestPostStatuses } from '../actions/PostStatus/requestPostStatuses'; import { setSearchFilter, setPostStatusFilter, + SortByFilterValues, + setSortByFilter, } from '../actions/changeFilters'; import { State } from '../reducers/rootReducer'; @@ -17,8 +19,14 @@ const mapStateToProps = (state: State) => ({ }); const mapDispatchToProps = (dispatch: any) => ({ - requestPosts(boardId: number, page: number = 1, searchQuery: string = '', postStatusIds: Array = null) { - dispatch(requestPosts(boardId, page, searchQuery, postStatusIds)); + requestPosts( + boardId: number, + page: number = 1, + searchQuery: string = '', + postStatusIds: Array = null, + sortBy: SortByFilterValues = null, + ) { + dispatch(requestPosts(boardId, page, searchQuery, postStatusIds, sortBy)); }, requestPostStatuses() { @@ -32,6 +40,10 @@ const mapDispatchToProps = (dispatch: any) => ({ handlePostStatusFilterChange(postStatusId: number) { dispatch(setPostStatusFilter(postStatusId)); }, + + handleSortByFilterChange(sortBy: SortByFilterValues) { + dispatch(setSortByFilter(sortBy)); + } }); export default connect( diff --git a/app/javascript/reducers/filtersReducer.ts b/app/javascript/reducers/filtersReducer.ts index fe1e44ad..3b018769 100644 --- a/app/javascript/reducers/filtersReducer.ts +++ b/app/javascript/reducers/filtersReducer.ts @@ -2,16 +2,20 @@ import { ChangeFiltersActionTypes, SET_SEARCH_FILTER, SET_POST_STATUS_FILTER, + SET_SORT_BY_FILTER, + SortByFilterValues, } from '../actions/changeFilters'; export interface FiltersState { searchQuery: string; postStatusIds: Array; + sortBy: SortByFilterValues; } const initialState: FiltersState = { searchQuery: '', postStatusIds: [], + sortBy: 'newest', } const filtersReducer = ( @@ -33,6 +37,12 @@ const filtersReducer = ( : [...state.postStatusIds, action.postStatusId], }; + case SET_SORT_BY_FILTER: + return { + ...state, + sortBy: action.sortBy, + }; + default: return state; } diff --git a/app/javascript/reducers/postsReducer.ts b/app/javascript/reducers/postsReducer.ts index 25ef166c..a1df2d7d 100644 --- a/app/javascript/reducers/postsReducer.ts +++ b/app/javascript/reducers/postsReducer.ts @@ -25,6 +25,7 @@ import { ChangeFiltersActionTypes, SET_SEARCH_FILTER, SET_POST_STATUS_FILTER, + SET_SORT_BY_FILTER, } from '../actions/changeFilters'; import { @@ -93,6 +94,7 @@ const postsReducer = ( case SET_SEARCH_FILTER: case SET_POST_STATUS_FILTER: + case SET_SORT_BY_FILTER: return { ...state, filters: filtersReducer(state.filters, action), diff --git a/app/models/post.rb b/app/models/post.rb index 1a8abd05..1af64bbd 100644 --- a/app/models/post.rb +++ b/app/models/post.rb @@ -25,5 +25,20 @@ class Post < ApplicationRecord s = sanitize_sql_like(s) where("posts.title ILIKE ? OR posts.description ILIKE ?", "%#{s}%", "%#{s}%") end + + def order_by(sort_by) + case sort_by + when 'newest' + order(created_at: :desc) + when 'trending' + order(hotness: :desc) + when 'most_voted' + order(likes_count: :desc) + when 'oldest' + order(created_at: :asc) + else + order(created_at: :desc) + end + end end end diff --git a/config/locales/en.yml b/config/locales/en.yml index dbec08fa..e7bf107d 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -106,6 +106,12 @@ en: title: 'Search' filter_box: title: 'Filter by status' + sort_by_box: + title: 'Sort by' + trending: 'Trending' + newest: 'Newest' + most_voted: 'Most voted' + oldest: 'Oldest' posts_list: empty: 'There are no posts' post: diff --git a/package.json b/package.json index 4980982b..9d7f864d 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "react-infinite-scroller": "1.2.4", "react-markdown": "5.0.3", "react-redux": "7.1.1", + "react-sticky-box": "1.0.2", "react_ujs": "2.6.0", "redux": "4.0.4", "redux-thunk": "2.3.0", diff --git a/yarn.lock b/yarn.lock index c42364d4..f9a6aa7c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2478,6 +2478,13 @@ react-redux@^7.2.0: prop-types "^15.7.2" react-is "^17.0.2" +react-sticky-box@1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/react-sticky-box/-/react-sticky-box-1.0.2.tgz#7e72a0f237bdf8270cec9254337f49519a411174" + integrity sha512-Kyvtppdtv1KqJyNU4DtrSMI0unyQRgtraZvVQ0GAazVbYiTsIVpyhpr+5R0Aavzu4uJNSe1awj2rk/qI7i6Zfw== + dependencies: + resize-observer-polyfill "^1.5.1" + react@16.9.0: version "16.9.0" resolved "https://registry.yarnpkg.com/react/-/react-16.9.0.tgz#40ba2f9af13bc1a38d75dbf2f4359a5185c4f7aa" @@ -2586,6 +2593,11 @@ require-from-string@^2.0.2: resolved "https://registry.yarnpkg.com/require-from-string/-/require-from-string-2.0.2.tgz#89a7fdd938261267318eafe14f9c32e598c36909" integrity sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw== +resize-observer-polyfill@^1.5.1: + version "1.5.1" + resolved "https://registry.yarnpkg.com/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz#0e9020dd3d21024458d4ebd27e23e40269810464" + integrity sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg== + resolve-cwd@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/resolve-cwd/-/resolve-cwd-3.0.0.tgz#0f0075f1bb2544766cf73ba6a6e2adfebcb13f2d"