Merge branch 'feat/editable-norms-and-goals' into 'dev'
Make nodes editable: norms, goals and keyword triggers See merge request ics/sp/2025/n25b/pepperplus-ui!18
This commit was merged in pull request #18.
This commit is contained in:
@@ -109,6 +109,10 @@ main {
|
|||||||
padding: 1rem 0;
|
padding: 1rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
input[type="checkbox"] {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
.flex-row {
|
.flex-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
|
|||||||
27
src/components/TextField.module.css
Normal file
27
src/components/TextField.module.css
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
.text-field {
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 5pt;
|
||||||
|
padding: 4px 8px;
|
||||||
|
outline: none;
|
||||||
|
background-color: canvas;
|
||||||
|
transition: border-color 0.2s, box-shadow 0.2s;
|
||||||
|
cursor: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-field.invalid {
|
||||||
|
border-color: red;
|
||||||
|
color: red;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-field:focus:not(.invalid) {
|
||||||
|
border-color: color-mix(in srgb, canvas, #777 10%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-field:read-only {
|
||||||
|
cursor: pointer;
|
||||||
|
background-color: color-mix(in srgb, canvas, #777 5%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-field:read-only:hover:not(.invalid) {
|
||||||
|
border-color: color-mix(in srgb, canvas, #777 10%);
|
||||||
|
}
|
||||||
101
src/components/TextField.tsx
Normal file
101
src/components/TextField.tsx
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import {useState} from "react";
|
||||||
|
import styles from "./TextField.module.css";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A text input element in our own style that calls `setValue` at every keystroke.
|
||||||
|
*
|
||||||
|
* @param {Object} props - The component props.
|
||||||
|
* @param {string} props.value - The value of the text input.
|
||||||
|
* @param {(value: string) => void} props.setValue - A function that sets the value of the text input.
|
||||||
|
* @param {string} [props.placeholder] - The placeholder text for the text input.
|
||||||
|
* @param {string} [props.className] - Additional CSS classes for the text input.
|
||||||
|
* @param {string} [props.id] - The ID of the text input.
|
||||||
|
* @param {string} [props.ariaLabel] - The ARIA label for the text input.
|
||||||
|
*/
|
||||||
|
export function RealtimeTextField({
|
||||||
|
value = "",
|
||||||
|
setValue,
|
||||||
|
onCommit,
|
||||||
|
placeholder,
|
||||||
|
className,
|
||||||
|
id,
|
||||||
|
ariaLabel,
|
||||||
|
invalid = false,
|
||||||
|
} : {
|
||||||
|
value: string,
|
||||||
|
setValue: (value: string) => void,
|
||||||
|
onCommit: () => void,
|
||||||
|
placeholder?: string,
|
||||||
|
className?: string,
|
||||||
|
id?: string,
|
||||||
|
ariaLabel?: string,
|
||||||
|
invalid?: boolean,
|
||||||
|
}) {
|
||||||
|
const [readOnly, setReadOnly] = useState(true);
|
||||||
|
|
||||||
|
const updateData = () => {
|
||||||
|
setReadOnly(true);
|
||||||
|
onCommit();
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateOnEnter = (event: React.KeyboardEvent<HTMLInputElement>) => { if (event.key === "Enter") (event.target as HTMLInputElement).blur(); };
|
||||||
|
|
||||||
|
return <input
|
||||||
|
type={"text"}
|
||||||
|
placeholder={placeholder}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => setValue(e.target.value)}
|
||||||
|
onFocus={() => setReadOnly(false)}
|
||||||
|
onBlur={updateData}
|
||||||
|
onKeyDown={updateOnEnter}
|
||||||
|
readOnly={readOnly}
|
||||||
|
id={id}
|
||||||
|
// ReactFlow uses the "drag" / "nodrag" classes to enable / disable dragging of nodes
|
||||||
|
className={`${readOnly ? "drag" : "nodrag"} ${styles.textField} ${invalid ? styles.invalid : ""} ${className}`}
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
/>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A text input element in our own style that calls `setValue` once the user presses the enter key or clicks outside the input.
|
||||||
|
*
|
||||||
|
* @param {Object} props - The component props.
|
||||||
|
* @param {string} props.value - The value of the text input.
|
||||||
|
* @param {(value: string) => void} props.setValue - A function that sets the value of the text input.
|
||||||
|
* @param {string} [props.placeholder] - The placeholder text for the text input.
|
||||||
|
* @param {string} [props.className] - Additional CSS classes for the text input.
|
||||||
|
* @param {string} [props.id] - The ID of the text input.
|
||||||
|
* @param {string} [props.ariaLabel] - The ARIA label for the text input.
|
||||||
|
*/
|
||||||
|
export function TextField({
|
||||||
|
value = "",
|
||||||
|
setValue,
|
||||||
|
placeholder,
|
||||||
|
className,
|
||||||
|
id,
|
||||||
|
ariaLabel,
|
||||||
|
invalid = false,
|
||||||
|
} : {
|
||||||
|
value: string,
|
||||||
|
setValue: (value: string) => void,
|
||||||
|
placeholder?: string,
|
||||||
|
className?: string,
|
||||||
|
id?: string,
|
||||||
|
ariaLabel?: string,
|
||||||
|
invalid?: boolean,
|
||||||
|
}) {
|
||||||
|
const [inputValue, setInputValue] = useState(value);
|
||||||
|
|
||||||
|
const onCommit = () => setValue(inputValue);
|
||||||
|
|
||||||
|
return <RealtimeTextField
|
||||||
|
placeholder={placeholder}
|
||||||
|
value={inputValue}
|
||||||
|
setValue={setInputValue}
|
||||||
|
onCommit={onCommit}
|
||||||
|
id={id}
|
||||||
|
className={className}
|
||||||
|
ariaLabel={ariaLabel}
|
||||||
|
invalid={invalid}
|
||||||
|
/>;
|
||||||
|
}
|
||||||
@@ -71,6 +71,16 @@
|
|||||||
filter: drop-shadow(0 0 0.25rem forestgreen);
|
filter: drop-shadow(0 0 0.25rem forestgreen);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.node-goal {
|
||||||
|
outline: yellow solid 2pt;
|
||||||
|
filter: drop-shadow(0 0 0.25rem yellow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-trigger {
|
||||||
|
outline: teal solid 2pt;
|
||||||
|
filter: drop-shadow(0 0 0.25rem teal);
|
||||||
|
}
|
||||||
|
|
||||||
.node-phase {
|
.node-phase {
|
||||||
outline: dodgerblue solid 2pt;
|
outline: dodgerblue solid 2pt;
|
||||||
filter: drop-shadow(0 0 0.25rem dodgerblue);
|
filter: drop-shadow(0 0 0.25rem dodgerblue);
|
||||||
@@ -102,6 +112,22 @@
|
|||||||
filter: drop-shadow(0 0 0.25rem forestgreen);
|
filter: drop-shadow(0 0 0.25rem forestgreen);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.draggable-node-goal {
|
||||||
|
padding: 3px 10px;
|
||||||
|
background-color: canvas;
|
||||||
|
border-radius: 5pt;
|
||||||
|
outline: yellow solid 2pt;
|
||||||
|
filter: drop-shadow(0 0 0.25rem yellow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.draggable-node-trigger {
|
||||||
|
padding: 3px 10px;
|
||||||
|
background-color: canvas;
|
||||||
|
border-radius: 5pt;
|
||||||
|
outline: teal solid 2pt;
|
||||||
|
filter: drop-shadow(0 0 0.25rem teal);
|
||||||
|
}
|
||||||
|
|
||||||
.draggable-node-phase {
|
.draggable-node-phase {
|
||||||
padding: 3px 10px;
|
padding: 3px 10px;
|
||||||
background-color: canvas;
|
background-color: canvas;
|
||||||
|
|||||||
@@ -13,13 +13,15 @@ import {
|
|||||||
StartNodeComponent,
|
StartNodeComponent,
|
||||||
EndNodeComponent,
|
EndNodeComponent,
|
||||||
PhaseNodeComponent,
|
PhaseNodeComponent,
|
||||||
NormNodeComponent
|
NormNodeComponent,
|
||||||
|
GoalNodeComponent,
|
||||||
} from './visualProgrammingUI/components/NodeDefinitions.tsx';
|
} from './visualProgrammingUI/components/NodeDefinitions.tsx';
|
||||||
import {DndToolbar} from './visualProgrammingUI/components/DragDropSidebar.tsx';
|
import {DndToolbar} from './visualProgrammingUI/components/DragDropSidebar.tsx';
|
||||||
import graphReducer from "./visualProgrammingUI/GraphReducer.ts";
|
import graphReducer from "./visualProgrammingUI/GraphReducer.ts";
|
||||||
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 TriggerNodeComponent from "./visualProgrammingUI/components/TriggerNodeComponent.tsx";
|
||||||
|
|
||||||
// --| config starting params for flow |--
|
// --| config starting params for flow |--
|
||||||
|
|
||||||
@@ -30,7 +32,9 @@ const NODE_TYPES = {
|
|||||||
start: StartNodeComponent,
|
start: StartNodeComponent,
|
||||||
end: EndNodeComponent,
|
end: EndNodeComponent,
|
||||||
phase: PhaseNodeComponent,
|
phase: PhaseNodeComponent,
|
||||||
norm: NormNodeComponent
|
norm: NormNodeComponent,
|
||||||
|
goal: GoalNodeComponent,
|
||||||
|
trigger: TriggerNodeComponent,
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -126,6 +130,7 @@ function VisualProgrammingUI() {
|
|||||||
function runProgram() {
|
function runProgram() {
|
||||||
const program = graphReducer();
|
const program = graphReducer();
|
||||||
console.log(program);
|
console.log(program);
|
||||||
|
console.log(JSON.stringify(program, null, 2));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -15,14 +15,15 @@ import type {
|
|||||||
Phase,
|
Phase,
|
||||||
PhaseReducer,
|
PhaseReducer,
|
||||||
PreparedGraph,
|
PreparedGraph,
|
||||||
PreparedPhase
|
PreparedPhase, Reduced, TriggerReducer
|
||||||
} from "./GraphReducerTypes.ts";
|
} from "./GraphReducerTypes.ts";
|
||||||
import type {
|
import type {
|
||||||
AppNode,
|
AppNode,
|
||||||
GoalNode,
|
GoalNode,
|
||||||
NormNode,
|
NormNode,
|
||||||
PhaseNode
|
PhaseNode, TriggerNode
|
||||||
} from "./VisProgTypes.tsx";
|
} from "./VisProgTypes.tsx";
|
||||||
|
import type {TriggerNodeProps} from "./components/TriggerNodeComponent.tsx";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reduces the current graph inside the visual programming editor into a BehaviorProgram
|
* Reduces the current graph inside the visual programming editor into a BehaviorProgram
|
||||||
@@ -31,13 +32,15 @@ import type {
|
|||||||
* @param {PhaseReducer} phaseReducer
|
* @param {PhaseReducer} phaseReducer
|
||||||
* @param {NormReducer} normReducer
|
* @param {NormReducer} normReducer
|
||||||
* @param {GoalReducer} goalReducer
|
* @param {GoalReducer} goalReducer
|
||||||
|
* @param {TriggerReducer} triggerReducer
|
||||||
* @returns {BehaviorProgram}
|
* @returns {BehaviorProgram}
|
||||||
*/
|
*/
|
||||||
export default function graphReducer(
|
export default function graphReducer(
|
||||||
graphPreprocessor: GraphPreprocessor = defaultGraphPreprocessor,
|
graphPreprocessor: GraphPreprocessor = defaultGraphPreprocessor,
|
||||||
phaseReducer: PhaseReducer = defaultPhaseReducer,
|
phaseReducer: PhaseReducer = defaultPhaseReducer,
|
||||||
normReducer: NormReducer = defaultNormReducer,
|
normReducer: NormReducer = defaultNormReducer,
|
||||||
goalReducer: GoalReducer = defaultGoalReducer
|
goalReducer: GoalReducer = defaultGoalReducer,
|
||||||
|
triggerReducer: TriggerReducer = defaultTriggerReducer,
|
||||||
) : BehaviorProgram {
|
) : BehaviorProgram {
|
||||||
const nodes: AppNode[] = useFlowStore.getState().nodes;
|
const nodes: AppNode[] = useFlowStore.getState().nodes;
|
||||||
const edges: Edge[] = useFlowStore.getState().edges;
|
const edges: Edge[] = useFlowStore.getState().edges;
|
||||||
@@ -47,7 +50,8 @@ export default function graphReducer(
|
|||||||
phaseReducer(
|
phaseReducer(
|
||||||
preparedPhase,
|
preparedPhase,
|
||||||
normReducer,
|
normReducer,
|
||||||
goalReducer
|
goalReducer,
|
||||||
|
triggerReducer,
|
||||||
));
|
));
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -58,12 +62,14 @@ export default function graphReducer(
|
|||||||
* @param {PreparedPhase} phase
|
* @param {PreparedPhase} phase
|
||||||
* @param {NormReducer} normReducer
|
* @param {NormReducer} normReducer
|
||||||
* @param {GoalReducer} goalReducer
|
* @param {GoalReducer} goalReducer
|
||||||
|
* @param {TriggerReducer} triggerReducer
|
||||||
* @returns {Phase}
|
* @returns {Phase}
|
||||||
*/
|
*/
|
||||||
export function defaultPhaseReducer(
|
export function defaultPhaseReducer(
|
||||||
phase: PreparedPhase,
|
phase: PreparedPhase,
|
||||||
normReducer: NormReducer = defaultNormReducer,
|
normReducer: NormReducer = defaultNormReducer,
|
||||||
goalReducer: GoalReducer = defaultGoalReducer
|
goalReducer: GoalReducer = defaultGoalReducer,
|
||||||
|
triggerReducer: TriggerReducer = defaultTriggerReducer,
|
||||||
) : Phase {
|
) : Phase {
|
||||||
return {
|
return {
|
||||||
id: phase.phaseNode.id,
|
id: phase.phaseNode.id,
|
||||||
@@ -71,7 +77,8 @@ export function defaultPhaseReducer(
|
|||||||
nextPhaseId: phase.nextPhaseId,
|
nextPhaseId: phase.nextPhaseId,
|
||||||
phaseData: {
|
phaseData: {
|
||||||
norms: phase.connectedNorms.map(normReducer),
|
norms: phase.connectedNorms.map(normReducer),
|
||||||
goals: phase.connectedGoals.map(goalReducer)
|
goals: phase.connectedGoals.map(goalReducer),
|
||||||
|
triggers: phase.connectedTriggers.map(triggerReducer),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -82,11 +89,12 @@ export function defaultPhaseReducer(
|
|||||||
* @param {GoalNode} node
|
* @param {GoalNode} node
|
||||||
* @returns {GoalData}
|
* @returns {GoalData}
|
||||||
*/
|
*/
|
||||||
function defaultGoalReducer(node: GoalNode) : GoalData {
|
function defaultGoalReducer(node: GoalNode) : Reduced<GoalData> {
|
||||||
return {
|
return {
|
||||||
id: node.id,
|
id: node.id,
|
||||||
name: node.data.label,
|
name: node.data.label,
|
||||||
value: node.data.value
|
description: node.data.description,
|
||||||
|
achieved: node.data.achieved,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,7 +104,7 @@ function defaultGoalReducer(node: GoalNode) : GoalData {
|
|||||||
* @param {NormNode} node
|
* @param {NormNode} node
|
||||||
* @returns {NormData}
|
* @returns {NormData}
|
||||||
*/
|
*/
|
||||||
function defaultNormReducer(node: NormNode) :NormData {
|
function defaultNormReducer(node: NormNode) :Reduced<NormData> {
|
||||||
return {
|
return {
|
||||||
id: node.id,
|
id: node.id,
|
||||||
name: node.data.label,
|
name: node.data.label,
|
||||||
@@ -104,6 +112,13 @@ function defaultNormReducer(node: NormNode) :NormData {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function defaultTriggerReducer(node: TriggerNode): Reduced<TriggerNodeProps> {
|
||||||
|
return {
|
||||||
|
id: node.id,
|
||||||
|
...node.data,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Graph preprocessing functions:
|
// Graph preprocessing functions:
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -117,6 +132,7 @@ function defaultNormReducer(node: NormNode) :NormData {
|
|||||||
export function defaultGraphPreprocessor(nodes: AppNode[], edges: Edge[]) : PreparedGraph {
|
export function defaultGraphPreprocessor(nodes: AppNode[], edges: Edge[]) : PreparedGraph {
|
||||||
const norms : NormNode[] = nodes.filter((node) => node.type === 'norm') as NormNode[];
|
const norms : NormNode[] = nodes.filter((node) => node.type === 'norm') as NormNode[];
|
||||||
const goals : GoalNode[] = nodes.filter((node) => node.type === 'goal') as GoalNode[];
|
const goals : GoalNode[] = nodes.filter((node) => node.type === 'goal') as GoalNode[];
|
||||||
|
const triggers : TriggerNode[] = nodes.filter((node) => node.type === 'trigger') as TriggerNode[];
|
||||||
const orderedPhases : OrderedPhases = orderPhases(nodes, edges);
|
const orderedPhases : OrderedPhases = orderPhases(nodes, edges);
|
||||||
|
|
||||||
return orderedPhases.phaseNodes.map((phase: PhaseNode) : PreparedPhase => {
|
return orderedPhases.phaseNodes.map((phase: PhaseNode) : PreparedPhase => {
|
||||||
@@ -125,7 +141,8 @@ export function defaultGraphPreprocessor(nodes: AppNode[], edges: Edge[]) : Prep
|
|||||||
phaseNode: phase,
|
phaseNode: phase,
|
||||||
nextPhaseId: nextPhase as string,
|
nextPhaseId: nextPhase as string,
|
||||||
connectedNorms: getIncomers({id: phase.id}, norms,edges),
|
connectedNorms: getIncomers({id: phase.id}, norms,edges),
|
||||||
connectedGoals: getIncomers({id: phase.id}, goals,edges)
|
connectedGoals: getIncomers({id: phase.id}, goals,edges),
|
||||||
|
connectedTriggers: getIncomers({id: phase.id}, triggers, edges),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import type {Edge} from "@xyflow/react";
|
import type {Edge} from "@xyflow/react";
|
||||||
import type {AppNode, GoalNode, NormNode, PhaseNode} from "./VisProgTypes.tsx";
|
import type {AppNode, GoalNode, NormNode, PhaseNode, TriggerNode} from "./VisProgTypes.tsx";
|
||||||
|
import type {TriggerNodeProps} from "./components/TriggerNodeComponent.tsx";
|
||||||
|
|
||||||
|
|
||||||
|
export type Reduced<T> = { id: string } & T;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* defines how a norm is represented in the simplified behavior program
|
* defines how a norm is represented in the simplified behavior program
|
||||||
*/
|
*/
|
||||||
export type NormData = {
|
export type NormData = {
|
||||||
id: string;
|
|
||||||
name: string;
|
name: string;
|
||||||
value: string;
|
value: string;
|
||||||
};
|
};
|
||||||
@@ -15,9 +17,9 @@ export type NormData = {
|
|||||||
* defines how a goal is represented in the simplified behavior program
|
* defines how a goal is represented in the simplified behavior program
|
||||||
*/
|
*/
|
||||||
export type GoalData = {
|
export type GoalData = {
|
||||||
id: string;
|
|
||||||
name: string;
|
name: string;
|
||||||
value: string;
|
description: string;
|
||||||
|
achieved: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -27,6 +29,7 @@ export type GoalData = {
|
|||||||
export type PhaseData = {
|
export type PhaseData = {
|
||||||
norms: NormData[];
|
norms: NormData[];
|
||||||
goals: GoalData[];
|
goals: GoalData[];
|
||||||
|
triggers: TriggerNodeProps[];
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -55,12 +58,14 @@ export type BehaviorProgram = Phase[];
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
export type NormReducer = (node: NormNode) => NormData;
|
export type NormReducer = (node: NormNode) => Reduced<NormData>;
|
||||||
export type GoalReducer = (node: GoalNode) => GoalData;
|
export type GoalReducer = (node: GoalNode) => Reduced<GoalData>;
|
||||||
|
export type TriggerReducer = (node: TriggerNode) => Reduced<TriggerNodeProps>;
|
||||||
export type PhaseReducer = (
|
export type PhaseReducer = (
|
||||||
preparedPhase: PreparedPhase,
|
preparedPhase: PreparedPhase,
|
||||||
normReducer: NormReducer,
|
normReducer: NormReducer,
|
||||||
goalReducer: GoalReducer
|
goalReducer: GoalReducer,
|
||||||
|
triggerReducer: TriggerReducer,
|
||||||
) => Phase;
|
) => Phase;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -90,6 +95,7 @@ export type PreparedPhase = {
|
|||||||
nextPhaseId: string;
|
nextPhaseId: string;
|
||||||
connectedNorms: NormNode[];
|
connectedNorms: NormNode[];
|
||||||
connectedGoals: GoalNode[];
|
connectedGoals: GoalNode[];
|
||||||
|
connectedTriggers: TriggerNode[];
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -6,18 +6,21 @@ import {
|
|||||||
type OnConnect,
|
type OnConnect,
|
||||||
type OnReconnect,
|
type OnReconnect,
|
||||||
} from '@xyflow/react';
|
} from '@xyflow/react';
|
||||||
|
import type {TriggerNodeProps} from "./components/TriggerNodeComponent.tsx";
|
||||||
|
|
||||||
|
|
||||||
type defaultNodeData = {
|
type defaultNodeData = {
|
||||||
label: string;
|
label: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type OurNode<T> = Node<T & defaultNodeData>;
|
||||||
|
|
||||||
export type StartNode = Node<defaultNodeData, 'start'>;
|
export type StartNode = Node<defaultNodeData, 'start'>;
|
||||||
export type EndNode = Node<defaultNodeData, 'end'>;
|
export type EndNode = Node<defaultNodeData, 'end'>;
|
||||||
export type GoalNode = Node<defaultNodeData & { value: string; }, 'goal'>;
|
export type GoalNode = Node<defaultNodeData & { description: string; achieved: boolean; }, 'goal'>;
|
||||||
export type NormNode = Node<defaultNodeData & { value: string; }, 'norm'>;
|
export type NormNode = Node<defaultNodeData & { value: string; }, 'norm'>;
|
||||||
export type PhaseNode = Node<defaultNodeData & { number: number; }, 'phase'>;
|
export type PhaseNode = Node<defaultNodeData & { number: number; }, 'phase'>;
|
||||||
|
export type TriggerNode = OurNode<TriggerNodeProps>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* a type meant to house different node types, currently not used
|
* a type meant to house different node types, currently not used
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
} from 'react';
|
} from 'react';
|
||||||
import useFlowStore from "../VisProgStores.tsx";
|
import useFlowStore from "../VisProgStores.tsx";
|
||||||
import styles from "../../VisProg.module.css"
|
import styles from "../../VisProg.module.css"
|
||||||
import type {AppNode, PhaseNode, NormNode} from "../VisProgTypes.tsx";
|
import type {AppNode, PhaseNode, NormNode, GoalNode, TriggerNode} from "../VisProgTypes.tsx";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -106,10 +106,48 @@ export function addNode(nodeType: string, position: XYPosition) {
|
|||||||
id: `norm-${normNumber}`,
|
id: `norm-${normNumber}`,
|
||||||
type: nodeType,
|
type: nodeType,
|
||||||
position,
|
position,
|
||||||
data: {label: `new norm node`, value: "Pepper should be formal"},
|
data: {label: `new norm node`, value: ""},
|
||||||
}
|
}
|
||||||
return normNode;
|
return normNode;
|
||||||
}
|
}
|
||||||
|
case "goal":
|
||||||
|
{
|
||||||
|
const goalNodes= nds.filter((node) => node.type === 'goal');
|
||||||
|
let goalNumber
|
||||||
|
if (goalNodes.length > 0) {
|
||||||
|
const finalGoalId : number = +(goalNodes[goalNodes.length - 1].id.split('-')[1]);
|
||||||
|
goalNumber = finalGoalId + 1;
|
||||||
|
} else {
|
||||||
|
goalNumber = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const goalNode : GoalNode = {
|
||||||
|
id: `goal-${goalNumber}`,
|
||||||
|
type: nodeType,
|
||||||
|
position,
|
||||||
|
data: {label: `new goal node`, description: "", achieved: false},
|
||||||
|
}
|
||||||
|
return goalNode;
|
||||||
|
}
|
||||||
|
case "trigger":
|
||||||
|
{
|
||||||
|
const triggerNodes= nds.filter((node) => node.type === 'trigger');
|
||||||
|
let triggerNumber
|
||||||
|
if (triggerNodes.length > 0) {
|
||||||
|
const finalGoalId : number = +(triggerNodes[triggerNodes.length - 1].id.split('-')[1]);
|
||||||
|
triggerNumber = finalGoalId + 1;
|
||||||
|
} else {
|
||||||
|
triggerNumber = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const triggerNode : TriggerNode = {
|
||||||
|
id: `trigger-${triggerNumber}`,
|
||||||
|
type: nodeType,
|
||||||
|
position,
|
||||||
|
data: {label: `new trigger node`, type: "keywords", value: []},
|
||||||
|
}
|
||||||
|
return triggerNode;
|
||||||
|
}
|
||||||
default: {
|
default: {
|
||||||
throw new Error(`Node ${nodeType} not found`);
|
throw new Error(`Node ${nodeType} not found`);
|
||||||
}
|
}
|
||||||
@@ -161,6 +199,12 @@ export function DndToolbar() {
|
|||||||
<DraggableNode className={styles.draggableNodeNorm} nodeType="norm" onDrop={handleNodeDrop}>
|
<DraggableNode className={styles.draggableNodeNorm} nodeType="norm" onDrop={handleNodeDrop}>
|
||||||
norm Node
|
norm Node
|
||||||
</DraggableNode>
|
</DraggableNode>
|
||||||
|
<DraggableNode className={styles.draggableNodeGoal} nodeType="goal" onDrop={handleNodeDrop}>
|
||||||
|
goal Node
|
||||||
|
</DraggableNode>
|
||||||
|
<DraggableNode className={styles.draggableNodeTrigger} nodeType="trigger" onDrop={handleNodeDrop}>
|
||||||
|
trigger Node
|
||||||
|
</DraggableNode>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -11,8 +11,9 @@ import type {
|
|||||||
StartNode,
|
StartNode,
|
||||||
EndNode,
|
EndNode,
|
||||||
PhaseNode,
|
PhaseNode,
|
||||||
NormNode
|
NormNode, GoalNode
|
||||||
} from "../VisProgTypes.tsx";
|
} from "../VisProgTypes.tsx";
|
||||||
|
import {TextField} from "../../../../components/TextField.tsx";
|
||||||
|
|
||||||
//Toolbar definitions
|
//Toolbar definitions
|
||||||
|
|
||||||
@@ -44,56 +45,6 @@ export function Toolbar({nodeId, allowDelete}: ToolbarProps) {
|
|||||||
</NodeToolbar>);
|
</NodeToolbar>);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Renaming component
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Adds a component that can be used to edit a node's label entry inside its Data
|
|
||||||
* can be added to any custom node that has a label inside its Data
|
|
||||||
*
|
|
||||||
* @param {string} nodeLabel
|
|
||||||
* @param {string} nodeId
|
|
||||||
* @returns {React.JSX.Element}
|
|
||||||
* @constructor
|
|
||||||
*/
|
|
||||||
export function EditableName({nodeLabel = "new node", nodeId} : { nodeLabel : string, nodeId: string}) {
|
|
||||||
const {updateNodeData} = useFlowStore();
|
|
||||||
|
|
||||||
const updateData = (event: React.FocusEvent<HTMLInputElement>) => {
|
|
||||||
const input = event.target.value;
|
|
||||||
updateNodeData(nodeId, {label: input});
|
|
||||||
event.currentTarget.setAttribute("readOnly", "true");
|
|
||||||
window.getSelection()?.empty();
|
|
||||||
event.currentTarget.classList.replace("nodrag", "drag"); // enable dragging of the node with cursor on the input box
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateOnEnter = (event: React.KeyboardEvent<HTMLInputElement>) => { if (event.key === "Enter") (event.target as HTMLInputElement).blur(); };
|
|
||||||
|
|
||||||
const enableEditing = (event: React.MouseEvent<HTMLInputElement>) => {
|
|
||||||
if(event.currentTarget.hasAttribute("readOnly")) {
|
|
||||||
event.currentTarget.removeAttribute("readOnly"); // enable editing
|
|
||||||
event.currentTarget.select(); // select the text input
|
|
||||||
window.getSelection()?.collapseToEnd(); // move the caret to the end of the current value
|
|
||||||
event.currentTarget.classList.replace("drag", "nodrag"); // disable dragging using input box
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={styles.NodeTextBar }>
|
|
||||||
<label>name: </label>
|
|
||||||
<input
|
|
||||||
className={`drag ${styles.nodeTextInput}`} // prevents dragging the component when user has focused the text input
|
|
||||||
type={"text"}
|
|
||||||
defaultValue={nodeLabel}
|
|
||||||
onKeyDown={updateOnEnter}
|
|
||||||
onBlur={updateData}
|
|
||||||
onClick={enableEditing}
|
|
||||||
maxLength={25}
|
|
||||||
readOnly
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// Definitions of Nodes
|
// Definitions of Nodes
|
||||||
|
|
||||||
@@ -148,11 +99,25 @@ export const EndNodeComponent = ({id, data}: NodeProps<EndNode>) => {
|
|||||||
* @constructor
|
* @constructor
|
||||||
*/
|
*/
|
||||||
export const PhaseNodeComponent = ({id, data}: NodeProps<PhaseNode>) => {
|
export const PhaseNodeComponent = ({id, data}: NodeProps<PhaseNode>) => {
|
||||||
|
const {updateNodeData} = useFlowStore();
|
||||||
|
|
||||||
|
const updateLabel = (value: string) => updateNodeData(id, {...data, label: value});
|
||||||
|
|
||||||
|
const label_input_id = `phase_${id}_label_input`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Toolbar nodeId={id} allowDelete={true}/>
|
<Toolbar nodeId={id} allowDelete={true}/>
|
||||||
<div className={`${styles.defaultNode} ${styles.nodePhase}`}>
|
<div className={`${styles.defaultNode} ${styles.nodePhase}`}>
|
||||||
<EditableName nodeLabel={data.label} nodeId={id}/>
|
<div className={"flex-row gap-sm"}>
|
||||||
|
<label htmlFor={label_input_id}>Name:</label>
|
||||||
|
<TextField
|
||||||
|
id={label_input_id}
|
||||||
|
value={data.label}
|
||||||
|
setValue={updateLabel}
|
||||||
|
placeholder={"Phase ..."}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<Handle type="target" position={Position.Left} id="target"/>
|
<Handle type="target" position={Position.Left} id="target"/>
|
||||||
<Handle type="target" position={Position.Bottom} id="norms"/>
|
<Handle type="target" position={Position.Bottom} id="norms"/>
|
||||||
<Handle type="source" position={Position.Right} id="source"/>
|
<Handle type="source" position={Position.Right} id="source"/>
|
||||||
@@ -167,17 +132,69 @@ export const PhaseNodeComponent = ({id, data}: NodeProps<PhaseNode>) => {
|
|||||||
*
|
*
|
||||||
* @param {string} id
|
* @param {string} id
|
||||||
* @param {defaultNodeData & {value: string}} data
|
* @param {defaultNodeData & {value: string}} data
|
||||||
* @returns {React.JSX.Element}
|
|
||||||
* @constructor
|
|
||||||
*/
|
*/
|
||||||
export const NormNodeComponent = ({id, data}: NodeProps<NormNode>) => {
|
export const NormNodeComponent = ({id, data}: NodeProps<NormNode>) => {
|
||||||
return (
|
const {updateNodeData} = useFlowStore();
|
||||||
<>
|
|
||||||
|
const text_input_id = `norm_${id}_text_input`;
|
||||||
|
|
||||||
|
const setValue = (value: string) => {
|
||||||
|
updateNodeData(id, {value: value});
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>
|
||||||
<Toolbar nodeId={id} allowDelete={true}/>
|
<Toolbar nodeId={id} allowDelete={true}/>
|
||||||
<div className={`${styles.defaultNode} ${styles.nodeNorm}`}>
|
<div className={`${styles.defaultNode} ${styles.nodeNorm}`}>
|
||||||
<EditableName nodeLabel={data.label} nodeId={id}/>
|
<div className={"flex-row gap-sm"}>
|
||||||
|
<label htmlFor={text_input_id}>Norm:</label>
|
||||||
|
<TextField
|
||||||
|
id={text_input_id}
|
||||||
|
value={data.value}
|
||||||
|
setValue={(val) => setValue(val)}
|
||||||
|
placeholder={"Pepper should ..."}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<Handle type="source" position={Position.Right} id="NormSource"/>
|
<Handle type="source" position={Position.Right} id="NormSource"/>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>;
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const GoalNodeComponent = ({id, data}: NodeProps<GoalNode>) => {
|
||||||
|
const {updateNodeData} = useFlowStore();
|
||||||
|
|
||||||
|
const text_input_id = `goal_${id}_text_input`;
|
||||||
|
const checkbox_id = `goal_${id}_checkbox`;
|
||||||
|
|
||||||
|
const setDescription = (value: string) => {
|
||||||
|
updateNodeData(id, {...data, description: value});
|
||||||
|
}
|
||||||
|
|
||||||
|
const setAchieved = (value: boolean) => {
|
||||||
|
updateNodeData(id, {...data, achieved: value});
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>
|
||||||
|
<Toolbar nodeId={id} allowDelete={true}/>
|
||||||
|
<div className={`${styles.defaultNode} ${styles.nodeGoal} flex-col gap-sm`}>
|
||||||
|
<div className={"flex-row gap-md"}>
|
||||||
|
<label htmlFor={text_input_id}>Goal:</label>
|
||||||
|
<TextField
|
||||||
|
id={text_input_id}
|
||||||
|
value={data.description}
|
||||||
|
setValue={(val) => setDescription(val)}
|
||||||
|
placeholder={"To ..."}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={"flex-row gap-md align-center"}>
|
||||||
|
<label htmlFor={checkbox_id}>Achieved:</label>
|
||||||
|
<input
|
||||||
|
id={checkbox_id}
|
||||||
|
type={"checkbox"}
|
||||||
|
value={data.achieved ? "checked" : ""}
|
||||||
|
onChange={(e) => setAchieved(e.target.checked)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Handle type="source" position={Position.Right} id="GoalSource"/>
|
||||||
|
</div>
|
||||||
|
</>;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import {Handle, type NodeProps, Position} from "@xyflow/react";
|
||||||
|
import type {TriggerNode} from "../VisProgTypes.tsx";
|
||||||
|
import useFlowStore from "../VisProgStores.tsx";
|
||||||
|
import styles from "../../VisProg.module.css";
|
||||||
|
import {RealtimeTextField, TextField} from "../../../../components/TextField.tsx";
|
||||||
|
import {Toolbar} from "./NodeDefinitions.tsx";
|
||||||
|
import {useState} from "react";
|
||||||
|
import duplicateIndices from "../../../../utils/duplicateIndices.ts";
|
||||||
|
|
||||||
|
export type EmotionTriggerNodeProps = {
|
||||||
|
type: "emotion";
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Keyword = { id: string, keyword: string };
|
||||||
|
|
||||||
|
export type KeywordTriggerNodeProps = {
|
||||||
|
type: "keywords";
|
||||||
|
value: Keyword[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TriggerNodeProps = EmotionTriggerNodeProps | KeywordTriggerNodeProps;
|
||||||
|
|
||||||
|
function KeywordAdder({ addKeyword }: { addKeyword: (keyword: string) => void }) {
|
||||||
|
const [input, setInput] = useState("");
|
||||||
|
|
||||||
|
const text_input_id = "keyword_adder_input";
|
||||||
|
|
||||||
|
return <div className={"flex-row gap-md"}>
|
||||||
|
<label htmlFor={text_input_id}>New Keyword:</label>
|
||||||
|
<RealtimeTextField
|
||||||
|
id={text_input_id}
|
||||||
|
value={input}
|
||||||
|
setValue={setInput}
|
||||||
|
onCommit={() => {
|
||||||
|
if (!input) return;
|
||||||
|
addKeyword(input);
|
||||||
|
setInput("");
|
||||||
|
}}
|
||||||
|
placeholder={"..."}
|
||||||
|
className={"flex-1"}
|
||||||
|
/>
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Keywords({
|
||||||
|
keywords,
|
||||||
|
setKeywords,
|
||||||
|
}: {
|
||||||
|
keywords: Keyword[];
|
||||||
|
setKeywords: (keywords: Keyword[]) => void;
|
||||||
|
}) {
|
||||||
|
type Interpolatable = string | number | boolean | bigint | null | undefined;
|
||||||
|
|
||||||
|
const inputElementId = (id: Interpolatable) => `keyword_${id}_input`;
|
||||||
|
|
||||||
|
/** Indices of duplicates in the keyword array. */
|
||||||
|
const [duplicates, setDuplicates] = useState<number[]>([]);
|
||||||
|
|
||||||
|
function replace(id: string, value: string) {
|
||||||
|
value = value.trim();
|
||||||
|
const newKeywords = value === ""
|
||||||
|
? keywords.filter((kw) => kw.id != id)
|
||||||
|
: keywords.map((kw) => kw.id === id ? {...kw, keyword: value} : kw);
|
||||||
|
setKeywords(newKeywords);
|
||||||
|
setDuplicates(duplicateIndices(newKeywords.map((kw) => kw.keyword)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function add(value: string) {
|
||||||
|
value = value.trim();
|
||||||
|
if (value === "") return;
|
||||||
|
const newKeywords = [...keywords, {id: crypto.randomUUID(), keyword: value}];
|
||||||
|
setKeywords(newKeywords);
|
||||||
|
setDuplicates(duplicateIndices(newKeywords.map((kw) => kw.keyword)));
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>
|
||||||
|
<span>Triggers when {keywords.length <= 1 ? "the keyword is" : "all keywords are"} spoken.</span>
|
||||||
|
{[...keywords].map(({id, keyword}, index) => {
|
||||||
|
return <div key={id} className={"flex-row gap-md"}>
|
||||||
|
<label htmlFor={inputElementId(id)}>Keyword:</label>
|
||||||
|
<TextField
|
||||||
|
id={inputElementId(id)}
|
||||||
|
value={keyword}
|
||||||
|
setValue={(val) => replace(id, val)}
|
||||||
|
placeholder={"..."}
|
||||||
|
className={"flex-1"}
|
||||||
|
invalid={duplicates.includes(index)}
|
||||||
|
/>
|
||||||
|
</div>;
|
||||||
|
})}
|
||||||
|
<KeywordAdder addKeyword={add} />
|
||||||
|
</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TriggerNodeComponent({
|
||||||
|
id,
|
||||||
|
data,
|
||||||
|
}: NodeProps<TriggerNode>) {
|
||||||
|
const {updateNodeData} = useFlowStore();
|
||||||
|
|
||||||
|
const setKeywords = (keywords: Keyword[]) => {
|
||||||
|
updateNodeData(id, {...data, value: keywords});
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>
|
||||||
|
<Toolbar nodeId={id} allowDelete={true}/>
|
||||||
|
<div className={`${styles.defaultNode} ${styles.nodeTrigger} flex-col gap-sm`}>
|
||||||
|
{data.type === "emotion" && (
|
||||||
|
<div className={"flex-row gap-md"}>Emotion?</div>
|
||||||
|
)}
|
||||||
|
{data.type === "keywords" && (
|
||||||
|
<Keywords
|
||||||
|
keywords={data.value}
|
||||||
|
setKeywords={setKeywords}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Handle type="source" position={Position.Right} id="TriggerSource"/>
|
||||||
|
</div>
|
||||||
|
</>;
|
||||||
|
}
|
||||||
19
src/utils/duplicateIndices.ts
Normal file
19
src/utils/duplicateIndices.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
/**
|
||||||
|
* Find the indices of all elements that occur more than once.
|
||||||
|
*
|
||||||
|
* @param array The array to search for duplicates.
|
||||||
|
* @returns An array of indices where an element occurs more than once, in no particular order.
|
||||||
|
*/
|
||||||
|
export default function duplicateIndices<T>(array: T[]): number[] {
|
||||||
|
const positions = new Map<T, number[]>();
|
||||||
|
|
||||||
|
array.forEach((value, i) => {
|
||||||
|
if (!positions.has(value)) positions.set(value, []);
|
||||||
|
positions.get(value)!.push(i);
|
||||||
|
});
|
||||||
|
|
||||||
|
// flatten all index lists with more than one element
|
||||||
|
return Array.from(positions.values())
|
||||||
|
.filter(idxs => idxs.length > 1)
|
||||||
|
.flat();
|
||||||
|
}
|
||||||
@@ -457,6 +457,7 @@ describe('Graph Reducer Tests', () => {
|
|||||||
nextPhaseId: 'end',
|
nextPhaseId: 'end',
|
||||||
connectedNorms: [],
|
connectedNorms: [],
|
||||||
connectedGoals: [],
|
connectedGoals: [],
|
||||||
|
connectedTriggers: [],
|
||||||
}]
|
}]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -472,6 +473,7 @@ describe('Graph Reducer Tests', () => {
|
|||||||
nextPhaseId: 'phase-2',
|
nextPhaseId: 'phase-2',
|
||||||
connectedNorms: [],
|
connectedNorms: [],
|
||||||
connectedGoals: [],
|
connectedGoals: [],
|
||||||
|
connectedTriggers: [],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
phaseNode: {
|
phaseNode: {
|
||||||
@@ -483,6 +485,7 @@ describe('Graph Reducer Tests', () => {
|
|||||||
nextPhaseId: 'phase-3',
|
nextPhaseId: 'phase-3',
|
||||||
connectedNorms: [],
|
connectedNorms: [],
|
||||||
connectedGoals: [],
|
connectedGoals: [],
|
||||||
|
connectedTriggers: [],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
phaseNode: {
|
phaseNode: {
|
||||||
@@ -494,6 +497,7 @@ describe('Graph Reducer Tests', () => {
|
|||||||
nextPhaseId: 'end',
|
nextPhaseId: 'end',
|
||||||
connectedNorms: [],
|
connectedNorms: [],
|
||||||
connectedGoals: [],
|
connectedGoals: [],
|
||||||
|
connectedTriggers: [],
|
||||||
}]
|
}]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -509,6 +513,7 @@ describe('Graph Reducer Tests', () => {
|
|||||||
nextPhaseId: 'phase-2',
|
nextPhaseId: 'phase-2',
|
||||||
connectedNorms: [],
|
connectedNorms: [],
|
||||||
connectedGoals: [],
|
connectedGoals: [],
|
||||||
|
connectedTriggers: [],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
phaseNode: {
|
phaseNode: {
|
||||||
@@ -525,6 +530,7 @@ describe('Graph Reducer Tests', () => {
|
|||||||
data: {label: 'Generic Norm', value: "generic"},
|
data: {label: 'Generic Norm', value: "generic"},
|
||||||
}],
|
}],
|
||||||
connectedGoals: [],
|
connectedGoals: [],
|
||||||
|
connectedTriggers: [],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
phaseNode: {
|
phaseNode: {
|
||||||
@@ -541,6 +547,7 @@ describe('Graph Reducer Tests', () => {
|
|||||||
data: {label: 'Generic Norm', value: "generic"},
|
data: {label: 'Generic Norm', value: "generic"},
|
||||||
}],
|
}],
|
||||||
connectedGoals: [],
|
connectedGoals: [],
|
||||||
|
connectedTriggers: [],
|
||||||
}]
|
}]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -561,6 +568,7 @@ describe('Graph Reducer Tests', () => {
|
|||||||
data: {label: 'Generic Norm', value: "generic"},
|
data: {label: 'Generic Norm', value: "generic"},
|
||||||
}],
|
}],
|
||||||
connectedGoals: [],
|
connectedGoals: [],
|
||||||
|
connectedTriggers: [],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
phaseNode: {
|
phaseNode: {
|
||||||
@@ -583,6 +591,7 @@ describe('Graph Reducer Tests', () => {
|
|||||||
data: {label: 'Generic Norm', value: "generic"},
|
data: {label: 'Generic Norm', value: "generic"},
|
||||||
}],
|
}],
|
||||||
connectedGoals: [],
|
connectedGoals: [],
|
||||||
|
connectedTriggers: [],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
phaseNode: {
|
phaseNode: {
|
||||||
@@ -605,6 +614,7 @@ describe('Graph Reducer Tests', () => {
|
|||||||
data: {label: 'Generic Norm', value: "generic"},
|
data: {label: 'Generic Norm', value: "generic"},
|
||||||
}],
|
}],
|
||||||
connectedGoals: [],
|
connectedGoals: [],
|
||||||
|
connectedTriggers: [],
|
||||||
}]
|
}]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -732,6 +742,7 @@ describe('Graph Reducer Tests', () => {
|
|||||||
nextPhaseId: 'end',
|
nextPhaseId: 'end',
|
||||||
connectedNorms: [],
|
connectedNorms: [],
|
||||||
connectedGoals: [],
|
connectedGoals: [],
|
||||||
|
connectedTriggers: [],
|
||||||
}
|
}
|
||||||
const output = defaultPhaseReducer(input);
|
const output = defaultPhaseReducer(input);
|
||||||
expect(output).toEqual({
|
expect(output).toEqual({
|
||||||
@@ -740,7 +751,8 @@ describe('Graph Reducer Tests', () => {
|
|||||||
nextPhaseId: 'end',
|
nextPhaseId: 'end',
|
||||||
phaseData: {
|
phaseData: {
|
||||||
norms: [],
|
norms: [],
|
||||||
goals: []
|
goals: [],
|
||||||
|
triggers: [],
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -760,6 +772,7 @@ describe('Graph Reducer Tests', () => {
|
|||||||
data: {label: 'Generic Norm', value: "generic"},
|
data: {label: 'Generic Norm', value: "generic"},
|
||||||
}],
|
}],
|
||||||
connectedGoals: [],
|
connectedGoals: [],
|
||||||
|
connectedTriggers: [],
|
||||||
}
|
}
|
||||||
const output = defaultPhaseReducer(input);
|
const output = defaultPhaseReducer(input);
|
||||||
expect(output).toEqual({
|
expect(output).toEqual({
|
||||||
@@ -772,7 +785,8 @@ describe('Graph Reducer Tests', () => {
|
|||||||
name: 'Generic Norm',
|
name: 'Generic Norm',
|
||||||
value: "generic"
|
value: "generic"
|
||||||
}],
|
}],
|
||||||
goals: []
|
goals: [],
|
||||||
|
triggers: [],
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -790,8 +804,9 @@ describe('Graph Reducer Tests', () => {
|
|||||||
id: 'goal-1',
|
id: 'goal-1',
|
||||||
type: 'goal',
|
type: 'goal',
|
||||||
position: {x: 0, y: 150},
|
position: {x: 0, y: 150},
|
||||||
data: {label: 'Generic Goal', value: "generic"},
|
data: {label: 'Generic Goal', description: "generic", achieved: false},
|
||||||
}],
|
}],
|
||||||
|
connectedTriggers: [],
|
||||||
}
|
}
|
||||||
const output = defaultPhaseReducer(input);
|
const output = defaultPhaseReducer(input);
|
||||||
expect(output).toEqual({
|
expect(output).toEqual({
|
||||||
@@ -803,7 +818,50 @@ describe('Graph Reducer Tests', () => {
|
|||||||
goals: [{
|
goals: [{
|
||||||
id: 'goal-1',
|
id: 'goal-1',
|
||||||
name: 'Generic Goal',
|
name: 'Generic Goal',
|
||||||
value: "generic"
|
description: "generic",
|
||||||
|
achieved: false,
|
||||||
|
}],
|
||||||
|
triggers: [],
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
test("defaultTriggerReducer reduces triggers correctly", () => {
|
||||||
|
const input : PreparedPhase = {
|
||||||
|
phaseNode: {
|
||||||
|
id: 'phase-1',
|
||||||
|
type: 'phase',
|
||||||
|
position: {x: 0, y: 150},
|
||||||
|
data: {label: 'Generic Phase', number: 1},
|
||||||
|
},
|
||||||
|
nextPhaseId: 'end',
|
||||||
|
connectedNorms: [],
|
||||||
|
connectedGoals: [],
|
||||||
|
connectedTriggers: [{
|
||||||
|
id: 'trigger-1',
|
||||||
|
type: 'trigger',
|
||||||
|
position: {x: 0, y: 150},
|
||||||
|
data: {label: 'Keyword Trigger', type: "keywords", value: [
|
||||||
|
{id: "some_id", keyword: "generic"},
|
||||||
|
{id: "another_id", keyword: "another"},
|
||||||
|
]},
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
const output = defaultPhaseReducer(input);
|
||||||
|
expect(output).toEqual({
|
||||||
|
id: 'phase-1',
|
||||||
|
name: 'Generic Phase',
|
||||||
|
nextPhaseId: 'end',
|
||||||
|
phaseData: {
|
||||||
|
norms: [],
|
||||||
|
goals: [],
|
||||||
|
triggers: [{
|
||||||
|
id: 'trigger-1',
|
||||||
|
label: 'Keyword Trigger',
|
||||||
|
type: "keywords",
|
||||||
|
value: [
|
||||||
|
{id: "some_id", keyword: "generic"},
|
||||||
|
{id: "another_id", keyword: "another"},
|
||||||
|
]
|
||||||
}]
|
}]
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -820,7 +878,8 @@ describe('Graph Reducer Tests', () => {
|
|||||||
nextPhaseId: 'end',
|
nextPhaseId: 'end',
|
||||||
phaseData: {
|
phaseData: {
|
||||||
norms: [],
|
norms: [],
|
||||||
goals: []
|
goals: [],
|
||||||
|
triggers: [],
|
||||||
}
|
}
|
||||||
}]
|
}]
|
||||||
},
|
},
|
||||||
@@ -833,7 +892,8 @@ describe('Graph Reducer Tests', () => {
|
|||||||
nextPhaseId: 'phase-2',
|
nextPhaseId: 'phase-2',
|
||||||
phaseData: {
|
phaseData: {
|
||||||
norms: [],
|
norms: [],
|
||||||
goals: []
|
goals: [],
|
||||||
|
triggers: [],
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -842,7 +902,8 @@ describe('Graph Reducer Tests', () => {
|
|||||||
nextPhaseId: 'phase-3',
|
nextPhaseId: 'phase-3',
|
||||||
phaseData: {
|
phaseData: {
|
||||||
norms: [],
|
norms: [],
|
||||||
goals: []
|
goals: [],
|
||||||
|
triggers: [],
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -851,7 +912,8 @@ describe('Graph Reducer Tests', () => {
|
|||||||
nextPhaseId: 'end',
|
nextPhaseId: 'end',
|
||||||
phaseData: {
|
phaseData: {
|
||||||
norms: [],
|
norms: [],
|
||||||
goals: []
|
goals: [],
|
||||||
|
triggers: [],
|
||||||
}
|
}
|
||||||
}]
|
}]
|
||||||
},
|
},
|
||||||
@@ -864,7 +926,8 @@ describe('Graph Reducer Tests', () => {
|
|||||||
nextPhaseId: 'phase-2',
|
nextPhaseId: 'phase-2',
|
||||||
phaseData: {
|
phaseData: {
|
||||||
norms: [],
|
norms: [],
|
||||||
goals: []
|
goals: [],
|
||||||
|
triggers: [],
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -879,7 +942,8 @@ describe('Graph Reducer Tests', () => {
|
|||||||
value: "generic"
|
value: "generic"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
goals: []
|
goals: [],
|
||||||
|
triggers: [],
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -892,7 +956,8 @@ describe('Graph Reducer Tests', () => {
|
|||||||
name: 'Generic Norm',
|
name: 'Generic Norm',
|
||||||
value: "generic"
|
value: "generic"
|
||||||
}],
|
}],
|
||||||
goals: []
|
goals: [],
|
||||||
|
triggers: [],
|
||||||
}
|
}
|
||||||
}]
|
}]
|
||||||
},
|
},
|
||||||
@@ -909,7 +974,8 @@ describe('Graph Reducer Tests', () => {
|
|||||||
name: 'Generic Norm',
|
name: 'Generic Norm',
|
||||||
value: "generic"
|
value: "generic"
|
||||||
}],
|
}],
|
||||||
goals: []
|
goals: [],
|
||||||
|
triggers: [],
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -929,7 +995,8 @@ describe('Graph Reducer Tests', () => {
|
|||||||
value: "generic"
|
value: "generic"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
goals: []
|
goals: [],
|
||||||
|
triggers: [],
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -947,7 +1014,8 @@ describe('Graph Reducer Tests', () => {
|
|||||||
name: 'Generic Norm',
|
name: 'Generic Norm',
|
||||||
value: "generic"
|
value: "generic"
|
||||||
}],
|
}],
|
||||||
goals: []
|
goals: [],
|
||||||
|
triggers: [],
|
||||||
}
|
}
|
||||||
}]
|
}]
|
||||||
},
|
},
|
||||||
|
|||||||
22
test/utils/duplicateIndices.test.ts
Normal file
22
test/utils/duplicateIndices.test.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import duplicateIndices from "../../src/utils/duplicateIndices.ts";
|
||||||
|
|
||||||
|
describe("duplicateIndices (unit)", () => {
|
||||||
|
it("returns an empty array for empty input", () => {
|
||||||
|
expect(duplicateIndices<number>([])).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns an empty array when no duplicates exist", () => {
|
||||||
|
expect(duplicateIndices([1, 2, 3, 4])).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns all positions for every duplicated value", () => {
|
||||||
|
const result = duplicateIndices(["a", "b", "a", "c", "b", "b"]);
|
||||||
|
expect(result.sort()).toEqual([0, 1, 2, 4, 5]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("only treats identical references as duplicate objects", () => {
|
||||||
|
const shared = { v: 1 };
|
||||||
|
const result = duplicateIndices([shared, { v: 1 }, shared, shared]);
|
||||||
|
expect(result.sort()).toEqual([0, 2, 3]);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user