diff --git a/frontend/src/components/core/Container.tsx b/frontend/src/components/core/Container.tsx index 4da847ea..09154b5a 100644 --- a/frontend/src/components/core/Container.tsx +++ b/frontend/src/components/core/Container.tsx @@ -4,6 +4,10 @@ export const RelativeContainer = styled.div` position: relative; `; +export const InlineContainer = styled.div` + display: inline; +`; + export const FlexContainer = styled.div` display: flex; flex: auto; diff --git a/frontend/src/components/core/HelpModal.tsx b/frontend/src/components/core/HelpModal.tsx index d56ce3cc..b01fe265 100644 --- a/frontend/src/components/core/HelpModal.tsx +++ b/frontend/src/components/core/HelpModal.tsx @@ -103,7 +103,7 @@ export function LobbyHelpModal(props: HelpModalProps) { Difficulty : If no problems are selected, the host can choose a difficulty setting - and play the game with a randomly selected problem. + and play the game with randomly selected problems. > <> diff --git a/frontend/src/components/core/Icon.tsx b/frontend/src/components/core/Icon.tsx index 9d6f7ed8..b70ac515 100644 --- a/frontend/src/components/core/Icon.tsx +++ b/frontend/src/components/core/Icon.tsx @@ -1,3 +1,4 @@ +import React from 'react'; import styled from 'styled-components'; export const InlineIcon = styled.i.attrs(() => ({ @@ -38,24 +39,12 @@ export const InlineErrorIcon = styled(InlineIcon).attrs((props: ShowError) => ({ } `; -export const InlineShowIcon = styled.i.attrs(() => ({ - className: 'material-icons', -}))` - display: inline-block; - position: relative; - top: 0.1rem; - margin-left: 0.3rem; - border-radius: 1rem; - font-size: ${({ theme }) => theme.fontSize.medium}; - color: ${({ theme }) => theme.colors.font}; -`; - export const SpectatorBackIcon = styled.i.attrs(() => ({ className: 'material-icons', }))` position: absolute; top: 50%; - left: 0%; + left: 0; transform: translate(0%, -50%); text-align: center; margin: 0; @@ -70,3 +59,15 @@ export const SpectatorBackIcon = styled.i.attrs(() => ({ box-shadow: 0 2px 12px rgba(0, 0, 0, 0.24); } `; + +export const PrevIcon = () => ( + + navigate_before + +); + +export const NextIcon = () => ( + + navigate_next + +); diff --git a/frontend/src/components/game/Editor.tsx b/frontend/src/components/game/Editor.tsx index 9409d625..fb85f7e0 100644 --- a/frontend/src/components/game/Editor.tsx +++ b/frontend/src/components/game/Editor.tsx @@ -77,7 +77,7 @@ const LanguageSelect = styled.select` const monacoEditorOptions: EditorConstructionOptions = { automaticLayout: true, fixedOverflowWidgets: true, - fontFamily: 'Monaco', + fontFamily: 'Monaco, monospace', hideCursorInOverviewRuler: true, minimap: { enabled: false }, overviewRulerBorder: false, diff --git a/frontend/src/components/game/PlayerGameView.tsx b/frontend/src/components/game/PlayerGameView.tsx index 3a93948b..e6587cc7 100644 --- a/frontend/src/components/game/PlayerGameView.tsx +++ b/frontend/src/components/game/PlayerGameView.tsx @@ -6,9 +6,7 @@ import React, { } from 'react'; import styled from 'styled-components'; import SplitterLayout from 'react-splitter-layout'; -import MarkdownEditor from 'rich-markdown-editor'; import { useBeforeunload } from 'react-beforeunload'; -import copy from 'copy-to-clipboard'; import { Message, Subscription } from 'stompjs'; import Editor from './Editor'; import { DefaultCodeType, getDefaultCodeMap, Problem } from '../../api/Problem'; @@ -20,7 +18,7 @@ import { } from '../core/Container'; import ErrorMessage from '../core/Error'; import 'react-splitter-layout/lib/index.css'; -import { ProblemHeaderText, BottomFooterText, NoMarginDefaultText } from '../core/Text'; +import { NoMarginDefaultText } from '../core/Text'; import Console from './Console'; import Loading from '../core/Loading'; import { @@ -33,37 +31,16 @@ import { Player, } from '../../api/Game'; import LeaderboardCard from '../card/LeaderboardCard'; -import { getDifficultyDisplayButton, InheritedTextButton, SmallButton } from '../core/Button'; import { SpectatorBackIcon } from '../core/Icon'; import Language from '../../api/Language'; -import { CopyIndicator, BottomCopyIndicatorContainer, InlineCopyIcon } from '../special/CopyIndicator'; -import { useAppSelector, useBestSubmission, useGetSubmission } from '../../util/Hook'; import { routes, send, subscribe } from '../../api/Socket'; import { User } from '../../api/User'; +import ProblemPanel from './ProblemPanel'; +import { useAppSelector, useBestSubmission, useGetSubmission } from '../../util/Hook'; import { getScore, getSubmissionCount, getSubmissionTime, getSubmission, } from '../../util/Utility'; -const StyledMarkdownEditor = styled(MarkdownEditor)` - margin-top: 15px; - padding: 0; - - p { - font-family: ${({ theme }) => theme.font}; - } - - // The specific list of attributes to have dark text color. - .ProseMirror > p, blockquote, h1, h2, h3, ul, ol, table { - color: ${({ theme }) => theme.colors.text}; - } -`; - -const OverflowPanel = styled(Panel)` - overflow-y: auto; - height: 100%; - padding: 0 25px; -`; - const NoPaddingPanel = styled(Panel)` padding: 0; `; @@ -72,7 +49,7 @@ const LeaderboardContent = styled.div` text-align: center; margin: 0 auto; width: 75%; - overflow-x: scroll; + overflow-x: auto; white-space: nowrap; // Show shadows if there is scrollable content @@ -160,7 +137,6 @@ function PlayerGameView(props: PlayerGameViewProps) { const { currentUser, game } = useAppSelector((state) => state); - const [copiedEmail, setCopiedEmail] = useState(false); const [submissions, setSubmissions] = useState([]); const [problems, setProblems] = useState([]); @@ -383,7 +359,7 @@ function PlayerGameView(props: PlayerGameViewProps) { // Set the 'test' submission type to correctly display result. // eslint-disable-next-line no-param-reassign res.submissionType = SubmissionType.Test; - currentSubmission = res; + currentSubmission = res; // note: this seems a bit improper (fine as long as it works ig) }) .catch((err) => { setLoading(false); @@ -418,17 +394,19 @@ function PlayerGameView(props: PlayerGameViewProps) { }; const nextProblem = () => { - setCurrentProblemIndex((currentProblemIndex + 1) % problems?.length); + const next = currentProblemIndex + 1; + + if (problems && next < problems.length) { + setCurrentProblemIndex(next); + } }; const previousProblem = () => { - let temp = currentProblemIndex - 1; + const prev = currentProblemIndex - 1; - if (temp < 0) { - temp += problems?.length; + if (prev >= 0) { + setCurrentProblemIndex(prev); } - - setCurrentProblemIndex(temp); }; const displayPlayerLeaderboard = useCallback(() => game?.players.map((player, index) => ( @@ -497,45 +475,13 @@ function PlayerGameView(props: PlayerGameViewProps) { secondaryMinSize={35} customClassName={!spectateGame ? 'game-splitter-container' : undefined} > - {/* Problem title/description panel */} - - - {!spectateGame - ? game?.problems[currentProblemIndex]?.name - : spectateGame?.problem.name} - - { - !spectateGame ? ( - getDifficultyDisplayButton(game?.problems[currentProblemIndex].difficulty!) - ) : ( - getDifficultyDisplayButton(spectateGame?.problem.difficulty!) - ) - } - ''} - readOnly - /> - - {'Notice an issue? Contact us at '} - { - copy('support@codejoust.co'); - setCopiedEmail(true); - }} - > - support@codejoust.co - content_copy - - - + p.problemId === spectateGame.problem.problemId) || 0} + onNext={currentProblemIndex < problems.length - 1 ? nextProblem : null} + onPrev={currentProblemIndex > 0 ? previousProblem : null} + /> {/* Code editor and console panels */} { @@ -567,7 +513,7 @@ function PlayerGameView(props: PlayerGameViewProps) { ) : ( - + - - Previous - Next - - - setCopiedEmail(false)}> - Email copied! ✕ - - > ); } diff --git a/frontend/src/components/game/ProblemPanel.tsx b/frontend/src/components/game/ProblemPanel.tsx new file mode 100644 index 00000000..fa52cd63 --- /dev/null +++ b/frontend/src/components/game/ProblemPanel.tsx @@ -0,0 +1,134 @@ +import React from 'react'; +import styled from 'styled-components'; +import MarkdownEditor from 'rich-markdown-editor'; +import { BottomFooterText, ProblemHeaderText, SmallText } from '../core/Text'; +import { DefaultButton, getDifficultyDisplayButton } from '../core/Button'; +import { Copyable } from '../special/CopyIndicator'; +import { CenteredContainer, Panel } from '../core/Container'; +import { Problem } from '../../api/Problem'; +import { NextIcon, PrevIcon } from '../core/Icon'; + +const StyledMarkdownEditor = styled(MarkdownEditor)` + margin-top: 15px; + padding: 0; + + p { + font-family: ${({ theme }) => theme.font}; + } + + // The specific list of attributes to have dark text color. + .ProseMirror > p, blockquote, h1, h2, h3, ul, ol, table { + color: ${({ theme }) => theme.colors.text}; + } +`; + +const OverflowPanel = styled(Panel)` + overflow-y: auto; + height: 100%; + padding: 0 25px; +`; + +const HeaderContainer = styled.div` + display: flex; + flex: auto; + justify-content: space-between; +`; + +const TitleContainer = styled.div` + display: -webkit-box; + -webkit-line-clamp: 1; + overflow: hidden; + -webkit-box-orient: vertical; + word-break: break-all; +`; + +const ProblemNavContainer = styled.div` + width: 100px; + min-width: 100px; + align-items: baseline; + padding: 15px 0; +`; + +const ProblemCountText = styled(SmallText)` + color: gray; +`; + +type ProblemNavButtonProps = { + disabled: boolean, +}; + +const ProblemNavButton = styled(DefaultButton)` + font-size: ${({ theme }) => theme.fontSize.default}; + color: ${({ theme, disabled }) => (disabled ? theme.colors.lightgray : theme.colors.gray)}; + background-color: ${({ theme }) => theme.colors.white}; + border-radius: 5px; + width: 35px; + height: 35px; + margin: 5px; + + box-shadow: 0 1px 6px rgba(0, 0, 0, 0.16); + + &:hover { + box-shadow: ${({ disabled }) => (disabled ? '0 1px 6px rgba(0, 0, 0, 0.16)' : '0 1px 6px rgba(0, 0, 0, 0.20)')}; + cursor: ${({ disabled }) => (disabled ? 'default' : 'pointer')}; + } + + i { + line-height: 35px; + } +`; + +type ProblemPanelProps = { + problems: Problem[], + index: number, + onNext: (() => void) | null, + onPrev: (() => void) | null, +}; + +function ProblemPanel(props: ProblemPanelProps) { + const { + problems, index, onNext, onPrev, + } = props; + + return ( + + + + + {problems[index]?.name || 'Loading...'} + + {problems[index] ? getDifficultyDisplayButton(problems[index].difficulty) : null} + + + + + + + + + + + + + {`Problem ${index + 1} of ${problems.length}`} + + + + + + ''} + readOnly + /> + + Notice an issue? Contact us at + {' '} + + + + ); +} + +export default ProblemPanel; diff --git a/frontend/src/components/problem/Selector.tsx b/frontend/src/components/problem/Selector.tsx index 89ee5aef..dc2ce10d 100644 --- a/frontend/src/components/problem/Selector.tsx +++ b/frontend/src/components/problem/Selector.tsx @@ -13,6 +13,7 @@ import { dedupProblems, problemMatchesFilterText } from '../../util/Utility'; type ProblemSelectorProps = { selectedProblems: SelectableProblem[], onSelect: (newlySelected: SelectableProblem) => void, + loading: boolean, }; type TagSelectorProps = { @@ -100,7 +101,7 @@ const ElementName = styled.p` `; export function ProblemSelector(props: ProblemSelectorProps) { - const { selectedProblems, onSelect } = props; + const { selectedProblems, onSelect, loading } = props; const [error, setError] = useState(''); const [verifiedProblems, setVerifiedProblems] = useState([]); @@ -153,9 +154,9 @@ export function ProblemSelector(props: ProblemSelectorProps) { return ( setShowProblems(!showProblems)} + onClick={() => !loading && setShowProblems(!showProblems)} onChange={setSearchStatus} - placeholder={allProblems.length ? 'Filter by name, difficulty, or tag (separate queries by comma)' : 'Loading...'} + placeholder={allProblems.length && !loading ? 'Filter by name, difficulty, or tag (separate queries by comma)' : 'Loading...'} /> diff --git a/frontend/src/components/special/CopyIndicator.tsx b/frontend/src/components/special/CopyIndicator.tsx index f18f851f..17259705 100644 --- a/frontend/src/components/special/CopyIndicator.tsx +++ b/frontend/src/components/special/CopyIndicator.tsx @@ -1,12 +1,14 @@ +import React, { useState, useRef } from 'react'; import styled from 'styled-components'; -import { DefaultButton } from '../core/Button'; -import { ContactHeaderText } from '../core/Text'; +import copy from 'copy-to-clipboard'; +import { DefaultButton, InheritedTextButton } from '../core/Button'; +import { InlineContainer } from '../core/Container'; type CopyIndicator = { copied: boolean, }; -export const CopyIndicatorContainer = styled.div.attrs((props: CopyIndicator) => ({ +const CopyIndicatorContainer = styled.div.attrs((props: CopyIndicator) => ({ style: { transform: (!props.copied) ? 'translateY(-60px)' : null, }, @@ -15,44 +17,105 @@ export const CopyIndicatorContainer = styled.div.attrs((props: CopyIndicator) => top: 20px; left: 50%; transition: transform 0.25s; + line-height: 0; z-index: 5; `; -export const BottomCopyIndicatorContainer = styled.div.attrs((props: CopyIndicator) => ({ +const BottomCopyIndicatorContainer = styled.div.attrs((props: CopyIndicator) => ({ style: { transform: (!props.copied) ? 'translateY(60px)' : null, - visibility: (props.copied) ? 'visible' : 'hidden', }, }))` position: fixed; bottom: 20px; left: 50%; transition: transform 0.25s; + line-height: 0; + z-index: 5; `; -export const CopyIndicator = styled(DefaultButton)` +const CopyIndicator = styled(DefaultButton)` position: relative; left: -50%; margin: 0 auto; padding: 0.25rem 1rem; + line-height: normal; color: ${({ theme }) => theme.colors.white}; background: ${({ theme }) => theme.colors.gradients.green}; `; -export const InlineBackgroundCopyText = styled(ContactHeaderText)` - display: inline-block; - margin: 0; - padding: 0.25rem 0.5rem; - font-size: ${({ theme }) => theme.fontSize.mediumLarge}; - background: ${({ theme }) => theme.colors.text}; - color: ${({ theme }) => theme.colors.white}; - border-radius: 0.5rem; - cursor: pointer; -`; - -export const InlineCopyIcon = styled.i.attrs(() => ({ - className: 'material-icons', -}))` +const InlineCopyIconWrapper = styled.i` margin-left: 5px; font-size: inherit; `; + +export const InlineCopyIcon = () => ( + + content_copy + +); + +type CopyableContentProps = { + children: React.ReactNode, + text: string, + top: boolean, +}; + +type CopyableProps = { + text: string, + top: boolean, +}; + +export function CopyableContent(props: CopyableContentProps) { + const { children, text, top } = props; + + const [copied, setCopied] = useState(false); + const timeoutRef = useRef | null>(null); + const Container = top ? CopyIndicatorContainer : BottomCopyIndicatorContainer; + + const onCopy = () => { + copy(text); + setCopied(true); + + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + timeoutRef.current = setTimeout(() => setCopied(false), 2000); + }; + + const closeIndicator = () => { + setCopied(false); + + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + timeoutRef.current = null; + } + }; + + return ( + <> + + + Copied! ✕ + + + + + {children} + + > + ); +} + +export function Copyable(props: CopyableProps) { + const { text, top } = props; + + return ( + + + {text} + + + + ); +} diff --git a/frontend/src/views/ContactUs.tsx b/frontend/src/views/ContactUs.tsx index 8ab76166..ef6b2bf5 100644 --- a/frontend/src/views/ContactUs.tsx +++ b/frontend/src/views/ContactUs.tsx @@ -1,22 +1,13 @@ -import React, { useState } from 'react'; -import copy from 'copy-to-clipboard'; +import React from 'react'; import { DynamicWidthContainer } from '../components/core/Container'; import { InlineExternalLink } from '../components/core/Link'; import { ContactHeaderTitle, ContactHeaderText } from '../components/core/Text'; -import { CopyIndicator, CopyIndicatorContainer, InlineCopyIcon } from '../components/special/CopyIndicator'; import Subscribe from '../components/special/Subscribe'; -import { InheritedTextButton } from '../components/core/Button'; +import { Copyable } from '../components/special/CopyIndicator'; function ContactUsPage() { - const [copiedEmail, setCopiedEmail] = useState(false); - return ( <> - - setCopiedEmail(false)}> - Email copied! ✕ - - Contact Us @@ -38,15 +29,7 @@ function ContactUsPage() { You can contact us at {' '} - { - copy('support@codejoust.co'); - setCopiedEmail(true); - }} - > - support@codejoust.co - content_copy - + . Say hello! diff --git a/frontend/src/views/Landing.tsx b/frontend/src/views/Landing.tsx index 6e86c4a8..133ba277 100644 --- a/frontend/src/views/Landing.tsx +++ b/frontend/src/views/Landing.tsx @@ -1,6 +1,5 @@ -import React, { useState } from 'react'; +import React from 'react'; import styled from 'styled-components'; -import copy from 'copy-to-clipboard'; import { PrimaryButtonLink, TextLink } from '../components/core/Link'; import { Image, ShadowImage } from '../components/core/Image'; import { @@ -9,8 +8,7 @@ import { import { ColumnContainer, RowContainer, Separator, TextLeftColumnContainer, } from '../components/core/Container'; -import { CopyIndicator, CopyIndicatorContainer, InlineCopyIcon } from '../components/special/CopyIndicator'; -import { InheritedTextButton } from '../components/core/Button'; +import { Copyable } from '../components/special/CopyIndicator'; import { FloatingCircles } from '../components/layout/CircleBackground'; const Content = styled.div` @@ -70,16 +68,8 @@ const CallToActionColumn = styled(ColumnContainer)` `; function LandingPage() { - const [copiedEmail, setCopiedEmail] = useState(false); - return ( - - setCopiedEmail(false)}> - Email copied! ✕ - - - @@ -193,15 +183,7 @@ function LandingPage() { Create an account now or email us at {' '} - { - copy('hello@codejoust.co'); - setCopiedEmail(true); - }} - > - hello@codejoust.co - content_copy - + {' '} for one-on-one support within 24 hours. diff --git a/frontend/src/views/Lobby.tsx b/frontend/src/views/Lobby.tsx index 16e20fb0..7d17924c 100644 --- a/frontend/src/views/Lobby.tsx +++ b/frontend/src/views/Lobby.tsx @@ -4,7 +4,6 @@ import { unwrapResult } from '@reduxjs/toolkit'; import { Message, Subscription } from 'stompjs'; import { useBeforeunload } from 'react-beforeunload'; import styled from 'styled-components'; -import copy from 'copy-to-clipboard'; import ErrorMessage from '../components/core/Error'; import { NoMarginMediumText, @@ -13,6 +12,7 @@ import { NoMarginSubtitleText, LowMarginText, Text, + ContactHeaderText, } from '../components/core/Text'; import { connect, routes, subscribe, disconnect, @@ -39,12 +39,6 @@ import { setSpectator, } from '../api/Room'; import { errorHandler } from '../api/Error'; -import { - CopyIndicator, - CopyIndicatorContainer, - InlineCopyIcon, - InlineBackgroundCopyText, -} from '../components/special/CopyIndicator'; import IdContainer from '../components/special/IdContainer'; import { FlexBareContainer, LeftContainer } from '../components/core/Container'; import { Slider, SliderContainer } from '../components/core/RangeSlider'; @@ -58,6 +52,7 @@ import { setCurrentUser } from '../redux/User'; import { LobbyHelpModal } from '../components/core/HelpModal'; import Modal from '../components/core/Modal'; import { setGame } from '../redux/Game'; +import { CopyableContent, InlineCopyIcon } from '../components/special/CopyIndicator'; type LobbyPageLocation = { user: User, @@ -150,6 +145,18 @@ const ExitModalButton = styled(PrimaryButton)` margin: 20px 0; `; +const CopyableRoomLink = styled(ContactHeaderText)` + display: inline-block; + margin: 0; + padding: 0.25rem 0.5rem; + font-size: ${({ theme }) => theme.fontSize.mediumLarge}; + background: ${({ theme }) => theme.colors.text}; + color: ${({ theme }) => theme.colors.white}; + border-radius: 0.5rem; + cursor: pointer; + text-decoration: none; +`; + function LobbyPage() { // Get history object to be able to move between different pages const history = useHistory(); @@ -165,6 +172,8 @@ function LobbyPage() { const [difficulty, setDifficulty] = useState(null); const [duration, setDuration] = useState(15); const [selectedProblems, setSelectedProblems] = useState([]); + const [tempSelectedProblems, setTempSelectedProblems] = useState([]); + const [modifiedProblems, setModifiedProblems] = useState(false); const [size, setSize] = useState(10); const [numProblems, setNumProblems] = useState(1); const [hoverVisible, setHoverVisible] = useState(false); @@ -187,9 +196,6 @@ function LobbyPage() { // Variable to hold the socket subscription, or null if not connected const [subscription, setSubscription] = useState(null); - // Variable to hold whether the room link was copied. - const [copiedRoomLink, setCopiedRoomLink] = useState(false); - // Variable to hold whether the modal explaining the user cards is active. const [actionCardHelp, setActionCardHelp] = useState(false); @@ -217,7 +223,10 @@ function LobbyPage() { setActive(newRoom.active); setDifficulty(newRoom.difficulty); setDuration(newRoom.duration / 60); - setSelectedProblems(newRoom.problems); + // If problems were modified, do not set the problems + if (!modifiedProblems) { + setSelectedProblems(newRoom.problems); + } setSize(newRoom.size); setNumProblems(newRoom.numProblems); @@ -228,7 +237,7 @@ function LobbyPage() { dispatch(setCurrentUser(user)); } }); - }, [currentUser, dispatch]); + }, [currentUser, modifiedProblems, dispatch]); // Map the room in Redux to the state variables used in this file useEffect(() => { @@ -432,17 +441,38 @@ function LobbyPage() { }) .finally(() => { setLoading(false); + setTempSelectedProblems([]); + setModifiedProblems(false); }); }; + // Add a problem to be selected - these will be submitted as a batch. const addProblem = (newProblem: SelectableProblem) => { - const newProblems = [...selectedProblems, newProblem]; - updateSelectedProblems(newProblems); + const problemsToUse = modifiedProblems ? tempSelectedProblems : selectedProblems; + const newProblems = [...problemsToUse, newProblem]; + setTempSelectedProblems(newProblems); + setModifiedProblems(true); }; - const removeProblem = (index: number) => { - const newProblems = selectedProblems.filter((_, i) => i !== index); - updateSelectedProblems(newProblems); + // Submit the batch of selected problems and close the modal. + const submitTempProblems = () => { + updateSelectedProblems(tempSelectedProblems); + setShowProblemSelector(false); + }; + + const removeTempProblem = (index: number) => { + const problemsToUse = modifiedProblems ? tempSelectedProblems : selectedProblems; + const newProblems = problemsToUse.filter((_, i) => i !== index); + + setTempSelectedProblems(newProblems); + setModifiedProblems(true); + }; + + // If user clicks close or outside of problem selection modal, discard changes + const closeModalWithoutSaving = () => { + setTempSelectedProblems([]); + setModifiedProblems(false); + setShowProblemSelector(false); }; const onSizeSliderChange = (e: React.ChangeEvent) => { @@ -656,27 +686,26 @@ function LobbyPage() { /> setShowProblemSelector(false)} + onExit={closeModalWithoutSaving} fullScreen > Select problems for this game: Use the dropdown below to select the problems for your game. - Alternatively, skip this step to create a game with a random problem. + Alternatively, skip this step to create a game with random problems. { error ? : null } - setShowProblemSelector(false)} - > + All Good! @@ -688,25 +717,16 @@ function LobbyPage() { > Only the host can start the game and update settings - - setCopiedRoomLink(false)}> - Link copied! ✕ - - Join with the link {' '} - { - copy(`https://codejoust.co/play?room=${currentRoomId}`); - setCopiedRoomLink(true); - }} - > - codejoust.co/play?room= - {currentRoomId} - content_copy - + + + {`codejoust.co/play?room=${currentRoomId}`} + + + {' '} or at {' '} @@ -730,38 +750,21 @@ function LobbyPage() { > Leave Room - Players - { - users - ? ` (${users.length})` - : null - } - - refresh - - setActionCardHelp(true)} - > - help_outline - + { users ? ` (${users.length})` : null } + refresh + setActionCardHelp(true)}>help_outline - { - displayUsers(activeUsers, true) - } - { - displayUsers(inactiveUsers, false) - } - { error ? : null } - { loading ? : null } + {displayUsers(activeUsers, true)} + {displayUsers(inactiveUsers, false)} + {error ? : null} + {loading ? : null} @@ -772,7 +775,7 @@ function LobbyPage() { Difficulty {isHost(currentUser) ? ( - Choose a difficulty for your randomly selected problem: + Choose a difficulty for your randomly selected problems: ) : null} @@ -794,18 +797,40 @@ function LobbyPage() { ); })} + Number of Problems + + {`${numProblems} problem${numProblems === 1 ? '' : 's'}`} + + + + + { + const newNumProblems = Number(e.target.value); + if (newNumProblems >= 1 && newNumProblems <= 10) { + setNumProblems(newNumProblems); + } + }} + onMouseUp={updateNumProblems} + /> + + > ) : ( <> Problems > )} - {isHost(currentUser) ? ( + {isHost(currentUser) && !loading ? ( setShowProblemSelector(true)}> {selectedProblems.length ? 'Edit problem selection ' : 'Or choose a specific problem '} @@ -848,28 +873,6 @@ function LobbyPage() { /> - Number of Problems - - {`${numProblems} problem${numProblems === 1 ? '' : 's'}`} - - - - - { - const newNumProblems = Number(e.target.value); - if (newNumProblems >= 1 && newNumProblems <= 10) { - setNumProblems(newNumProblems); - } - }} - onMouseUp={updateNumProblems} - /> - - diff --git a/frontend/src/views/Results.tsx b/frontend/src/views/Results.tsx index 5024fd4f..6318306b 100644 --- a/frontend/src/views/Results.tsx +++ b/frontend/src/views/Results.tsx @@ -1,6 +1,5 @@ import React, { useCallback, useEffect, useState } from 'react'; import styled from 'styled-components'; -import copy from 'copy-to-clipboard'; import { useBeforeunload } from 'react-beforeunload'; import { useLocation, useHistory } from 'react-router-dom'; import { Message } from 'stompjs'; @@ -18,11 +17,6 @@ import { import { User } from '../api/User'; import Podium from '../components/results/Podium'; import { HoverContainer, HoverElement, HoverTooltip } from '../components/core/HoverTooltip'; -import { - CopyIndicator, - CopyIndicatorContainer, - InlineCopyIcon, -} from '../components/special/CopyIndicator'; import ResultsTable from '../components/results/ResultsTable'; import Modal from '../components/core/Modal'; import FeedbackPopup from '../components/results/FeedbackPopup'; @@ -31,6 +25,7 @@ import { fetchGame, setGame } from '../redux/Game'; import { setCurrentUser } from '../redux/User'; import { setRoom } from '../redux/Room'; import PreviewCodeContent from '../components/results/PreviewCodeContent'; +import { CopyableContent, InlineCopyIcon } from '../components/special/CopyIndicator'; const Content = styled.div` padding: 0; @@ -101,7 +96,6 @@ function GameResultsPage() { const [connected, setConnected] = useState(false); const [hoverVisible, setHoverVisible] = useState(false); - const [copiedRoomLink, setCopiedRoomLink] = useState(false); const [showFeedbackModal, setShowFeedbackModal] = useState(false); const [showFeedbackPrompt, setShowFeedbackPrompt] = useState(false); const [codeModal, setCodeModal] = useState(-1); @@ -224,26 +218,18 @@ function GameResultsPage() { // Content to display for inviting players (if not enough players on the podium) const inviteContent = () => ( - { - copy(`https://codejoust.co/play?room=${roomId}`); - setCopiedRoomLink(true); - }} - > - - Invite - content_copy - - + + + + Invite + + + + ); return ( - - setCopiedRoomLink(false)}> - Link copied! ✕ - - problems = game.getProblems(); problems.addAll(room.getProblems()); - // Fill remaining problems with random ones by difficulty - int remaining = room.getNumProblems() - problems.size(); - - if (remaining < 0) { - throw new ApiException(RoomError.TOO_MANY_PROBLEMS); - } else if (remaining > 0) { - List otherProblems = problemService.getProblemsFromDifficulty(room.getDifficulty(), room.getNumProblems()); - for (Problem problem : otherProblems) { - if (!problems.contains(problem) && problems.size() < room.getNumProblems()) { - problems.add(problem); - } - } - } + // Otherwise, fetch random problems + if (problems.size() == 0) { + List randomProblems = problemService.getProblemsFromDifficulty(room.getDifficulty(), room.getNumProblems()); + problems.addAll(randomProblems); - if (problems.size() < room.getNumProblems()) { - throw new ApiException(ProblemError.NOT_ENOUGH_FOUND); + if (problems.size() < room.getNumProblems()) { + throw new ApiException(ProblemError.NOT_ENOUGH_FOUND); + } } setStartGameTimer(game, time); diff --git a/src/main/java/com/codejoust/main/service/RoomService.java b/src/main/java/com/codejoust/main/service/RoomService.java index 4fdca28a..290d8a5e 100644 --- a/src/main/java/com/codejoust/main/service/RoomService.java +++ b/src/main/java/com/codejoust/main/service/RoomService.java @@ -331,7 +331,8 @@ public RoomDto updateRoomSettings(String roomId, UpdateSettingsRequest request) // Set number of problems if not null if (request.getNumProblems() != null) { - if (request.getNumProblems() <= 0 || request.getNumProblems() > MAX_NUM_PROBLEMS) { + if (request.getNumProblems() <= 0 || request.getNumProblems() > MAX_NUM_PROBLEMS + || request.getProblems() != null && request.getProblems().size() > MAX_NUM_PROBLEMS) { throw new ApiException(ProblemError.INVALID_NUMBER_REQUEST); } room.setNumProblems(request.getNumProblems()); @@ -345,13 +346,13 @@ public RoomDto updateRoomSettings(String roomId, UpdateSettingsRequest request) return roomDto; } - private Room updateRoomSettingsSelectedProblems(List selectedProblems, Room room) { + private void updateRoomSettingsSelectedProblems(List selectedProblems, Room room) { // Set selected problems if not null boolean problemsHaveChanged = false; if (selectedProblems != null) { - if (selectedProblems.size() > room.getNumProblems()) { - throw new ApiException(RoomError.TOO_MANY_PROBLEMS); + if (selectedProblems.size() > MAX_NUM_PROBLEMS) { + throw new ApiException(ProblemError.INVALID_NUMBER_REQUEST); } problemsHaveChanged = checkSelectedProblemChanges(selectedProblems, room); @@ -369,9 +370,13 @@ private Room updateRoomSettingsSelectedProblems(List selec newProblems.add(problem); } room.setProblems(newProblems); - } - return room; + if (newProblems.size() > 0) { + room.setNumProblems(newProblems.size()); + } else { + room.setNumProblems(1); + } + } } private boolean checkSelectedProblemChanges(List selectedProblems, Room room) { diff --git a/src/test/java/com/codejoust/main/api/GameTests.java b/src/test/java/com/codejoust/main/api/GameTests.java index 107f4215..e7e9eadf 100644 --- a/src/test/java/com/codejoust/main/api/GameTests.java +++ b/src/test/java/com/codejoust/main/api/GameTests.java @@ -127,11 +127,9 @@ public void startGameWithProblemIdGetsCorrectProblem() throws Exception { GameDto gameDto = MockHelper.getRequest(this.mockMvc, TestUrls.getGame(roomDto.getRoomId()), GameDto.class, HttpStatus.OK); - assertEquals(3, gameDto.getRoom().getNumProblems()); - assertEquals(3, gameDto.getProblems().size()); + assertEquals(1, gameDto.getRoom().getNumProblems()); + assertEquals(1, gameDto.getProblems().size()); assertEquals(problemDto.getProblemId(), gameDto.getProblems().get(0).getProblemId()); - assertNotEquals(gameDto.getProblems().get(0).getProblemId(), gameDto.getProblems().get(1).getProblemId()); - assertNotEquals(gameDto.getProblems().get(1).getProblemId(), gameDto.getProblems().get(2).getProblemId()); } @Test diff --git a/src/test/java/com/codejoust/main/service/GameManagementServiceTests.java b/src/test/java/com/codejoust/main/service/GameManagementServiceTests.java index 6c6e5705..daaf259a 100644 --- a/src/test/java/com/codejoust/main/service/GameManagementServiceTests.java +++ b/src/test/java/com/codejoust/main/service/GameManagementServiceTests.java @@ -159,57 +159,6 @@ public void startGameSuccess() { assertEquals(room.getDuration(), game.getGameTimer().getDuration()); } - @Test - public void startGameWithProblemAndRandomProblemSuccess() { - User host = new User(); - host.setNickname(TestFields.NICKNAME); - host.setUserId(TestFields.USER_ID); - - Room room = new Room(); - room.setRoomId(TestFields.ROOM_ID); - room.setHost(host); - room.setDifficulty(ProblemDifficulty.HARD); - room.setNumProblems(2); - - Problem problem = new Problem(); - problem.setProblemId(TestFields.PROBLEM_ID); - problem.setName(TestFields.PROBLEM_NAME); - problem.setDescription(TestFields.PROBLEM_DESCRIPTION); - problem.setDifficulty(ProblemDifficulty.EASY); - - Problem problem2 = new Problem(); - problem.setProblemId(TestFields.PROBLEM_ID_2); - problem.setName(TestFields.PROBLEM_NAME_2); - problem.setDescription(TestFields.PROBLEM_DESCRIPTION_2); - problem.setDifficulty(ProblemDifficulty.HARD); - - room.setProblems(Collections.singletonList(problem)); - - StartGameRequest request = new StartGameRequest(); - request.setInitiator(UserMapper.toDto(host)); - - Mockito.doReturn(room).when(repository).findRoomByRoomId(room.getRoomId()); - Mockito.doReturn(Arrays.asList(problem, problem2)) - .when(problemService).getProblemsFromDifficulty(ProblemDifficulty.HARD, 2); - - RoomDto response = gameService.startGame(TestFields.ROOM_ID, request); - - verify(socketService).sendSocketUpdate(eq(response)); - - assertEquals(TestFields.ROOM_ID, response.getRoomId()); - assertTrue(response.isActive()); - - Game game = gameService.getGameFromRoomId(TestFields.ROOM_ID); - assertNotNull(game); - - assertEquals(2, game.getRoom().getNumProblems()); - assertEquals(2, game.getProblems().size()); - assertEquals(problem.getProblemId(), game.getProblems().get(0).getProblemId()); - assertEquals(problem.getDifficulty(), game.getProblems().get(0).getDifficulty()); - assertEquals(problem2.getProblemId(), game.getProblems().get(1).getProblemId()); - assertEquals(problem2.getDescription(), game.getProblems().get(1).getDescription()); - } - @Test public void startGameWithOnlySelectedProblems() { User host = new User(); @@ -220,7 +169,7 @@ public void startGameWithOnlySelectedProblems() { room.setRoomId(TestFields.ROOM_ID); room.setHost(host); room.setDifficulty(ProblemDifficulty.HARD); - room.setNumProblems(2); + room.setNumProblems(5); Problem problem = new Problem(); problem.setProblemId(TestFields.PROBLEM_ID); @@ -240,47 +189,16 @@ public void startGameWithOnlySelectedProblems() { request.setInitiator(UserMapper.toDto(host)); Mockito.doReturn(room).when(repository).findRoomByRoomId(room.getRoomId()); + verify(problemService, never()).getProblemsFromDifficulty(Mockito.any(), Mockito.any()); gameService.startGame(TestFields.ROOM_ID, request); Game game = gameService.getGameFromRoomId(TestFields.ROOM_ID); - // Only 2 problems are selected (no duplicate problems are included) - assertEquals(2, game.getRoom().getNumProblems()); assertEquals(2, game.getProblems().size()); assertEquals(problem.getProblemId(), game.getProblems().get(0).getProblemId()); assertEquals(problem.getOutputType(), game.getProblems().get(0).getOutputType()); } - @Test - public void startGameNotEnoughProblems() { - User host = new User(); - host.setNickname(TestFields.NICKNAME); - host.setUserId(TestFields.USER_ID); - - Room room = new Room(); - room.setRoomId(TestFields.ROOM_ID); - room.setHost(host); - room.setDifficulty(ProblemDifficulty.EASY); - room.setNumProblems(2); - - Problem problem = new Problem(); - problem.setProblemId(TestFields.PROBLEM_ID); - problem.setName(TestFields.PROBLEM_NAME); - problem.setDescription(TestFields.PROBLEM_DESCRIPTION); - problem.setDifficulty(ProblemDifficulty.EASY); - room.setProblems(Collections.singletonList(problem)); - - StartGameRequest request = new StartGameRequest(); - request.setInitiator(UserMapper.toDto(host)); - - Mockito.doReturn(room).when(repository).findRoomByRoomId(room.getRoomId()); - Mockito.doReturn(Collections.singletonList(problem)) - .when(problemService).getProblemsFromDifficulty(ProblemDifficulty.EASY, 2); - - ApiException exception = assertThrows(ApiException.class, () -> gameService.startGame(TestFields.ROOM_ID, request)); - assertEquals(ProblemError.NOT_ENOUGH_FOUND, exception.getError()); - } - @Test public void startGameRoomNotFound() { UserDto user = new UserDto(); diff --git a/src/test/java/com/codejoust/main/service/RoomServiceTests.java b/src/test/java/com/codejoust/main/service/RoomServiceTests.java index a88c390e..7ca45d03 100644 --- a/src/test/java/com/codejoust/main/service/RoomServiceTests.java +++ b/src/test/java/com/codejoust/main/service/RoomServiceTests.java @@ -1,5 +1,6 @@ package com.codejoust.main.service; +import com.codejoust.main.model.problem.Problem; import com.codejoust.main.util.TestFields; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -20,6 +21,7 @@ import static org.mockito.Mockito.times; import java.util.Arrays; +import java.util.Collections; import java.util.List; import java.util.stream.Collectors; @@ -51,6 +53,9 @@ public class RoomServiceTests { @Mock private RoomRepository repository; + @Mock + private ProblemService problemService; + @Mock private SocketService socketService; @@ -581,7 +586,14 @@ public void updateRoomSettingsExceedsMaxProblems() { } @Test - public void updateRoomSettingsTooManySelectedProblems() { + public void updateRoomSettingsChoosingProblemsModifiesNumProblems() { + /** + * 1. Update the room with two problems + * 2. Verify the numProblems field is now set to 2 + * 3. Get rid of all the problems + * 4. Verify the numProblems field is now set to 1 + */ + Room room = new Room(); room.setRoomId(TestFields.ROOM_ID); User host = new User(); @@ -590,6 +602,7 @@ public void updateRoomSettingsTooManySelectedProblems() { room.addUser(host); Mockito.doReturn(room).when(repository).findRoomByRoomId(eq(TestFields.ROOM_ID)); + Mockito.doReturn(new Problem()).when(problemService).getProblemEntity(Mockito.any()); UpdateSettingsRequest request = new UpdateSettingsRequest(); request.setInitiator(UserMapper.toDto(host)); @@ -601,9 +614,12 @@ public void updateRoomSettingsTooManySelectedProblems() { problemDto.setProblemId(TestFields.PROBLEM_ID_2); request.setProblems(Arrays.asList(problemDto, problemDto2)); - ApiException exception = assertThrows(ApiException.class, () -> - roomService.updateRoomSettings(TestFields.ROOM_ID, request)); - assertEquals(RoomError.TOO_MANY_PROBLEMS, exception.getError()); + RoomDto response = roomService.updateRoomSettings(TestFields.ROOM_ID, request); + assertEquals(2, response.getNumProblems()); + + request.setProblems(Collections.emptyList()); + response = roomService.updateRoomSettings(TestFields.ROOM_ID, request); + assertEquals(1, response.getNumProblems()); } @Test