No crew in advanced training.
+No recorded activity.
+ )} + {entries.map(entry => ( ++ {entry.contents} +
+ ))} +Fabrication halted by engineering
+ )} +{activeMode?.description}
+ {
+ if (!naturalWidth || !naturalHeight) return;
+ const {id, layerId, iconWidth, iconHeight} = this.props;
+ if (iconWidth !== naturalWidth) {
+ this.props.updateObject("iconWidth", naturalWidth, {id, layerId});
+ }
+ if (iconHeight !== naturalHeight) {
+ this.props.updateObject("iconHeight", naturalHeight, {id, layerId});
+ }
+ };
render() {
const {destination} = this.state;
const {
@@ -84,6 +104,9 @@ export default class TacticalIcon extends Component {
interval,
movement = {x: 0, y: 0, z: 0},
isSelected,
+ keepOnScreen,
+ iconWidth,
+ iconHeight,
} = this.props;
if (icon) {
return (
@@ -107,6 +130,10 @@ export default class TacticalIcon extends Component {
fontSize={fontSize}
label={label}
core={core}
+ keepOnScreen={keepOnScreen}
+ iconWidth={iconWidth}
+ iconHeight={iconHeight}
+ onIconLoad={this.handleIconLoad}
/>
);
}
diff --git a/src/components/views/TacticalMap/preview/layerComps/clampToBounds.js b/src/components/views/TacticalMap/preview/layerComps/clampToBounds.js
new file mode 100644
index 000000000..82221262c
--- /dev/null
+++ b/src/components/views/TacticalMap/preview/layerComps/clampToBounds.js
@@ -0,0 +1,55 @@
+// Shared "keep on screen" clamp math for Tactical Map objects.
+//
+// Tactical items store their position as normalized {x, y, z} fractions where 0 is
+// the left/top edge and 1 is the right/bottom edge, rendered with
+// `translate(x*100%, y*100%)`. When an item has `keepOnScreen` enabled we constrain
+// the position so the *entire* scaled icon stays within [0, 1].
+//
+// The footprint is computed against the canonical 1920x1080 viewscreen so the clamp
+// is identical on the server (authoritative) and on every client, regardless of the
+// actual canvas size (the rendered position is normalized, so it is scale-invariant).
+//
+// NOTE: This file is intentionally duplicated at
+// `server/helpers/tacticalBounds.js`. The client (Vite, tsconfig include: src) and the
+// server (tsconfig include: server) cannot import across that boundary, so keep the two
+// copies in sync.
+
+export const CANONICAL_WIDTH = 1920;
+export const CANONICAL_HEIGHT = 1080;
+
+// Returns the normalized {w, h} footprint of the scaled icon. `iconWidth`/`iconHeight`
+// are the icon image's intrinsic pixel dimensions (measured once on the client).
+export function getFootprint(
+ item,
+ canvasWidth = CANONICAL_WIDTH,
+ canvasHeight = CANONICAL_HEIGHT,
+) {
+ const size = item.size || 1;
+ const w = ((item.iconWidth || 0) * size) / canvasWidth;
+ const h = ((item.iconHeight || 0) * size) / canvasHeight;
+ return {w, h};
+}
+
+// Clamps a normalized position so the icon's footprint stays fully on screen.
+export function clampToBounds(position, footprint) {
+ const maxX = Math.max(0, 1 - footprint.w);
+ const maxY = Math.max(0, 1 - footprint.h);
+ return {
+ x: Math.min(Math.max(position.x, 0), maxX),
+ y: Math.min(Math.max(position.y, 0), maxY),
+ z: position.z,
+ };
+}
+
+// Convenience: clamp a position for a given item using its stored footprint.
+export function clampItemPosition(
+ item,
+ position,
+ canvasWidth = CANONICAL_WIDTH,
+ canvasHeight = CANONICAL_HEIGHT,
+) {
+ return clampToBounds(
+ position,
+ getFootprint(item, canvasWidth, canvasHeight),
+ );
+}
diff --git a/src/components/views/TacticalMap/preview/layerComps/clampToBounds.test.js b/src/components/views/TacticalMap/preview/layerComps/clampToBounds.test.js
new file mode 100644
index 000000000..54d5fc79d
--- /dev/null
+++ b/src/components/views/TacticalMap/preview/layerComps/clampToBounds.test.js
@@ -0,0 +1,72 @@
+import {describe, it, expect} from "vitest";
+import {getFootprint, clampToBounds, clampItemPosition} from "./clampToBounds";
+
+describe("getFootprint", () => {
+ it("normalizes the scaled icon against the canonical viewscreen", () => {
+ const fp = getFootprint({size: 1, iconWidth: 192, iconHeight: 108});
+ expect(fp.w).toBeCloseTo(0.1);
+ expect(fp.h).toBeCloseTo(0.1);
+ });
+
+ it("scales with the icon size", () => {
+ const fp = getFootprint({size: 2, iconWidth: 192, iconHeight: 108});
+ expect(fp.w).toBeCloseTo(0.2);
+ expect(fp.h).toBeCloseTo(0.2);
+ });
+
+ it("treats missing dimensions as zero footprint", () => {
+ const fp = getFootprint({size: 1});
+ expect(fp).toEqual({w: 0, h: 0});
+ });
+});
+
+describe("clampToBounds", () => {
+ const footprint = {w: 0.1, h: 0.1};
+
+ it("leaves an in-bounds position untouched", () => {
+ expect(clampToBounds({x: 0.5, y: 0.5, z: 0}, footprint)).toEqual({
+ x: 0.5,
+ y: 0.5,
+ z: 0,
+ });
+ });
+
+ it("clamps past the right/bottom edge to keep the full icon visible", () => {
+ expect(clampToBounds({x: 1.5, y: 2, z: 0}, footprint)).toEqual({
+ x: 0.9,
+ y: 0.9,
+ z: 0,
+ });
+ });
+
+ it("clamps past the left/top edge to zero", () => {
+ expect(clampToBounds({x: -1, y: -0.3, z: 0}, footprint)).toEqual({
+ x: 0,
+ y: 0,
+ z: 0,
+ });
+ });
+
+ it("pins an oversized icon (footprint > 1) to the top-left", () => {
+ expect(clampToBounds({x: 0.5, y: 0.5, z: 0}, {w: 1.5, h: 2})).toEqual({
+ x: 0,
+ y: 0,
+ z: 0,
+ });
+ });
+
+ it("preserves the z coordinate", () => {
+ expect(clampToBounds({x: 0.5, y: 0.5, z: 0.42}, footprint).z).toBe(0.42);
+ });
+});
+
+describe("clampItemPosition", () => {
+ it("clamps using the item's stored footprint", () => {
+ const item = {size: 1, iconWidth: 192, iconHeight: 108};
+ expect(clampItemPosition(item, {x: 1, y: 1, z: 0})).toEqual({
+ x: 0.9,
+ y: 0.9,
+ z: 0,
+ });
+ });
+});
diff --git a/src/components/views/TacticalMap/preview/layerComps/objects.jsx b/src/components/views/TacticalMap/preview/layerComps/objects.jsx
index a42a423e8..18e3b01d3 100644
--- a/src/components/views/TacticalMap/preview/layerComps/objects.jsx
+++ b/src/components/views/TacticalMap/preview/layerComps/objects.jsx
@@ -2,6 +2,12 @@ import React from "react";
import TacticalIcon from "./TacticalIcon";
import Selection from "./select";
import useInterval from "helpers/hooks/useInterval";
+import {clampItemPosition} from "./clampToBounds";
+
+// Extra slack (in px) added to the off-screen deletion boundary so contacts that
+// sit right at the edge — especially keepOnScreen ones pinned against it — are not
+// deleted by accident during a multi-select drag.
+const DELETE_MARGIN = 40;
const Objects = ({
id,
@@ -40,16 +46,30 @@ const Objects = ({
x = x + movement.x;
y = y + movement.y;
+ // Constrained contacts are clamped back on screen instead of being
+ // dragged off and deleted.
+ if (item.keepOnScreen) {
+ updateObject(
+ "destination",
+ clampItemPosition(item, {x, y, z}),
+ item,
+ speed,
+ );
+ return;
+ }
+
const el = document.getElementById(`tactical-icon-${item.id}`);
const elBounds = el.getBoundingClientRect();
const leftBound =
(-1 * (elBounds.width / item.size + canvasBounds.left)) /
- canvasBounds.width;
- const rightBound = 1 + 20 / canvasBounds.width;
+ canvasBounds.width -
+ DELETE_MARGIN / canvasBounds.width;
+ const rightBound = 1 + DELETE_MARGIN / canvasBounds.width;
const topBound =
(-1 * (elBounds.height / item.size + canvasBounds.top)) /
- canvasBounds.height;
- const bottomBound = 1 + 20 / canvasBounds.height;
+ canvasBounds.height -
+ DELETE_MARGIN / canvasBounds.height;
+ const bottomBound = 1 + DELETE_MARGIN / canvasBounds.height;
if (
x > rightBound ||
x < leftBound ||
diff --git a/src/components/views/TacticalMap/queries/tacticalMap.graphql b/src/components/views/TacticalMap/queries/tacticalMap.graphql
index b86f0021e..222bd5154 100644
--- a/src/components/views/TacticalMap/queries/tacticalMap.graphql
+++ b/src/components/views/TacticalMap/queries/tacticalMap.graphql
@@ -19,6 +19,9 @@ subscription TacticalMapUpdate($id: ID!) {
fontColor
icon
size
+ iconWidth
+ iconHeight
+ keepOnScreen
speed
velocity {
x
diff --git a/src/components/views/index.ts b/src/components/views/index.ts
index 0d0ffa902..35a854383 100644
--- a/src/components/views/index.ts
+++ b/src/components/views/index.ts
@@ -128,6 +128,7 @@ const HullPlating = React.lazy(() => import("./HullPlating"));
const EdVenturesApp = React.lazy(() => import("./EdVenturesApp"));
const AdvancedNavigation = React.lazy(() => import("./AdvancedNavAndAstrometrics/AdvancedNavigationCard"));
const Astrometrics = React.lazy(() => import("./AdvancedNavAndAstrometrics/AstrometricsCard"));
+const Aegis = React.lazy(() => import("./Aegis"));
// Cores
const EngineControlCore = React.lazy(() => import("./EngineControl/core"));
const TransporterCore = React.lazy(() => import("./Transporters/core"));
@@ -230,6 +231,8 @@ const HullPlatingCore = React.lazy(() => import("./HullPlating/core"));
const EdVenturesAppCore = React.lazy(() => import("./EdVenturesApp/core"));
const AdvancedNavigationCore = React.lazy(() => import("./AdvancedNavAndAstrometrics/CoreAdvancedNavigation"));
const AstrometricsCore = React.lazy(() => import("./AdvancedNavAndAstrometrics/CoreAstrometrics"));
+const AegisCore = React.lazy(() => import("./Aegis/core"));
+const AdvancedTrainingCore = React.lazy(() => import("./AdvancedTraining/core"));
// Widgets
const ComposerWidget = React.lazy(() => import("./LongRangeComm/Composer"));
const CalculatorWidget = React.lazy(() => import("./Widgets/calculator"));
@@ -340,7 +343,8 @@ const Views = {
HullPlating,
EdVenturesApp,
AdvancedNavigation,
- Astrometrics
+ Astrometrics,
+ Aegis,
};
export const Widgets = {
@@ -543,7 +547,9 @@ export const Cores = {
HullPlatingCore,
EdVenturesAppCore,
AdvancedNavigationCore,
- AstrometricsCore
+ AstrometricsCore,
+ AegisCore,
+ AdvancedTrainingCore
};
export default Views;
diff --git a/src/components/viewscreens/TacticalMap/index.jsx b/src/components/viewscreens/TacticalMap/index.jsx
index a52740252..d62d98f7d 100644
--- a/src/components/viewscreens/TacticalMap/index.jsx
+++ b/src/components/viewscreens/TacticalMap/index.jsx
@@ -26,6 +26,9 @@ const fragment = gql`
fontColor
icon
size
+ iconWidth
+ iconHeight
+ keepOnScreen
speed
velocity {
x
diff --git a/src/containers/FlightDirector/AdvancedTrainingDashboard/AdvancedTrainingDashboard.scss b/src/containers/FlightDirector/AdvancedTrainingDashboard/AdvancedTrainingDashboard.scss
new file mode 100644
index 000000000..b3c270f6b
--- /dev/null
+++ b/src/containers/FlightDirector/AdvancedTrainingDashboard/AdvancedTrainingDashboard.scss
@@ -0,0 +1,181 @@
+.advanced-training-dashboard {
+ padding: 20px;
+
+ .dashboard-title {
+ color: #00bcd4;
+ font-weight: 600;
+ margin-bottom: 20px;
+ text-transform: uppercase;
+ letter-spacing: 1px;
+ }
+
+ .no-clients {
+ text-align: center;
+ padding: 40px 20px;
+ color: #b0bec5;
+ }
+
+ .client-training-card {
+ background: rgba(0, 0, 0, 0.3);
+ border: 1px solid rgba(0, 188, 212, 0.2);
+ }
+
+ .client-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ background: rgba(0, 188, 212, 0.08);
+ border-bottom: 1px solid rgba(0, 188, 212, 0.2);
+ }
+
+ .client-info {
+ display: flex;
+ flex-direction: column;
+ }
+
+ .client-label {
+ font-weight: 600;
+ color: #e0f7fa;
+ }
+
+ .client-station {
+ font-size: 12px;
+ color: #78909c;
+ }
+
+ .client-body {
+ padding: 12px;
+ }
+
+ .progress-section {
+ margin-bottom: 12px;
+ }
+
+ .progress-label {
+ color: #78909c;
+ display: block;
+ margin-bottom: 4px;
+ }
+
+ .section-label {
+ color: #546e7a;
+ text-transform: uppercase;
+ font-size: 10px;
+ letter-spacing: 0.5px;
+ }
+
+ .active-chapter {
+ margin-bottom: 12px;
+ padding: 8px;
+ background: rgba(0, 188, 212, 0.08);
+ border-radius: 4px;
+ border-left: 3px solid #00bcd4;
+
+ .chapter-name {
+ font-weight: 600;
+ color: #e0f7fa;
+ }
+
+ .chapter-card {
+ font-size: 12px;
+ color: #78909c;
+ }
+ }
+
+ .chapter-list {
+ margin-bottom: 12px;
+ }
+
+ .chapter-row {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 6px 4px;
+ border-bottom: 1px solid rgba(255, 255, 255, 0.05);
+ flex-wrap: wrap;
+
+ &.active {
+ background: rgba(0, 188, 212, 0.05);
+
+ .chapter-name-text {
+ color: #00bcd4;
+ font-weight: 600;
+ }
+ }
+
+ &.completed {
+ .chapter-name-text {
+ color: #4caf50;
+ }
+
+ .chapter-index {
+ color: #4caf50;
+ }
+ }
+ }
+
+ .chapter-index {
+ color: #546e7a;
+ font-size: 12px;
+ min-width: 18px;
+ }
+
+ .chapter-name-text {
+ flex: 1;
+ font-size: 13px;
+ color: #b0bec5;
+ }
+
+ .chapter-progress-text {
+ font-size: 11px;
+ color: #546e7a;
+ }
+
+ .chapter-action-btn {
+ font-size: 11px;
+ padding: 1px 8px;
+ }
+
+ .sub-chapter-row {
+ width: 100%;
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ padding: 3px 4px 3px 26px;
+ font-size: 12px;
+
+ &.completed {
+ .sub-name {
+ color: #78909c;
+ text-decoration: line-through;
+ }
+ .sub-check {
+ color: #4caf50;
+ }
+ }
+ }
+
+ .sub-check {
+ color: #546e7a;
+ font-size: 10px;
+ min-width: 14px;
+ }
+
+ .sub-name {
+ flex: 1;
+ color: #b0bec5;
+ }
+
+ .sub-action-btn {
+ font-size: 10px;
+ padding: 0px 6px;
+ }
+
+ .intervention-actions {
+ display: flex;
+ gap: 8px;
+ margin-top: 8px;
+ padding-top: 8px;
+ border-top: 1px solid rgba(255, 255, 255, 0.05);
+ }
+}
diff --git a/src/containers/FlightDirector/AdvancedTrainingDashboard/index.tsx b/src/containers/FlightDirector/AdvancedTrainingDashboard/index.tsx
new file mode 100644
index 000000000..138d80353
--- /dev/null
+++ b/src/containers/FlightDirector/AdvancedTrainingDashboard/index.tsx
@@ -0,0 +1,311 @@
+import React from "react";
+import {
+ Container,
+ Row,
+ Col,
+ Card,
+ CardBody,
+ CardHeader,
+ Button,
+ Progress,
+ Badge,
+} from "helpers/reactstrap";
+import {useQuery, useMutation, useSubscription} from "react-apollo";
+import gql from "graphql-tag.macro";
+import {
+ ADVANCED_TRAINING_PROGRESS_SUB,
+ FD_ADVANCE_CHAPTER,
+ FD_COMPLETE_SUBCHAPTER,
+ FD_RESET_PROGRESS,
+} from "components/training/queries";
+import {getActionLabel, getCardLabel} from "components/training/actionRegistry";
+import "./AdvancedTrainingDashboard.scss";
+
+const CLIENTS_QUERY = gql`
+ query AdvancedTrainingClients {
+ clients(all: true) {
+ id
+ label
+ connected
+ simulatorId
+ station
+ training
+ simulator {
+ id
+ name
+ stationSets {
+ id
+ stations {
+ name
+ advancedTraining {
+ enabled
+ chapters {
+ id
+ name
+ cardComponent
+ subChapters {
+ id
+ name
+ requiredActions {
+ id
+ eventName
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+`;
+
+const CLIENTS_SUB = gql`
+ subscription AdvancedTrainingClientsSub {
+ clientChanged {
+ id
+ label
+ connected
+ simulatorId
+ station
+ training
+ }
+ }
+`;
+
+const AdvancedTrainingDashboard: React.FC = () => {
+ const {data: clientsData} = useQuery(CLIENTS_QUERY, {
+ fetchPolicy: "network-only",
+ });
+ useSubscription(CLIENTS_SUB);
+
+ const {data: progressData} = useSubscription(ADVANCED_TRAINING_PROGRESS_SUB, {
+ variables: {},
+ });
+
+ const [advanceChapter] = useMutation(FD_ADVANCE_CHAPTER);
+ const [completeSubChapter] = useMutation(FD_COMPLETE_SUBCHAPTER);
+ const [resetProgress] = useMutation(FD_RESET_PROGRESS);
+
+ const progressList = progressData?.advancedTrainingProgressUpdate || [];
+
+ const clients = clientsData?.clients || [];
+
+ // Find clients that have advanced training configured
+ const trainingClients = clients.filter((client: any) => {
+ if (!client.connected || !client.station || !client.simulatorId) {
+ return false;
+ }
+ const progress = progressList.find((p: any) => p.clientId === client.id);
+ return !!progress;
+ });
+
+ const getClientConfig = (client: any) => {
+ const sim = client.simulator;
+ if (!sim) {
+ return null;
+ }
+ for (const ss of sim.stationSets || []) {
+ const station = ss.stations?.find((s: any) => s.name === client.station);
+ if (station?.advancedTraining?.enabled) {
+ return station.advancedTraining;
+ }
+ }
+ return null;
+ };
+
+ return (
+
+ Advanced Training Dashboard
+
+ {trainingClients.length === 0 && (
+
+ No crew members are currently in advanced training.
+
+ Crew members can start advanced training from their login screen
+ when it is configured for their station.
+
+
+ )}
+
+
+ {trainingClients.map((client: any) => {
+ const progress = progressList.find(
+ (p: any) => p.clientId === client.id,
+ );
+ const config = getClientConfig(client);
+ if (!progress || !config) {
+ return null;
+ }
+
+ const chapters = config.chapters || [];
+ const activeChapter = chapters.find(
+ (c: any) => c.id === progress.activeChapterId,
+ );
+
+ const totalSubChapters = chapters.reduce(
+ (sum: number, ch: any) => sum + (ch.subChapters?.length || 0),
+ 0,
+ );
+ const completedSubChapters =
+ progress.completedSubChapterIds?.length || 0;
+ const overallPercent =
+ totalSubChapters > 0
+ ? Math.round((completedSubChapters / totalSubChapters) * 100)
+ : 0;
+
+ return (
+
+
+
+
+
+ {client.label || client.id}
+
+ {client.station}
+
+
+ {overallPercent}%
+
+
+
+ {/* Overall progress */}
+
+
+ Overall: {completedSubChapters}/{totalSubChapters}{" "}
+ sub-tasks
+
+
+
+
+ {/* Active chapter */}
+ {activeChapter && (
+
+ Active Chapter:
+ {activeChapter.name}
+
+ {getCardLabel(activeChapter.cardComponent)}
+
+
+ )}
+
+ {/* Chapter list */}
+
+ {chapters.map((ch: any, idx: number) => {
+ const isCompleted =
+ progress.completedChapterIds?.includes(ch.id);
+ const isActive = progress.activeChapterId === ch.id;
+ const chSubCount = ch.subChapters?.length || 0;
+ const chCompleted =
+ ch.subChapters?.filter((sc: any) =>
+ progress.completedSubChapterIds?.includes(sc.id),
+ ).length || 0;
+
+ return (
+
+ {idx + 1}
+ {ch.name}
+
+ {chCompleted}/{chSubCount}
+
+ {!isActive && !isCompleted && (
+
+ )}
+
+ {/* Sub-chapters for active chapter */}
+ {isActive &&
+ ch.subChapters?.map((sc: any) => {
+ const scCompleted =
+ progress.completedSubChapterIds?.includes(
+ sc.id,
+ );
+ return (
+
+
+ {scCompleted ? "\u2713" : "\u25CB"}
+
+ {sc.name}
+ {!scCompleted && (
+
+ )}
+
+ );
+ })}
+
+ );
+ })}
+
+
+ {/* Actions */}
+
+
+
+
+
+
+ );
+ })}
+
+
+ );
+};
+
+export default AdvancedTrainingDashboard;
diff --git a/src/containers/FlightDirector/SimulatorConfig/config/Stations/AdvancedTrainingConfig.tsx b/src/containers/FlightDirector/SimulatorConfig/config/Stations/AdvancedTrainingConfig.tsx
new file mode 100644
index 000000000..406071d55
--- /dev/null
+++ b/src/containers/FlightDirector/SimulatorConfig/config/Stations/AdvancedTrainingConfig.tsx
@@ -0,0 +1,191 @@
+import React from "react";
+import {Button, Label, CustomInput, Container} from "helpers/reactstrap";
+import {useMutation} from "react-apollo";
+import {useParams, useNavigate} from "react-router-dom";
+import {useStationSetConfigSubscription} from "generated/graphql";
+import {TOGGLE_ADVANCED_TRAINING_MODE} from "components/training/queries";
+import {useAdvancedTrainingConfigEditor} from "./useAdvancedTrainingConfigEditor";
+import AdvancedTrainingEditor from "./AdvancedTrainingEditor";
+
+const AdvancedTrainingConfig: React.FC = () => {
+ const {
+ simulatorId,
+ stationSetId,
+ stationName: encodedStationName,
+ } = useParams();
+ const stationName = decodeURI(encodedStationName || "");
+ const navigate = useNavigate();
+
+ const {data: stationData} = useStationSetConfigSubscription();
+ const stationSets = stationData?.stationSetUpdate?.filter(
+ (s: any) => s?.simulator?.id === simulatorId,
+ );
+ const stationSet = stationSets?.find((s: any) => s?.id === stationSetId);
+ const station = stationSet?.stations?.find(
+ (s: any) => s?.name === stationName,
+ );
+
+ const advancedTraining = (station as any)?.advancedTraining;
+ const enabled = advancedTraining?.enabled ?? false;
+ const sequentialChapters = advancedTraining?.sequentialChapters ?? false;
+ const chapters = advancedTraining?.chapters ?? [];
+ const inFlightChapters = advancedTraining?.inFlightChapters ?? [];
+ const stationCards = station?.cards || [];
+
+ const [toggleMode] = useMutation(TOGGLE_ADVANCED_TRAINING_MODE);
+
+ const editor = useAdvancedTrainingConfigEditor({
+ advancedTraining,
+ chapters,
+ inFlightChapters,
+ sequentialChapters,
+ enabled,
+ stationCards,
+ stationSetId,
+ stationName,
+ });
+
+ const handleToggle = () => {
+ if (!stationSetId || !stationName) {
+ return;
+ }
+ toggleMode({
+ variables: {stationSetID: stationSetId, stationName, enabled: !enabled},
+ });
+ };
+
+ const goBack = () => {
+ navigate(
+ `/config/simulator/${simulatorId}/Stations/${stationSetId}/${encodeURI(
+ stationName,
+ )}`,
+ );
+ };
+
+ if (!station) {
+ return (
+
+ Loading station data...
+
+
+ );
+ }
+
+ return (
+
+
+
+ Advanced Training — {stationName}
+
+
+
+
+ {enabled && (
+
+ {!editor.isEditing ? (
+ <>
+
+ {chapters.length} chapter
+ {chapters.length !== 1 ? "s" : ""} configured
+ {advancedTraining?.loginChapter && (
+
+ + login chapter
+
+ )}
+ {advancedTraining?.completionChapter && (
+
+ + completion chapter
+
+ )}
+ {inFlightChapters.length > 0 && (
+
+ + {inFlightChapters.length} in-flight help chapter
+ {inFlightChapters.length !== 1 ? "s" : ""}
+
+ )}
+
+ {chapters.map((ch: any, idx: number) => (
+
+ {idx + 1}. {ch.name}{" "}
+
+ ({ch.cardComponent}, {ch.subChapters?.length || 0}{" "}
+ sub-tasks)
+
+
+ ))}
+ {inFlightChapters.map((ch: any) => (
+
+ ⚑ {ch.name}{" "}
+
+ ({ch.cardComponent}, {ch.subChapters?.length || 0}{" "}
+ sub-tasks)
+
+
+ ))}
+
+ >
+ ) : (
+
+ )}
+
+ )}
+
+ );
+};
+
+export default AdvancedTrainingConfig;
diff --git a/src/containers/FlightDirector/SimulatorConfig/config/Stations/AdvancedTrainingEditor.tsx b/src/containers/FlightDirector/SimulatorConfig/config/Stations/AdvancedTrainingEditor.tsx
new file mode 100644
index 000000000..9b795238f
--- /dev/null
+++ b/src/containers/FlightDirector/SimulatorConfig/config/Stations/AdvancedTrainingEditor.tsx
@@ -0,0 +1,425 @@
+import React from "react";
+import {
+ Button,
+ Input,
+ Label,
+ FormGroup,
+ Modal,
+ ModalHeader,
+ ModalBody,
+ ModalFooter,
+} from "helpers/reactstrap";
+import FileExplorer from "components/views/TacticalMap/fileExplorer";
+import {ChapterEditor, emptyChapter} from "./ChapterEditor";
+import InFlightChaptersSection from "./InFlightChaptersSection";
+import RecordActionsModal from "./RecordActionsModal";
+import type {useAdvancedTrainingConfigEditor} from "./useAdvancedTrainingConfigEditor";
+
+interface AdvancedTrainingEditorProps {
+ editor: ReturnType;
+ stationCards: any[];
+ sequentialChapters: boolean;
+ simulatorId: string;
+ stationSetId: string;
+ stationName: string;
+}
+
+// The "edit chapters" UI for the Advanced Training config page: training-wide
+// settings, the optional login/completion chapters, the regular chapter list,
+// the in-flight help section, and the media-picker / action-recording modals.
+// All state and handlers come from the editor hook passed in as a prop.
+const AdvancedTrainingEditor: React.FC = ({
+ editor,
+ stationCards,
+ sequentialChapters,
+ simulatorId,
+ stationSetId,
+ stationName,
+}) => {
+ const {
+ displayChapters,
+ editingInFlightChapters,
+ editingSequential,
+ setEditingSequential,
+ editingStripPosition,
+ setEditingStripPosition,
+ editingLogin,
+ setEditingLoginChapter,
+ editingCompletion,
+ setEditingCompletionChapter,
+ expandedChapter,
+ toggleExpand,
+ setExpandedChapter,
+ mediaPickerChapter,
+ setMediaPickerChapter,
+ saveEditing,
+ cancelEditing,
+ addChapter,
+ removeChapter,
+ updateChapter,
+ addInFlightChapter,
+ removeInFlightChapter,
+ updateInFlightChapter,
+ addSubChapter,
+ removeSubChapter,
+ updateSubChapter,
+ recordingSubChapter,
+ recordingChapter,
+ recordingSubChapterData,
+ startRecording,
+ cancelRecording,
+ saveRecording,
+ } = editor;
+
+ return (
+ <>
+
+
+
+ Training bar:
+
+
+
+
+
+ {/* Login chapter */}
+
+
+ {editingLogin && (
+ <>
+
+
+ Station Login
+
+ {(["none", "immediate", "on-complete"] as const).map(opt => (
+
+ ))}
+
+ toggleExpand(editingLogin.id)}
+ onUpdate={updates =>
+ setEditingLoginChapter((prev: any) => ({...prev, ...updates}))
+ }
+ onAddSubChapter={() => addSubChapter(editingLogin.id, "login")}
+ onRemoveSubChapter={subId =>
+ removeSubChapter(editingLogin.id, subId, "login")
+ }
+ onUpdateSubChapter={(subId, updates) =>
+ updateSubChapter(editingLogin.id, subId, updates, "login")
+ }
+ onStartRecording={subId => startRecording(editingLogin.id, subId)}
+ onSetMediaPicker={() =>
+ setMediaPickerChapter(`login:${editingLogin.id}`)
+ }
+ showCardSelector={false}
+ isLoginChapter
+ />
+ >
+ )}
+
+
+ {/* Regular chapters */}
+ {displayChapters?.map((chapter: any, chIdx: number) => (
+ toggleExpand(chapter.id)}
+ onUpdate={updates => updateChapter(chapter.id, updates)}
+ onRemove={() => removeChapter(chapter.id)}
+ onAddSubChapter={() => addSubChapter(chapter.id)}
+ onRemoveSubChapter={subId => removeSubChapter(chapter.id, subId)}
+ onUpdateSubChapter={(subId, updates) =>
+ updateSubChapter(chapter.id, subId, updates)
+ }
+ onStartRecording={subId => startRecording(chapter.id, subId)}
+ onSetMediaPicker={() => setMediaPickerChapter(chapter.id)}
+ />
+ ))}
+
+ {/* In-flight help chapters */}
+ addSubChapter(chapterId, "inflight")}
+ onRemoveSubChapter={(chapterId, subId) =>
+ removeSubChapter(chapterId, subId, "inflight")
+ }
+ onUpdateSubChapter={(chapterId, subId, updates) =>
+ updateSubChapter(chapterId, subId, updates, "inflight")
+ }
+ onStartRecording={(chapterId, subId) =>
+ startRecording(chapterId, subId)
+ }
+ onSetMediaPicker={chapterId =>
+ setMediaPickerChapter(`inflight:${chapterId}`)
+ }
+ />
+
+ {/* Completion chapter */}
+
+
+ {editingCompletion && (
+ toggleExpand(editingCompletion.id)}
+ onUpdate={updates =>
+ setEditingCompletionChapter((prev: any) => ({
+ ...prev,
+ ...updates,
+ }))
+ }
+ onAddSubChapter={() =>
+ addSubChapter(editingCompletion.id, "completion")
+ }
+ onRemoveSubChapter={subId =>
+ removeSubChapter(editingCompletion.id, subId, "completion")
+ }
+ onUpdateSubChapter={(subId, updates) =>
+ updateSubChapter(
+ editingCompletion.id,
+ subId,
+ updates,
+ "completion",
+ )
+ }
+ onStartRecording={subId =>
+ startRecording(editingCompletion.id, subId)
+ }
+ onSetMediaPicker={() =>
+ setMediaPickerChapter(`completion:${editingCompletion.id}`)
+ }
+ showCardSelector={false}
+ />
+ )}
+
+
+
+
+
+
+
+
+ {/* Media picker modal */}
+ setMediaPickerChapter(null)}
+ >
+ setMediaPickerChapter(null)}>
+ Select Training Media
+
+
+ {
+ if (mediaPickerChapter) {
+ const isLogin = mediaPickerChapter.startsWith("login:");
+ const isCompletion =
+ mediaPickerChapter.startsWith("completion:");
+ const isInFlight = mediaPickerChapter.startsWith("inflight:");
+ if (isLogin) {
+ setEditingLoginChapter((prev: any) => ({
+ ...prev,
+ mediaAsset: container.fullPath,
+ }));
+ } else if (isCompletion) {
+ setEditingCompletionChapter((prev: any) => ({
+ ...prev,
+ mediaAsset: container.fullPath,
+ }));
+ } else if (isInFlight) {
+ updateInFlightChapter(
+ mediaPickerChapter.slice("inflight:".length),
+ {mediaAsset: container.fullPath},
+ );
+ } else {
+ updateChapter(mediaPickerChapter, {
+ mediaAsset: container.fullPath,
+ });
+ }
+ }
+ setMediaPickerChapter(null);
+ }}
+ />
+
+
+
+
+
+
+ {/* Record actions modal */}
+
+ >
+ );
+};
+
+export default AdvancedTrainingEditor;
diff --git a/src/containers/FlightDirector/SimulatorConfig/config/Stations/CardPreviewErrorBoundary.tsx b/src/containers/FlightDirector/SimulatorConfig/config/Stations/CardPreviewErrorBoundary.tsx
new file mode 100644
index 000000000..ded1f64c2
--- /dev/null
+++ b/src/containers/FlightDirector/SimulatorConfig/config/Stations/CardPreviewErrorBoundary.tsx
@@ -0,0 +1,46 @@
+import React from "react";
+
+// Error boundary around the live card preview in the Record Actions modal.
+// A card component can throw when rendered outside a real flight; this shows the
+// error instead of crashing the modal, and reminds the FD they can still pick
+// actions from the list.
+class CardPreviewErrorBoundary extends React.Component<
+ {children: React.ReactNode; cardName: string},
+ {error: Error | null}
+> {
+ state = {error: null as Error | null};
+
+ static getDerivedStateFromError(error: Error) {
+ return {error};
+ }
+
+ render() {
+ if (this.state.error) {
+ return (
+
+ Unable to render card preview for "{this.props.cardName}".
+
+ {this.state.error.message}
+
+
+ You can still select actions from the list on the right.
+
+
+ );
+ }
+ return this.props.children;
+ }
+}
+
+export default CardPreviewErrorBoundary;
diff --git a/src/containers/FlightDirector/SimulatorConfig/config/Stations/ChapterEditor.tsx b/src/containers/FlightDirector/SimulatorConfig/config/Stations/ChapterEditor.tsx
new file mode 100644
index 000000000..023e68968
--- /dev/null
+++ b/src/containers/FlightDirector/SimulatorConfig/config/Stations/ChapterEditor.tsx
@@ -0,0 +1,443 @@
+import React from "react";
+import {
+ Button,
+ Input,
+ Label,
+ FormGroup,
+ Card,
+ CardBody,
+ CardHeader,
+ Collapse,
+} from "helpers/reactstrap";
+import {
+ getActionLabel,
+ VIDEO_COMPLETE_EVENT,
+ LOGIN_EVENT,
+} from "components/training/actionRegistry";
+
+// 3x3 grid of anchor points for positioning a chapter's media overlay.
+const MEDIA_POSITIONS = [
+ "top-left",
+ "top-center",
+ "top-right",
+ "middle-left",
+ "middle-center",
+ "middle-right",
+ "bottom-left",
+ "bottom-center",
+ "bottom-right",
+];
+
+// A blank chapter, used when the FD adds a new chapter of any kind.
+export function emptyChapter(id: string, name: string) {
+ return {
+ id,
+ name,
+ cardComponent: "",
+ mediaAsset: null,
+ autoOpenMedia: false,
+ autoAdvance: false,
+ autoLogin: "none",
+ cardSwitchBehavior: "manual",
+ mediaSize: "small",
+ mediaPosition: "bottom-right",
+ subChapters: [],
+ };
+}
+
+// Normalize a chapter (and its nested sub-chapters/actions) into the exact shape
+// the server mutation expects, dropping any extra client-only fields.
+export function serializeChapter(ch: any) {
+ return {
+ id: ch.id,
+ name: ch.name,
+ cardComponent: ch.cardComponent || "",
+ mediaAsset: ch.mediaAsset || null,
+ autoOpenMedia: ch.autoOpenMedia ?? false,
+ autoAdvance: ch.autoAdvance ?? false,
+ autoLogin: ch.autoLogin ?? "none",
+ cardSwitchBehavior: ch.cardSwitchBehavior || "manual",
+ mediaSize: ch.mediaSize || "small",
+ mediaPosition: ch.mediaPosition || "bottom-right",
+ subChapters: (ch.subChapters || []).map((sc: any) => ({
+ id: sc.id,
+ name: sc.name,
+ requiredActions: (sc.requiredActions || []).map((ra: any) => ({
+ id: ra.id,
+ eventName: ra.eventName,
+ args: ra.args || null,
+ })),
+ })),
+ };
+}
+
+interface SyntheticActionToggleProps {
+ sub: any;
+ eventName: string;
+ idPrefix: string;
+ label: string;
+ onUpdateSubChapter: (subId: string, updates: any) => void;
+}
+
+// Checkbox that adds/removes a single synthetic required action (e.g. "media
+// finished" or "logged in") on a sub-chapter. These actions have no card UI to
+// click, so the FD toggles them directly.
+const SyntheticActionToggle: React.FC = ({
+ sub,
+ eventName,
+ idPrefix,
+ label,
+ onUpdateSubChapter,
+}) => {
+ const current = sub.requiredActions || [];
+ const checked = current.some((ra: any) => ra.eventName === eventName);
+ return (
+
+
+
+ );
+};
+
+interface ChapterEditorProps {
+ chapter: any;
+ index: number;
+ label?: string;
+ stationCards: any[];
+ // When provided, the Card selector uses this explicit list instead of the
+ // station's cards (e.g. in-flight chapters that can target any card component,
+ // including ones not on this station). Each entry is {value, label}.
+ cardOptions?: {value: string; label: string}[];
+ isExpanded: boolean;
+ onToggleExpand: () => void;
+ onUpdate: (updates: any) => void;
+ onRemove?: () => void;
+ onAddSubChapter: () => void;
+ onRemoveSubChapter: (subId: string) => void;
+ onUpdateSubChapter: (subId: string, updates: any) => void;
+ onStartRecording: (subId: string) => void;
+ onSetMediaPicker: () => void;
+ showCardSelector?: boolean;
+ isLoginChapter?: boolean;
+}
+
+export const ChapterEditor: React.FC = ({
+ chapter,
+ index,
+ label,
+ stationCards,
+ cardOptions,
+ isExpanded,
+ onToggleExpand,
+ onUpdate,
+ onRemove,
+ onAddSubChapter,
+ onRemoveSubChapter,
+ onUpdateSubChapter,
+ onStartRecording,
+ onSetMediaPicker,
+ showCardSelector = true,
+ isLoginChapter = false,
+}) => {
+ const cardSelectOptions =
+ cardOptions ||
+ stationCards.map((c: any) => ({
+ value: c.component,
+ label: `${c.name} (${c.component})`,
+ }));
+ return (
+
+
+
+ {label || `${index + 1}. ${chapter.name}`}
+ {showCardSelector && (
+
+ {chapter.cardComponent}
+
+ )}
+
+ {onRemove && (
+
+ )}
+
+
+
+
+
+ onUpdate({name: e.target.value})}
+ />
+
+ {showCardSelector && (
+
+
+ onUpdate({cardComponent: e.target.value})}
+ >
+
+ {cardSelectOptions.map((c: {value: string; label: string}) => (
+
+ ))}
+
+
+ )}
+
+
+
+
+ {chapter.mediaAsset && (
+
+ )}
+
+
+ {chapter.mediaAsset && (
+
+
+
+ onUpdate({mediaSize: e.target.value})}
+ >
+
+
+
+
+
+
+
+
+ {MEDIA_POSITIONS.map(pos => (
+
+
+ {chapter.mediaPosition || "bottom-right"}
+
+
+
+ )}
+
+
+
+ {showCardSelector && (
+
+ )}
+
+
+ {/* Sub-chapters */}
+
+
+ {(chapter.subChapters || []).map((sub: any, sIdx: number) => (
+
+
+
+ {index + 1}.{sIdx + 1}
+
+
+ onUpdateSubChapter(sub.id, {name: e.target.value})
+ }
+ style={{flex: 1}}
+ />
+
+
+
+ {chapter.mediaAsset && (
+
+ )}
+ {isLoginChapter && (
+
+ )}
+ {sub.requiredActions?.length > 0 && (
+
+ Required:{" "}
+ {sub.requiredActions
+ .map((ra: any) =>
+ getActionLabel(ra.eventName, chapter.cardComponent),
+ )
+ .join(", ")}
+
+ )}
+
+ ))}
+
+
+
+
+
+ );
+};
diff --git a/src/containers/FlightDirector/SimulatorConfig/config/Stations/InFlightChaptersSection.tsx b/src/containers/FlightDirector/SimulatorConfig/config/Stations/InFlightChaptersSection.tsx
new file mode 100644
index 000000000..fc06dbf91
--- /dev/null
+++ b/src/containers/FlightDirector/SimulatorConfig/config/Stations/InFlightChaptersSection.tsx
@@ -0,0 +1,116 @@
+import React from "react";
+import {Button} from "helpers/reactstrap";
+import Views from "components/views/index";
+import {ChapterEditor} from "./ChapterEditor";
+
+/**
+ * In-flight help chapters are tied to a card component and reached mid-flight
+ * via the question-mark help widget (rather than the normal sequential flow).
+ * Unlike regular chapters, they may target ANY card component — including cards
+ * not currently on this station — so an FD can pre-author help for a card or
+ * system added later in the flight. The card picker is therefore built from the
+ * full set of known card views, not just the station's cards.
+ */
+
+interface InFlightChaptersSectionProps {
+ chapters: any[];
+ stationCards: any[];
+ expandedChapter: string | null;
+ onToggleExpand: (chapterId: string) => void;
+ onAdd: () => void;
+ onRemove: (chapterId: string) => void;
+ onUpdate: (chapterId: string, updates: any) => void;
+ onAddSubChapter: (chapterId: string) => void;
+ onRemoveSubChapter: (chapterId: string, subId: string) => void;
+ onUpdateSubChapter: (chapterId: string, subId: string, updates: any) => void;
+ onStartRecording: (chapterId: string, subId: string) => void;
+ onSetMediaPicker: (chapterId: string) => void;
+}
+
+// Mirrors CardsTable's view list: every known card component, minus the ones
+// that aren't selectable as a station card.
+function buildCardOptions(
+ stationCards: any[],
+): {value: string; label: string}[] {
+ const onStation = new Set(
+ (stationCards || []).map((c: any) => c.component).filter(Boolean),
+ );
+ return Object.keys(Views)
+ .filter(v => v !== "Offline" && v !== "Login" && v !== "Viewscreen")
+ .sort()
+ .map(component => ({
+ value: component,
+ // ✅ marks components already present on this station (same cue as the
+ // Add-Card picker), while still allowing off-station components.
+ label: `${onStation.has(component) ? "✅ " : ""}${component}`,
+ }));
+}
+
+const InFlightChaptersSection: React.FC = ({
+ chapters,
+ stationCards,
+ expandedChapter,
+ onToggleExpand,
+ onAdd,
+ onRemove,
+ onUpdate,
+ onAddSubChapter,
+ onRemoveSubChapter,
+ onUpdateSubChapter,
+ onStartRecording,
+ onSetMediaPicker,
+}) => {
+ const cardOptions = React.useMemo(
+ () => buildCardOptions(stationCards),
+ [stationCards],
+ );
+
+ return (
+
+
+ In-Flight Help Chapters
+
+
+ Reached mid-flight by pressing the help (question-mark) widget while on
+ the chapter's card. Excluded from the normal sequence and from the
+ overall progress bar. Can target any card, including ones not on this
+ station.
+
+
+ {chapters.map((chapter: any, idx: number) => (
+ onToggleExpand(chapter.id)}
+ onUpdate={updates => onUpdate(chapter.id, updates)}
+ onRemove={() => onRemove(chapter.id)}
+ onAddSubChapter={() => onAddSubChapter(chapter.id)}
+ onRemoveSubChapter={subId => onRemoveSubChapter(chapter.id, subId)}
+ onUpdateSubChapter={(subId, updates) =>
+ onUpdateSubChapter(chapter.id, subId, updates)
+ }
+ onStartRecording={subId => onStartRecording(chapter.id, subId)}
+ onSetMediaPicker={() => onSetMediaPicker(chapter.id)}
+ />
+ ))}
+
+
+
+ );
+};
+
+export default InFlightChaptersSection;
diff --git a/src/containers/FlightDirector/SimulatorConfig/config/Stations/RecordActionsModal.tsx b/src/containers/FlightDirector/SimulatorConfig/config/Stations/RecordActionsModal.tsx
new file mode 100644
index 000000000..9c28d45ab
--- /dev/null
+++ b/src/containers/FlightDirector/SimulatorConfig/config/Stations/RecordActionsModal.tsx
@@ -0,0 +1,295 @@
+import React, {useCallback, Suspense} from "react";
+import {
+ Modal,
+ ModalHeader,
+ ModalBody,
+ ModalFooter,
+ Button,
+ Label,
+} from "helpers/reactstrap";
+import Views from "components/views";
+import {
+ getActionsForCard,
+ getGlobalActions,
+ getActionLabel,
+} from "components/training/actionRegistry";
+import CardPreviewErrorBoundary from "./CardPreviewErrorBoundary";
+import {useSandboxFlight} from "./useSandboxFlight";
+import {useActionRecorder} from "./useActionRecorder";
+
+interface RecordActionsModalProps {
+ isOpen: boolean;
+ chapter: any;
+ existingActions: any[];
+ simulatorId: string;
+ stationSetId: string;
+ stationName: string;
+ onSave: (actions: any[]) => void;
+ onCancel: () => void;
+}
+
+// Shared style for the centered status messages inside the preview pane.
+const previewMessageStyle: React.CSSProperties = {
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "center",
+ height: "100%",
+ color: "#888",
+};
+
+const RecordActionsModal: React.FC = ({
+ isOpen,
+ chapter,
+ existingActions,
+ simulatorId,
+ stationSetId,
+ stationName,
+ onSave,
+ onCancel,
+}) => {
+ const {
+ sandboxFlightId,
+ sandboxSimulatorId,
+ sandboxReady,
+ simulator,
+ cleanupSandbox,
+ } = useSandboxFlight({isOpen, simulatorId, stationSetId});
+
+ const {recordedActions, lastCaptured, captureClick, addAction, removeAction} =
+ useActionRecorder({isOpen, existingActions});
+
+ const handleSave = useCallback(() => {
+ cleanupSandbox();
+ onSave(recordedActions);
+ }, [cleanupSandbox, onSave, recordedActions]);
+
+ const handleCancel = useCallback(() => {
+ cleanupSandbox();
+ onCancel();
+ }, [cleanupSandbox, onCancel]);
+
+ const cardComponentName = chapter?.cardComponent;
+ const availableActions = [
+ ...getGlobalActions(),
+ ...(cardComponentName ? getActionsForCard(cardComponentName) : []),
+ ];
+
+ const CardComponent = cardComponentName
+ ? (Views as any)[cardComponentName]
+ : null;
+
+ // Build preview props using the sandbox simulator
+ const effectiveSimId = sandboxSimulatorId || simulatorId;
+ const previewProps = {
+ simulator: simulator || {
+ id: effectiveSimId,
+ name: "Preview",
+ alertlevel: "5",
+ },
+ station: {name: stationName, cards: []},
+ flight: {id: sandboxFlightId || "preview"},
+ clientObj: {
+ id: "preview-client",
+ simulatorId: effectiveSimId,
+ station: stationName,
+ training: false,
+ offlineState: null,
+ },
+ cardName: cardComponentName,
+ changeCard: () => {},
+ };
+
+ return (
+
+
+
+ ● REC
+
+ Record Required Actions
+ {chapter && (
+
+ Card: {cardComponentName}
+
+ )}
+
+
+
+ {/* Left: Card Preview — interactive for recording */}
+ {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */}
+
+ {/* Recording flash overlay */}
+ {lastCaptured && (
+
+ Captured: {getActionLabel(lastCaptured, cardComponentName)}
+
+ )}
+
+ {!sandboxReady || !simulator ? (
+
+ Initializing sandbox environment...
+
+ ) : CardComponent ? (
+ Loading card preview...
+ }
+ >
+
+
+
+
+
+
+ ) : (
+
+ No card component selected for this chapter.
+
+ )}
+
+
+ {/* Right: Action Picker */}
+
+
+ Interact with the card on the left to automatically record
+ actions, or manually select them below.
+
+
+
+
+
+ {availableActions.length === 0 && (
+
+ No actions registered for this card component.
+
+ )}
+ {availableActions.map(action => {
+ const existing = recordedActions.find(
+ (ra: any) => ra.eventName === action.eventName,
+ );
+ const isSelected = !!existing;
+ return (
+
+ );
+ })}
+
+
+
+
+
+ {recordedActions.length === 0 && (
+
+ No actions recorded yet. Click buttons on the card or select
+ actions above.
+
+ )}
+ {recordedActions.map((action: any) => (
+
+
+ {getActionLabel(action.eventName, cardComponentName)}
+
+
+
+ ))}
+
+
+