Improve roadmap (#405)

* Make it possible to embed the roadmap in iframe
* Add board and status filters
* Add query params to show/hide roadmap filters
This commit is contained in:
Riccardo Graziosi
2024-09-11 19:27:13 +02:00
committed by GitHub
parent 2e07f7b00d
commit 5780d8494e
18 changed files with 820 additions and 69 deletions

View File

@@ -1,51 +1,75 @@
.roadmapColumns {
@extend
.d-flex,
.justify-content-between,
.flex-wrap;
// parent .container is 960px wide with 15px padding on each side => 930px available
// each roadmap column is 31% wide => 930px * 0.31 = 288.3px * 3 = 864.9px plus 16px gap between columns (2 gaps) => 864.9px + 32px = 896.9px occupied
.roadmapColumn {
.roadmap {
.filters {
@extend
.card,
.my-2,
.p-0;
width: 32%;
background-color: var(--astuto-grey-light);
@include media-breakpoint-down(sm) {
width: 100%;
}
}
.columnHeader {
@extend
.card-header,
.d-flex;
.columnTitle {
color: white;
text-transform: uppercase;
}
}
.scrollContainer {
overflow-y: auto;
max-height: 350px;
}
.postLink {
@extend .my-2;
&:hover { text-decoration: none; }
}
.postListItem {
@extend
.card3D,
.d-flex,
.flex-column,
.m-2,
.py-2;
.mb-4;
gap: 16px;
@include media-breakpoint-down(sm) { flex-direction: column; }
.boardSelect, .postStatusSelect {
width: 50%;
@include media-breakpoint-down(sm) { width: 100%; }
}
.boardSelect { @extend .align-self-end; }
}
}
.roadmapColumns {
@extend
.d-flex,
.justify-content-start,
.flex-wrap;
gap: 16px;
.roadmapColumn {
@extend
.card,
.p-0;
width: calc(33% - 9px); // each col takes 1/3 of the space minus 9px (to account for flex-gap: 16px, otherwise it'd wrap)
background-color: var(--astuto-grey-light);
@include media-breakpoint-down(sm) {
width: 100%;
}
}
.columnHeader {
@extend
.card-header,
.d-flex;
.columnTitle {
color: white;
text-transform: uppercase;
}
}
.scrollContainer {
overflow-y: auto;
max-height: 350px;
}
.postLink {
@extend .my-2;
&:hover { text-decoration: none; }
}
.postListItem {
@extend
.card3D,
.d-flex,
.flex-column,
.m-2,
.py-2;
}
}
}

View File

@@ -52,4 +52,8 @@
}
}
}
}
#roadmapEmbedCode {
@extend .mt-4;
}

View File

@@ -1,5 +1,6 @@
class StaticPagesController < ApplicationController
skip_before_action :load_tenant_data, only: [:showcase, :pending_tenant, :blocked_tenant]
before_action :allow_iframe_embedding, only: [:embedded_roadmap]
def root
@board = Board.find_by(id: Current.tenant.tenant_setting.root_board_id)
@@ -19,6 +20,14 @@ class StaticPagesController < ApplicationController
get_roadmap_data
end
def embedded_roadmap
@page_title = t('roadmap.title')
get_roadmap_data
@is_embedded = true
render 'static_pages/roadmap', layout: 'embedded'
end
def showcase
render html: 'Showcase home page.'
end
@@ -40,4 +49,8 @@ class StaticPagesController < ApplicationController
.find_with_post_status_in(@post_statuses)
.select(:id, :title, :board_id, :post_status_id, :user_id, :created_at)
end
def allow_iframe_embedding
response.headers['X-Frame-Options'] = 'ALLOWALL'
end
end

View File

