From d1bc83ece5a8a0ded36362f0211d8e13ee381a25 Mon Sep 17 00:00:00 2001 From: Johannes Loher <johannes.loher@tngtech.com> Date: Fri, 26 Apr 2024 14:02:28 +0200 Subject: [PATCH] Convert ThreatModal to TypeScript Signed-off-by: Johannes Loher <johannes.loher@tngtech.com> --- src/client/components/board/board.tsx | 1 - .../components/threatbar/threatbar.test.tsx | 3 - src/client/components/threatbar/threatbar.tsx | 4 +- .../components/threatmodal/threatmodal.jsx | 264 ------------------ ...reatmodal.test.js => threatmodal.test.tsx} | 171 ++++-------- .../components/threatmodal/threatmodal.tsx | 235 ++++++++++++++++ 6 files changed, 298 insertions(+), 380 deletions(-) delete mode 100644 src/client/components/threatmodal/threatmodal.jsx rename src/client/components/threatmodal/{threatmodal.test.js => threatmodal.test.tsx} (70%) create mode 100644 src/client/components/threatmodal/threatmodal.tsx diff --git a/src/client/components/board/board.tsx b/src/client/components/board/board.tsx index 92032e7f..9cb9271b 100644 --- a/src/client/components/board/board.tsx +++ b/src/client/components/board/board.tsx @@ -201,7 +201,6 @@ const Board: FC<BoardProps> = ({ /> <Threatbar G={G} - ctx={ctx} playerID={playerID} model={model} names={names} diff --git a/src/client/components/threatbar/threatbar.test.tsx b/src/client/components/threatbar/threatbar.test.tsx index de41c0af..e550f907 100644 --- a/src/client/components/threatbar/threatbar.test.tsx +++ b/src/client/components/threatbar/threatbar.test.tsx @@ -3,7 +3,6 @@ import { GameMode } from '../../../utils/GameMode'; import React from 'react'; import Threatbar from './threatbar'; import type { GameState } from '../../../game/gameState'; -import type { Ctx } from 'boardgame.io'; import { ModelType } from '../../../utils/constants'; import type { ThreatDragonModel } from '../../../types/ThreatDragonModel'; @@ -106,7 +105,6 @@ describe('<Threatbar>', () => { render( <Threatbar G={G} - ctx={{} as Ctx} moves={{}} active names={[]} @@ -179,7 +177,6 @@ describe('<Threatbar>', () => { render( <Threatbar G={G} - ctx={{} as Ctx} moves={{}} active names={[]} diff --git a/src/client/components/threatbar/threatbar.tsx b/src/client/components/threatbar/threatbar.tsx index 8725c3bb..946ce1fb 100644 --- a/src/client/components/threatbar/threatbar.tsx +++ b/src/client/components/threatbar/threatbar.tsx @@ -36,13 +36,12 @@ type ThreatbarProps = { active: boolean; names: string[]; isInThreatStage: boolean; -} & Pick<BoardProps<GameState>, 'G' | 'ctx' | 'moves' | 'playerID'>; +} & Pick<BoardProps<GameState>, 'G' | 'moves' | 'playerID'>; const Threatbar: FC<ThreatbarProps> = ({ playerID, model, G, - ctx, moves, active, names, @@ -220,7 +219,6 @@ const Threatbar: FC<ThreatbarProps> = ({ <ThreatModal isOpen={G.threat.modal} G={G} - ctx={ctx} playerID={playerID} moves={moves} names={names} diff --git a/src/client/components/threatmodal/threatmodal.jsx b/src/client/components/threatmodal/threatmodal.jsx deleted file mode 100644 index 764fa633..00000000 --- a/src/client/components/threatmodal/threatmodal.jsx +++ /dev/null @@ -1,264 +0,0 @@ -import _ from 'lodash'; -import PropTypes from 'prop-types'; -import React from 'react'; -import { - Button, - Form, - FormGroup, - Input, - Label, - Modal, - ModalBody, - ModalFooter, - ModalHeader, -} from 'reactstrap'; -import { getSuitDisplayName, getSuits } from '../../../utils/cardDefinitions'; -import { ModelType } from '../../../utils/constants'; - -class ThreatModal extends React.Component { - static get propTypes() { - return { - playerID: PropTypes.any, - G: PropTypes.any.isRequired, - ctx: PropTypes.any.isRequired, - moves: PropTypes.any.isRequired, - names: PropTypes.any.isRequired, - isOpen: PropTypes.bool.isRequired, - }; - } - - constructor(props) { - super(props); - this.state = { - title: '', - description: '', - mitigation: '', - showMitigation: false, - }; - } - - componentDidUpdate(prevProps) { - if ( - prevProps.G.threat.title !== this.props.G.threat.title || - prevProps.G.threat.description !== this.props.G.threat.description || - prevProps.G.threat.mitigation !== this.props.G.threat.mitigation - ) { - this.setState({ - title: this.props.G.threat.title, - description: this.props.G.threat.description, - mitigation: this.props.G.threat.mitigation, - }); - } - } - - saveThreat() { - for (let field in ['title', 'description', 'mitigation']) { - if (this.props.G.threat[field] !== this.state[field]) { - this.props.moves.updateThreat(field, this.state[field]); - } - } - - if (!this.props.G.threat.mitigation) { - this.props.moves.updateThreat('mitigation', 'No mitigation provided.'); - } - - if (!this.props.G.threat.description) { - this.props.moves.updateThreat('description', 'No description provided.'); - } - } - - addOrUpdate() { - // update the values from the state - this.saveThreat(); - this.props.moves.addOrUpdateThreat(); - this.toggleMitigationField(false); - } - - toggleMitigationField(isShown) { - this.setState({ - showMitigation: isShown, - }); - } - - get isInvalid() { - return _.isEmpty(this.state.title); - } - - get isOwner() { - return this.props.G.threat.owner === this.props.playerID; - } - - get isPrivacyEnhancedMode() { - return this.props.G.modelType === ModelType.PRIVACY_ENHANCED; - } - - threatDetailModalBody() { - return ( - <ModalBody> - <FormGroup> - <Label for="title">Title</Label> - <Input - type="text" - name="title" - id="title" - disabled={!this.isOwner} - autoComplete="off" - value={this.state.title} - onBlur={(e) => - this.props.moves.updateThreat('title', e.target.value) - } - onChange={(e) => this.setState({ title: e.target.value })} - /> - </FormGroup> - <FormGroup> - <Label for="type">Threat type</Label> - <Input - type="select" - name="type" - id="type" - disabled={!this.isOwner} - value={this.props.G.threat.type} - onChange={(e) => - this.props.moves.updateThreat('type', e.target.value) - } - > - {getSuits(this.props.G.gameMode).map((suit) => ( - <option value={suit} key={`threat-category-${suit}`}> - {getSuitDisplayName(this.props.G.gameMode, suit)} - </option> - ))} - </Input> - </FormGroup> - <FormGroup> - <Label for="severity">Severity</Label> - <Input - type="select" - name="severity" - id="severity" - disabled={!this.isOwner} - value={this.props.G.threat.severity} - onChange={(e) => - this.props.moves.updateThreat('severity', e.target.value) - } - > - <option>Low</option> - <option>Medium</option> - <option>High</option> - </Input> - </FormGroup> - <FormGroup> - <Label for="description">Description</Label> - <Input - type="textarea" - name="description" - id="description" - disabled={!this.isOwner} - style={{ height: 150 }} - value={this.state.description} - onBlur={(e) => - this.props.moves.updateThreat('description', e.target.value) - } - onChange={(e) => this.setState({ description: e.target.value })} - /> - </FormGroup> - <FormGroup hidden={!this.isOwner}> - <div className="checkbox-item"> - <Input - className="pointer" - type="checkbox" - id="showMitigation" - onChange={(e) => this.toggleMitigationField(e.target.checked)} - /> - <Label for="showMitigation"> - Add a mitigation <em>(optional)</em> - </Label> - </div> - </FormGroup> - <FormGroup hidden={this.isOwner && !this.state.showMitigation}> - <Label for="mitigation">Mitigation</Label> - <Input - type="textarea" - name="mitigation" - id="mitigation" - disabled={!this.isOwner} - style={{ height: 150 }} - value={this.state.mitigation} - onBlur={(e) => - this.props.moves.updateThreat('mitigation', e.target.value) - } - onChange={(e) => this.setState({ mitigation: e.target.value })} - /> - </FormGroup> - </ModalBody> - ); - } - - threatRestrictedDetailModalBody() { - return ( - <ModalBody> - <FormGroup> - <Label for="referenceInputField"> - Reference <em>(e.g. link to external bug tracking system)</em> - </Label> - <Input - type="text" - name="referenceInputField" - disabled={!this.isOwner} - autoComplete="off" - value={this.state.title} - onBlur={(e) => - this.props.moves.updateThreat('title', e.target.value) - } - onChange={(e) => this.setState({ title: e.target.value })} - /> - </FormGroup> - </ModalBody> - ); - } - - render() { - return ( - <Modal isOpen={this.props.isOpen}> - <Form> - <ModalHeader - toggle={ - this.isOwner ? () => this.props.moves.toggleModal() : undefined - } - style={{ width: '100%' }} - > - {this.props.G.threat.new ? 'Add' : 'Update'} Threat —{' '} - <small className="text-muted"> - being {this.props.G.threat.new ? 'added' : 'updated'} by{' '} - {this.props.names[this.props.G.threat.owner]} - </small> - </ModalHeader> - - {this.isPrivacyEnhancedMode - ? this.threatRestrictedDetailModalBody() - : this.threatDetailModalBody()} - - {this.isOwner && ( - <ModalFooter> - <Button - color="primary" - className="mr-auto" - disabled={this.isInvalid} - onClick={() => this.addOrUpdate()} - > - Save - </Button> - <Button - color="secondary" - onClick={() => this.props.moves.toggleModal()} - > - Cancel - </Button> - </ModalFooter> - )} - </Form> - </Modal> - ); - } -} - -export default ThreatModal; diff --git a/src/client/components/threatmodal/threatmodal.test.js b/src/client/components/threatmodal/threatmodal.test.tsx similarity index 70% rename from src/client/components/threatmodal/threatmodal.test.js rename to src/client/components/threatmodal/threatmodal.test.tsx index ca3a2c7b..15a4d38b 100644 --- a/src/client/components/threatmodal/threatmodal.test.js +++ b/src/client/components/threatmodal/threatmodal.test.tsx @@ -2,100 +2,81 @@ import React from 'react'; import { render, fireEvent, screen } from '@testing-library/react'; import ThreatModal from './threatmodal'; import { DEFAULT_GAME_MODE } from '../../../utils/GameMode'; +import type { GameState } from '../../../game/gameState'; +import { ModelType } from '../../../utils/constants'; + +const baseG: GameState = { + dealt: ['T1'], + scores: [0, 0, 0], + selectedComponent: '', + selectedDiagram: 0, + identifiedThreats: [], + threat: { + modal: true, + new: true, + owner: '0', + }, + gameMode: DEFAULT_GAME_MODE, + passed: [], + suit: undefined, + dealtBy: '', + players: [], + round: 0, + numCardsPlayed: 0, + lastWinner: 0, + maxRounds: 0, + selectedThreat: '', + startingCard: '', + turnDuration: 0, + modelType: ModelType.IMAGE, +}; + +const moves = {}; it('renders without crashing for a new threat', () => { - const G = { - dealt: ['T1'], - order: [0, 1, 2], - scores: [0, 0, 0], - selectedComponent: '', - selectedDiagram: '0', - identifiedThreats: [], - threat: { - modal: true, - owner: '0', - }, - gameMode: DEFAULT_GAME_MODE, - }; - const ctx = {}; const moves = {}; render( <ThreatModal - isOpen - G={G} - ctx={ctx} - model={null} + playerID="0" + G={baseG} moves={moves} - active={true} names={['P1', 'P2', 'P3']} - playerID="0" + isOpen />, ); }); it('renders without crashing for an existing threat', () => { - const G = { - dealt: ['T1'], - order: [0, 1, 2], - scores: [0, 0, 0], - selectedComponent: '', - selectedDiagram: '0', - identifiedThreats: [], - threat: { - modal: true, - new: false, - owner: '0', - }, - gameMode: DEFAULT_GAME_MODE, - }; - const ctx = {}; - const moves = {}; + const G: GameState = { ...baseG, threat: { ...baseG.threat, new: false } }; render( <ThreatModal - isOpen + playerID="0" G={G} - ctx={ctx} - model={null} moves={moves} - active={true} names={['P1', 'P2', 'P3']} - playerID="0" + isOpen />, ); }); describe('for the owner of the threat', () => { const playerID = '0'; - const G = { - dealt: ['T1'], - order: [0, 1, 2], - scores: [0, 0, 0], - selectedComponent: '', - selectedDiagram: '0', - identifiedThreats: [], - threat: { - modal: true, - owner: playerID, - }, - gameMode: DEFAULT_GAME_MODE, + const G: GameState = { + ...baseG, + threat: { ...baseG.threat, owner: playerID }, }; - const ctx = {}; - const moves = {}; it('renders a close button', () => { // when render( <ThreatModal - isOpen + playerID={playerID} G={G} - ctx={ctx} - model={null} moves={moves} - active={true} names={['P1', 'P2', 'P3']} - playerID={playerID} + isOpen />, ); @@ -107,14 +88,11 @@ describe('for the owner of the threat', () => { // when render( <ThreatModal - isOpen + playerID={playerID} G={G} - ctx={ctx} - model={null} moves={moves} - active={true} names={['P1', 'P2', 'P3']} - playerID={playerID} + isOpen />, ); @@ -128,14 +106,11 @@ describe('for the owner of the threat', () => { const toggleModal = jest.fn(); render( <ThreatModal - isOpen + playerID={playerID} G={G} - ctx={ctx} - model={null} moves={{ toggleModal }} - active={true} names={['P1', 'P2', 'P3']} - playerID={playerID} + isOpen />, ); @@ -153,14 +128,11 @@ describe('for the owner of the threat', () => { const toggleModal = jest.fn(); render( <ThreatModal - isOpen + playerID={playerID} G={G} - ctx={ctx} - model={null} moves={{ toggleModal }} - active={true} names={['P1', 'P2', 'P3']} - playerID={playerID} + isOpen />, ); @@ -180,14 +152,11 @@ describe('for the owner of the threat', () => { render( <ThreatModal - isOpen + playerID={playerID} G={G} - ctx={ctx} - model={null} moves={{ addOrUpdateThreat, updateThreat }} - active={true} names={['P1', 'P2', 'P3']} - playerID={playerID} + isOpen />, ); @@ -210,14 +179,11 @@ describe('for the owner of the threat', () => { render( <ThreatModal - isOpen + playerID={playerID} G={G} - ctx={ctx} - model={null} moves={{ addOrUpdateThreat, updateThreat }} - active={true} names={['P1', 'P2', 'P3']} - playerID={playerID} + isOpen />, ); @@ -245,58 +211,45 @@ describe('for the owner of the threat', () => { describe('for players other than the owner of the threat', () => { const playerID = '0'; const ownerID = '1'; - const G = { - dealt: ['T1'], - order: [0, 1, 2], - scores: [0, 0, 0], - selectedComponent: '', - selectedDiagram: '0', - identifiedThreats: [], + + const G: GameState = { + ...baseG, threat: { - modal: true, + ...baseG.threat, owner: ownerID, }, - gameMode: DEFAULT_GAME_MODE, }; - const ctx = {}; - const moves = {}; it('does not render a close button', () => { // when render( <ThreatModal - isOpen + playerID={playerID} G={G} - ctx={ctx} - model={null} moves={moves} - active={true} names={['P1', 'P2', 'P3']} - playerID={playerID} + isOpen />, ); // then - expect(screen.queryByText('×')).toBeNull(); + expect(screen.queryByText('×')).not.toBeInTheDocument(); }); it('does not render save and cancel buttons', () => { // when render( <ThreatModal - isOpen + playerID={playerID} G={G} - ctx={ctx} - model={null} moves={moves} - active={true} names={['P1', 'P2', 'P3']} - playerID={playerID} + isOpen />, ); // then - expect(screen.queryByText('Save')).toBeNull(); - expect(screen.queryByText('Cancel')).toBeNull(); + expect(screen.queryByText('Save')).not.toBeInTheDocument(); + expect(screen.queryByText('Cancel')).not.toBeInTheDocument(); }); }); diff --git a/src/client/components/threatmodal/threatmodal.tsx b/src/client/components/threatmodal/threatmodal.tsx new file mode 100644 index 00000000..fef4f336 --- /dev/null +++ b/src/client/components/threatmodal/threatmodal.tsx @@ -0,0 +1,235 @@ +import type { BoardProps } from 'boardgame.io/react'; +import _ from 'lodash'; +import React, { FC, useCallback, useEffect, useState } from 'react'; +import { + Button, + Form, + FormGroup, + Input, + Label, + Modal, + ModalBody, + ModalFooter, + ModalHeader, +} from 'reactstrap'; +import { getSuitDisplayName, getSuits } from '../../../utils/cardDefinitions'; +import { ModelType } from '../../../utils/constants'; +import type { GameState } from '../../../game/gameState'; +import { resolvePlayerName } from '../../../utils/utils'; + +type ThreatModalProps = { + names: string[]; + isOpen: boolean; +} & Pick<BoardProps<GameState>, 'G' | 'moves' | 'playerID'>; + +const ThreatModal2: FC<ThreatModalProps> = ({ + playerID, + G, + moves, + names, + isOpen, +}) => { + const [title, setTitle] = useState(G.threat.title); + const [description, setDescription] = useState(G.threat.description); + const [mitigation, setMitigation] = useState(G.threat.mitigation); + const [showMitigation, setShowMitigation] = useState(false); + + useEffect(() => { + setTitle(G.threat.title); + setDescription(G.threat.description); + setMitigation(G.threat.mitigation); + }, [G.threat.title, G.threat.description, G.threat.mitigation]); + + const isPrivacyEnhancedMode = G.modelType === ModelType.PRIVACY_ENHANCED; + const isOwner = G.threat.owner === playerID; + const isInvalid = _.isEmpty(title); + + const saveThreat = () => { + if (G.threat.title !== title) { + moves.updateThreat('title', title); + } + + const descriptionToUse = description ?? 'No description provided.'; + if (G.threat.description !== descriptionToUse) { + moves.updateThreat('description', descriptionToUse); + } + + if (G.threat.mitigation !== mitigation) { + moves.updateThreat('mitigation', mitigation); + } + + if (!G.threat.mitigation) { + moves.updateThreat('mitigation', 'No mitigation provided.'); + } + }; + + const addOrUpdate = () => { + // update the values from the state + saveThreat(); + moves.addOrUpdateThreat(); + setShowMitigation(false); + }; + + const threatDetailModalBody = useCallback( + () => ( + <ModalBody> + <FormGroup> + <Label for="title">Title</Label> + <Input + type="text" + name="title" + id="title" + disabled={!isOwner} + autoComplete="off" + value={title} + onBlur={(e) => moves.updateThreat('title', e.target.value)} + onChange={(e) => setTitle(e.target.value)} + /> + </FormGroup> + <FormGroup> + <Label for="type">Threat type</Label> + <Input + type="select" + name="type" + id="type" + disabled={!isOwner} + value={G.threat.type} + onChange={(e) => moves.updateThreat('type', e.target.value)} + > + {getSuits(G.gameMode).map((suit) => ( + <option value={suit} key={`threat-category-${suit}`}> + {getSuitDisplayName(G.gameMode, suit)} + </option> + ))} + </Input> + </FormGroup> + <FormGroup> + <Label for="severity">Severity</Label> + <Input + type="select" + name="severity" + id="severity" + disabled={!isOwner} + value={G.threat.severity} + onChange={(e) => moves.updateThreat('severity', e.target.value)} + > + <option>Low</option> + <option>Medium</option> + <option>High</option> + </Input> + </FormGroup> + <FormGroup> + <Label for="description">Description</Label> + <Input + type="textarea" + name="description" + id="description" + disabled={!isOwner} + style={{ height: 150 }} + value={description} + onBlur={(e) => moves.updateThreat('description', e.target.value)} + onChange={(e) => setDescription(e.target.value)} + /> + </FormGroup> + <FormGroup hidden={!isOwner}> + <div className="checkbox-item"> + <Input + className="pointer" + type="checkbox" + id="showMitigation" + onChange={(e) => setShowMitigation(e.target.checked)} + /> + <Label for="showMitigation"> + Add a mitigation <em>(optional)</em> + </Label> + </div> + </FormGroup> + <FormGroup hidden={isOwner && !showMitigation}> + <Label for="mitigation">Mitigation</Label> + <Input + type="textarea" + name="mitigation" + id="mitigation" + disabled={!isOwner} + style={{ height: 150 }} + value={mitigation} + onBlur={(e) => moves.updateThreat('mitigation', e.target.value)} + onChange={(e) => setMitigation(e.target.value)} + /> + </FormGroup> + </ModalBody> + ), + [ + isOwner, + title, + moves.updateThreat, + G.threat.type, + G.gameMode, + G.threat.severity, + description, + showMitigation, + mitigation, + ], // TODO: add eslint rule to check for exhaustive dependencies + ); + + const threatRestrictedDetailModalBody = useCallback( + () => ( + <ModalBody> + <FormGroup> + <Label for="referenceInputField"> + Reference <em>(e.g. link to external bug tracking system)</em> + </Label> + <Input + type="text" + name="referenceInputField" + disabled={!isOwner} + autoComplete="off" + value={title} + onBlur={(e) => moves.updateThreat('title', e.target.value)} + onChange={(e) => setTitle(e.target.value)} + /> + </FormGroup> + </ModalBody> + ), + [isOwner, title, moves.updateThreat], + ); + + return ( + <Modal isOpen={isOpen}> + <Form> + <ModalHeader + toggle={isOwner ? () => moves.toggleModal() : undefined} + style={{ width: '100%' }} + > + {G.threat.new ? 'Add' : 'Update'} Threat —{' '} + <small className="text-muted"> + being {G.threat.new ? 'added' : 'updated'} by{' '} + {resolvePlayerName(G.threat.owner ?? '', names, playerID)} + </small> + </ModalHeader> + + {isPrivacyEnhancedMode + ? threatRestrictedDetailModalBody() + : threatDetailModalBody()} + + {isOwner && ( + <ModalFooter> + <Button + color="primary" + className="mr-auto" + disabled={isInvalid} + onClick={() => addOrUpdate()} + > + Save + </Button> + <Button color="secondary" onClick={() => moves.toggleModal()}> + Cancel + </Button> + </ModalFooter> + )} + </Form> + </Modal> + ); +}; + +export default ThreatModal2;