diff --git a/app/controllers/admin/application_controller.rb b/app/controllers/admin/application_controller.rb index c14aa51b..34b9e79c 100644 --- a/app/controllers/admin/application_controller.rb +++ b/app/controllers/admin/application_controller.rb @@ -6,22 +6,10 @@ # you're free to overwrite the RESTful controller actions. module Admin class ApplicationController < Administrate::ApplicationController + include ApplicationHelper + before_action :authenticate_admin - def authenticate_admin - unless user_signed_in? - flash[:alert] = 'You must be logged in to access this page.' - redirect_to new_user_session_path - return - end - - unless current_user.moderator? || current_user.admin? - flash[:alert] = 'You do not have the privilegies to access this page.' - redirect_to root_path - return - end - end - # Override this value to specify the number of elements to display at a time # on index pages. Defaults to 20. # def records_per_page diff --git a/app/controllers/post_statuses_controller.rb b/app/controllers/post_statuses_controller.rb index 8de13bd9..b383b6a3 100644 --- a/app/controllers/post_statuses_controller.rb +++ b/app/controllers/post_statuses_controller.rb @@ -1,7 +1,75 @@ class PostStatusesController < ApplicationController + include ApplicationHelper + + before_action :authenticate_admin, only: [:create, :update, :update_order, :destroy] + def index post_statuses = PostStatus.order(order: :asc) render json: post_statuses end -end \ No newline at end of file + + def create + post_status = PostStatus.new(post_status_params) + + if post_status.save + render json: post_status, status: :created + else + render json: { + error: I18n.t('errors.post_status.create', message: post_status.errors.full_messages) + }, status: :unprocessable_entity + end + end + + def update + post_status = PostStatus.find(params[:id]) + post_status.assign_attributes(post_status_params) + + if post_status.save + render json: post_status, status: :ok + else + render json: { + error: I18n.t('errors.post_status.update', message: post_status.errors.full_messages) + }, status: :unprocessable_entity + end + end + + def destroy + post_status = PostStatus.find(params[:id]) + + if post_status.destroy + render json: { + id: params[:id] + }, status: :accepted + else + render json: { + error: I18n.t('errors.post_statuses.destroy', message: post_status.errors.full_messages) + }, status: :unprocessable_entity + end + end + + def update_order + workflow_output = ReorderWorkflow.new( + entity_classname: PostStatus, + column_name: 'order', + entity_id: params[:post_status][:id], + src_index: params[:post_status][:src_index], + dst_index: params[:post_status][:dst_index] + ).run + + if workflow_output + render json: workflow_output + else + render json: { + error: I18n.t("errors.post_status.update_order") + }, status: :unprocessable_entity + end + end + + private + def post_status_params + params + .require(:post_status) + .permit(:name, :color) + end +end diff --git a/app/controllers/site_settings_controller.rb b/app/controllers/site_settings_controller.rb new file mode 100644 index 00000000..96208a0c --- /dev/null +++ b/app/controllers/site_settings_controller.rb @@ -0,0 +1,11 @@ +class SiteSettingsController < ApplicationController + include ApplicationHelper + + before_action :authenticate_admin + + def general + end + + def post_statuses + end +end \ No newline at end of file diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index de6be794..cac248d0 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -1,2 +1,15 @@ module ApplicationHelper + def authenticate_admin + unless user_signed_in? + flash[:alert] = 'You must be logged in to access this page.' + redirect_to new_user_session_path + return + end + + unless current_user.moderator? || current_user.admin? + flash[:alert] = 'You do not have the privilegies to access this page.' + redirect_to root_path + return + end + end end diff --git a/app/javascript/actions/deletePostStatus.ts b/app/javascript/actions/deletePostStatus.ts new file mode 100644 index 00000000..7e4ebbfc --- /dev/null +++ b/app/javascript/actions/deletePostStatus.ts @@ -0,0 +1,62 @@ +import { Action } from "redux"; +import { ThunkAction } from "redux-thunk"; +import buildRequestHeaders from "../helpers/buildRequestHeaders"; +import { State } from "../reducers/rootReducer"; + +export const POST_STATUS_DELETE_START = 'POST_STATUS_DELETE_START'; +interface PostStatusDeleteStartAction { + type: typeof POST_STATUS_DELETE_START; +} + +export const POST_STATUS_DELETE_SUCCESS = 'POST_STATUS_DELETE_SUCCESS'; +interface PostStatusDeleteSuccessAction { + type: typeof POST_STATUS_DELETE_SUCCESS; + id: number; +} + +export const POST_STATUS_DELETE_FAILURE = 'POST_STATUS_DELETE_FAILURE'; +interface PostStatusDeleteFailureAction { + type: typeof POST_STATUS_DELETE_FAILURE; + error: string; +} + +export type PostStatusDeleteActionTypes = + PostStatusDeleteStartAction | + PostStatusDeleteSuccessAction | + PostStatusDeleteFailureAction; + +const postStatusDeleteStart = (): PostStatusDeleteStartAction => ({ + type: POST_STATUS_DELETE_START, +}); + +const postStatusDeleteSuccess = ( + id: number, +): PostStatusDeleteSuccessAction => ({ + type: POST_STATUS_DELETE_SUCCESS, + id, +}); + +const postStatusDeleteFailure = (error: string): PostStatusDeleteFailureAction => ({ + type: POST_STATUS_DELETE_FAILURE, + error, +}); + +export const deletePostStatus = ( + id: number, + authenticityToken: string, +): ThunkAction> => ( + async (dispatch) => { + dispatch(postStatusDeleteStart()); + + try { + const response = await fetch(`/post_statuses/${id}`, { + method: 'DELETE', + headers: buildRequestHeaders(authenticityToken), + }); + const json = await response.json(); + dispatch(postStatusDeleteSuccess(id)); + } catch (e) { + dispatch(postStatusDeleteFailure(e)); + } + } +); \ No newline at end of file diff --git a/app/javascript/actions/submitComment.ts b/app/javascript/actions/submitComment.ts index 6cd21e15..f169c1fe 100644 --- a/app/javascript/actions/submitComment.ts +++ b/app/javascript/actions/submitComment.ts @@ -4,6 +4,7 @@ import { State } from '../reducers/rootReducer'; import ICommentJSON from '../interfaces/json/IComment'; import buildRequestHeaders from '../helpers/buildRequestHeaders'; +import HttpStatus from '../constants/http_status'; export const COMMENT_SUBMIT_START = 'COMMENT_SUBMIT_START'; interface CommentSubmitStartAction { @@ -68,7 +69,7 @@ export const submitComment = ( }); const json = await res.json(); - if (res.status === 201) { + if (res.status === HttpStatus.Created) { dispatch(commentSubmitSuccess(json)); } else { dispatch(commentSubmitFailure(parentId, json.error)); diff --git a/app/javascript/actions/submitLike.ts b/app/javascript/actions/submitLike.ts index 542739ff..22d4a069 100644 --- a/app/javascript/actions/submitLike.ts +++ b/app/javascript/actions/submitLike.ts @@ -4,6 +4,7 @@ import { ThunkAction } from "redux-thunk"; import { State } from "../reducers/rootReducer"; import ILikeJSON from "../interfaces/json/ILike"; import buildRequestHeaders from "../helpers/buildRequestHeaders"; +import HttpStatus from "../constants/http_status"; export const LIKE_SUBMIT_SUCCESS = 'LIKE_SUBMIT_SUCCESS'; interface LikeSubmitSuccessAction { @@ -38,7 +39,7 @@ export const submitLike = ( }); const json = await res.json(); - if (res.status === 201 || res.status === 202) + if (res.status === HttpStatus.Created || res.status === HttpStatus.Accepted) dispatch(likeSubmitSuccess(postId, isLike, json)); } catch (e) { console.log('An error occurred while liking a post'); diff --git a/app/javascript/actions/submitPostStatus.ts b/app/javascript/actions/submitPostStatus.ts new file mode 100644 index 00000000..5bde53db --- /dev/null +++ b/app/javascript/actions/submitPostStatus.ts @@ -0,0 +1,78 @@ +import { Action } from "redux"; +import { ThunkAction } from "redux-thunk"; +import HttpStatus from "../constants/http_status"; +import buildRequestHeaders from "../helpers/buildRequestHeaders"; +import IPostStatusJSON from "../interfaces/json/IPostStatus"; +import { State } from "../reducers/rootReducer"; + +export const POSTSTATUS_SUBMIT_START = 'POSTSTATUS_SUBMIT_START'; +interface PostStatusSubmitStartAction { + type: typeof POSTSTATUS_SUBMIT_START; +} + +export const POSTSTATUS_SUBMIT_SUCCESS = 'POSTSTATUS_SUBMIT_SUCCESS'; +interface PostStatusSubmitSuccessAction { + type: typeof POSTSTATUS_SUBMIT_SUCCESS; + postStatus: IPostStatusJSON; +} + +export const POSTSTATUS_SUBMIT_FAILURE = 'POSTSTATUS_SUBMIT_FAILURE'; +interface PostStatusSubmitFailureAction { + type: typeof POSTSTATUS_SUBMIT_FAILURE; + error: string; +} + +export type PostStatusSubmitActionTypes = + PostStatusSubmitStartAction | + PostStatusSubmitSuccessAction | + PostStatusSubmitFailureAction; + +const postStatusSubmitStart = (): PostStatusSubmitStartAction => ({ + type: POSTSTATUS_SUBMIT_START, +}); + +const postStatusSubmitSuccess = ( + postStatusJSON: IPostStatusJSON, +): PostStatusSubmitSuccessAction => ({ + type: POSTSTATUS_SUBMIT_SUCCESS, + postStatus: postStatusJSON, +}); + +const postStatusSubmitFailure = (error: string): PostStatusSubmitFailureAction => ({ + type: POSTSTATUS_SUBMIT_FAILURE, + error, +}); + +export const submitPostStatus = ( + name: string, + color: string, + authenticityToken: string, +): ThunkAction> => async (dispatch) => { + dispatch(postStatusSubmitStart()); + + try { + const res = await fetch(`/post_statuses`, { + method: 'POST', + headers: buildRequestHeaders(authenticityToken), + body: JSON.stringify({ + post_status: { + name, + color, + }, + }), + }); + const json = await res.json(); + + if (res.status === HttpStatus.Created) { + dispatch(postStatusSubmitSuccess(json)); + } else { + dispatch(postStatusSubmitFailure(json.error)); + } + + return Promise.resolve(res); + } catch (e) { + dispatch(postStatusSubmitFailure(e)); + + return Promise.resolve(null); + } +}; \ No newline at end of file diff --git a/app/javascript/actions/updatePostStatus.ts b/app/javascript/actions/updatePostStatus.ts new file mode 100644 index 00000000..a08ff76d --- /dev/null +++ b/app/javascript/actions/updatePostStatus.ts @@ -0,0 +1,79 @@ +import { Action } from "redux"; +import { ThunkAction } from "redux-thunk"; +import HttpStatus from "../constants/http_status"; +import buildRequestHeaders from "../helpers/buildRequestHeaders"; +import IPostStatusJSON from "../interfaces/json/IPostStatus"; +import { State } from "../reducers/rootReducer"; + +export const POSTSTATUS_UPDATE_START = 'POSTSTATUS_UPDATE_START'; +interface PostStatusUpdateStartAction { + type: typeof POSTSTATUS_UPDATE_START; +} + +export const POSTSTATUS_UPDATE_SUCCESS = 'POSTSTATUS_UPDATE_SUCCESS'; +interface PostStatusUpdateSuccessAction { + type: typeof POSTSTATUS_UPDATE_SUCCESS; + postStatus: IPostStatusJSON; +} + +export const POSTSTATUS_UPDATE_FAILURE = 'POSTSTATUS_UPDATE_FAILURE'; +interface PostStatusUpdateFailureAction { + type: typeof POSTSTATUS_UPDATE_FAILURE; + error: string; +} + +export type PostStatusUpdateActionTypes = + PostStatusUpdateStartAction | + PostStatusUpdateSuccessAction | + PostStatusUpdateFailureAction; + +const postStatusUpdateStart = (): PostStatusUpdateStartAction => ({ + type: POSTSTATUS_UPDATE_START, +}); + +const postStatusUpdateSuccess = ( + postStatusJSON: IPostStatusJSON, +): PostStatusUpdateSuccessAction => ({ + type: POSTSTATUS_UPDATE_SUCCESS, + postStatus: postStatusJSON, +}); + +const postStatusUpdateFailure = (error: string): PostStatusUpdateFailureAction => ({ + type: POSTSTATUS_UPDATE_FAILURE, + error, +}); + +export const updatePostStatus = ( + id: number, + name: string, + color: string, + authenticityToken: string, +): ThunkAction> => async (dispatch) => { + dispatch(postStatusUpdateStart()); + + try { + const res = await fetch(`/post_statuses/${id}`, { + method: 'PATCH', + headers: buildRequestHeaders(authenticityToken), + body: JSON.stringify({ + post_status: { + name, + color, + }, + }), + }); + const json = await res.json(); + + if (res.status === HttpStatus.OK) { + dispatch(postStatusUpdateSuccess(json)); + } else { + dispatch(postStatusUpdateFailure(json.error)); + } + + return Promise.resolve(res); + } catch (e) { + dispatch(postStatusUpdateFailure(e)); + + return Promise.resolve(null); + } +}; \ No newline at end of file diff --git a/app/javascript/actions/updatePostStatusOrder.ts b/app/javascript/actions/updatePostStatusOrder.ts new file mode 100644 index 00000000..0981411e --- /dev/null +++ b/app/javascript/actions/updatePostStatusOrder.ts @@ -0,0 +1,96 @@ +import { Action } from "redux"; +import { ThunkAction } from "redux-thunk"; +import HttpStatus from "../constants/http_status"; + +import buildRequestHeaders from "../helpers/buildRequestHeaders"; +import IPostStatus from "../interfaces/IPostStatus"; +import { State } from "../reducers/rootReducer"; + +export const POSTSTATUS_ORDER_UPDATE_START = 'POSTSTATUS_ORDER_UPDATE_START'; +interface PostStatusOrderUpdateStartAction { + type: typeof POSTSTATUS_ORDER_UPDATE_START; + newOrder: Array; +} + +export const POSTSTATUS_ORDER_UPDATE_SUCCESS = 'POSTSTATUS_ORDER_UPDATE_SUCCESS'; +interface PostStatusOrderUpdateSuccessAction { + type: typeof POSTSTATUS_ORDER_UPDATE_SUCCESS; +} + +export const POSTSTATUS_ORDER_UPDATE_FAILURE = 'POSTSTATUS_ORDER_UPDATE_FAILURE'; +interface PostStatusOrderUpdateFailureAction { + type: typeof POSTSTATUS_ORDER_UPDATE_FAILURE; + error: string; +} + +export type PostStatusOrderUpdateActionTypes = + PostStatusOrderUpdateStartAction | + PostStatusOrderUpdateSuccessAction | + PostStatusOrderUpdateFailureAction; + +const postStatusOrderUpdateStart = ( + newOrder: Array +): PostStatusOrderUpdateStartAction => ({ + type: POSTSTATUS_ORDER_UPDATE_START, + newOrder, +}); + +const postStatusOrderUpdateSuccess = (): PostStatusOrderUpdateSuccessAction => ({ + type: POSTSTATUS_ORDER_UPDATE_SUCCESS, +}); + +const postStatusOrderUpdateFailure = ( + error: string +): PostStatusOrderUpdateFailureAction => ({ + type: POSTSTATUS_ORDER_UPDATE_FAILURE, + error, +}); + +export const updatePostStatusOrder = ( + id: number, + postStatuses: Array, + sourceIndex: number, + destinationIndex: number, + + authenticityToken: string, +): ThunkAction> => async (dispatch) => { + let newOrder = createNewOrder(postStatuses, sourceIndex, destinationIndex); + + dispatch(postStatusOrderUpdateStart(newOrder)); + + try { + const res = await fetch(`/post_statuses/update_order`, { + method: 'PATCH', + headers: buildRequestHeaders(authenticityToken), + body: JSON.stringify({ + post_status: { + id: id, + src_index: sourceIndex, + dst_index: destinationIndex, + }, + }), + }); + const json = await res.json(); + + if (res.status === HttpStatus.OK) { + dispatch(postStatusOrderUpdateSuccess()); + } else { + dispatch(postStatusOrderUpdateFailure(json.error)); + } + } catch (e) { + dispatch(postStatusOrderUpdateFailure(e)); + } +}; + +function createNewOrder( + oldOrder: Array, + sourceIndex: number, + destinationIndex: number +) { + let newOrder = JSON.parse(JSON.stringify(oldOrder)); + + const [reorderedItem] = newOrder.splice(sourceIndex, 1); + newOrder.splice(destinationIndex, 0, reorderedItem); + + return newOrder; +} \ No newline at end of file diff --git a/app/javascript/components/Board/NewPost.tsx b/app/javascript/components/Board/NewPost.tsx index 6c197920..6a7170b2 100644 --- a/app/javascript/components/Board/NewPost.tsx +++ b/app/javascript/components/Board/NewPost.tsx @@ -11,6 +11,7 @@ import Button from '../shared/Button'; import IBoard from '../../interfaces/IBoard'; import buildRequestHeaders from '../../helpers/buildRequestHeaders'; +import HttpStatus from '../../constants/http_status'; interface Props { board: IBoard; @@ -106,7 +107,7 @@ class NewPost extends React.Component { const json = await res.json(); this.setState({isLoading: false}); - if (res.status === 201) { + if (res.status === HttpStatus.Created) { this.setState({ success: 'Post published! You will be redirected soon...', diff --git a/app/javascript/components/SiteSettings/PostStatuses/PostStatusEditable.tsx b/app/javascript/components/SiteSettings/PostStatuses/PostStatusEditable.tsx new file mode 100644 index 00000000..e5053e66 --- /dev/null +++ b/app/javascript/components/SiteSettings/PostStatuses/PostStatusEditable.tsx @@ -0,0 +1,113 @@ +import * as React from 'react'; + +import { Draggable } from 'react-beautiful-dnd'; + +import PostStatusLabel from "../../shared/PostStatusLabel"; +import DragZone from '../../shared/DragZone'; +import Separator from '../../shared/Separator'; +import PostStatusForm from './PostStatusForm'; + +interface Props { + id: number; + name: string; + color: string; + index: number; + settingsAreUpdating: boolean; + + handleUpdate( + id: number, + name: string, + color: string, + onSuccess: Function, + ): void; + handleDelete(id: number): void; +} + +interface State { + editMode: boolean; +} + +class PostStatusEditable extends React.Component { + constructor(props) { + super(props); + + this.state = { + editMode: false, + }; + + this.toggleEditMode = this.toggleEditMode.bind(this); + this.handleUpdate = this.handleUpdate.bind(this); + } + + toggleEditMode() { + this.setState({editMode: !this.state.editMode}); + } + + handleUpdate(id: number, name: string, color: string) { + this.props.handleUpdate( + id, + name, + color, + () => this.setState({editMode: false}), + ); + } + + render() { + const { + id, + name, + color, + index, + settingsAreUpdating, + handleDelete + } = this.props; + const {editMode} = this.state; + + return ( + + {provided => ( +
  • + + + { editMode === false ? + + + + + + : + + + + + Cancel + + + } +
  • + )} +
    + ); + } +} + +export default PostStatusEditable; \ No newline at end of file diff --git a/app/javascript/components/SiteSettings/PostStatuses/PostStatusForm.tsx b/app/javascript/components/SiteSettings/PostStatuses/PostStatusForm.tsx new file mode 100644 index 00000000..7f2ce921 --- /dev/null +++ b/app/javascript/components/SiteSettings/PostStatuses/PostStatusForm.tsx @@ -0,0 +1,115 @@ +import * as React from 'react'; + +import Button from '../../shared/Button'; + +interface Props { + mode: 'create' | 'update'; + + id?: number; + name?: string; + color?: string; + + handleSubmit?( + name: string, + color: string, + onSuccess: Function, + ): void; + handleUpdate?( + id: number, + name: string, + color: string, + ): void; +} + +interface State { + name: string; + color: string; +} + +class PostStatusForm extends React.Component { + initialState: State = { + name: '', + color: this.getRandomColor(), + }; + + constructor(props: Props) { + super(props); + + this.state = this.props.mode === 'create' ? + this.initialState + : + { + name: this.props.name, + color: this.props.color, + }; + + this.onSubmit = this.onSubmit.bind(this); + } + + getRandomColor() { + return '#' + (Math.random() * 0xFFFFFF << 0).toString(16).padStart(6, '0'); + } + + isFormValid() { + return this.state.name && this.state.name.length > 0 && + this.state.color && this.state.color.length === 7; + } + + onNameChange(nameText: string) { + this.setState({ + name: nameText, + }); + } + + onColorChange(colorText: string) { + this.setState({ + color: colorText, + }); + } + + onSubmit() { + if (this.props.mode === 'create') { + this.props.handleSubmit( + this.state.name, + this.state.color, + () => this.setState({...this.initialState, color: this.getRandomColor()}), + ); + } else { + this.props.handleUpdate(this.props.id, this.state.name, this.state.color); + } + } + + render() { + const {mode} = this.props; + const {name, color} = this.state; + + return ( +
    + this.onNameChange(e.target.value)} + className="form-control" + /> + + this.onColorChange(e.target.value)} + className="form-control" + /> + + +
    + ); + } +} + +export default PostStatusForm; \ No newline at end of file diff --git a/app/javascript/components/SiteSettings/PostStatuses/PostStatusesSiteSettingsP.tsx b/app/javascript/components/SiteSettings/PostStatuses/PostStatusesSiteSettingsP.tsx new file mode 100644 index 00000000..5dda58fb --- /dev/null +++ b/app/javascript/components/SiteSettings/PostStatuses/PostStatusesSiteSettingsP.tsx @@ -0,0 +1,135 @@ +import * as React from 'react'; + +import { DragDropContext, Droppable } from 'react-beautiful-dnd'; +import IPostStatus from '../../../interfaces/IPostStatus'; + +import { PostStatusesState } from "../../../reducers/postStatusesReducer"; +import { CenteredMutedText } from '../../shared/CustomTexts'; +import SiteSettingsInfoBox from '../../shared/SiteSettingsInfoBox'; +import PostStatusForm from './PostStatusForm'; +import PostStatusEditable from './PostStatusEditable'; +import Spinner from '../../shared/Spinner'; + +interface Props { + authenticityToken: string; + postStatuses: PostStatusesState; + settingsAreUpdating: boolean; + settingsError: string; + + requestPostStatuses(): void; + submitPostStatus( + name: string, + color: string, + onSuccess: Function, + authenticityToken: string, + ): void; + updatePostStatus( + id: number, + name: string, + color: string, + onSuccess: Function, + authenticityToken: string, + ): void; + updatePostStatusOrder( + id: number, + postStatuses: Array, + sourceIndex: number, + destinationIndex: number, + authenticityToken: string, + ): void; + deletePostStatus(id: number, authenticityToken: string): void; +} + +class PostStatusesSiteSettingsP extends React.Component { + constructor(props: Props) { + super(props); + + this.handleSubmit = this.handleSubmit.bind(this); + this.handleUpdate = this.handleUpdate.bind(this); + this.handleDragEnd = this.handleDragEnd.bind(this); + this.handleDelete = this.handleDelete.bind(this); + } + + componentDidMount() { + this.props.requestPostStatuses(); + } + + handleSubmit(name: string, color: string, onSuccess: Function) { + this.props.submitPostStatus(name, color, onSuccess, this.props.authenticityToken); + } + + handleUpdate(id: number, name: string, color: string, onSuccess: Function) { + this.props.updatePostStatus(id, name, color, onSuccess, this.props.authenticityToken); + } + + handleDragEnd(result) { + if (result.destination == null || result.source.index === result.destination.index) + return; + + this.props.updatePostStatusOrder( + parseInt(result.draggableId), + this.props.postStatuses.items, + result.source.index, + result.destination.index, + this.props.authenticityToken, + ); + } + + handleDelete(id: number) { + this.props.deletePostStatus(id, this.props.authenticityToken); + } + + render() { + const { postStatuses, settingsAreUpdating, settingsError } = this.props; + + return ( + +
    +

    Post statuses

    + + { + postStatuses.items.length > 0 ? + + + {provided => ( +
      + {postStatuses.items.map((postStatus, i) => ( + + ))} + {provided.placeholder} +
    + )} +
    +
    + : + postStatuses.areLoading ? + + : + There are no post statuses. Create one below! + } +
    + +
    +

    New

    + + +
    + + +
    + ); + } +} + +export default PostStatusesSiteSettingsP; \ No newline at end of file diff --git a/app/javascript/components/SiteSettings/PostStatuses/index.tsx b/app/javascript/components/SiteSettings/PostStatuses/index.tsx new file mode 100644 index 00000000..66c8ce53 --- /dev/null +++ b/app/javascript/components/SiteSettings/PostStatuses/index.tsx @@ -0,0 +1,33 @@ +import * as React from 'react'; +import { Provider } from 'react-redux'; +import { Store } from 'redux'; + +import PostStatusesSiteSettings from '../../../containers/PostStatusesSiteSettings'; +import createStoreHelper from '../../../helpers/createStore'; +import { State } from '../../../reducers/rootReducer'; + +interface Props { + authenticityToken: string; +} + +class PostStatusesSiteSettingsRoot extends React.Component { + store: Store; + + constructor(props: Props) { + super(props); + + this.store = createStoreHelper(); + } + + render() { + return ( + + + + ); + } +} + +export default PostStatusesSiteSettingsRoot; \ No newline at end of file diff --git a/app/javascript/components/shared/Button.tsx b/app/javascript/components/shared/Button.tsx index 4d0039ac..fd5015e0 100644 --- a/app/javascript/components/shared/Button.tsx +++ b/app/javascript/components/shared/Button.tsx @@ -5,12 +5,14 @@ interface Props { onClick(e: React.FormEvent): void; className?: string; outline?: boolean; + disabled?: boolean; } -const Button = ({ children, onClick, className = '', outline = false}: Props) => ( +const Button = ({ children, onClick, className = '', outline = false, disabled = false}: Props) => ( diff --git a/app/javascript/components/shared/DragZone.tsx b/app/javascript/components/shared/DragZone.tsx new file mode 100644 index 00000000..aec43c7c --- /dev/null +++ b/app/javascript/components/shared/DragZone.tsx @@ -0,0 +1,12 @@ +import * as React from 'react'; + +const DragZone = ({dndProvided, isDragDisabled}) => ( + + + +); + +export default DragZone; \ No newline at end of file diff --git a/app/javascript/components/shared/SiteSettingsInfoBox.tsx b/app/javascript/components/shared/SiteSettingsInfoBox.tsx new file mode 100644 index 00000000..3e4188bb --- /dev/null +++ b/app/javascript/components/shared/SiteSettingsInfoBox.tsx @@ -0,0 +1,24 @@ +import * as React from 'react'; + +import Spinner from './Spinner'; + +interface Props { + areUpdating: boolean; + error: string; +} + +const SiteSettingsInfoBox = ({ areUpdating, error }: Props) => ( +
    + { + areUpdating ? + + : + error ? + An error occurred: {error} + : + Everything up to date + } +
    +); + +export default SiteSettingsInfoBox; \ No newline at end of file diff --git a/app/javascript/constants/http_status.ts b/app/javascript/constants/http_status.ts new file mode 100644 index 00000000..0aafca1c --- /dev/null +++ b/app/javascript/constants/http_status.ts @@ -0,0 +1,7 @@ +enum HttpStatus { + OK = 200, + Created = 201, + Accepted = 202, +} + +export default HttpStatus; \ No newline at end of file diff --git a/app/javascript/containers/PostStatusesSiteSettings.tsx b/app/javascript/containers/PostStatusesSiteSettings.tsx new file mode 100644 index 00000000..19846d5d --- /dev/null +++ b/app/javascript/containers/PostStatusesSiteSettings.tsx @@ -0,0 +1,64 @@ +import { connect } from "react-redux"; +import { deletePostStatus } from "../actions/deletePostStatus"; + +import { requestPostStatuses } from "../actions/requestPostStatuses"; +import { submitPostStatus } from "../actions/submitPostStatus"; +import { updatePostStatus } from "../actions/updatePostStatus"; +import { updatePostStatusOrder } from "../actions/updatePostStatusOrder"; +import PostStatusesSiteSettingsP from "../components/SiteSettings/PostStatuses/PostStatusesSiteSettingsP"; +import HttpStatus from "../constants/http_status"; +import IPostStatus from "../interfaces/IPostStatus"; +import { State } from "../reducers/rootReducer"; + +const mapStateToProps = (state: State) => ({ + postStatuses: state.postStatuses, + settingsAreUpdating: state.siteSettings.postStatuses.areUpdating, + settingsError: state.siteSettings.postStatuses.error, +}); + +const mapDispatchToProps = (dispatch: any) => ({ + requestPostStatuses() { + dispatch(requestPostStatuses()); + }, + + submitPostStatus( + name: string, + color: string, + onSuccess: Function, + authenticityToken: string + ) { + dispatch(submitPostStatus(name, color, authenticityToken)).then(res => { + if (res && res.status === HttpStatus.Created) onSuccess(); + }); + }, + + updatePostStatus( + id: number, + name: string, + color: string, + onSuccess: Function, + authenticityToken: string, + ) { + dispatch(updatePostStatus(id, name, color, authenticityToken)).then(res => { + if (res && res.status === HttpStatus.OK) onSuccess(); + }); + }, + + updatePostStatusOrder( + id: number, + postStatuses: Array, + sourceIndex: number, + destinationIndex: number, + authenticityToken: string) { + dispatch(updatePostStatusOrder(id, postStatuses, sourceIndex, destinationIndex, authenticityToken)); + }, + + deletePostStatus(id: number, authenticityToken: string) { + dispatch(deletePostStatus(id, authenticityToken)); + }, +}); + +export default connect( + mapStateToProps, + mapDispatchToProps +)(PostStatusesSiteSettingsP); \ No newline at end of file diff --git a/app/javascript/interfaces/json/IPostStatus.ts b/app/javascript/interfaces/json/IPostStatus.ts new file mode 100644 index 00000000..c1f99dcb --- /dev/null +++ b/app/javascript/interfaces/json/IPostStatus.ts @@ -0,0 +1,7 @@ +interface IPostStatusJSON { + id: number; + name: string; + color: string; +} + +export default IPostStatusJSON; \ No newline at end of file diff --git a/app/javascript/reducers/SiteSettings/postStatusesReducer.ts b/app/javascript/reducers/SiteSettings/postStatusesReducer.ts new file mode 100644 index 00000000..724c1637 --- /dev/null +++ b/app/javascript/reducers/SiteSettings/postStatusesReducer.ts @@ -0,0 +1,81 @@ +import { + PostStatusOrderUpdateActionTypes, + POSTSTATUS_ORDER_UPDATE_START, + POSTSTATUS_ORDER_UPDATE_SUCCESS, + POSTSTATUS_ORDER_UPDATE_FAILURE, +} from '../../actions/updatePostStatusOrder'; + +import { + PostStatusDeleteActionTypes, + POST_STATUS_DELETE_START, + POST_STATUS_DELETE_SUCCESS, + POST_STATUS_DELETE_FAILURE, +} from '../../actions/deletePostStatus'; + +import { + PostStatusSubmitActionTypes, + POSTSTATUS_SUBMIT_START, + POSTSTATUS_SUBMIT_SUCCESS, + POSTSTATUS_SUBMIT_FAILURE, +} from '../../actions/submitPostStatus'; + +import { + PostStatusUpdateActionTypes, + POSTSTATUS_UPDATE_START, + POSTSTATUS_UPDATE_SUCCESS, + POSTSTATUS_UPDATE_FAILURE, +} from '../../actions/updatePostStatus'; + +export interface SiteSettingsPostStatusesState { + areUpdating: boolean; + error: string; +} + +const initialState: SiteSettingsPostStatusesState = { + areUpdating: false, + error: '', +}; + +const siteSettingsPostStatusesReducer = ( + state = initialState, + action: PostStatusOrderUpdateActionTypes | + PostStatusDeleteActionTypes | + PostStatusSubmitActionTypes | + PostStatusUpdateActionTypes +): SiteSettingsPostStatusesState => { + switch (action.type) { + case POSTSTATUS_SUBMIT_START: + case POSTSTATUS_UPDATE_START: + case POSTSTATUS_ORDER_UPDATE_START: + case POST_STATUS_DELETE_START: + return { + ...state, + areUpdating: true, + }; + + case POSTSTATUS_SUBMIT_SUCCESS: + case POSTSTATUS_UPDATE_SUCCESS: + case POSTSTATUS_ORDER_UPDATE_SUCCESS: + case POST_STATUS_DELETE_SUCCESS: + return { + ...state, + areUpdating: false, + error: '', + }; + + case POSTSTATUS_SUBMIT_FAILURE: + case POSTSTATUS_UPDATE_FAILURE: + case POSTSTATUS_ORDER_UPDATE_FAILURE: + case POST_STATUS_DELETE_FAILURE: + return { + ...state, + areUpdating: false, + error: action.error, + }; + + default: + return state; + } +}; + +export default siteSettingsPostStatusesReducer; \ No newline at end of file diff --git a/app/javascript/reducers/postStatusesReducer.ts b/app/javascript/reducers/postStatusesReducer.ts index b697f046..4c7cff5b 100644 --- a/app/javascript/reducers/postStatusesReducer.ts +++ b/app/javascript/reducers/postStatusesReducer.ts @@ -5,6 +5,26 @@ import { POST_STATUSES_REQUEST_FAILURE, } from '../actions/requestPostStatuses'; +import { + PostStatusOrderUpdateActionTypes, + POSTSTATUS_ORDER_UPDATE_START, +} from '../actions/updatePostStatusOrder'; + +import { + PostStatusDeleteActionTypes, + POST_STATUS_DELETE_SUCCESS, +} from '../actions/deletePostStatus'; + +import { + PostStatusSubmitActionTypes, + POSTSTATUS_SUBMIT_SUCCESS, +} from '../actions/submitPostStatus'; + +import { + PostStatusUpdateActionTypes, + POSTSTATUS_UPDATE_SUCCESS, +} from '../actions/updatePostStatus'; + import IPostStatus from '../interfaces/IPostStatus'; export interface PostStatusesState { @@ -21,7 +41,11 @@ const initialState: PostStatusesState = { const postStatusesReducer = ( state = initialState, - action: PostStatusesRequestActionTypes, + action: PostStatusesRequestActionTypes | + PostStatusOrderUpdateActionTypes | + PostStatusDeleteActionTypes | + PostStatusSubmitActionTypes | + PostStatusUpdateActionTypes ) => { switch (action.type) { case POST_STATUSES_REQUEST_START: @@ -49,6 +73,33 @@ const postStatusesReducer = ( error: action.error, }; + case POSTSTATUS_SUBMIT_SUCCESS: + return { + ...state, + items: [...state.items, action.postStatus], + }; + + case POSTSTATUS_UPDATE_SUCCESS: + return { + ...state, + items: state.items.map(postStatus => { + if (postStatus.id !== action.postStatus.id) return postStatus; + return {...postStatus, name: action.postStatus.name, color: action.postStatus.color}; + }), + }; + + case POST_STATUS_DELETE_SUCCESS: + return { + ...state, + items: state.items.filter(postStatus => postStatus.id !== action.id), + }; + + case POSTSTATUS_ORDER_UPDATE_START: + return { + ...state, + items: action.newOrder, + }; + default: return state; } diff --git a/app/javascript/reducers/replyFormsReducer.ts b/app/javascript/reducers/replyFormsReducer.ts index 26446626..bd061c43 100644 --- a/app/javascript/reducers/replyFormsReducer.ts +++ b/app/javascript/reducers/replyFormsReducer.ts @@ -18,7 +18,6 @@ import { COMMENT_SUBMIT_FAILURE, } from '../actions/submitComment'; - import replyFormReducer, { ReplyFormState } from './replyFormReducer'; import ICommentJSON from '../interfaces/json/IComment'; diff --git a/app/javascript/reducers/rootReducer.ts b/app/javascript/reducers/rootReducer.ts index 22e0dcae..d9963610 100644 --- a/app/javascript/reducers/rootReducer.ts +++ b/app/javascript/reducers/rootReducer.ts @@ -3,11 +3,13 @@ import { combineReducers } from 'redux'; import postsReducer from './postsReducer'; import postStatusesReducer from './postStatusesReducer'; import currentPostReducer from './currentPostReducer'; +import siteSettingsReducer from './siteSettingsReducer'; const rootReducer = combineReducers({ posts: postsReducer, postStatuses: postStatusesReducer, currentPost: currentPostReducer, + siteSettings: siteSettingsReducer, }); export type State = ReturnType diff --git a/app/javascript/reducers/siteSettingsReducer.ts b/app/javascript/reducers/siteSettingsReducer.ts new file mode 100644 index 00000000..5512fe59 --- /dev/null +++ b/app/javascript/reducers/siteSettingsReducer.ts @@ -0,0 +1,68 @@ +import { + PostStatusOrderUpdateActionTypes, + POSTSTATUS_ORDER_UPDATE_START, + POSTSTATUS_ORDER_UPDATE_SUCCESS, + POSTSTATUS_ORDER_UPDATE_FAILURE, +} from '../actions/updatePostStatusOrder'; + +import { + PostStatusDeleteActionTypes, + POST_STATUS_DELETE_START, + POST_STATUS_DELETE_SUCCESS, + POST_STATUS_DELETE_FAILURE, +} from '../actions/deletePostStatus'; + +import { + PostStatusSubmitActionTypes, + POSTSTATUS_SUBMIT_START, + POSTSTATUS_SUBMIT_SUCCESS, + POSTSTATUS_SUBMIT_FAILURE, +} from '../actions/submitPostStatus'; + +import { + PostStatusUpdateActionTypes, + POSTSTATUS_UPDATE_START, + POSTSTATUS_UPDATE_SUCCESS, + POSTSTATUS_UPDATE_FAILURE, +} from '../actions/updatePostStatus'; + +import siteSettingsPostStatusesReducer, { SiteSettingsPostStatusesState } from './SiteSettings/postStatusesReducer'; + +interface SiteSettingsState { + postStatuses: SiteSettingsPostStatusesState; +} + +const initialState: SiteSettingsState = { + postStatuses: siteSettingsPostStatusesReducer(undefined, {} as any), +}; + +const siteSettingsReducer = ( + state = initialState, + action: PostStatusOrderUpdateActionTypes | + PostStatusDeleteActionTypes | + PostStatusSubmitActionTypes | + PostStatusUpdateActionTypes +): SiteSettingsState => { + switch (action.type) { + case POSTSTATUS_ORDER_UPDATE_START: + case POSTSTATUS_ORDER_UPDATE_SUCCESS: + case POSTSTATUS_ORDER_UPDATE_FAILURE: + case POST_STATUS_DELETE_START: + case POST_STATUS_DELETE_SUCCESS: + case POST_STATUS_DELETE_FAILURE: + case POSTSTATUS_SUBMIT_START: + case POSTSTATUS_SUBMIT_SUCCESS: + case POSTSTATUS_SUBMIT_FAILURE: + case POSTSTATUS_UPDATE_START: + case POSTSTATUS_UPDATE_SUCCESS: + case POSTSTATUS_UPDATE_FAILURE: + return { + postStatuses: siteSettingsPostStatusesReducer(state.postStatuses, action) + }; + + default: + return state; + } +}; + +export default siteSettingsReducer; \ No newline at end of file diff --git a/app/javascript/stylesheets/components/SiteSettings/PostStatuses/index.scss b/app/javascript/stylesheets/components/SiteSettings/PostStatuses/index.scss new file mode 100644 index 00000000..05db7561 --- /dev/null +++ b/app/javascript/stylesheets/components/SiteSettings/PostStatuses/index.scss @@ -0,0 +1,32 @@ +.postStatusList { + @extend + .p-0, + .m-0; + + list-style: none; + + .postStatusEditable { + @extend + .d-flex, + .justify-content-between, + .p-3; + + a { + cursor: pointer; + + &:hover { text-decoration: underline; } + } + + .postStatusFormCancelButton { + align-self: center; + } + } +} + +.postStatusForm { + @extend + .d-flex, + .m-2; + + column-gap: 16px; +} \ No newline at end of file diff --git a/app/javascript/stylesheets/components/SiteSettings/index.scss b/app/javascript/stylesheets/components/SiteSettings/index.scss new file mode 100644 index 00000000..9ca4c2f7 --- /dev/null +++ b/app/javascript/stylesheets/components/SiteSettings/index.scss @@ -0,0 +1,7 @@ +.siteSettingsInfo { + text-align: center; + + .error { + color: red; + } +} \ No newline at end of file diff --git a/app/javascript/stylesheets/general/_components.scss b/app/javascript/stylesheets/general/_components.scss index f3e29f90..3d4bc981 100644 --- a/app/javascript/stylesheets/general/_components.scss +++ b/app/javascript/stylesheets/general/_components.scss @@ -27,6 +27,35 @@ a { } } +.multiColumnContainer { + @extend + .d-flex, + .justify-content-between, + .align-items-start; + + flex-direction: row; + + @include media-breakpoint-down(sm) { + flex-direction: column; + + .postAndCommentsContainer { width: 100%; } + } +} + +.multiRowContent { + @extend + .flex-grow-1, + .w-100; +} + +.content { + @extend + .card, + .flex-grow-1, + .p-3, + .mb-3; +} + .sidebar { position: sticky; top: 79px; @@ -48,6 +77,39 @@ a { } } +.verticalNavigation { + @extend + .nav, + .flex-column, + .nav-pills, + .text-center, + .align-self-stretch; + + .nav-link.active { + background-color: $astuto-black; + } +} + +.drag-zone { + @extend + .align-self-center, + .pl-4, + .pr-4, + .pt-1, + .pb-1; + + cursor: grab; + + &.drag-zone-disabled { + cursor: not-allowed; + + &:active { cursor: not-allowed; } + } + + &:active { cursor: grabbing; } +} + + @include media-breakpoint-down(sm) { .sidebar { position: relative; diff --git a/app/javascript/stylesheets/icons/drag_icon.scss b/app/javascript/stylesheets/icons/drag_icon.scss new file mode 100644 index 00000000..bb0956d7 --- /dev/null +++ b/app/javascript/stylesheets/icons/drag_icon.scss @@ -0,0 +1,23 @@ +/* Code taken from: https://gist.github.com/JakeSidSmith/83c324dbe7e4d91ee8c52525b1d504d9 */ +/* Thanks JakeSidSmith */ + +span.drag-icon { + display: inline-block; + width: 16px; + height: 8px; +} + +span.drag-icon, +span.drag-icon::before { + background-image: radial-gradient(black 40%, transparent 40%); + background-size: 4px 4px; + background-position: 0 100%; + background-repeat: repeat-x; +} + +span.drag-icon::before { + content: ''; + display: block; + width: 100%; + height: 33%; +} \ No newline at end of file diff --git a/app/javascript/stylesheets/main.scss b/app/javascript/stylesheets/main.scss index e018c65a..e0ef19e5 100644 --- a/app/javascript/stylesheets/main.scss +++ b/app/javascript/stylesheets/main.scss @@ -13,4 +13,11 @@ @import 'components/Comments'; @import 'components/LikeButton'; @import 'components/Post'; -@import 'components/Roadmap'; \ No newline at end of file +@import 'components/Roadmap'; + + /* Site Settings Components */ + @import 'components/SiteSettings'; + @import 'components/SiteSettings/PostStatuses'; + +/* Icons */ +@import 'icons/drag_icon'; \ No newline at end of file diff --git a/app/models/post_status.rb b/app/models/post_status.rb index 1eaa9ef0..9dd74ea4 100644 --- a/app/models/post_status.rb +++ b/app/models/post_status.rb @@ -2,10 +2,11 @@ class PostStatus < ApplicationRecord has_many :posts, dependent: :nullify after_initialize :set_random_color, :set_order_to_last + after_destroy :ensure_coherent_order validates :name, presence: true, uniqueness: true validates :color, format: { with: /\A#(?:[0-9a-fA-F]{3}){1,2}\z/ } - validates :order, numericality: { only_integer: true, greater_than: 0 } + validates :order, presence: true, numericality: { only_integer: true, greater_than_or_equal_to: 0 } class << self def find_roadmap @@ -25,7 +26,14 @@ class PostStatus < ApplicationRecord return unless new_record? return unless order.nil? - order_last = PostStatus.maximum(:order) || 0 + order_last = PostStatus.maximum(:order) || -1 self.order = order_last + 1 end + + def ensure_coherent_order + EnsureCoherentOrderingWorkflow.new( + entity_classname: PostStatus, + column_name: 'order' + ).run + end end diff --git a/app/models/user.rb b/app/models/user.rb index 51633117..3ae70b5f 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -32,4 +32,8 @@ class User < ApplicationRecord def power_user? role == 'admin' || role == 'moderator' end + + def admin? + role == 'admin' + end end diff --git a/app/views/layouts/_header.html.erb b/app/views/layouts/_header.html.erb index 1ac2366d..7b728f22 100644 --- a/app/views/layouts/_header.html.erb +++ b/app/views/layouts/_header.html.erb @@ -18,17 +18,18 @@