diff --git a/app/assets/stylesheets/components/Roadmap.scss b/app/assets/stylesheets/components/Roadmap.scss index d7ed3774..e29ecdfa 100644 --- a/app/assets/stylesheets/components/Roadmap.scss +++ b/app/assets/stylesheets/components/Roadmap.scss @@ -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; } } -} \ No newline at end of file + + .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; + } + } +} diff --git a/app/assets/stylesheets/components/SiteSettings/Roadmap/index.scss b/app/assets/stylesheets/components/SiteSettings/Roadmap/index.scss index a90b1a31..96a94fc0 100644 --- a/app/assets/stylesheets/components/SiteSettings/Roadmap/index.scss +++ b/app/assets/stylesheets/components/SiteSettings/Roadmap/index.scss @@ -52,4 +52,8 @@ } } } +} + +#roadmapEmbedCode { + @extend .mt-4; } \ No newline at end of file diff --git a/app/controllers/static_pages_controller.rb b/app/controllers/static_pages_controller.rb index 4d96de86..2b1d5cf6 100644 --- a/app/controllers/static_pages_controller.rb +++ b/app/controllers/static_pages_controller.rb @@ -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 \ No newline at end of file diff --git a/app/javascript/components/Roadmap/PostList.tsx b/app/javascript/components/Roadmap/PostList.tsx index 1ab2f3eb..1fead746 100644 --- a/app/javascript/components/Roadmap/PostList.tsx +++ b/app/javascript/components/Roadmap/PostList.tsx @@ -10,9 +10,10 @@ import IBoard from '../../interfaces/IBoard'; interface Props { posts: Array; boards: Array; + openPostsInNewTab: boolean; } -const PostList = ({ posts, boards }: Props) => ( +const PostList = ({ posts, boards, openPostsInNewTab }: Props) => (
{ 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} /> )) diff --git a/app/javascript/components/Roadmap/PostListByPostStatus.tsx b/app/javascript/components/Roadmap/PostListByPostStatus.tsx index 031a5a1a..4da72920 100644 --- a/app/javascript/components/Roadmap/PostListByPostStatus.tsx +++ b/app/javascript/components/Roadmap/PostListByPostStatus.tsx @@ -11,9 +11,10 @@ interface Props { postStatus: IPostStatus; posts: Array; boards: Array; + openPostsInNewTab: boolean; } -const PostListByPostStatus = ({ postStatus, posts, boards }: Props) => ( +const PostListByPostStatus = ({ postStatus, posts, boards, openPostsInNewTab }: Props) => (
@@ -23,6 +24,7 @@ const PostListByPostStatus = ({ postStatus, posts, boards }: Props) => (
diff --git a/app/javascript/components/Roadmap/PostListItem.tsx b/app/javascript/components/Roadmap/PostListItem.tsx index ac6ab7df..f93a814c 100644 --- a/app/javascript/components/Roadmap/PostListItem.tsx +++ b/app/javascript/components/Roadmap/PostListItem.tsx @@ -6,10 +6,11 @@ interface Props { id: number; title: string; boardName: string; + openPostInNewTab: boolean; } -const PostListItem = ({id, title, boardName}: Props) => ( - +const PostListItem = ({id, title, boardName, openPostInNewTab}: Props) => ( +
{title} {boardName} diff --git a/app/javascript/components/Roadmap/index.tsx b/app/javascript/components/Roadmap/index.tsx index 0cf7bd03..d0ba91c2 100644 --- a/app/javascript/components/Roadmap/index.tsx +++ b/app/javascript/components/Roadmap/index.tsx @@ -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; posts: Array; boards: Array; + isEmbedded: boolean; } -class Roadmap extends React.Component { - render () { +interface State { + selectedBoards: Array; + selectedPostStatuses: Array; +} + +class Roadmap extends React.Component { + private showBoardFilter: boolean; + private showPostStatusFilter: boolean; + private boardsToShow: Array; + private postStatusesToShow: Array; + + 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) { + this.setState({ + ...this.state, + selectedBoards, + }); + } + + setSelectedPostStatuses(selectedPostStatuses: Array) { + 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 ( -
- {postStatuses.map((postStatus, i) => ( - post.post_status_id === postStatus.id)} - boards={boards} +
+
+ { + this.showBoardFilter && + + } - key={i} - /> - ))} + { + this.showPostStatusFilter && + + } +
+ +
+ {filteredPostStatuses.map((postStatus, i) => ( + post.post_status_id === postStatus.id)} + boards={boards} + openPostsInNewTab={this.props.isEmbedded} + key={i} + /> + ))} +
); } diff --git a/app/javascript/components/SiteSettings/Roadmap/RoadmapEmbedding.tsx b/app/javascript/components/SiteSettings/Roadmap/RoadmapEmbedding.tsx new file mode 100644 index 00000000..6110d9dc --- /dev/null +++ b/app/javascript/components/SiteSettings/Roadmap/RoadmapEmbedding.tsx @@ -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 = ({ embeddedRoadmapUrl }) => { + const [showBoardFilter, setShowBoardFilter] = React.useState(true); + const [showPostStatusFilter, setShowPostStatusFilter] = React.useState(false); + const [embedCode, setEmbedCode] = React.useState(''); + + React.useEffect(() => { + const code = ` + + `; + setEmbedCode(code.replace(/\s+/g, ' ').trim()); + }, [embeddedRoadmapUrl, showBoardFilter, showPostStatusFilter]); + + return ( + +

{I18n.t('site_settings.roadmap.title_embed')}

+ + setShowBoardFilter(!showBoardFilter)} + checked={showBoardFilter} + htmlId="showBoardFilterCheckbox" + /> + + setShowPostStatusFilter(!showPostStatusFilter)} + checked={showPostStatusFilter} + htmlId="showPostStatusFilterCheckbox" + /> + + + + {I18n.t('site_settings.roadmap.embed_help')} + +
+ +
+
+ ); +}; + +export default RoadmapEmbedding; \ No newline at end of file diff --git a/app/javascript/components/SiteSettings/Roadmap/RoadmapSiteSettingsP.tsx b/app/javascript/components/SiteSettings/Roadmap/RoadmapSiteSettingsP.tsx index 7e3ddcab..ae6240be 100644 --- a/app/javascript/components/SiteSettings/Roadmap/RoadmapSiteSettingsP.tsx +++ b/app/javascript/components/SiteSettings/Roadmap/RoadmapSiteSettingsP.tsx @@ -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 { } 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 { + + ); diff --git a/app/javascript/components/SiteSettings/Roadmap/index.tsx b/app/javascript/components/SiteSettings/Roadmap/index.tsx index 87608d57..a9c91c07 100644 --- a/app/javascript/components/SiteSettings/Roadmap/index.tsx +++ b/app/javascript/components/SiteSettings/Roadmap/index.tsx @@ -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 { return ( diff --git a/app/javascript/components/common/MultiSelect.tsx b/app/javascript/components/common/MultiSelect.tsx new file mode 100644 index 00000000..397a5352 --- /dev/null +++ b/app/javascript/components/common/MultiSelect.tsx @@ -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; + defaultValue: Array; + onChange: (selectedOptions: Array) => void; + className?: string; +} + +const SELECTED_COLOR = '#e5e5e5'; + +const ColoredOption = props => { + return ( + + + {props.data.label} + + + ); +}; + +const MultiSelect = ({ + options, + defaultValue, + onChange, + className, +}: Props) => { + return ( +