feat: The Big One UI #47
167
src/pages/SimpleProgram/SimpleProgram.module.css
Normal file
167
src/pages/SimpleProgram/SimpleProgram.module.css
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
/* ---------- Layout ---------- */
|
||||||
|
|
||||||
|
.container {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: #1e1e1e;
|
||||||
|
color: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: clamp(0.75rem, 2vw, 1.25rem);
|
||||||
|
background: #2a2a2a;
|
||||||
|
border-bottom: 1px solid #3a3a3a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header h2 {
|
||||||
|
font-size: clamp(1rem, 2.2vw, 1.4rem);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls button {
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
padding: 0.4rem 0.9rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: none;
|
||||||
|
background: #111;
|
||||||
|
color: white;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls button:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Content ---------- */
|
||||||
|
|
||||||
|
.content {
|
||||||
|
flex: 1;
|
||||||
|
padding: 2%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Grid ---------- */
|
||||||
|
|
||||||
|
.phaseGrid {
|
||||||
|
height: 100%;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
grid-template-rows: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 2%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Box ---------- */
|
||||||
|
|
||||||
|
.box {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: #ffffff;
|
||||||
|
color: #1e1e1e;
|
||||||
|
border-radius: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.boxHeader {
|
||||||
|
padding: 0.6rem 0.9rem;
|
||||||
|
background: linear-gradient(135deg, #dcdcdc, #e9e9e9);
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: clamp(0.9rem, 1.5vw, 1.05rem);
|
||||||
|
border-bottom: 1px solid #cfcfcf;
|
||||||
|
}
|
||||||
|
|
||||||
|
.boxContent {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0.8rem 1rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Lists ---------- */
|
||||||
|
|
||||||
|
.iconList {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iconList li {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-size: clamp(0.85rem, 1.3vw, 1rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulletList {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulletList li {
|
||||||
|
margin-bottom: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Icons ---------- */
|
||||||
|
|
||||||
|
.successIcon,
|
||||||
|
.failIcon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 1.5rem;
|
||||||
|
height: 1.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-weight: bold;
|
||||||
|
color: white;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.successIcon {
|
||||||
|
background: #3cb371;
|
||||||
|
}
|
||||||
|
|
||||||
|
.failIcon {
|
||||||
|
background: #e5533d;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Empty ---------- */
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
opacity: 0.55;
|
||||||
|
font-style: italic;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Responsive ---------- */
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.phaseGrid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
grid-template-rows: repeat(4, minmax(0, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.leftControls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backButton {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid #555;
|
||||||
|
color: #ddd;
|
||||||
|
padding: 0.35rem 0.75rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backButton:hover {
|
||||||
|
background: #333;
|
||||||
|
}
|
||||||
192
src/pages/SimpleProgram/SimpleProgram.tsx
Normal file
192
src/pages/SimpleProgram/SimpleProgram.tsx
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
import React from "react";
|
||||||
|
import styles from "./SimpleProgram.module.css";
|
||||||
|
import useProgramStore from "../../utils/programStore.ts";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generic container box with a header and content area.
|
||||||
|
*/
|
||||||
|
type BoxProps = {
|
||||||
|
title: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
const Box: React.FC<BoxProps> = ({ title, children }) => (
|
||||||
|
<div className={styles.box}>
|
||||||
|
<div className={styles.boxHeader}>{title}</div>
|
||||||
|
<div className={styles.boxContent}>{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders a list of goals for a phase.
|
||||||
|
* Expects goal-like objects from the program store.
|
||||||
|
*/
|
||||||
|
const GoalList: React.FC<{ goals: unknown[] }> = ({ goals }) => {
|
||||||
|
if (!goals.length) {
|
||||||
|
return <p className={styles.empty}>No goals defined.</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ul className={styles.iconList}>
|
||||||
|
{goals.map((g, idx) => {
|
||||||
|
const goal = g as {
|
||||||
|
id?: string;
|
||||||
|
description?: string;
|
||||||
|
achieved?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li key={goal.id ?? idx}>
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
goal.achieved ? styles.successIcon : styles.failIcon
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{goal.achieved ? "✔" : "✖"}
|
||||||
|
</span>
|
||||||
|
{goal.description ?? "Unnamed goal"}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders a list of triggers for a phase.
|
||||||
|
*/
|
||||||
|
const TriggerList: React.FC<{ triggers: unknown[] }> = ({ triggers }) => {
|
||||||
|
if (!triggers.length) {
|
||||||
|
return <p className={styles.empty}>No triggers defined.</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ul className={styles.iconList}>
|
||||||
|
{triggers.map((t, idx) => {
|
||||||
|
const trigger = t as {
|
||||||
|
id?: string;
|
||||||
|
label?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li key={trigger.id ?? idx}>
|
||||||
|
<span className={styles.failIcon}>✖</span>
|
||||||
|
{trigger.label ?? "Unnamed trigger"}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders a list of norms for a phase.
|
||||||
|
*/
|
||||||
|
const NormList: React.FC<{ norms: unknown[] }> = ({ norms }) => {
|
||||||
|
if (!norms.length) {
|
||||||
|
return <p className={styles.empty}>No norms defined.</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ul className={styles.bulletList}>
|
||||||
|
{norms.map((n, idx) => {
|
||||||
|
const norm = n as {
|
||||||
|
id?: string;
|
||||||
|
norm?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
return <li key={norm.id ?? idx}>{norm.norm ?? "Unnamed norm"}</li>;
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Displays all phase-related information in a grid layout.
|
||||||
|
*/
|
||||||
|
type PhaseGridProps = {
|
||||||
|
norms: unknown[];
|
||||||
|
goals: unknown[];
|
||||||
|
triggers: unknown[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const PhaseGrid: React.FC<PhaseGridProps> = ({
|
||||||
|
norms,
|
||||||
|
goals,
|
||||||
|
triggers,
|
||||||
|
}) => (
|
||||||
|
<div className={styles.phaseGrid}>
|
||||||
|
<Box title="Norms">
|
||||||
|
<NormList norms={norms} />
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box title="Triggers">
|
||||||
|
<TriggerList triggers={triggers} />
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box title="Goals">
|
||||||
|
<GoalList goals={goals} />
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box title="Conditional Norms">
|
||||||
|
<p className={styles.empty}>No conditional norms defined.</p>
|
||||||
|
</Box>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main program viewer.
|
||||||
|
* Reads all data from the program store and allows
|
||||||
|
* navigating between phases.
|
||||||
|
*/
|
||||||
|
const SimpleProgram: React.FC = () => {
|
||||||
|
const getPhaseIds = useProgramStore((s) => s.getPhaseIds);
|
||||||
|
const getNormsInPhase = useProgramStore((s) => s.getNormsInPhase);
|
||||||
|
const getGoalsInPhase = useProgramStore((s) => s.getGoalsInPhase);
|
||||||
|
const getTriggersInPhase = useProgramStore((s) => s.getTriggersInPhase);
|
||||||
|
|
||||||
|
const phaseIds = getPhaseIds();
|
||||||
|
const [phaseIndex, setPhaseIndex] = React.useState(0);
|
||||||
|
|
||||||
|
if (phaseIds.length === 0) {
|
||||||
|
return <p className={styles.empty}>No program loaded.</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const phaseId = phaseIds[phaseIndex];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.container}>
|
||||||
|
<header className={styles.header}>
|
||||||
|
<h2>
|
||||||
|
Phase {phaseIndex + 1} / {phaseIds.length}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className={styles.controls}>
|
||||||
|
<button
|
||||||
|
disabled={phaseIndex === 0}
|
||||||
|
onClick={() => setPhaseIndex((i) => i - 1)}
|
||||||
|
>
|
||||||
|
◀ Prev
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
disabled={phaseIndex === phaseIds.length - 1}
|
||||||
|
onClick={() => setPhaseIndex((i) => i + 1)}
|
||||||
|
>
|
||||||
|
Next ▶
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className={styles.content}>
|
||||||
|
<PhaseGrid
|
||||||
|
norms={getNormsInPhase(phaseId)}
|
||||||
|
goals={getGoalsInPhase(phaseId)}
|
||||||
|
triggers={getTriggersInPhase(phaseId)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SimpleProgram;
|
||||||
@@ -7,14 +7,16 @@ import {
|
|||||||
MarkerType,
|
MarkerType,
|
||||||
} from '@xyflow/react';
|
} from '@xyflow/react';
|
||||||
import '@xyflow/react/dist/style.css';
|
import '@xyflow/react/dist/style.css';
|
||||||
import {useEffect} from "react";
|
import {useEffect, useState} from "react";
|
||||||
import {useShallow} from 'zustand/react/shallow';
|
import {useShallow} from 'zustand/react/shallow';
|
||||||
|
import useProgramStore from "../../utils/programStore.ts";
|
||||||
import {DndToolbar} from './visualProgrammingUI/components/DragDropSidebar.tsx';
|
import {DndToolbar} from './visualProgrammingUI/components/DragDropSidebar.tsx';
|
||||||
import useFlowStore from './visualProgrammingUI/VisProgStores.tsx';
|
import useFlowStore from './visualProgrammingUI/VisProgStores.tsx';
|
||||||
import type {FlowState} from './visualProgrammingUI/VisProgTypes.tsx';
|
import type {FlowState} from './visualProgrammingUI/VisProgTypes.tsx';
|
||||||
import styles from './VisProg.module.css'
|
import styles from './VisProg.module.css'
|
||||||
import { NodeReduces, NodeTypes } from './visualProgrammingUI/NodeRegistry.ts';
|
import { NodeReduces, NodeTypes } from './visualProgrammingUI/NodeRegistry.ts';
|
||||||
import SaveLoadPanel from './visualProgrammingUI/components/SaveLoadPanel.tsx';
|
import SaveLoadPanel from './visualProgrammingUI/components/SaveLoadPanel.tsx';
|
||||||
|
import SimpleProgram from "../SimpleProgram/SimpleProgram.tsx";
|
||||||
|
|
||||||
// --| config starting params for flow |--
|
// --| config starting params for flow |--
|
||||||
|
|
||||||
@@ -138,7 +140,7 @@ function VisualProgrammingUI() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// currently outputs the prepared program to the console
|
// currently outputs the prepared program to the console
|
||||||
function runProgram() {
|
function runProgramm() {
|
||||||
const phases = graphReducer();
|
const phases = graphReducer();
|
||||||
const program = {phases}
|
const program = {phases}
|
||||||
console.log(JSON.stringify(program, null, 2));
|
console.log(JSON.stringify(program, null, 2));
|
||||||
@@ -152,6 +154,10 @@ function runProgram() {
|
|||||||
).then((res) => {
|
).then((res) => {
|
||||||
if (!res.ok) throw new Error("Failed communicating with the backend.")
|
if (!res.ok) throw new Error("Failed communicating with the backend.")
|
||||||
console.log("Successfully sent the program to the backend.");
|
console.log("Successfully sent the program to the backend.");
|
||||||
|
|
||||||
|
// store reduced program in global program store for further use in the UI
|
||||||
|
// when the program was sent to the backend successfully:
|
||||||
|
useProgramStore.getState().setProgramState(structuredClone(program));
|
||||||
}).catch(() => console.log("Failed to send program to the backend."));
|
}).catch(() => console.log("Failed to send program to the backend."));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -174,6 +180,27 @@ function graphReducer() {
|
|||||||
* @constructor
|
* @constructor
|
||||||
*/
|
*/
|
||||||
function VisProgPage() {
|
function VisProgPage() {
|
||||||
|
const [showSimpleProgram, setShowSimpleProgram] = useState(false);
|
||||||
|
const setProgramState = useProgramStore((state) => state.setProgramState);
|
||||||
|
|
||||||
|
const runProgram = () => {
|
||||||
|
const phases = graphReducer(); // reduce graph
|
||||||
|
setProgramState({ phases }); // <-- save to store
|
||||||
|
setShowSimpleProgram(true); // show SimpleProgram
|
||||||
|
runProgramm(); // send to backend if needed
|
||||||
|
};
|
||||||
|
|
||||||
|
if (showSimpleProgram) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<button onClick={() => setShowSimpleProgram(false)}>
|
||||||
|
Back to Editor ◀
|
||||||
|
</button>
|
||||||
|
<SimpleProgram/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<VisualProgrammingUI/>
|
<VisualProgrammingUI/>
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ const initialNodes : Node[] = [
|
|||||||
createNode('start', 'start', {x: 100, y: 100}, {label: "Start"}, false),
|
createNode('start', 'start', {x: 100, y: 100}, {label: "Start"}, false),
|
||||||
createNode('end', 'end', {x: 500, y: 100}, {label: "End"}, false),
|
createNode('end', 'end', {x: 500, y: 100}, {label: "End"}, false),
|
||||||
createNode('phase-1', 'phase', {x:200, y:100}, {label: "Phase 1", children : []}),
|
createNode('phase-1', 'phase', {x:200, y:100}, {label: "Phase 1", children : []}),
|
||||||
createNode('norms-1', 'norm', {x:-200, y:100}, {label: "Initial Norms", normList: ["Be a robot", "get good"]}),
|
createNode('norms-1', 'norm', {x:-200, y:100}, {label: "Initial Norms", normList: ["Be a robot", "get good"], critical:false}),
|
||||||
];
|
];
|
||||||
|
|
||||||
// * Initial edges * /
|
// * Initial edges * /
|
||||||
|
|||||||
@@ -8,4 +8,5 @@ export const NormNodeDefaults: NormNodeData = {
|
|||||||
droppable: true,
|
droppable: true,
|
||||||
norm: "",
|
norm: "",
|
||||||
hasReduce: true,
|
hasReduce: true,
|
||||||
|
critical: false,
|
||||||
};
|
};
|
||||||
@@ -21,6 +21,7 @@ export type NormNodeData = {
|
|||||||
droppable: boolean;
|
droppable: boolean;
|
||||||
norm: string;
|
norm: string;
|
||||||
hasReduce: boolean;
|
hasReduce: boolean;
|
||||||
|
critical: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type NormNode = Node<NormNodeData>
|
export type NormNode = Node<NormNodeData>
|
||||||
@@ -35,11 +36,16 @@ export default function NormNode(props: NodeProps<NormNode>) {
|
|||||||
const {updateNodeData} = useFlowStore();
|
const {updateNodeData} = useFlowStore();
|
||||||
|
|
||||||
const text_input_id = `norm_${props.id}_text_input`;
|
const text_input_id = `norm_${props.id}_text_input`;
|
||||||
|
const checkbox_id = `goal_${props.id}_checkbox`;
|
||||||
|
|
||||||
const setValue = (value: string) => {
|
const setValue = (value: string) => {
|
||||||
updateNodeData(props.id, {norm: value});
|
updateNodeData(props.id, {norm: value});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const setCritical = (value: boolean) => {
|
||||||
|
updateNodeData(props.id, {...data, critical: value});
|
||||||
|
}
|
||||||
|
|
||||||
return <>
|
return <>
|
||||||
<Toolbar nodeId={props.id} allowDelete={true}/>
|
<Toolbar nodeId={props.id} allowDelete={true}/>
|
||||||
<div className={`${styles.defaultNode} ${styles.nodeNorm}`}>
|
<div className={`${styles.defaultNode} ${styles.nodeNorm}`}>
|
||||||
@@ -52,6 +58,15 @@ export default function NormNode(props: NodeProps<NormNode>) {
|
|||||||
placeholder={"Pepper should ..."}
|
placeholder={"Pepper should ..."}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className={"flex-row gap-md align-center"}>
|
||||||
|
<label htmlFor={checkbox_id}>Critical:</label>
|
||||||
|
<input
|
||||||
|
id={checkbox_id}
|
||||||
|
type={"checkbox"}
|
||||||
|
checked={data.critical || false}
|
||||||
|
onChange={(e) => setCritical(e.target.checked)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<Handle type="source" position={Position.Right} id="norms"/>
|
<Handle type="source" position={Position.Right} id="norms"/>
|
||||||
</div>
|
</div>
|
||||||
</>;
|
</>;
|
||||||
@@ -69,6 +84,7 @@ export function NormReduce(node: Node, _nodes: Node[]) {
|
|||||||
id: node.id,
|
id: node.id,
|
||||||
label: data.label,
|
label: data.label,
|
||||||
norm: data.norm,
|
norm: data.norm,
|
||||||
|
critical: data.critical,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
81
src/utils/programStore.ts
Normal file
81
src/utils/programStore.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import {create} from "zustand";
|
||||||
|
|
||||||
|
// the type of a reduced program
|
||||||
|
export type ReducedProgram = { phases: Record<string, unknown>[] };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* the type definition of the programStore
|
||||||
|
*/
|
||||||
|
export type ProgramState = {
|
||||||
|
// Basic store functionality:
|
||||||
|
currentProgram: ReducedProgram;
|
||||||
|
setProgramState: (state: ReducedProgram) => void;
|
||||||
|
getProgramState: () => ReducedProgram;
|
||||||
|
|
||||||
|
// Utility functions:
|
||||||
|
// to avoid having to manually go through the entire state for every instance where data is required
|
||||||
|
getPhaseIds: () => string[];
|
||||||
|
getNormsInPhase: (currentPhaseId: string) => Record<string, unknown>[];
|
||||||
|
getGoalsInPhase: (currentPhaseId: string) => Record<string, unknown>[];
|
||||||
|
getTriggersInPhase: (currentPhaseId: string) => Record<string, unknown>[];
|
||||||
|
// if more specific utility functions are needed they can be added here:
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* the ProgramStore can be used to access all information of the most recently sent program,
|
||||||
|
* it contains basic functions to set and get the current program.
|
||||||
|
* And it contains some utility functions that allow you to easily gain access
|
||||||
|
* to the norms, triggers and goals of a specific phase.
|
||||||
|
*/
|
||||||
|
const useProgramStore = create<ProgramState>((set, get) => ({
|
||||||
|
currentProgram: { phases: [] as Record<string, unknown>[]},
|
||||||
|
/**
|
||||||
|
* sets the current program by cloning the provided program using a structuredClone
|
||||||
|
*/
|
||||||
|
setProgramState: (program: ReducedProgram) => set({currentProgram: structuredClone(program)}),
|
||||||
|
/**
|
||||||
|
* gets the current program
|
||||||
|
*/
|
||||||
|
getProgramState: () => get().currentProgram,
|
||||||
|
|
||||||
|
// utility functions:
|
||||||
|
/**
|
||||||
|
* gets the ids of all phases in the program
|
||||||
|
*/
|
||||||
|
getPhaseIds: () => get().currentProgram.phases.map(entry => entry["id"] as string),
|
||||||
|
/**
|
||||||
|
* gets the norms for the provided phase
|
||||||
|
*/
|
||||||
|
getNormsInPhase: (currentPhaseId) => {
|
||||||
|
const program = get().currentProgram;
|
||||||
|
const phase = program.phases.find(val => val["id"] === currentPhaseId);
|
||||||
|
if (phase) {
|
||||||
|
return phase["norms"] as Record<string, unknown>[];
|
||||||
|
}
|
||||||
|
throw new Error(`phase with id:"${currentPhaseId}" not found`)
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* gets the goals for the provided phase
|
||||||
|
*/
|
||||||
|
getGoalsInPhase: (currentPhaseId) => {
|
||||||
|
const program = get().currentProgram;
|
||||||
|
const phase = program.phases.find(val => val["id"] === currentPhaseId);
|
||||||
|
if (phase) {
|
||||||
|
return phase["goals"] as Record<string, unknown>[];
|
||||||
|
}
|
||||||
|
throw new Error(`phase with id:"${currentPhaseId}" not found`)
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* gets the triggers for the provided phase
|
||||||
|
*/
|
||||||
|
getTriggersInPhase: (currentPhaseId) => {
|
||||||
|
const program = get().currentProgram;
|
||||||
|
const phase = program.phases.find(val => val["id"] === currentPhaseId);
|
||||||
|
if (phase) {
|
||||||
|
return phase["triggers"] as Record<string, unknown>[];
|
||||||
|
}
|
||||||
|
throw new Error(`phase with id:"${currentPhaseId}" not found`)
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
export default useProgramStore;
|
||||||
83
test/pages/simpleProgram/SimpleProgram.tsx
Normal file
83
test/pages/simpleProgram/SimpleProgram.tsx
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import { render, screen, fireEvent } from "@testing-library/react";
|
||||||
|
import SimpleProgram from "../../../src/pages/SimpleProgram/SimpleProgram";
|
||||||
|
import useProgramStore from "../../../src/utils/programStore";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to preload the program store before rendering.
|
||||||
|
*/
|
||||||
|
function loadProgram(phases: Record<string, unknown>[]) {
|
||||||
|
useProgramStore.getState().setProgramState({ phases });
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("SimpleProgram", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
loadProgram([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("shows empty state when no program is loaded", () => {
|
||||||
|
render(<SimpleProgram />);
|
||||||
|
expect(screen.getByText("No program loaded.")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("renders first phase content", () => {
|
||||||
|
loadProgram([
|
||||||
|
{
|
||||||
|
id: "phase-1",
|
||||||
|
norms: [{ id: "n1", norm: "Be polite" }],
|
||||||
|
goals: [{ id: "g1", description: "Finish task", achieved: true }],
|
||||||
|
triggers: [{ id: "t1", label: "Keyword trigger" }],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
render(<SimpleProgram />);
|
||||||
|
|
||||||
|
expect(screen.getByText("Phase 1 / 1")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Be polite")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Finish task")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Keyword trigger")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("allows navigating between phases", () => {
|
||||||
|
loadProgram([
|
||||||
|
{
|
||||||
|
id: "phase-1",
|
||||||
|
norms: [],
|
||||||
|
goals: [],
|
||||||
|
triggers: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "phase-2",
|
||||||
|
norms: [{ id: "n2", norm: "Be careful" }],
|
||||||
|
goals: [],
|
||||||
|
triggers: [],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
render(<SimpleProgram />);
|
||||||
|
|
||||||
|
expect(screen.getByText("Phase 1 / 2")).toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText("Next ▶"));
|
||||||
|
|
||||||
|
expect(screen.getByText("Phase 2 / 2")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Be careful")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("prev button is disabled on first phase", () => {
|
||||||
|
loadProgram([
|
||||||
|
{ id: "phase-1", norms: [], goals: [], triggers: [] },
|
||||||
|
]);
|
||||||
|
|
||||||
|
render(<SimpleProgram />);
|
||||||
|
expect(screen.getByText("◀ Prev")).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("next button is disabled on last phase", () => {
|
||||||
|
loadProgram([
|
||||||
|
{ id: "phase-1", norms: [], goals: [], triggers: [] },
|
||||||
|
]);
|
||||||
|
|
||||||
|
render(<SimpleProgram />);
|
||||||
|
expect(screen.getByText("Next ▶")).toBeDisabled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -98,6 +98,7 @@ describe('NormNode', () => {
|
|||||||
droppable: true,
|
droppable: true,
|
||||||
norm: '',
|
norm: '',
|
||||||
hasReduce: true,
|
hasReduce: true,
|
||||||
|
critical: false
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -627,6 +628,54 @@ describe('NormNode', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should properly update the store when editing critical checkbox', async () => {
|
||||||
|
const mockNode: Node = {
|
||||||
|
id: 'norm-1',
|
||||||
|
type: 'norm',
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
data: {
|
||||||
|
label: 'Test Norm',
|
||||||
|
droppable: true,
|
||||||
|
norm: '',
|
||||||
|
hasReduce: true,
|
||||||
|
critical: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
useFlowStore.setState({
|
||||||
|
nodes: [mockNode],
|
||||||
|
edges: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
renderWithProviders(
|
||||||
|
<NormNode
|
||||||
|
id={mockNode.id}
|
||||||
|
type={mockNode.type as string}
|
||||||
|
data={mockNode.data as any}
|
||||||
|
selected={false}
|
||||||
|
isConnectable={true}
|
||||||
|
zIndex={0}
|
||||||
|
dragging={false}
|
||||||
|
selectable={true}
|
||||||
|
deletable={true}
|
||||||
|
draggable={true}
|
||||||
|
positionAbsoluteX={0}
|
||||||
|
positionAbsoluteY={0}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const checkbox = screen.getByLabelText('Critical:');
|
||||||
|
await user.click(checkbox);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const state = useFlowStore.getState();
|
||||||
|
expect(state.nodes).toHaveLength(1);
|
||||||
|
expect(state.nodes[0].id).toBe('norm-1');
|
||||||
|
expect(state.nodes[0].data.norm).toBe('');
|
||||||
|
expect(state.nodes[0].data.critical).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('should not affect other nodes when updating one norm node', async () => {
|
it('should not affect other nodes when updating one norm node', async () => {
|
||||||
const norm1: Node = {
|
const norm1: Node = {
|
||||||
id: 'norm-1',
|
id: 'norm-1',
|
||||||
|
|||||||
@@ -2,6 +2,11 @@ import '@testing-library/jest-dom';
|
|||||||
import { cleanup } from '@testing-library/react';
|
import { cleanup } from '@testing-library/react';
|
||||||
import useFlowStore from '../src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx';
|
import useFlowStore from '../src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx';
|
||||||
|
|
||||||
|
if (!globalThis.structuredClone) {
|
||||||
|
globalThis.structuredClone = (obj: any) => {
|
||||||
|
return JSON.parse(JSON.stringify(obj));
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// To make sure that the tests are working, it's important that you are using
|
// To make sure that the tests are working, it's important that you are using
|
||||||
// this implementation of ResizeObserver and DOMMatrixReadOnly
|
// this implementation of ResizeObserver and DOMMatrixReadOnly
|
||||||
|
|||||||
100
test/utils/programStore.test.ts
Normal file
100
test/utils/programStore.test.ts
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import useProgramStore, {type ReducedProgram} from "../../src/utils/programStore.ts";
|
||||||
|
|
||||||
|
|
||||||
|
describe('useProgramStore', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
// Reset store before each test
|
||||||
|
useProgramStore.setState({
|
||||||
|
currentProgram: { phases: [] },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockProgram: ReducedProgram = {
|
||||||
|
phases: [
|
||||||
|
{
|
||||||
|
id: 'phase-1',
|
||||||
|
norms: [{ id: 'norm-1' }],
|
||||||
|
goals: [{ id: 'goal-1' }],
|
||||||
|
triggers: [{ id: 'trigger-1' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'phase-2',
|
||||||
|
norms: [{ id: 'norm-2' }],
|
||||||
|
goals: [{ id: 'goal-2' }],
|
||||||
|
triggers: [{ id: 'trigger-2' }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
it('should set and get the program state', () => {
|
||||||
|
useProgramStore.getState().setProgramState(mockProgram);
|
||||||
|
|
||||||
|
const program = useProgramStore.getState().getProgramState();
|
||||||
|
expect(program).toEqual(mockProgram);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return the ids of all phases in the program', () => {
|
||||||
|
useProgramStore.getState().setProgramState(mockProgram);
|
||||||
|
|
||||||
|
const phaseIds = useProgramStore.getState().getPhaseIds();
|
||||||
|
expect(phaseIds).toEqual(['phase-1', 'phase-2']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return all norms for a given phase', () => {
|
||||||
|
useProgramStore.getState().setProgramState(mockProgram);
|
||||||
|
|
||||||
|
const norms = useProgramStore.getState().getNormsInPhase('phase-1');
|
||||||
|
expect(norms).toEqual([{ id: 'norm-1' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return all goals for a given phase', () => {
|
||||||
|
useProgramStore.getState().setProgramState(mockProgram);
|
||||||
|
|
||||||
|
const goals = useProgramStore.getState().getGoalsInPhase('phase-2');
|
||||||
|
expect(goals).toEqual([{ id: 'goal-2' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return all triggers for a given phase', () => {
|
||||||
|
useProgramStore.getState().setProgramState(mockProgram);
|
||||||
|
|
||||||
|
const triggers = useProgramStore.getState().getTriggersInPhase('phase-1');
|
||||||
|
expect(triggers).toEqual([{ id: 'trigger-1' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws if phase does not exist when getting norms', () => {
|
||||||
|
useProgramStore.getState().setProgramState(mockProgram);
|
||||||
|
|
||||||
|
expect(() =>
|
||||||
|
useProgramStore.getState().getNormsInPhase('missing-phase')
|
||||||
|
).toThrow('phase with id:"missing-phase" not found');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws if phase does not exist when getting goals', () => {
|
||||||
|
useProgramStore.getState().setProgramState(mockProgram);
|
||||||
|
|
||||||
|
expect(() =>
|
||||||
|
useProgramStore.getState().getGoalsInPhase('missing-phase')
|
||||||
|
).toThrow('phase with id:"missing-phase" not found');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws if phase does not exist when getting triggers', () => {
|
||||||
|
useProgramStore.getState().setProgramState(mockProgram);
|
||||||
|
|
||||||
|
expect(() =>
|
||||||
|
useProgramStore.getState().getTriggersInPhase('missing-phase')
|
||||||
|
).toThrow('phase with id:"missing-phase" not found');
|
||||||
|
});
|
||||||
|
|
||||||
|
// this test should be at the bottom to avoid conflicts with the previous tests
|
||||||
|
it('should clone program state when setting it (no shared references should exist)', () => {
|
||||||
|
useProgramStore.getState().setProgramState(mockProgram);
|
||||||
|
|
||||||
|
const storedProgram = useProgramStore.getState().getProgramState();
|
||||||
|
|
||||||
|
// mutate original
|
||||||
|
(mockProgram.phases[0].norms as any[]).push({ id: 'norm-mutated' });
|
||||||
|
|
||||||
|
// store should NOT change
|
||||||
|
expect(storedProgram.phases[0]['norms']).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user