Add roadmap management to Site settings (#123)

This commit is contained in:
Riccardo Graziosi
2022-06-12 15:22:06 +02:00
committed by GitHub
parent db674eaf6a
commit e2065b2c5e
31 changed files with 495 additions and 60 deletions

View File

@@ -75,6 +75,6 @@ class PostStatusesController < ApplicationController
def post_status_params
params
.require(:post_status)
.permit(:name, :color)
.permit(:name, :color, :show_in_roadmap)
end
end

View File

@@ -11,4 +11,7 @@ class SiteSettingsController < ApplicationController
def post_statuses
end
def roadmap
end
end

View File

@@ -1,7 +1,7 @@
import { Action } from 'redux';
import { ThunkAction } from 'redux-thunk';
import IPostStatus from '../../interfaces/IPostStatus';
import IPostStatusJSON from '../../interfaces/json/IPostStatus';
import { State } from '../../reducers/rootReducer';
@@ -13,7 +13,7 @@ interface PostStatusesRequestStartAction {
export const POST_STATUSES_REQUEST_SUCCESS = 'POST_STATUSES_REQUEST_SUCCESS';
interface PostStatusesRequestSuccessAction {
type: typeof POST_STATUSES_REQUEST_SUCCESS;
postStatuses: Array<IPostStatus>;
postStatuses: Array<IPostStatusJSON>;
}
export const POST_STATUSES_REQUEST_FAILURE = 'POST_STATUSES_REQUEST_FAILURE';
@@ -33,7 +33,7 @@ const postStatusesRequestStart = (): PostStatusesRequestActionTypes => ({
});
const postStatusesRequestSuccess = (
postStatuses: Array<IPostStatus>
postStatuses: Array<IPostStatusJSON>
): PostStatusesRequestActionTypes => ({
type: POST_STATUSES_REQUEST_SUCCESS,
postStatuses,

View File

@@ -43,24 +43,34 @@ const postStatusUpdateFailure = (error: string): PostStatusUpdateFailureAction =
error,
});
export const updatePostStatus = (
id: number,
name: string,
color: string,
authenticityToken: string,
): ThunkAction<void, State, null, Action<string>> => async (dispatch) => {
interface UpdatePostStatusParams {
id: number;
name?: string;
color?: string;
showInRoadmap?: boolean;
authenticityToken: string;
}
export const updatePostStatus = ({
id,
name = null,
color = null,
showInRoadmap = null,
authenticityToken,
}: UpdatePostStatusParams): ThunkAction<void, State, null, Action<string>> => async (dispatch) => {
dispatch(postStatusUpdateStart());
const post_status = Object.assign({},
name !== null ? {name} : null,
color !== null ? {color} : null,
showInRoadmap !== null ? {show_in_roadmap: showInRoadmap} : null
);
try {
const res = await fetch(`/post_statuses/${id}`, {
method: 'PATCH',
headers: buildRequestHeaders(authenticityToken),
body: JSON.stringify({
post_status: {
name,
color,
},
}),
body: JSON.stringify({post_status}),
});
const json = await res.json();

View File

@@ -81,7 +81,7 @@ const Comment = ({
</a>
{
isPowerUser ?
<React.Fragment>
<>
<Separator />
<a
onClick={() => handleToggleIsCommentUpdate(id, isPostUpdate)}
@@ -103,7 +103,7 @@ const Comment = ({
{I18n.t('common.buttons.delete')}
</a>
</React.Fragment>
</>
:
null
}

View File

@@ -36,7 +36,7 @@ const CommentList = ({
isPowerUser,
userEmail,
}: Props) => (
<React.Fragment>
<>
{comments.map((comment, i) => {
if (comment.parentId === parentId) {
return (
@@ -77,7 +77,7 @@ const CommentList = ({
);
} else return null;
})}
</React.Fragment>
</>
);
export default CommentList;

View File

@@ -41,11 +41,11 @@ const NewComment = ({
isPowerUser,
userEmail,
}: Props) => (
<React.Fragment>
<>
<div className="newCommentForm">
{
isLoggedIn ?
<React.Fragment>
<>
<div className="commentBodyForm">
<Gravatar email={userEmail} size={48} className="currentUserAvatar" />
<textarea
@@ -69,7 +69,7 @@ const NewComment = ({
:
null
}
</React.Fragment>
</>
:
<a href="/users/sign_in" className="loginInfo">
{I18n.t('post.new_comment.not_logged_in')}
@@ -78,7 +78,7 @@ const NewComment = ({
</div>
{ error ? <DangerText>{error}</DangerText> : null }
</React.Fragment>
</>
);
export default NewComment;

View File

@@ -56,12 +56,12 @@ const PostUpdateList = ({
{postUpdate.body}
</ReactMarkdown>
:
<React.Fragment>
<>
<i>{I18n.t('post.updates_box.status_change')}</i>&nbsp;
<PostStatusLabel
{...postStatuses.find(postStatus => postStatus.id === postUpdate.postStatusId)}
/>
</React.Fragment>
</>
}
</div>

View File

@@ -72,7 +72,7 @@ class BoardsEditable extends React.Component<Props, State> {
<DragZone dndProvided={provided} isDragDisabled={settingsAreUpdating} />
{ editMode === false ?
<React.Fragment>
<>
<div className="boardInfo">
<div className="boardName">
<PostBoardLabel name={name} />
@@ -94,9 +94,9 @@ class BoardsEditable extends React.Component<Props, State> {
{I18n.t('common.buttons.delete')}
</a>
</div>
</React.Fragment>
</>
:
<React.Fragment>
<>
<BoardForm
mode='update'
id={id}
@@ -110,7 +110,7 @@ class BoardsEditable extends React.Component<Props, State> {
onClick={this.toggleEditMode}>
{I18n.t('common.buttons.cancel')}
</a>
</React.Fragment>
</>
}
</li>
)}

View File

@@ -1,6 +1,6 @@
import * as React from 'react';
import I18n from 'i18n-js';
import { DragDropContext, Droppable } from 'react-beautiful-dnd';
import I18n from 'i18n-js';
import BoardEditable from './BoardEditable';
import BoardForm from './BoardForm';

View File

@@ -1,7 +1,6 @@
import * as React from 'react';
import I18n from 'i18n-js';
import { Draggable } from 'react-beautiful-dnd';
import I18n from 'i18n-js';
import PostStatusLabel from "../../common/PostStatusLabel";
import DragZone from '../../common/DragZone';
@@ -71,7 +70,7 @@ class PostStatusEditable extends React.Component<Props, State> {
<DragZone dndProvided={provided} isDragDisabled={settingsAreUpdating} />
{ editMode === false ?
<React.Fragment>
<>
<PostStatusLabel name={name} color={color} />
<div className="postStatusEditableActions">
@@ -86,9 +85,9 @@ class PostStatusEditable extends React.Component<Props, State> {
{I18n.t('common.buttons.delete')}
</a>
</div>
</React.Fragment>
</>
:
<React.Fragment>
<>
<PostStatusForm
mode='update'
id={id}
@@ -102,7 +101,7 @@ class PostStatusEditable extends React.Component<Props, State> {
onClick={this.toggleEditMode}>
{I18n.t('common.buttons.cancel')}
</a>
</React.Fragment>
</>
}
</li>
)}

View File

@@ -1,10 +1,7 @@
import * as React from 'react';
import { DragDropContext, Droppable } from 'react-beautiful-dnd';
import I18n from 'i18n-js';
import { DragDropContext, Droppable } from 'react-beautiful-dnd';
import IPostStatus from '../../../interfaces/IPostStatus';
import { PostStatusesState } from "../../../reducers/postStatusesReducer";
import { CenteredMutedText } from '../../common/CustomTexts';
import SiteSettingsInfoBox from '../../common/SiteSettingsInfoBox';
import PostStatusForm from './PostStatusForm';
@@ -12,6 +9,9 @@ import PostStatusEditable from './PostStatusEditable';
import Spinner from '../../common/Spinner';
import Box from '../../common/Box';
import { PostStatusesState } from "../../../reducers/postStatusesReducer";
import IPostStatus from '../../../interfaces/IPostStatus';
interface Props {
authenticityToken: string;
postStatuses: PostStatusesState;

View File

@@ -0,0 +1,40 @@
import * as React from 'react';
import { Draggable } from 'react-beautiful-dnd';
import DragZone from '../../common/DragZone';
import { TitleText } from '../../common/CustomTexts';
interface Props {
id: number;
name: string;
color: string;
index: number;
settingsAreUpdating: boolean;
headerOnly?: boolean;
}
const RoadmapPostStatus = ({
id,
name,
color,
index,
settingsAreUpdating,
headerOnly,
}: Props) => (
<Draggable key={id} draggableId={id.toString()} index={index} isDragDisabled={settingsAreUpdating}>
{(provided, snapshot) => (
<div
className={`roadmapPostStatus${snapshot.isDragging ? '' : ' notDragging'}${headerOnly ? ' headerOnly' : ''}`}
ref={provided.innerRef}
{...provided.draggableProps}
>
<div className="roadmapPostStatusHeader" style={{backgroundColor: color}}>
<DragZone color='white' dndProvided={provided} isDragDisabled={settingsAreUpdating} />
<TitleText>{name}</TitleText>
</div>
</div>
)}
</Draggable>
);
export default RoadmapPostStatus;

View File

@@ -0,0 +1,167 @@
import * as React from 'react';
import { DragDropContext, Droppable } from 'react-beautiful-dnd';
import I18n from 'i18n-js';
import { PostStatusesState } from "../../../reducers/postStatusesReducer";
import Box from '../../common/Box';
import SiteSettingsInfoBox from '../../common/SiteSettingsInfoBox';
import RoadmapPostStatus from './RoadmapPostStatus';
import IPostStatus from '../../../interfaces/IPostStatus';
import { MutedText } from '../../common/CustomTexts';
interface Props {
authenticityToken: string,
postStatuses: PostStatusesState,
settingsAreUpdating: boolean,
settingsError: string,
requestPostStatuses(): void;
updatePostStatus(
id: number,
showInRoadmap: boolean,
onComplete: Function,
authenticityToken: string,
): void;
}
interface State {
isDragging: number;
}
class RoadmapSiteSettingsP extends React.Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = {
isDragging: null,
};
this.handleDragStart = this.handleDragStart.bind(this);
this.handleDragEnd = this.handleDragEnd.bind(this);
}
componentDidMount() {
this.props.requestPostStatuses();
}
handleDragStart(result) {
this.setState({ isDragging: parseInt(result.draggableId) });
}
handleDragEnd(result) {
if (result.destination == null || result.source.droppableId === result.destination.droppableId) {
this.setState({ isDragging: null });
return;
}
this.props.updatePostStatus(
result.draggableId,
result.destination.droppableId === 'statusesInRoadmap',
() => this.setState({ isDragging: null }),
this.props.authenticityToken,
);
}
// Workaround needed because after dropping a post status, the state is not yet updated
// with the new showInRoadmap value (we need to wait POSTSTATUS_UPDATE_SUCCESS dispatch)
// and the UI would flicker, moving the poststatus back in its original spot
placeDraggingStatusDuringUpdate(statusesInRoadmap: IPostStatus[], statusesNotInRoadmap: IPostStatus[]) {
const { postStatuses } = this.props;
const movedPostStatus = postStatuses.items.find(postStatus => postStatus.id === this.state.isDragging);
if (movedPostStatus.showInRoadmap) {
statusesInRoadmap = statusesInRoadmap.filter(postStatus => postStatus.id !== this.state.isDragging);
statusesNotInRoadmap.push(movedPostStatus);
statusesNotInRoadmap.sort((ps1, ps2) => ps1.order - ps2.order);
} else {
statusesNotInRoadmap = statusesNotInRoadmap.filter(postStatus => postStatus.id !== this.state.isDragging);
statusesInRoadmap.push(movedPostStatus);
statusesInRoadmap.sort((ps1, ps2) => ps1.order - ps2.order);
}
return [statusesInRoadmap, statusesNotInRoadmap];
}
render() {
const { postStatuses, settingsAreUpdating, settingsError } = this.props;
const { isDragging } = this.state;
let statusesInRoadmap = postStatuses.items.filter(postStatus => postStatus.showInRoadmap);
let statusesNotInRoadmap = postStatuses.items.filter(postStatus => !postStatus.showInRoadmap);
if (settingsAreUpdating && this.state.isDragging) {
[statusesInRoadmap, statusesNotInRoadmap] = this.placeDraggingStatusDuringUpdate(statusesInRoadmap, statusesNotInRoadmap);
}
return (
<DragDropContext onDragStart={this.handleDragStart} onDragEnd={this.handleDragEnd}>
<Box>
<h2>{I18n.t('site_settings.roadmap.title')}</h2>
<Droppable droppableId="statusesInRoadmap" direction="horizontal">
{(provided, snapshot) => (
<div
ref={provided.innerRef}
className={`roadmapPostStatuses${isDragging ? ' isDraggingSomething' : ''}${snapshot.isDraggingOver ? ' isDraggingOver' : ''}`}
{...provided.droppableProps}
>
{statusesInRoadmap.map((postStatus, i) => (
<RoadmapPostStatus
id={postStatus.id}
name={postStatus.name}
color={postStatus.color}
index={i}
settingsAreUpdating={settingsAreUpdating}
key={postStatus.id}
/>
))}
{provided.placeholder}
</div>
)}
</Droppable>
{
statusesInRoadmap.length > 0 ?
null
:
<MutedText>{I18n.t('site_settings.roadmap.empty')}</MutedText>
}
<MutedText>{I18n.t('site_settings.roadmap.help')}</MutedText>
</Box>
<Box>
<h2>{I18n.t('site_settings.roadmap.title2')}</h2>
<Droppable droppableId="statusesNotInRoadmap" direction="horizontal">
{(provided, snapshot) => (
<div
ref={provided.innerRef}
className={`roadmapPostStatuses${isDragging ? ' isDraggingSomething' : ''}${snapshot.isDraggingOver ? ' isDraggingOver' : ''}`}
{...provided.droppableProps}
>
{statusesNotInRoadmap.map((postStatus, i) => (
<RoadmapPostStatus
id={postStatus.id}
name={postStatus.name}
color={postStatus.color}
index={i}
settingsAreUpdating={settingsAreUpdating}
headerOnly
key={postStatus.id}
/>
))}
{provided.placeholder}
</div>
)}
</Droppable>
</Box>
<SiteSettingsInfoBox areUpdating={settingsAreUpdating || postStatuses.areLoading} error={settingsError} />
</DragDropContext>
);
}
}
export default RoadmapSiteSettingsP;

View File

@@ -0,0 +1,33 @@
import * as React from 'react';
import { Provider } from 'react-redux';
import { Store } from 'redux';
import RoadmapSiteSettings from '../../../containers/RoadmapSiteSettings';
import createStoreHelper from '../../../helpers/createStore';
import { State } from '../../../reducers/rootReducer';
interface Props {
authenticityToken: string;
}
class RoadmapSiteSettingsRoot extends React.Component<Props> {
store: Store<State, any>;
constructor(props: Props) {
super(props);
this.store = createStoreHelper();
}
render() {
return (
<Provider store={this.store}>
<RoadmapSiteSettings
authenticityToken={this.props.authenticityToken}
/>
</Provider>
);
}
}
export default RoadmapSiteSettingsRoot;

View File

@@ -1,11 +1,11 @@
import * as React from 'react';
const DragZone = ({dndProvided, isDragDisabled}) => (
const DragZone = ({dndProvided, isDragDisabled, color = 'black'}) => (
<span
className={`drag-zone${isDragDisabled ? ' drag-zone-disabled' : ''}`}
{...dndProvided.dragHandleProps}
>
<span className="drag-icon"></span>
<span className={`drag-icon${color === 'white' ? ' drag-icon-white' : ''}`}></span>
</span>
);

View File

@@ -40,7 +40,7 @@ const mapDispatchToProps = (dispatch: any) => ({
onSuccess: Function,
authenticityToken: string,
) {
dispatch(updatePostStatus(id, name, color, authenticityToken)).then(res => {
dispatch(updatePostStatus({id, name, color, authenticityToken})).then(res => {
if (res && res.status === HttpStatus.OK) onSuccess();
});
},

View File

@@ -0,0 +1,30 @@
import { connect } from "react-redux";
import RoadmapSiteSettingsP from "../components/SiteSettings/Roadmap/RoadmapSiteSettingsP";
import { requestPostStatuses } from "../actions/PostStatus/requestPostStatuses";
import { State } from "../reducers/rootReducer";
import { updatePostStatus } from "../actions/PostStatus/updatePostStatus";
const mapStateToProps = (state: State) => ({
postStatuses: state.postStatuses,
settingsAreUpdating: state.siteSettings.roadmap.areUpdating,
settingsError: state.siteSettings.roadmap.error,
});
const mapDispatchToProps = (dispatch: any) => ({
requestPostStatuses() {
dispatch(requestPostStatuses());
},
updatePostStatus(id: number, showInRoadmap: boolean, onComplete: Function, authenticityToken: string) {
dispatch(updatePostStatus({id, showInRoadmap, authenticityToken})).then(() => {
onComplete();
});
},
});
export default connect(
mapStateToProps,
mapDispatchToProps
)(RoadmapSiteSettingsP);

View File

@@ -2,6 +2,8 @@ interface IPostStatus {
id: number;
name: string;
color: string;
order: number;
showInRoadmap: boolean;
}
export default IPostStatus;

View File

@@ -2,6 +2,8 @@ interface IPostStatusJSON {
id: number;
name: string;
color: string;
order: number;
show_in_roadmap: boolean;
}
export default IPostStatusJSON;

View File

@@ -0,0 +1,48 @@
import {
PostStatusUpdateActionTypes,
POSTSTATUS_UPDATE_START,
POSTSTATUS_UPDATE_SUCCESS,
POSTSTATUS_UPDATE_FAILURE,
} from '../../actions/PostStatus/updatePostStatus';
export interface SiteSettingsRoadmapState {
areUpdating: boolean;
error: string;
}
const initialState = {
areUpdating: false,
error: '',
};
const siteSettingsRoadmapReducer = (
state = initialState,
action: PostStatusUpdateActionTypes,
): SiteSettingsRoadmapState => {
switch (action.type) {
case POSTSTATUS_UPDATE_START:
return {
...state,
areUpdating: true,
};
case POSTSTATUS_UPDATE_SUCCESS:
return {
...state,
areUpdating: false,
error: '',
};
case POSTSTATUS_UPDATE_FAILURE:
return {
...state,
areUpdating: false,
error: action.error,
};
default:
return state;
}
}
export default siteSettingsRoadmapReducer;

View File

@@ -62,6 +62,8 @@ const postStatusesReducer = (
id: postStatus.id,
name: postStatus.name,
color: postStatus.color,
order: postStatus.order,
showInRoadmap: postStatus.show_in_roadmap,
})),
areLoading: false,
error: '',
@@ -85,7 +87,12 @@ const postStatusesReducer = (
...state,
items: state.items.map(postStatus => {
if (postStatus.id !== action.postStatus.id) return postStatus;
return {...postStatus, name: action.postStatus.name, color: action.postStatus.color};
return {
...postStatus,
name: action.postStatus.name,
color: action.postStatus.color,
showInRoadmap: action.postStatus.show_in_roadmap,
};
}),
};

View File

@@ -63,15 +63,18 @@ import {
import siteSettingsBoardsReducer, { SiteSettingsBoardsState } from './SiteSettings/boardsReducer';
import siteSettingsPostStatusesReducer, { SiteSettingsPostStatusesState } from './SiteSettings/postStatusesReducer';
import siteSettingsRoadmapReducer, { SiteSettingsRoadmapState } from './SiteSettings/roadmapReducer';
interface SiteSettingsState {
boards: SiteSettingsBoardsState;
postStatuses: SiteSettingsPostStatusesState;
roadmap: SiteSettingsRoadmapState;
}
const initialState: SiteSettingsState = {
boards: siteSettingsBoardsReducer(undefined, {} as any),
postStatuses: siteSettingsPostStatusesReducer(undefined, {} as any),
roadmap: siteSettingsRoadmapReducer(undefined, {} as any),
};
const siteSettingsReducer = (
@@ -108,21 +111,27 @@ const siteSettingsReducer = (
boards: siteSettingsBoardsReducer(state.boards, action),
};
case POSTSTATUS_SUBMIT_START:
case POSTSTATUS_SUBMIT_SUCCESS:
case POSTSTATUS_SUBMIT_FAILURE:
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:
return {
...state,
postStatuses: siteSettingsPostStatusesReducer(state.postStatuses, action),
};
case POSTSTATUS_UPDATE_START:
case POSTSTATUS_UPDATE_SUCCESS:
case POSTSTATUS_UPDATE_FAILURE:
return {
...state,
postStatuses: siteSettingsPostStatusesReducer(state.postStatuses, action),
roadmap: siteSettingsRoadmapReducer(state.roadmap, action),
};
default:

View File

@@ -0,0 +1,52 @@
.roadmapPostStatuses {
@extend
.d-flex,
.flex-row,
.flex-wrap;
min-height: 150px;
box-sizing: border-box;
border: 2px dotted transparent;
border-radius: 0.5rem;
&.isDraggingSomething {
border-color: black;
}
&.isDraggingOver {
background-color: rgba(255, 255, 0, 0.2);
}
.roadmapPostStatus {
@extend
.card,
.m-2,
.p-0;
@include media-breakpoint-down(sm) {
flex: 0 0 100%;
}
box-sizing: border-box;
flex: 0 0 28%;
background-color: $astuto-light-grey;
overflow: hidden;
height: 150px;
&.headerOnly { height: fit-content; }
&.notDragging { transform: none !important; }
.roadmapPostStatusHeader {
@extend
.d-flex,
.flex-row,
.card-header;
color: white;
padding: 8px 4px;
.titleText { @extend .align-self-center; }
}
}
}

View File

@@ -15,6 +15,11 @@ span.drag-icon::before {
background-repeat: repeat-x;
}
span.drag-icon.drag-icon-white,
span.drag-icon.drag-icon-white::before {
background-image: radial-gradient(white 40%, transparent 40%);
}
span.drag-icon::before {
content: '';
display: block;

View File

@@ -19,6 +19,7 @@
@import 'components/SiteSettings';
@import 'components/SiteSettings/Boards';
@import 'components/SiteSettings/PostStatuses';
@import 'components/SiteSettings/Roadmap';
/* Icons */
@import 'icons/drag_icon';

View File

@@ -5,6 +5,7 @@
<div class="verticalNavigation" role="tablist" aria-orientation="vertical">
<%= render 'menu_link', label: t('site_settings.menu.boards'), path: site_settings_boards_path %>
<%= render 'menu_link', label: t('site_settings.menu.post_statuses'), path: site_settings_post_statuses_path %>
<%= render 'menu_link', label: t('site_settings.menu.roadmap'), path: site_settings_roadmap_path %>
</div>
</div>
</div>

View File

@@ -0,0 +1,13 @@
<div class="twoColumnsContainer">
<%= render 'menu' %>
<div>
<%=
react_component(
'SiteSettings/Roadmap',
{
authenticityToken: form_authenticity_token
}
)
%>
</div>
</div>

View File

@@ -103,6 +103,7 @@ en:
title: 'Site settings'
boards: 'Boards'
post_statuses: 'Statuses'
roadmap: 'Roadmap'
info_box:
up_to_date: 'All changes saved'
error: 'An error occurred: %{message}'
@@ -119,6 +120,11 @@ en:
new: 'New'
form:
name: 'Status name'
roadmap:
title: 'Roadmap'
title2: 'Not in roadmap'
empty: 'The roadmap is empty.'
help: 'You can add new statuses to the roadmap by dragging them from the section below. If you want to add a new status or change their order, go to Site settings -> Statuses.'
user_mailer:
opening_greeting: 'Hello!'
closing_greeting: 'Have a great day!'

View File

@@ -103,6 +103,7 @@ it:
title: 'Impostazioni sito'
boards: 'Bacheche'
post_statuses: 'Stati'
roadmap: 'Roadmap'
info_box:
up_to_date: 'Tutte le modifiche sono state salvate'
error: 'Si è verificato un errore: %{message}'
@@ -119,6 +120,11 @@ it:
new: 'Nuovo'
form:
name: 'Nome stato'
roadmap:
title: 'Roadmap'
title2: 'Non mostrati in roadmap'
empty: 'La roadmap è vuota.'
help: "Puoi aggiungere nuovi stati alla roadmap trascinandoli dalla sezione sottostante. Se vuoi aggiungere un nuovo stato o cambiarne l'ordine, vai in Impostazioni sito -> Stati."
user_mailer:
opening_greeting: 'Ciao!'
closing_greeting: 'Buona giornata!'

View File

@@ -35,5 +35,6 @@ Rails.application.routes.draw do
get 'general'
get 'boards'
get 'post_statuses'
get 'roadmap'
end
end