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 &mdash;{' '}
-            <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 &mdash;{' '}
+          <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;