@@ -10,9 +10,10 @@ import IBoard from '../../interfaces/IBoard';
interface Props {
posts: Array<IPostJSON>;
boards: Array<IBoard>;
openPostsInNewTab: boolean;
}
const PostList = ({ posts, boards }: Props) => (
const PostList = ({ posts, boards, openPostsInNewTab }: Props) => (
<div className="postList">
{
posts.length > 0 ?
@@ -21,7 +22,7 @@ const PostList = ({ posts, boards }: Props) => (
id={post.id}
title={post.title}
boardName={boards.find(board => board.id === post.board_id).name}
openPostInNewTab={openPostsInNewTab}
key={i}
/>
))

View File

@@ -11,9 +11,10 @@ interface Props {
postStatus: IPostStatus;
posts: Array<IPostJSON>;
boards: Array<IBoard>;
openPostsInNewTab: boolean;
}
const PostListByPostStatus = ({ postStatus, posts, boards }: Props) => (
const PostListByPostStatus = ({ postStatus, posts, boards, openPostsInNewTab }: Props) => (
<div className="roadmapColumn">
<div className="columnHeader"
style={{backgroundColor: postStatus.color}}>
@@ -23,6 +24,7 @@ const PostListByPostStatus = ({ postStatus, posts, boards }: Props) => (
<PostList
posts={posts}
boards={boards}
openPostsInNewTab={openPostsInNewTab}
/>
</div>
</div>

View File

@@ -6,10 +6,11 @@ interface Props {
id: number;
title: string;
boardName: string;
openPostInNewTab: boolean;
}
const PostListItem = ({id, title, boardName}: Props) => (
<a href={`/posts/${id}`} className="postLink">
const PostListItem = ({id, title, boardName, openPostInNewTab}: Props) => (
<a href={`/posts/${id}`} className="postLink" target={openPostInNewTab ? '_blank' : '_self'}>
<div className="postListItem">
<TitleText>{title}</TitleText>
<UppercaseText>{boardName}</UppercaseText>

View File

@@ -1,6 +1,7 @@
import * as React from 'react';
import PostListByPostStatus from './PostListByPostStatus';
import MultiSelect, { MultiSelectOption } from '../common/MultiSelect';
import IPostStatus from '../../interfaces/IPostStatus';
import IPostJSON from '../../interfaces/json/IPost';
@@ -10,23 +11,105 @@ interface Props {
postStatuses: Array<IPostStatus>;
posts: Array<IPostJSON>;
boards: Array<IBoard>;
isEmbedded: boolean;
}
class Roadmap extends React.Component<Props> {
render () {
interface State {
selectedBoards: Array<MultiSelectOption>;
selectedPostStatuses: Array<MultiSelectOption>;
}
class Roadmap extends React.Component<Props, State> {
private showBoardFilter: boolean;
private showPostStatusFilter: boolean;
private boardsToShow: Array<number>;
private postStatusesToShow: Array<number>;
constructor(props: Props) {
super(props);
// read query params
const queryParams = new URLSearchParams(window.location.search);
this.showBoardFilter = queryParams.get('show_board_filter') !== 'false';
this.showPostStatusFilter = queryParams.get('show_status_filter') !== 'false';
this.boardsToShow = queryParams.get('show_boards') ? queryParams.get('show_boards').split(',').map(Number) : props.boards.map(board => board.id);
this.postStatusesToShow = queryParams.get('show_statuses') ? queryParams.get('show_statuses').split(',').map(Number) : props.postStatuses.map(postStatus => postStatus.id);
this.state = {
selectedBoards: props.boards.filter(board => this.boardsToShow.includes(board.id)).map(board => ({ value: board.id, label: board.name })),
selectedPostStatuses: props.postStatuses.filter(postStatus => this.postStatusesToShow.includes(postStatus.id)).map(postStatus => ({ value: postStatus.id, label: postStatus.name, color: postStatus.color })),
};
this.setSelectedBoards = this.setSelectedBoards.bind(this);
this.setSelectedPostStatuses = this.setSelectedPostStatuses.bind(this);
}
setSelectedBoards(selectedBoards: Array<MultiSelectOption>) {
this.setState({
...this.state,
selectedBoards,
});
}
setSelectedPostStatuses(selectedPostStatuses: Array<MultiSelectOption>) {
this.setState({
...this.state,
selectedPostStatuses,
});
}
render() {
const { postStatuses, posts, boards } = this.props;
const { selectedBoards, selectedPostStatuses } = this.state;
const boardSelectOptions = boards.filter(board => this.boardsToShow.includes(board.id)).map(board => ({ value: board.id, label: board.name }));
const postStatusSelectOptions = postStatuses.filter(postStatus => this.postStatusesToShow.includes(postStatus.id)).map(postStatus => ({ value: postStatus.id, label: postStatus.name, color: postStatus.color }));
// Filter by board
const filteredPosts = posts.filter(post =>
selectedBoards.some(selectedBoard => selectedBoard.value === post.board_id)
);
// Filter by post status
const filteredPostStatuses = postStatuses.filter(postStatus =>
selectedPostStatuses.some(selectedPostStatus => selectedPostStatus.value === postStatus.id)
);
return (
<div className="roadmapColumns">
{postStatuses.map((postStatus, i) => (
<PostListByPostStatus
postStatus={postStatus}
posts={posts.filter(post => post.post_status_id === postStatus.id)}
boards={boards}
<div className="roadmap">
<div className="filters">
{
this.showBoardFilter &&
<MultiSelect
options={boardSelectOptions}
defaultValue={selectedBoards}
onChange={this.setSelectedBoards}
className="boardSelect"
/>
}
key={i}
/>
))}
{
this.showPostStatusFilter &&
<MultiSelect
options={postStatusSelectOptions}
defaultValue={selectedPostStatuses}
onChange={this.setSelectedPostStatuses}
className="postStatusSelect"
/>
}
</div>
<div className="roadmapColumns">
{filteredPostStatuses.map((postStatus, i) => (
<PostListByPostStatus
postStatus={postStatus}
posts={filteredPosts.filter(post => post.post_status_id === postStatus.id)}
boards={boards}
openPostsInNewTab={this.props.isEmbedded}
key={i}
/>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,69 @@
import * as React from 'react';
import I18n from 'i18n-js';
import Box from '../../common/Box';
import { MutedText } from '../../common/CustomTexts';
import CopyToClipboardButton from '../../common/CopyToClipboardButton';
import Switch from '../../common/Switch';
interface Props {
embeddedRoadmapUrl: string;
}
const RoadmapEmbedding: React.FC<Props> = ({ embeddedRoadmapUrl }) => {
const [showBoardFilter, setShowBoardFilter] = React.useState(true);
const [showPostStatusFilter, setShowPostStatusFilter] = React.useState(false);
const [embedCode, setEmbedCode] = React.useState('');
React.useEffect(() => {
const code = `
<iframe
src="${embeddedRoadmapUrl}?show_board_filter=${showBoardFilter}&show_status_filter=${showPostStatusFilter}"
width="860"
height="600"
seamless
frameborder="0"></iframe>
`;
setEmbedCode(code.replace(/\s+/g, ' ').trim());
}, [embeddedRoadmapUrl, showBoardFilter, showPostStatusFilter]);
return (
<Box>
<h2>{I18n.t('site_settings.roadmap.title_embed')}</h2>
<Switch
label={I18n.t('site_settings.roadmap.show_board_filter')}
onClick={() => setShowBoardFilter(!showBoardFilter)}
checked={showBoardFilter}
htmlId="showBoardFilterCheckbox"
/>
<Switch
label={I18n.t('site_settings.roadmap.show_post_status_filter')}
onClick={() => setShowPostStatusFilter(!showPostStatusFilter)}
checked={showPostStatusFilter}
htmlId="showPostStatusFilterCheckbox"
/>
<textarea
value={embedCode}
onChange={event => setEmbedCode(event.target.value)}
rows={5}
id="roadmapEmbedCode"
>
</textarea>
<MutedText>{I18n.t('site_settings.roadmap.embed_help')}</MutedText>
<div>
<CopyToClipboardButton
label={I18n.t('common.buttons.copy_to_clipboard')}
textToCopy={embedCode}
copiedLabel={I18n.t('common.copied')}
/>
</div>
</Box>
);
};
export default RoadmapEmbedding;

View File

@@ -2,14 +2,16 @@ 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 RoadmapEmbedding from './RoadmapEmbedding';
import SiteSettingsInfoBox from '../../common/SiteSettingsInfoBox';
import RoadmapPostStatus from './RoadmapPostStatus';
import IPostStatus from '../../../interfaces/IPostStatus';
import { PostStatusesState } from "../../../reducers/postStatusesReducer";
import { MutedText } from '../../common/CustomTexts';
interface Props {
embeddedRoadmapUrl: string,
authenticityToken: string,
postStatuses: PostStatusesState,
settingsAreUpdating: boolean,
@@ -83,7 +85,7 @@ class RoadmapSiteSettingsP extends React.Component<Props, State> {
}
render() {
const { postStatuses, settingsAreUpdating, settingsError } = this.props;
const { embeddedRoadmapUrl, postStatuses, settingsAreUpdating, settingsError } = this.props;
const { isDragging } = this.state;
let statusesInRoadmap = postStatuses.items.filter(postStatus => postStatus.showInRoadmap);
@@ -158,6 +160,8 @@ class RoadmapSiteSettingsP extends React.Component<Props, State> {
</Droppable>
</Box>
<RoadmapEmbedding embeddedRoadmapUrl={embeddedRoadmapUrl} />
<SiteSettingsInfoBox areUpdating={settingsAreUpdating || postStatuses.areLoading} error={settingsError} />
</DragDropContext>
);

View File

@@ -7,6 +7,7 @@ import createStoreHelper from '../../../helpers/createStore';
import { State } from '../../../reducers/rootReducer';
interface Props {
embeddedRoadmapUrl: string;
authenticityToken: string;
}
@@ -23,6 +24,7 @@ class RoadmapSiteSettingsRoot extends React.Component<Props> {
return (
<Provider store={this.store}>
<RoadmapSiteSettings
embeddedRoadmapUrl={this.props.embeddedRoadmapUrl}
authenticityToken={this.props.authenticityToken}
/>
</Provider>

View File

@@ -0,0 +1,106 @@
import * as React from 'react';
import I18n from 'i18n-js';
import Select, { components } from 'react-select';
export type MultiSelectOption = {
value: number;
label: string;
color?: string;
};
interface Props {
options: Array<MultiSelectOption>;
defaultValue: Array<MultiSelectOption>;
onChange: (selectedOptions: Array<MultiSelectOption>) => void;
className?: string;
}
const SELECTED_COLOR = '#e5e5e5';
const ColoredOption = props => {
return (
<components.Option {...props}>
<span style={{ backgroundColor: props.data.color, color: 'white', padding: '4px', borderRadius: '4px' }}>
{props.data.label}
</span>
</components.Option>
);
};
const MultiSelect = ({
options,
defaultValue,
onChange,
className,
}: Props) => {
return (
<Select
isMulti
options={options}
defaultValue={defaultValue}
onChange={onChange}
className={className}
hideSelectedOptions={false}
isClearable={false}
isSearchable
noOptionsMessage={() => I18n.t('common.select_no_options_available')}
placeholder={I18n.t('common.select_placeholder')}
components={{ Option: (options && options.length > 0 && 'color' in options[0]) ? ColoredOption : components.Option }}
styles={{
control: (provided, state) => ({
...provided,
boxShadow: 'none',
borderColor: state.isFocused ? '#333333' : '#cdcdcd',
'&:hover': {
boxShadow: 'none',
borderColor: '#333333',
},
}),
option: (provided, state) => ({
...provided,
backgroundColor: state.isSelected ? SELECTED_COLOR : 'white',
color: state.isSelected ? '#333333' : 'inherit',
'&:hover': {
filter: 'brightness(0.8)',
},
'&:active': {
backgroundColor: SELECTED_COLOR,
},
}),
multiValue: (provided, state) => {
return {
...provided,
marginRight: '4px',
};
},
multiValueLabel: (provided, state) => {
const option = options.find(opt => opt.value === state.data.value);
return {
...provided,
backgroundColor: option.color ? option.color : 'inherit',
color: option.color ? 'white' : 'inherit',
borderTopRightRadius: '0px',
borderBottomRightRadius: '0px',
};
},
multiValueRemove: (provided, state) => {
const option = options.find(opt => opt.value === state.data.value);
return {
...provided,
backgroundColor: option.color ? option.color : 'inherit',
color: option.color ? 'white' : 'inherit',
borderTopLeftRadius: '0px',
borderBottomLeftRadius: '0px',
'&:hover': {
backgroundColor: option.color ? option.color : '#fbfbfb',
color: option.color ? 'white' : 'inherit',
filter: 'brightness(0.8)',
},
};
},
}}
/>
);
}
export default MultiSelect;

View File

@@ -0,0 +1,32 @@
<!DOCTYPE html>
<html>
<head>
<title><%= render 'layouts/page_title' %></title>
<%= csrf_meta_tags %>
<%= csp_meta_tag %>
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="turbolinks-cache-control" content="no-cache">
<%= render 'layouts/set_js_locale' %>
<%= javascript_include_tag "application", "data-turbo-track": "reload" %>
<%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>
<%= favicon_link_tag asset_path('favicon.png') %>
</head>
<body>
<div>
<%= yield %>
</div>
<% if @tenant and not @tenant.tenant_setting.custom_css.blank? %>
<style type="text/css">
<%= @tenant.tenant_setting.custom_css %>
</style>
<% end %>
</body>
</html>

View File

@@ -5,6 +5,7 @@
react_component(
'SiteSettings/Roadmap',
{
embeddedRoadmapUrl: get_url_for(method(:embedded_roadmap_url)),
authenticityToken: form_authenticity_token
}
)

View File

@@ -5,6 +5,7 @@
postStatuses: @post_statuses,
posts: @posts,
boards: @boards,
isEmbedded: @is_embedded
}
)
%>