From 8d4c3fc64b4a5f72ab881718726af829d96d9250 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Otgaar?= Date: Tue, 16 Dec 2025 12:03:48 +0100 Subject: [PATCH 01/27] feat: add conditions and beliefs, add tests ref: N25B-392 --- .../nodes/NormNode.default.ts | 1 + .../visualProgrammingUI/nodes/NormNode.tsx | 45 ++++- .../nodes/NormNode.test.tsx | 178 ++++++++++++++++-- 3 files changed, 201 insertions(+), 23 deletions(-) diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.default.ts b/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.default.ts index 4b4a3ed..8df25cc 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.default.ts +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.default.ts @@ -6,6 +6,7 @@ import type { NormNodeData } from "./NormNode"; export const NormNodeDefaults: NormNodeData = { label: "Norm Node", droppable: true, + conditions: [], norm: "", hasReduce: true, critical: false, diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.tsx index 348b95d..371ab77 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.tsx @@ -8,6 +8,7 @@ import { Toolbar } from '../components/NodeComponents'; import styles from '../../VisProg.module.css'; import { TextField } from '../../../../components/TextField'; import useFlowStore from '../VisProgStores'; +import { BasicBeliefReduce } from './BasicBeliefNode'; /** * The default data dot a phase node @@ -19,6 +20,7 @@ import useFlowStore from '../VisProgStores'; export type NormNodeData = { label: string; droppable: boolean; + conditions: string[]; // List of (basic) belief nodes' ids. norm: string; hasReduce: boolean; critical: boolean; @@ -67,7 +69,14 @@ export default function NormNode(props: NodeProps) { onChange={(e) => setAchieved(e.target.checked)} /> + + {data.conditions.length > 0 && (
+ +
)} + + + ; }; @@ -78,14 +87,29 @@ export default function NormNode(props: NodeProps) { * @param node The Node Properties of this node. * @param _nodes all the nodes in the graph */ -export function NormReduce(node: Node, _nodes: Node[]) { +export function NormReduce(node: Node, nodes: Node[]) { const data = node.data as NormNodeData; - return { - id: node.id, - label: data.label, - norm: data.norm, - critical: data.critical, - } + + // conditions nodes - make sure to check for empty arrays + let conditionNodes: Node[] = []; + if (data.conditions) + conditionNodes = nodes.filter((node) => data.conditions.includes(node.id)); + + // Build the result object + const result: Record = { + id: node.id, + label: data.label, + norm: data.norm, + critical: data.critical, + }; + + // Go over our conditionNodes. They should either be Basic (OR TODO: Inferred) + const reducer = BasicBeliefReduce; + result["basic_beliefs"] = conditionNodes.map((condition) => reducer(condition, nodes)) + + // When the Inferred is being implemented, you should follow the same kind of structure that PhaseNode has, + // dividing the conditions into basic and inferred, then calling the correct reducer on them. + return result } /** @@ -94,7 +118,12 @@ export function NormReduce(node: Node, _nodes: Node[]) { * @param _sourceNodeId the source of the received connection */ export function NormConnectionTarget(_thisNode: Node, _sourceNodeId: string) { - // no additional connection logic exists yet + const data = _thisNode.data as NormNodeData; + + // If we got a belief connected, this is a condition for the norm. + if ((useFlowStore.getState().nodes.find((node) => node.id === _sourceNodeId && node.type === 'basic_belief' /* TODO: Add the option for an inferred belief */))) { + data.conditions.push(_sourceNodeId); + } } /** diff --git a/test/pages/visProgPage/visualProgrammingUI/nodes/NormNode.test.tsx b/test/pages/visProgPage/visualProgrammingUI/nodes/NormNode.test.tsx index a9848b2..272efbc 100644 --- a/test/pages/visProgPage/visualProgrammingUI/nodes/NormNode.test.tsx +++ b/test/pages/visProgPage/visualProgrammingUI/nodes/NormNode.test.tsx @@ -10,8 +10,9 @@ import NormNode, { import useFlowStore from '../../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores'; import type { Node } from '@xyflow/react'; import '@testing-library/jest-dom' - - +import { NormNodeDefaults } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.default.ts'; +import { BasicBeliefNodeDefaults } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.default.ts'; +import BasicBeliefNode, { BasicBeliefConnectionSource } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.tsx'; describe('NormNode', () => { let user: ReturnType; @@ -26,12 +27,7 @@ describe('NormNode', () => { id: 'norm-1', type: 'norm', position: { x: 0, y: 0 }, - data: { - label: 'Test Norm', - droppable: true, - norm: '', - hasReduce: true, - }, + data: NormNodeDefaults, }; renderWithProviders( @@ -60,6 +56,7 @@ describe('NormNode', () => { type: 'norm', position: { x: 0, y: 0 }, data: { + ...NormNodeDefaults, label: 'Test Norm', droppable: true, norm: 'Be respectful to humans', @@ -94,8 +91,10 @@ describe('NormNode', () => { type: 'norm', position: { x: 0, y: 0 }, data: { + ...NormNodeDefaults, label: 'Test Norm', droppable: true, + conditions: [], norm: '', hasReduce: true, critical: false @@ -129,6 +128,7 @@ describe('NormNode', () => { type: 'norm', position: { x: 0, y: 0 }, data: { + ...NormNodeDefaults, label: 'Test Norm', droppable: true, norm: 'Dragged norm', @@ -165,6 +165,7 @@ describe('NormNode', () => { type: 'norm', position: { x: 0, y: 0 }, data: { + ...NormNodeDefaults, label: 'Test Norm', droppable: true, norm: '', @@ -210,6 +211,7 @@ describe('NormNode', () => { type: 'norm', position: { x: 0, y: 0 }, data: { + ...NormNodeDefaults, label: 'Test Norm', droppable: true, norm: 'Initial norm text', @@ -261,6 +263,7 @@ describe('NormNode', () => { type: 'norm', position: { x: 0, y: 0 }, data: { + ...NormNodeDefaults, label: 'Test Norm', droppable: true, norm: '', @@ -314,6 +317,7 @@ describe('NormNode', () => { type: 'norm', position: { x: 0, y: 0 }, data: { + ...NormNodeDefaults, label: 'Test Norm', droppable: true, norm: '', @@ -358,6 +362,7 @@ describe('NormNode', () => { type: 'norm', position: { x: 0, y: 0 }, data: { + ...NormNodeDefaults, label: 'Test Norm', droppable: true, norm: '', @@ -404,6 +409,7 @@ describe('NormNode', () => { type: 'norm', position: { x: 0, y: 0 }, data: { + ...NormNodeDefaults, label: 'Safety Norm', droppable: true, norm: 'Never harm humans', @@ -418,6 +424,8 @@ describe('NormNode', () => { id: 'norm-1', label: 'Safety Norm', norm: 'Never harm humans', + critical: false, + basic_beliefs: [], }); }); @@ -427,6 +435,7 @@ describe('NormNode', () => { type: 'norm', position: { x: 0, y: 0 }, data: { + ...NormNodeDefaults, label: 'Norm 1', droppable: true, norm: 'Be helpful', @@ -439,6 +448,7 @@ describe('NormNode', () => { type: 'norm', position: { x: 100, y: 0 }, data: { + ...NormNodeDefaults, label: 'Norm 2', droppable: true, norm: 'Be honest', @@ -463,6 +473,7 @@ describe('NormNode', () => { type: 'norm', position: { x: 0, y: 0 }, data: { + ...NormNodeDefaults, label: 'Empty Norm', droppable: true, norm: '', @@ -482,6 +493,7 @@ describe('NormNode', () => { type: 'norm', position: { x: 0, y: 0 }, data: { + ...NormNodeDefaults, label: 'Custom Label', droppable: false, norm: 'Test norm', @@ -502,6 +514,7 @@ describe('NormNode', () => { type: 'norm', position: { x: 0, y: 0 }, data: { + ...NormNodeDefaults, label: 'Test Norm', droppable: true, norm: 'Test', @@ -514,6 +527,7 @@ describe('NormNode', () => { type: 'phase', position: { x: 100, y: 0 }, data: { + ...NormNodeDefaults, label: 'Phase 1', droppable: true, children: [], @@ -532,6 +546,7 @@ describe('NormNode', () => { type: 'norm', position: { x: 0, y: 0 }, data: { + ...NormNodeDefaults, label: 'Test Norm', droppable: true, norm: 'Test', @@ -544,6 +559,7 @@ describe('NormNode', () => { type: 'phase', position: { x: 100, y: 0 }, data: { + ...NormNodeDefaults, label: 'Phase 1', droppable: true, children: [], @@ -562,6 +578,7 @@ describe('NormNode', () => { type: 'norm', position: { x: 0, y: 0 }, data: { + ...NormNodeDefaults, label: 'Test Norm', droppable: true, norm: 'Test', @@ -583,6 +600,7 @@ describe('NormNode', () => { type: 'norm', position: { x: 0, y: 0 }, data: { + ...NormNodeDefaults, label: 'Test Norm', droppable: true, norm: '', @@ -634,6 +652,7 @@ describe('NormNode', () => { type: 'norm', position: { x: 0, y: 0 }, data: { + ...NormNodeDefaults, label: 'Test Norm', droppable: true, norm: '', @@ -682,6 +701,7 @@ describe('NormNode', () => { type: 'norm', position: { x: 0, y: 0 }, data: { + ...NormNodeDefaults, label: 'Norm 1', droppable: true, norm: 'Original norm 1', @@ -694,6 +714,7 @@ describe('NormNode', () => { type: 'norm', position: { x: 100, y: 0 }, data: { + ...NormNodeDefaults, label: 'Norm 2', droppable: true, norm: 'Original norm 2', @@ -748,6 +769,7 @@ describe('NormNode', () => { type: 'norm', position: { x: 0, y: 0 }, data: { + ...NormNodeDefaults, label: 'Test Norm', droppable: true, norm: 'haa haa fuyaaah - link', @@ -778,21 +800,147 @@ describe('NormNode', () => { ); const input = screen.getByPlaceholderText('Pepper should ...'); + expect(input).toBeDefined() - await user.type(input, 'a'); + await user.type(input, 'a{enter}'); await waitFor(() => { - expect(useFlowStore.getState().nodes[0].data.norm).toBe('haa haa fuyaaah - link'); + expect(useFlowStore.getState().nodes[0].data.norm).toBe('haa haa fuyaaah - linka'); }); - await user.type(input, 'b'); + await user.type(input, 'b{enter}'); await waitFor(() => { - expect(useFlowStore.getState().nodes[0].data.norm).toBe('haa haa fuyaaah - link'); + expect(useFlowStore.getState().nodes[0].data.norm).toBe('haa haa fuyaaah - linkab'); }); - await user.type(input, 'c'); + await user.type(input, 'c{enter}'); await waitFor(() => { - expect(useFlowStore.getState().nodes[0].data.norm).toBe('haa haa fuyaaah - link'); - }, { timeout: 3000 }); + expect(useFlowStore.getState().nodes[0].data.norm).toBe('haa haa fuyaaah - linkabc'); + }); }); }); + + describe('Integration beliefs', () => { + it('should update visually when adding beliefs', async () => { + // Setup state + const mockNode: Node = { + id: 'norm-1', + type: 'norm', + position: { x: 0, y: 0 }, + data: { + ...NormNodeDefaults, + label: 'Test Norm', + droppable: true, + norm: 'haa haa fuyaaah - link', + hasReduce: true, + } + }; + + const mockBelief: Node = { + id: 'basic_belief-1', + type: 'basic_belief', + position: {x:100, y:100}, + data: { + ...BasicBeliefNodeDefaults + } + }; + + useFlowStore.setState({ + nodes: [mockNode, mockBelief], + edges: [], + }); + + // Simulate connecting + NormConnectionTarget(mockNode, mockBelief.id); + BasicBeliefConnectionSource(mockBelief, mockNode.id) + + renderWithProviders( +
+ + +
+ ); + + await waitFor(() => { + expect(screen.getByTestId('norm-condition-information')).toBeInTheDocument(); + }); + }); + + it('should update the data when adding beliefs', async () => { + // Setup state + const mockNode: Node = { + id: 'norm-1', + type: 'norm', + position: { x: 0, y: 0 }, + data: { + ...NormNodeDefaults, + label: 'Test Norm', + droppable: true, + norm: 'haa haa fuyaaah - link', + hasReduce: true, + } + }; + + const mockBelief1: Node = { + id: 'basic_belief-1', + type: 'basic_belief', + position: {x:100, y:100}, + data: { + ...BasicBeliefNodeDefaults + } + }; + + const mockBelief2: Node = { + id: 'basic_belief-2', + type: 'basic_belief', + position: {x:300, y:300}, + data: { + ...BasicBeliefNodeDefaults + } + }; + + useFlowStore.setState({ + nodes: [mockNode, mockBelief1, mockBelief2], + edges: [], + }); + + // Simulate connecting + NormConnectionTarget(mockNode, mockBelief1.id); + NormConnectionTarget(mockNode, mockBelief2.id); + BasicBeliefConnectionSource(mockBelief1, mockNode.id); + BasicBeliefConnectionSource(mockBelief2, mockNode.id); + + const state = useFlowStore.getState(); + const updatedNorm = state.nodes.find(n => n.id === 'norm-1'); + expect(updatedNorm?.data.conditions).toBe(["basic_belief-1", "basic_belief-2"]); + }); + + + + }); }); \ No newline at end of file From 099afebe98ffb1f2471613c40fb41eb659980023 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Otgaar?= Date: Tue, 16 Dec 2025 14:31:00 +0100 Subject: [PATCH 02/27] test: extra norm tests ref: N25B-392 --- .../visualProgrammingUI/VisProgStores.tsx | 2 +- .../visualProgrammingUI/nodes/NormNode.tsx | 6 ++- .../nodes/NormNode.test.tsx | 20 ++++++-- .../nodes/UniversalNodes.test.tsx | 46 ++++++++++++++++++- 4 files changed, 66 insertions(+), 8 deletions(-) diff --git a/src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx b/src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx index 0847945..1decf8e 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx @@ -36,7 +36,7 @@ function createNode(id: string, type: string, position: XYPosition, data: Record position, deletable, data: { - ...defaultData, + ...JSON.parse(JSON.stringify(defaultData)), ...data, }, } diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.tsx index 371ab77..bba42d0 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.tsx @@ -141,7 +141,11 @@ export function NormConnectionSource(_thisNode: Node, _targetNodeId: string) { * @param _sourceNodeId the source of the disconnected connection */ export function NormDisconnectionTarget(_thisNode: Node, _sourceNodeId: string) { - // no additional connection logic exists yet + const data = _thisNode.data as NormNodeData; + // If we got a belief connected, this is a condition for the norm. + if ((useFlowStore.getState().nodes.find((node) => node.id === _sourceNodeId && node.type === 'basic_belief' /* TODO: Add the option for an inferred belief */))) { + data.conditions = data.conditions.filter(id => id != _sourceNodeId); + } } /** diff --git a/test/pages/visProgPage/visualProgrammingUI/nodes/NormNode.test.tsx b/test/pages/visProgPage/visualProgrammingUI/nodes/NormNode.test.tsx index 272efbc..91c9962 100644 --- a/test/pages/visProgPage/visualProgrammingUI/nodes/NormNode.test.tsx +++ b/test/pages/visProgPage/visualProgrammingUI/nodes/NormNode.test.tsx @@ -930,14 +930,24 @@ describe('NormNode', () => { }); // Simulate connecting - NormConnectionTarget(mockNode, mockBelief1.id); - NormConnectionTarget(mockNode, mockBelief2.id); - BasicBeliefConnectionSource(mockBelief1, mockNode.id); - BasicBeliefConnectionSource(mockBelief2, mockNode.id); + useFlowStore.getState().onConnect({ + source: 'basic_belief-1', + target: 'norm-1', + sourceHandle: null, + targetHandle: null, + }); + useFlowStore.getState().onConnect({ + source: 'basic_belief-2', + target: 'norm-1', + sourceHandle: null, + targetHandle: null, + }); + const state = useFlowStore.getState(); const updatedNorm = state.nodes.find(n => n.id === 'norm-1'); - expect(updatedNorm?.data.conditions).toBe(["basic_belief-1", "basic_belief-2"]); + console.log(updatedNorm?.data.conditions); + expect(updatedNorm?.data.conditions).toEqual(["basic_belief-1", "basic_belief-1", "basic_belief-2"]); }); diff --git a/test/pages/visProgPage/visualProgrammingUI/nodes/UniversalNodes.test.tsx b/test/pages/visProgPage/visualProgrammingUI/nodes/UniversalNodes.test.tsx index c023722..4491388 100644 --- a/test/pages/visProgPage/visualProgrammingUI/nodes/UniversalNodes.test.tsx +++ b/test/pages/visProgPage/visualProgrammingUI/nodes/UniversalNodes.test.tsx @@ -8,7 +8,7 @@ import { createElement } from 'react'; import useFlowStore from '../../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores'; -describe('NormNode', () => { +describe('Universal Nodes', () => { beforeEach(() => { jest.clearAllMocks(); }); @@ -109,6 +109,50 @@ describe('NormNode', () => { }); }); + describe('Disconnecting', () => { + test.each(getAllTypes())('it should remove the correct data when something is disconnected on a %s node.', (nodeType) => { + // Create two nodes - one of the current type and one to connect to + const sourceNode = createNode('source-1', nodeType, {x: 100, y: 100}, {}); + const targetNode = createNode('target-1', 'basic_belief', {x: 300, y: 100}, {}); + + // Add nodes to store + useFlowStore.setState({ nodes: [sourceNode, targetNode] }); + + // Spy on the connect functions + const sourceConnectSpy = jest.spyOn(NodeConnections.Sources, nodeType as keyof typeof NodeConnections.Sources); + const targetConnectSpy = jest.spyOn(NodeConnections.Targets, 'basic_belief'); + + // Simulate connection + useFlowStore.getState().onConnect({ + source: 'source-1', + target: 'target-1', + sourceHandle: null, + targetHandle: null, + }); + + + // Verify the connect functions were called + expect(sourceConnectSpy).toHaveBeenCalledWith(sourceNode, targetNode.id); + expect(targetConnectSpy).toHaveBeenCalledWith(targetNode, sourceNode.id); + + // Find this connection, and delete it + const edge = useFlowStore.getState().edges[0]; + useFlowStore.getState().onEdgesDelete([edge]); + + // Find the nodes in the flow + const newSourceNode = useFlowStore.getState().nodes.find((node) => node.id == "source-1"); + const newTargetNode = useFlowStore.getState().nodes.find((node) => node.id == "target-1"); + + // Expect them to be the same after deleting the edges + expect(newSourceNode).toBe(sourceNode); + expect(newTargetNode).toBe(targetNode); + + // Restore our spies + sourceConnectSpy.mockRestore(); + targetConnectSpy.mockRestore(); + }); + }); + describe('Reducing', () => { test.each(getAllTypes())('it should correctly call/ not call the reduce function when %s node is in a phase', (nodeType) => { // Create a phase node and a node of the current type From 709dd28959d9fba28873a18c6da4942ae6a190cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Otgaar?= Date: Tue, 16 Dec 2025 14:52:57 +0100 Subject: [PATCH 03/27] fix: fixing the tests ref: N25B-392 --- .../visualProgrammingUI/nodes/NormNode.tsx | 1 - .../nodes/NormNode.test.tsx | 58 ++++++++++--------- .../nodes/UniversalNodes.test.tsx | 2 +- 3 files changed, 31 insertions(+), 30 deletions(-) diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.tsx index bba42d0..36217e2 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.tsx @@ -119,7 +119,6 @@ export function NormReduce(node: Node, nodes: Node[]) { */ export function NormConnectionTarget(_thisNode: Node, _sourceNodeId: string) { const data = _thisNode.data as NormNodeData; - // If we got a belief connected, this is a condition for the norm. if ((useFlowStore.getState().nodes.find((node) => node.id === _sourceNodeId && node.type === 'basic_belief' /* TODO: Add the option for an inferred belief */))) { data.conditions.push(_sourceNodeId); diff --git a/test/pages/visProgPage/visualProgrammingUI/nodes/NormNode.test.tsx b/test/pages/visProgPage/visualProgrammingUI/nodes/NormNode.test.tsx index 91c9962..f003547 100644 --- a/test/pages/visProgPage/visualProgrammingUI/nodes/NormNode.test.tsx +++ b/test/pages/visProgPage/visualProgrammingUI/nodes/NormNode.test.tsx @@ -27,7 +27,7 @@ describe('NormNode', () => { id: 'norm-1', type: 'norm', position: { x: 0, y: 0 }, - data: NormNodeDefaults, + data: {...JSON.parse(JSON.stringify(NormNodeDefaults))}, }; renderWithProviders( @@ -56,7 +56,7 @@ describe('NormNode', () => { type: 'norm', position: { x: 0, y: 0 }, data: { - ...NormNodeDefaults, + ...JSON.parse(JSON.stringify(NormNodeDefaults)), label: 'Test Norm', droppable: true, norm: 'Be respectful to humans', @@ -91,7 +91,7 @@ describe('NormNode', () => { type: 'norm', position: { x: 0, y: 0 }, data: { - ...NormNodeDefaults, + ...JSON.parse(JSON.stringify(NormNodeDefaults)), label: 'Test Norm', droppable: true, conditions: [], @@ -128,7 +128,7 @@ describe('NormNode', () => { type: 'norm', position: { x: 0, y: 0 }, data: { - ...NormNodeDefaults, + ...JSON.parse(JSON.stringify(NormNodeDefaults)), label: 'Test Norm', droppable: true, norm: 'Dragged norm', @@ -165,7 +165,7 @@ describe('NormNode', () => { type: 'norm', position: { x: 0, y: 0 }, data: { - ...NormNodeDefaults, + ...JSON.parse(JSON.stringify(NormNodeDefaults)), label: 'Test Norm', droppable: true, norm: '', @@ -211,7 +211,7 @@ describe('NormNode', () => { type: 'norm', position: { x: 0, y: 0 }, data: { - ...NormNodeDefaults, + ...JSON.parse(JSON.stringify(NormNodeDefaults)), label: 'Test Norm', droppable: true, norm: 'Initial norm text', @@ -263,7 +263,7 @@ describe('NormNode', () => { type: 'norm', position: { x: 0, y: 0 }, data: { - ...NormNodeDefaults, + ...JSON.parse(JSON.stringify(NormNodeDefaults)), label: 'Test Norm', droppable: true, norm: '', @@ -317,7 +317,7 @@ describe('NormNode', () => { type: 'norm', position: { x: 0, y: 0 }, data: { - ...NormNodeDefaults, + ...JSON.parse(JSON.stringify(NormNodeDefaults)), label: 'Test Norm', droppable: true, norm: '', @@ -362,7 +362,7 @@ describe('NormNode', () => { type: 'norm', position: { x: 0, y: 0 }, data: { - ...NormNodeDefaults, + ...JSON.parse(JSON.stringify(NormNodeDefaults)), label: 'Test Norm', droppable: true, norm: '', @@ -409,7 +409,7 @@ describe('NormNode', () => { type: 'norm', position: { x: 0, y: 0 }, data: { - ...NormNodeDefaults, + ...JSON.parse(JSON.stringify(NormNodeDefaults)), label: 'Safety Norm', droppable: true, norm: 'Never harm humans', @@ -435,7 +435,7 @@ describe('NormNode', () => { type: 'norm', position: { x: 0, y: 0 }, data: { - ...NormNodeDefaults, + ...JSON.parse(JSON.stringify(NormNodeDefaults)), label: 'Norm 1', droppable: true, norm: 'Be helpful', @@ -448,7 +448,7 @@ describe('NormNode', () => { type: 'norm', position: { x: 100, y: 0 }, data: { - ...NormNodeDefaults, + ...JSON.parse(JSON.stringify(NormNodeDefaults)), label: 'Norm 2', droppable: true, norm: 'Be honest', @@ -473,7 +473,7 @@ describe('NormNode', () => { type: 'norm', position: { x: 0, y: 0 }, data: { - ...NormNodeDefaults, + ...JSON.parse(JSON.stringify(NormNodeDefaults)), label: 'Empty Norm', droppable: true, norm: '', @@ -493,7 +493,7 @@ describe('NormNode', () => { type: 'norm', position: { x: 0, y: 0 }, data: { - ...NormNodeDefaults, + ...JSON.parse(JSON.stringify(NormNodeDefaults)), label: 'Custom Label', droppable: false, norm: 'Test norm', @@ -514,7 +514,7 @@ describe('NormNode', () => { type: 'norm', position: { x: 0, y: 0 }, data: { - ...NormNodeDefaults, + ...JSON.parse(JSON.stringify(NormNodeDefaults)), label: 'Test Norm', droppable: true, norm: 'Test', @@ -527,7 +527,7 @@ describe('NormNode', () => { type: 'phase', position: { x: 100, y: 0 }, data: { - ...NormNodeDefaults, + ...JSON.parse(JSON.stringify(NormNodeDefaults)), label: 'Phase 1', droppable: true, children: [], @@ -546,7 +546,7 @@ describe('NormNode', () => { type: 'norm', position: { x: 0, y: 0 }, data: { - ...NormNodeDefaults, + ...JSON.parse(JSON.stringify(NormNodeDefaults)), label: 'Test Norm', droppable: true, norm: 'Test', @@ -559,7 +559,7 @@ describe('NormNode', () => { type: 'phase', position: { x: 100, y: 0 }, data: { - ...NormNodeDefaults, + ...JSON.parse(JSON.stringify(NormNodeDefaults)), label: 'Phase 1', droppable: true, children: [], @@ -600,7 +600,7 @@ describe('NormNode', () => { type: 'norm', position: { x: 0, y: 0 }, data: { - ...NormNodeDefaults, + ...JSON.parse(JSON.stringify(NormNodeDefaults)), label: 'Test Norm', droppable: true, norm: '', @@ -652,7 +652,7 @@ describe('NormNode', () => { type: 'norm', position: { x: 0, y: 0 }, data: { - ...NormNodeDefaults, + ...JSON.parse(JSON.stringify(NormNodeDefaults)), label: 'Test Norm', droppable: true, norm: '', @@ -701,7 +701,7 @@ describe('NormNode', () => { type: 'norm', position: { x: 0, y: 0 }, data: { - ...NormNodeDefaults, + ...JSON.parse(JSON.stringify(NormNodeDefaults)), label: 'Norm 1', droppable: true, norm: 'Original norm 1', @@ -714,7 +714,7 @@ describe('NormNode', () => { type: 'norm', position: { x: 100, y: 0 }, data: { - ...NormNodeDefaults, + ...JSON.parse(JSON.stringify(NormNodeDefaults)), label: 'Norm 2', droppable: true, norm: 'Original norm 2', @@ -827,7 +827,7 @@ describe('NormNode', () => { type: 'norm', position: { x: 0, y: 0 }, data: { - ...NormNodeDefaults, + ...JSON.parse(JSON.stringify(NormNodeDefaults)), label: 'Test Norm', droppable: true, norm: 'haa haa fuyaaah - link', @@ -840,7 +840,7 @@ describe('NormNode', () => { type: 'basic_belief', position: {x:100, y:100}, data: { - ...BasicBeliefNodeDefaults + ...JSON.parse(JSON.stringify(BasicBeliefNodeDefaults)) } }; @@ -889,6 +889,8 @@ describe('NormNode', () => { await waitFor(() => { expect(screen.getByTestId('norm-condition-information')).toBeInTheDocument(); }); + + }); it('should update the data when adding beliefs', async () => { @@ -898,7 +900,7 @@ describe('NormNode', () => { type: 'norm', position: { x: 0, y: 0 }, data: { - ...NormNodeDefaults, + ...JSON.parse(JSON.stringify(NormNodeDefaults)), label: 'Test Norm', droppable: true, norm: 'haa haa fuyaaah - link', @@ -911,7 +913,7 @@ describe('NormNode', () => { type: 'basic_belief', position: {x:100, y:100}, data: { - ...BasicBeliefNodeDefaults + ...JSON.parse(JSON.stringify(BasicBeliefNodeDefaults)) } }; @@ -920,7 +922,7 @@ describe('NormNode', () => { type: 'basic_belief', position: {x:300, y:300}, data: { - ...BasicBeliefNodeDefaults + ...JSON.parse(JSON.stringify(BasicBeliefNodeDefaults)) } }; @@ -947,7 +949,7 @@ describe('NormNode', () => { const state = useFlowStore.getState(); const updatedNorm = state.nodes.find(n => n.id === 'norm-1'); console.log(updatedNorm?.data.conditions); - expect(updatedNorm?.data.conditions).toEqual(["basic_belief-1", "basic_belief-1", "basic_belief-2"]); + expect(updatedNorm?.data.conditions).toEqual(["basic_belief-1", "basic_belief-2"]); }); diff --git a/test/pages/visProgPage/visualProgrammingUI/nodes/UniversalNodes.test.tsx b/test/pages/visProgPage/visualProgrammingUI/nodes/UniversalNodes.test.tsx index 4491388..48a3fb9 100644 --- a/test/pages/visProgPage/visualProgrammingUI/nodes/UniversalNodes.test.tsx +++ b/test/pages/visProgPage/visualProgrammingUI/nodes/UniversalNodes.test.tsx @@ -124,7 +124,7 @@ describe('Universal Nodes', () => { // Simulate connection useFlowStore.getState().onConnect({ - source: 'source-1', + source: 'source-1', target: 'target-1', sourceHandle: null, targetHandle: null, From 0b29cb585864ff41ead575f8c2219ea42cf6ca05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Otgaar?= Date: Tue, 16 Dec 2025 15:41:30 +0100 Subject: [PATCH 04/27] chore: remove console log ref: N25B-392 --- .../visProgPage/visualProgrammingUI/nodes/NormNode.test.tsx | 5 ----- 1 file changed, 5 deletions(-) diff --git a/test/pages/visProgPage/visualProgrammingUI/nodes/NormNode.test.tsx b/test/pages/visProgPage/visualProgrammingUI/nodes/NormNode.test.tsx index f003547..c762fff 100644 --- a/test/pages/visProgPage/visualProgrammingUI/nodes/NormNode.test.tsx +++ b/test/pages/visProgPage/visualProgrammingUI/nodes/NormNode.test.tsx @@ -945,14 +945,9 @@ describe('NormNode', () => { targetHandle: null, }); - const state = useFlowStore.getState(); const updatedNorm = state.nodes.find(n => n.id === 'norm-1'); - console.log(updatedNorm?.data.conditions); expect(updatedNorm?.data.conditions).toEqual(["basic_belief-1", "basic_belief-2"]); }); - - - }); }); \ No newline at end of file From c1ef924be1e13b1c6da75b15394e04e0f1ef9375 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Otgaar?= Date: Tue, 16 Dec 2025 18:21:19 +0100 Subject: [PATCH 05/27] feat: create dialog for plan creation in triggers, make sure to bind the correct things in triggers. Change the norms to take one condition, rather than a list. yes, tests are probably still broken. ref: N25B-412 --- src/pages/VisProgPage/VisProg.module.css | 64 ++++ .../components/Plan.default.ts | 7 + .../visualProgrammingUI/components/Plan.tsx | 25 ++ .../visualProgrammingUI/nodes/NormNode.tsx | 38 +-- .../visualProgrammingUI/nodes/TriggerNode.tsx | 280 +++++++++++++++++- 5 files changed, 377 insertions(+), 37 deletions(-) create mode 100644 src/pages/VisProgPage/visualProgrammingUI/components/Plan.default.ts create mode 100644 src/pages/VisProgPage/visualProgrammingUI/components/Plan.tsx diff --git a/src/pages/VisProgPage/VisProg.module.css b/src/pages/VisProgPage/VisProg.module.css index 14619c5..cd61b5e 100644 --- a/src/pages/VisProgPage/VisProg.module.css +++ b/src/pages/VisProgPage/VisProg.module.css @@ -138,4 +138,68 @@ border-radius: 5pt; outline: plum solid 2pt; filter: drop-shadow(0 0 0.25rem plum); +} + +.planDialog { + width: 80vw; + max-width: 900px; + padding: 1rem; + border: none; + border-radius: 8px; +} + +.planDialog::backdrop { + background: rgba(0, 0, 0, 0.4); +} + +.planEditor { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1rem; + min-width: 600px; +} + +.planEditorLeft { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.planEditorRight { + display: flex; + flex-direction: column; + gap: 0.5rem; + border-left: 1px solid var(--border-color, #ccc); + padding-left: 1rem; + max-height: 300px; + overflow-y: auto; +} + +.planStep { + display: flex; + align-items: center; + gap: 0.5rem; + cursor: pointer; + transition: text-decoration 0.2s; +} + +.planStep:hover { + text-decoration: line-through; +} + +.stepType { + margin-left: auto; + opacity: 0.7; + font-size: 0.85em; +} + +.stepIndex { + opacity: 0.6; +} + + + +.emptySteps { + opacity: 0.5; + font-style: italic; } \ No newline at end of file diff --git a/src/pages/VisProgPage/visualProgrammingUI/components/Plan.default.ts b/src/pages/VisProgPage/visualProgrammingUI/components/Plan.default.ts new file mode 100644 index 0000000..b2ea31b --- /dev/null +++ b/src/pages/VisProgPage/visualProgrammingUI/components/Plan.default.ts @@ -0,0 +1,7 @@ +import type { Plan } from "./Plan"; + +export const defaultPlan: Plan = { + name: "Default Plan", + id: "-1", + steps: [], +} \ No newline at end of file diff --git a/src/pages/VisProgPage/visualProgrammingUI/components/Plan.tsx b/src/pages/VisProgPage/visualProgrammingUI/components/Plan.tsx new file mode 100644 index 0000000..a74cbf2 --- /dev/null +++ b/src/pages/VisProgPage/visualProgrammingUI/components/Plan.tsx @@ -0,0 +1,25 @@ +export type Plan = { + name: string, + id: string, + steps: PlanElement[], +} + +export type PlanElement = Goal | Action + +export type Goal = { + id: string, + name: string, + plan: Plan, + can_fail: boolean, + type: "goal" +} + +// Actions +export type Action = SpeechAction | GestureAction | LLMAction +export type SpeechAction = { name: string, id: string, text: string, type:"speech" } +export type GestureAction = { name: string, id: string, gesture: string, type:"gesture" } +export type LLMAction = { name: string, id: string, goal: string, type:"llm" } + +export type ActionTypes = "speech" | "gesture" | "llm"; + + diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.tsx index 4e94834..ef65215 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.tsx @@ -20,7 +20,7 @@ import { BasicBeliefReduce } from './BasicBeliefNode'; export type NormNodeData = { label: string; droppable: boolean; - conditions: string[]; // List of (basic) belief nodes' ids. + condition?: string; // id of this node's belief. norm: string; hasReduce: boolean; critical: boolean; @@ -70,13 +70,12 @@ export default function NormNode(props: NodeProps) { /> - {data.conditions.length > 0 && (
- + {data.condition && (
+
)} - - - + +
; }; @@ -90,11 +89,6 @@ export default function NormNode(props: NodeProps) { export function NormReduce(node: Node, nodes: Node[]) { const data = node.data as NormNodeData; - // conditions nodes - make sure to check for empty arrays - let conditionNodes: Node[] = []; - if (data.conditions) - conditionNodes = nodes.filter((node) => data.conditions.includes(node.id)); - // Build the result object const result: Record = { id: node.id, @@ -103,12 +97,14 @@ export function NormReduce(node: Node, nodes: Node[]) { critical: data.critical, }; - // Go over our conditionNodes. They should either be Basic (OR TODO: Inferred) - const reducer = BasicBeliefReduce; - result["basic_beliefs"] = conditionNodes.map((condition) => reducer(condition, nodes)) + if (data.condition) { + const reducer = BasicBeliefReduce; // TODO: also add inferred. + const conditionNode = nodes.find((node) => node.id === data.condition); + // In case something went wrong, and our condition doesn't actually exist; + if (conditionNode == undefined) return result; + result["belief"] = reducer(conditionNode, nodes) + } - // When the Inferred is being implemented, you should follow the same kind of structure that PhaseNode has, - // dividing the conditions into basic and inferred, then calling the correct reducer on them. return result } @@ -119,9 +115,9 @@ export function NormReduce(node: Node, nodes: Node[]) { */ export function NormConnectionTarget(_thisNode: Node, _sourceNodeId: string) { const data = _thisNode.data as NormNodeData; - // If we got a belief connected, this is a condition for the norm. + // If we got a belief connected, this is the condition for the norm. if ((useFlowStore.getState().nodes.find((node) => node.id === _sourceNodeId && node.type === 'basic_belief' /* TODO: Add the option for an inferred belief */))) { - data.conditions.push(_sourceNodeId); + data.condition = _sourceNodeId; } } @@ -141,10 +137,8 @@ export function NormConnectionSource(_thisNode: Node, _targetNodeId: string) { */ export function NormDisconnectionTarget(_thisNode: Node, _sourceNodeId: string) { const data = _thisNode.data as NormNodeData; - // If we got a belief connected, this is a condition for the norm. - if ((useFlowStore.getState().nodes.find((node) => node.id === _sourceNodeId && node.type === 'basic_belief' /* TODO: Add the option for an inferred belief */))) { - data.conditions = data.conditions.filter(id => id != _sourceNodeId); - } + // remove if the target of disconnection was our condition + if (_sourceNodeId == data.condition) data.condition = undefined } /** diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.tsx index cad7015..6e3d940 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.tsx @@ -9,9 +9,11 @@ import { import { Toolbar } from '../components/NodeComponents'; import styles from '../../VisProg.module.css'; import useFlowStore from '../VisProgStores'; -import { useState } from 'react'; +import { useState, useRef } from 'react'; import { RealtimeTextField, TextField } from '../../../../components/TextField'; import duplicateIndices from '../../../../utils/duplicateIndices'; +import type { Action, ActionTypes, Plan } from '../components/Plan'; +import { defaultPlan } from '../components/Plan.default'; /** * The default data structure for a Trigger node @@ -28,8 +30,8 @@ import duplicateIndices from '../../../../utils/duplicateIndices'; export type TriggerNodeData = { label: string; droppable: boolean; - triggerType: "keywords" | string; - triggers: Keyword[] | never; + condition?: string; // id of the belief + plan?: Plan; hasReduce: boolean; }; @@ -55,25 +57,265 @@ export function TriggerNodeCanConnect(connection: Connection | Edge): boolean { export default function TriggerNode(props: NodeProps) { const data = props.data; const {updateNodeData} = useFlowStore(); + const dialogRef = useRef(null); + const [draftPlan, setDraftPlan] = useState(null); + + // Helpers for inside plan creation + const [newActionType, setNewActionType] = useState("speech"); + const [newActionName, setNewActionName] = useState(""); + const [newActionValue, setNewActionValue] = useState(""); + + + // Create a new Plan + const openCreateDialog = () => { + setDraftPlan(JSON.parse(JSON.stringify(defaultPlan))); + dialogRef.current?.showModal(); + }; + + // Edit our current plan + const openEditDialog = () => { + if (!data.plan) return; + setDraftPlan(JSON.parse(JSON.stringify(data.plan))); + dialogRef.current?.showModal(); + }; + + // Close the creating/editing of our plan + const closeDialog = () => { + dialogRef.current?.close(); + }; + + + // Define the function for creating actions + const buildAction = (): Action => { + const id = crypto.randomUUID(); + + switch (newActionType) { + case "speech": + return { + id, + name: newActionName, + text: newActionValue, + type: "speech" + }; + case "gesture": + return { + id, + name: newActionName, + gesture: newActionValue, + type: "gesture" + }; + case "llm": + return { + id, + name: newActionName, + goal: newActionValue, + type: "llm" + }; + } + }; - const setKeywords = (keywords: Keyword[]) => { - updateNodeData(props.id, {...data, triggers: keywords}); - } return <>
- {data.triggerType === "emotion" && ( -
Emotion?
- )} - {data.triggerType === "keywords" && ( - - )} +
Triggers when the condition is met.
+
Condition/ Belief is currently {data.condition ? "" : "not"} set. {data.condition ? "🟢" : "🔴"}
+
Plan{data.plan ? (": " + data.plan.name) : ""} is currently {data.plan ? "" : "not"} set. {data.plan ? "🟢" : "🔴"}
+ + + {/* We don't have a plan yet, show our create plan button */} + {!data.plan && ( + + )} + + {/* We have a plan, show our edit plan button */} + {data.plan && ( + + )}
+ + {/* Define how our dialog should work */} + e.preventDefault()} + > +
+

{draftPlan?.id === data.plan?.id ? "Edit Plan" : "Create Plan"}

+ + {/*Text field to edit the name of our draft plan*/} +
+ {/* LEFT: plan info + add action */} +
+ {/* Plan name */} + {draftPlan && ( +
+ + + setDraftPlan({ + ...draftPlan, + name, + }) + } + /> +
+ )} + + {/* Add action UI */} + {draftPlan && ( +
+

Add Action

+ + + + + + + + +
+ )} +
+ + {/* RIGHT: steps list */} +
+

Steps

+ + {draftPlan && draftPlan.steps.length === 0 && ( +
+ No steps yet +
+ )} + + {draftPlan?.steps.map((step, index) => ( +
{ + if (!draftPlan) return; + setDraftPlan({ + ...draftPlan, + steps: draftPlan.steps.filter((s) => s.id !== step.id), + }); + }} + > + {index + 1}. + {step.name} + {step.type} +
+ ))} +
+
+ +
+ {/*Button to close the plan editor.*/} + + + {/*Button to save the draftPlan to the plan in the Node.*/} + + + {/*Button to reset the plan*/} + +
+
+
; } @@ -108,6 +350,11 @@ export function TriggerReduce(node: Node, _nodes: Node[]) { */ export function TriggerConnectionTarget(_thisNode: Node, _sourceNodeId: string) { // no additional connection logic exists yet + const data = _thisNode.data as TriggerNodeData; + // If we got a belief connected, this is the condition for the norm. + if ((useFlowStore.getState().nodes.find((node) => node.id === _sourceNodeId && node.type === 'basic_belief' /* TODO: Add the option for an inferred belief */))) { + data.condition = _sourceNodeId; + } } /** @@ -126,6 +373,9 @@ export function TriggerConnectionSource(_thisNode: Node, _targetNodeId: string) */ export function TriggerDisconnectionTarget(_thisNode: Node, _sourceNodeId: string) { // no additional connection logic exists yet + const data = _thisNode.data as TriggerNodeData; + // remove if the target of disconnection was our condition + if (_sourceNodeId == data.condition) data.condition = undefined } /** From 444e8b0289154f5c3feb4573cd2c2cf3a6592541 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Otgaar?= Date: Wed, 17 Dec 2025 15:51:50 +0100 Subject: [PATCH 06/27] feat: fix a lot of small changes to match cb, add functionality for all plans, add tests for the new plan editor. even more i dont really know anymore. ref: N25B-412 --- src/components/TextField.module.css | 2 + src/components/TextField.tsx | 2 +- src/pages/VisProgPage/VisProg.module.css | 16 +- .../visualProgrammingUI/components/Plan.tsx | 51 +- .../components/PlanEditor.tsx | 237 +++++++++ .../nodes/BasicBeliefNode.tsx | 24 +- .../nodes/GoalNode.default.ts | 3 +- .../visualProgrammingUI/nodes/GoalNode.tsx | 44 +- .../nodes/NormNode.default.ts | 1 - .../visualProgrammingUI/nodes/PhaseNode.tsx | 2 +- .../nodes/TriggerNode.default.ts | 2 - .../visualProgrammingUI/nodes/TriggerNode.tsx | 385 +-------------- .../components/PlanEditor.test.tsx | 450 ++++++++++++++++++ .../nodes/NormNode.test.tsx | 23 +- .../nodes/TriggerNode.test.tsx | 201 ++------ .../nodes/UniversalNodes.test.tsx | 2 +- 16 files changed, 884 insertions(+), 561 deletions(-) create mode 100644 src/pages/VisProgPage/visualProgrammingUI/components/PlanEditor.tsx create mode 100644 test/pages/visProgPage/visualProgrammingUI/components/PlanEditor.test.tsx diff --git a/src/components/TextField.module.css b/src/components/TextField.module.css index de66531..1f40d85 100644 --- a/src/components/TextField.module.css +++ b/src/components/TextField.module.css @@ -2,6 +2,8 @@ border: 1px solid transparent; border-radius: 5pt; padding: 4px 8px; + max-width: 50vw; + min-width: 10vw; outline: none; background-color: canvas; transition: border-color 0.2s, box-shadow 0.2s; diff --git a/src/components/TextField.tsx b/src/components/TextField.tsx index 6dbc47b..6395e18 100644 --- a/src/components/TextField.tsx +++ b/src/components/TextField.tsx @@ -63,7 +63,7 @@ export function RealtimeTextField({ 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}`} + className={`${readOnly ? "drag" : "nodrag"} flex-1 ${styles.textField} ${invalid ? styles.invalid : ""} ${className}`} aria-label={ariaLabel} />; } diff --git a/src/pages/VisProgPage/VisProg.module.css b/src/pages/VisProgPage/VisProg.module.css index cd61b5e..7731e42 100644 --- a/src/pages/VisProgPage/VisProg.module.css +++ b/src/pages/VisProgPage/VisProg.module.css @@ -183,23 +183,33 @@ transition: text-decoration 0.2s; } + .planStep:hover { text-decoration: line-through; } .stepType { - margin-left: auto; opacity: 0.7; font-size: 0.85em; } + .stepIndex { opacity: 0.6; } - - .emptySteps { opacity: 0.5; font-style: italic; +} + +.stepSuggestion { + opacity: 0.5; + font-style: italic; +} + +.planNoIterate { + opacity: 0.5; + font-style: italic; + text-decoration: line-through; } \ No newline at end of file diff --git a/src/pages/VisProgPage/visualProgrammingUI/components/Plan.tsx b/src/pages/VisProgPage/visualProgrammingUI/components/Plan.tsx index a74cbf2..4955d86 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/components/Plan.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/components/Plan.tsx @@ -16,10 +16,55 @@ export type Goal = { // Actions export type Action = SpeechAction | GestureAction | LLMAction -export type SpeechAction = { name: string, id: string, text: string, type:"speech" } -export type GestureAction = { name: string, id: string, gesture: string, type:"gesture" } -export type LLMAction = { name: string, id: string, goal: string, type:"llm" } +export type SpeechAction = { id: string, text: string, type:"speech" } +export type GestureAction = { id: string, gesture: string, type:"gesture" } +export type LLMAction = { id: string, goal: string, type:"llm" } export type ActionTypes = "speech" | "gesture" | "llm"; +// Extract the wanted information from a plan within the reducing of nodes +export function PlanReduce(plan?: Plan) { + if (!plan) return "" + return { + name: plan.name, + id: plan.id, + steps: plan.steps, + } +} + +/** + * Finds out whether the plan can iterate multiple times, or always stops after one action. + * This comes down to checking if the plan only has speech/ gesture actions, or others as well. + * @param plan: the plan to check + * @returns: a boolean + */ +export function DoesPlanIterate(plan?: Plan) : boolean { + // TODO: should recursively check plans that have goals (and thus more plans) in them. + if (!plan) return false + return plan.steps.filter((step) => step.type == "llm").length > 0; +} + +/** + * Returns the value of the action. + * Since typescript can't polymorphicly access the value field, + * we need to switch over the types and return the correct field. + * @param action: action to retrieve the value from + * @returns string | undefined + */ +export function GetActionValue(action: Action) { + let returnAction; + switch (action.type) { + case "gesture": + returnAction = action as GestureAction + return returnAction.gesture; + case "speech": + returnAction = action as SpeechAction + return returnAction.text; + case "llm": + returnAction = action as LLMAction + return returnAction.goal; + default: + break; + } +} \ No newline at end of file diff --git a/src/pages/VisProgPage/visualProgrammingUI/components/PlanEditor.tsx b/src/pages/VisProgPage/visualProgrammingUI/components/PlanEditor.tsx new file mode 100644 index 0000000..af05310 --- /dev/null +++ b/src/pages/VisProgPage/visualProgrammingUI/components/PlanEditor.tsx @@ -0,0 +1,237 @@ +import { useRef, useState } from "react"; +import styles from '../../VisProg.module.css'; +import { GetActionValue, type Action, type ActionTypes, type Plan } from "../components/Plan"; +import { defaultPlan } from "../components/Plan.default"; +import { TextField } from "../../../../components/TextField"; + +type PlanEditorDialogProps = { + plan?: Plan; + onSave: (plan: Plan | undefined) => void; + description? : string; +}; + +/** + * Adds an element to a React.JSX.Element that allows for the creation and editing of plans. + * Renders a dialog in the current screen with buttons and text fields for names, actions and other configurability. + * @param param0: Takes in a current plan, which can be undefined and a function which is called on saving with the potential plan. + * @returns: JSX.Element + * @example + * ``` + * // Within a Node's default JSX Element function + * { + * updateNodeData(props.id, { + * ...data, + * plan, + * }); + * }} + * /> + * ``` + */ +export default function PlanEditorDialog({ + plan, + onSave, + description, +}: PlanEditorDialogProps) { + // UseStates and references + const dialogRef = useRef(null); + const [draftPlan, setDraftPlan] = useState(null); + const [newActionType, setNewActionType] = useState("speech"); + const [newActionValue, setNewActionValue] = useState(""); + + //Button Actions + const openCreate = () => { + setDraftPlan({...structuredClone(defaultPlan), id: crypto.randomUUID()}); + dialogRef.current?.showModal(); + }; + + const openCreateWithDescription = () => { + setDraftPlan({...structuredClone(defaultPlan), id: crypto.randomUUID(), name: description!}); + setNewActionType("llm") + setNewActionValue(description!) + dialogRef.current?.showModal(); + } + + + + const openEdit = () => { + if (!plan) return; + setDraftPlan(structuredClone(plan)); + dialogRef.current?.showModal(); + }; + + const close = () => { + dialogRef.current?.close(); + setDraftPlan(null); + }; + + const buildAction = (): Action => { + const id = crypto.randomUUID(); + switch (newActionType) { + case "speech": + return { id, text: newActionValue, type: "speech" }; + case "gesture": + return { id, gesture: newActionValue, type: "gesture" }; + case "llm": + return { id, goal: newActionValue, type: "llm" }; + } + }; + + return (<> + {/* Create and edit buttons */} + {!plan && ( + + )} + {plan && ( + + )} + + {/* Start of dialog (plan editor) */} + e.preventDefault()} + > +
+

{draftPlan?.id === plan?.id ? "Edit Plan" : "Create Plan"}

+ + {/* Plan name text field */} + {draftPlan && ( + + setDraftPlan({ ...draftPlan, name })} + placeholder="Plan name" + data-testid="name_text_field"/> + )} + + {/* Entire "bottom" part (adder and steps) without cancel, confirm and reset */} + {draftPlan && (
+
+ {/* Left Side (Action Adder) */} +

Add Action

+ {(!plan && description && draftPlan.steps.length === 0) && (
+ + +
)} + + + {/* Action value editor */} + + + {/* Adding steps */} + +
+ + {/* Right Side (Steps shown) */} +
+

Steps

+ + {/* Show if there are no steps yet */} + {draftPlan.steps.length === 0 && ( +
+ No steps yet +
+ )} + + + {/* Map over all steps, create a div for them that deletes them + if clicked on and add the index, name and type. as spans */} + {draftPlan.steps.map((step, index) => ( +
{ + setDraftPlan({ + ...draftPlan, + steps: draftPlan.steps.filter((s) => s.id !== step.id),}); + }}> + {index + 1}. + {step.type}: + { + step.type == "goal" ? ""/* TODO: Add support for goals */ + : GetActionValue(step)} + +
+ ))} +
+
+ )} {/* End Action Editor and steps shower */} + + {/* Buttons */} +
+ {/* Close button */} + + + {/* Confirm/ Create button */} + + + {/* Reset button */} + +
+ +
+ + ); +} diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.tsx index a8f7ceb..9fa4017 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.tsx @@ -181,11 +181,27 @@ export default function BasicBeliefNode(props: NodeProps) { */ export function BasicBeliefReduce(node: Node, _nodes: Node[]) { const data = node.data as BasicBeliefNodeData; - return { - id: node.id, - type: data.belief.type, - value: data.belief.value + const result: Record = { + id: node.id, + }; + + switch (data.belief.type) { + case "emotion": + result["emotion"] = data.belief.value; + break; + case "keyword": + result["keyword"] = data.belief.value; + break; + case "object": + result["object"] = data.belief.value; + break; + case "semantic": + result["description"] = data.belief.value; + break; + default: + break; } + return result } /** diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.default.ts b/src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.default.ts index fc4d3aa..4cf314c 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.default.ts +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.default.ts @@ -6,7 +6,8 @@ import type { GoalNodeData } from "./GoalNode"; export const GoalNodeDefaults: GoalNodeData = { label: "Goal Node", droppable: true, - description: "The robot will strive towards this goal", + description: "", achieved: false, hasReduce: true, + can_fail: true, }; \ No newline at end of file diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.tsx index 1564969..75b8b99 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.tsx @@ -8,6 +8,8 @@ import { Toolbar } from '../components/NodeComponents'; import styles from '../../VisProg.module.css'; import { TextField } from '../../../../components/TextField'; import useFlowStore from '../VisProgStores'; +import { DoesPlanIterate, PlanReduce, type Plan } from '../components/Plan'; +import PlanEditorDialog from '../components/PlanEditor'; /** * The default data dot a phase node @@ -22,6 +24,8 @@ export type GoalNodeData = { droppable: boolean; achieved: boolean; hasReduce: boolean; + can_fail: boolean; + plan?: Plan; }; export type GoalNode = Node @@ -37,13 +41,14 @@ export default function GoalNode({id, data}: NodeProps) { const text_input_id = `goal_${id}_text_input`; const checkbox_id = `goal_${id}_checkbox`; + const planIterate = DoesPlanIterate(data.plan); const setDescription = (value: string) => { updateNodeData(id, {...data, description: value}); } - const setAchieved = (value: boolean) => { - updateNodeData(id, {...data, achieved: value}); + const setFailable = (value: boolean) => { + updateNodeData(id, {...data, can_fail: value}); } return <> @@ -57,14 +62,34 @@ export default function GoalNode({id, data}: NodeProps) { setValue={(val) => setDescription(val)} placeholder={"To ..."} /> + -
- +
+ +
+ {data.plan && (
+ {planIterate ? "" : } + setAchieved(e.target.checked)} + disabled={!planIterate} + checked={!planIterate || data.can_fail} + onChange={(e) => planIterate ? setFailable(e.target.checked) : undefined} + /> +
+)} + +
+ { + updateNodeData(id, { + ...data, + plan, + }); + }} + description={data.description} />
@@ -80,11 +105,12 @@ export default function GoalNode({id, data}: NodeProps) { */ export function GoalReduce(node: Node, _nodes: Node[]) { const data = node.data as GoalNodeData; - return { + return { id: node.id, - label: data.label, + name: data.label, description: data.description, - achieved: data.achieved, + can_fail: data.can_fail, + plan: data.plan ? PlanReduce(data.plan) : "", } } diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.default.ts b/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.default.ts index 8df25cc..4b4a3ed 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.default.ts +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.default.ts @@ -6,7 +6,6 @@ import type { NormNodeData } from "./NormNode"; export const NormNodeDefaults: NormNodeData = { label: "Norm Node", droppable: true, - conditions: [], norm: "", hasReduce: true, critical: false, diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.tsx index 41679f1..d12ad62 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.tsx @@ -86,7 +86,7 @@ export function PhaseReduce(node: Node, nodes: Node[]) { // Build the result object const result: Record = { id: thisnode.id, - label: data.label, + name: data.label, }; nodesInPhase.forEach((type) => { diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.default.ts b/src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.default.ts index d1daf4a..2a63661 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.default.ts +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.default.ts @@ -6,7 +6,5 @@ import type { TriggerNodeData } from "./TriggerNode"; export const TriggerNodeDefaults: TriggerNodeData = { label: "Trigger Node", droppable: true, - triggers: [], - triggerType: "keywords", hasReduce: true, }; \ No newline at end of file diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.tsx index 6e3d940..1778d32 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.tsx @@ -9,11 +9,9 @@ import { import { Toolbar } from '../components/NodeComponents'; import styles from '../../VisProg.module.css'; import useFlowStore from '../VisProgStores'; -import { useState, useRef } from 'react'; -import { RealtimeTextField, TextField } from '../../../../components/TextField'; -import duplicateIndices from '../../../../utils/duplicateIndices'; -import type { Action, ActionTypes, Plan } from '../components/Plan'; -import { defaultPlan } from '../components/Plan.default'; +import { PlanReduce, type Plan } from '../components/Plan'; +import PlanEditorDialog from '../components/PlanEditor'; +import { BasicBeliefReduce } from './BasicBeliefNode'; /** * The default data structure for a Trigger node @@ -23,8 +21,6 @@ import { defaultPlan } from '../components/Plan.default'; * * @property label: the display label of this Trigger node. * @property droppable: Whether this node can be dropped from the toolbar (default: true). - * @property triggerType - The type of trigger ("keywords" or a custom string). - * @property triggers - The list of keyword triggers (if applicable). * @property hasReduce - Whether this node supports reduction logic. */ export type TriggerNodeData = { @@ -44,6 +40,7 @@ export type TriggerNode = Node * * @param connection - The connection or edge being attempted to connect towards. * @returns `true` if the connection is defined; otherwise, `false`. + * */ export function TriggerNodeCanConnect(connection: Connection | Edge): boolean { return (connection != undefined); @@ -57,64 +54,7 @@ export function TriggerNodeCanConnect(connection: Connection | Edge): boolean { export default function TriggerNode(props: NodeProps) { const data = props.data; const {updateNodeData} = useFlowStore(); - const dialogRef = useRef(null); - const [draftPlan, setDraftPlan] = useState(null); - - // Helpers for inside plan creation - const [newActionType, setNewActionType] = useState("speech"); - const [newActionName, setNewActionName] = useState(""); - const [newActionValue, setNewActionValue] = useState(""); - - - // Create a new Plan - const openCreateDialog = () => { - setDraftPlan(JSON.parse(JSON.stringify(defaultPlan))); - dialogRef.current?.showModal(); - }; - - // Edit our current plan - const openEditDialog = () => { - if (!data.plan) return; - setDraftPlan(JSON.parse(JSON.stringify(data.plan))); - dialogRef.current?.showModal(); - }; - - // Close the creating/editing of our plan - const closeDialog = () => { - dialogRef.current?.close(); - }; - - - // Define the function for creating actions - const buildAction = (): Action => { - const id = crypto.randomUUID(); - - switch (newActionType) { - case "speech": - return { - id, - name: newActionName, - text: newActionValue, - type: "speech" - }; - case "gesture": - return { - id, - name: newActionName, - gesture: newActionValue, - type: "gesture" - }; - case "llm": - return { - id, - name: newActionName, - goal: newActionValue, - type: "llm" - }; - } - }; - - + return <>
@@ -123,199 +63,16 @@ export default function TriggerNode(props: NodeProps) {
Plan{data.plan ? (": " + data.plan.name) : ""} is currently {data.plan ? "" : "not"} set. {data.plan ? "🟢" : "🔴"}
- - {/* We don't have a plan yet, show our create plan button */} - {!data.plan && ( - - )} - - {/* We have a plan, show our edit plan button */} - {data.plan && ( - - )} + { + updateNodeData(props.id, { + ...data, + plan, + }); + }} + />
- - {/* Define how our dialog should work */} - e.preventDefault()} - > -
-

{draftPlan?.id === data.plan?.id ? "Edit Plan" : "Create Plan"}

- - {/*Text field to edit the name of our draft plan*/} -
- {/* LEFT: plan info + add action */} -
- {/* Plan name */} - {draftPlan && ( -
- - - setDraftPlan({ - ...draftPlan, - name, - }) - } - /> -
- )} - - {/* Add action UI */} - {draftPlan && ( -
-

Add Action

- - - - - - - - -
- )} -
- - {/* RIGHT: steps list */} -
-

Steps

- - {draftPlan && draftPlan.steps.length === 0 && ( -
- No steps yet -
- )} - - {draftPlan?.steps.map((step, index) => ( -
{ - if (!draftPlan) return; - setDraftPlan({ - ...draftPlan, - steps: draftPlan.steps.filter((s) => s.id !== step.id), - }); - }} - > - {index + 1}. - {step.name} - {step.type} -
- ))} -
-
- -
- {/*Button to close the plan editor.*/} - - - {/*Button to save the draftPlan to the plan in the Node.*/} - - - {/*Button to reset the plan*/} - -
-
-
; } @@ -325,22 +82,16 @@ export default function TriggerNode(props: NodeProps) { * @param _nodes - The list of all nodes in the current flow graph. * @returns A simplified object containing the node label and its list of triggers. */ -export function TriggerReduce(node: Node, _nodes: Node[]) { - const data = node.data; - switch (data.triggerType) { - case "keywords": - return { - id: node.id, - type: "keywords", - label: data.label, - keywords: data.triggers, - }; - default: - return { - ...data, - id: node.id, - }; +export function TriggerReduce(node: Node, nodes: Node[]) { + const data = node.data as TriggerNodeData; + const conditionNode = data.condition ? nodes.find((n)=>n.id===data.condition) : undefined + const conditionData = conditionNode ? BasicBeliefReduce(conditionNode, nodes) : "" + return { + id: node.id, + condition: conditionData, // Make sure we have a condition before reducing, or default to "" + plan: !data.plan ? "" : PlanReduce(data.plan), // Make sure we have a plan when reducing, or default to "" } + } /** @@ -405,92 +156,4 @@ export type KeywordTriggerNodeProps = { } /** Union type for all possible Trigger node configurations. */ -export type TriggerNodeProps = EmotionTriggerNodeProps | KeywordTriggerNodeProps; - -/** - * Renders an input element that allows users to add new keyword triggers. - * - * When the input is committed, the `addKeyword` callback is called with the new keyword. - * - * @param param0 - An object containing the `addKeyword` function. - * @returns A React element(React.JSX.Element) providing an input for adding keywords. - */ -function KeywordAdder({ addKeyword }: { addKeyword: (keyword: string) => void }) { - const [input, setInput] = useState(""); - - const text_input_id = "keyword_adder_input"; - - return
- - { - if (!input) return; - addKeyword(input); - setInput(""); - }} - placeholder={"..."} - className={"flex-1"} - /> -
; -} - -/** - * Displays and manages a list of keyword triggers for a Trigger node. - * Handles adding, editing, and removing keywords, as well as detecting duplicate entries. - * - * @param keywords - The current list of keyword triggers. - * @param setKeywords - A callback to update the keyword list in the parent node. - * @returns A React element(React.JSX.Element) for editing keyword triggers. - */ -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([]); - - 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 <> - Triggers when {keywords.length <= 1 ? "the keyword is" : "all keywords are"} spoken. - {[...keywords].map(({id, keyword}, index) => { - return
- - replace(id, val)} - placeholder={"..."} - className={"flex-1"} - invalid={duplicates.includes(index)} - /> -
; - })} - - ; -} \ No newline at end of file +export type TriggerNodeProps = EmotionTriggerNodeProps | KeywordTriggerNodeProps; \ No newline at end of file diff --git a/test/pages/visProgPage/visualProgrammingUI/components/PlanEditor.test.tsx b/test/pages/visProgPage/visualProgrammingUI/components/PlanEditor.test.tsx new file mode 100644 index 0000000..c7de9a7 --- /dev/null +++ b/test/pages/visProgPage/visualProgrammingUI/components/PlanEditor.test.tsx @@ -0,0 +1,450 @@ +// PlanEditorDialog.test.tsx +import { describe, it, beforeEach, jest } from '@jest/globals'; +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { renderWithProviders } from '../../../../test-utils/test-utils.tsx'; +import PlanEditorDialog from '../../../../../src/pages/VisProgPage/visualProgrammingUI/components/PlanEditor'; +import type { Plan } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/components/Plan'; +import '@testing-library/jest-dom'; + +// Mock crypto.randomUUID for consistent IDs in tests +const mockUUID = 'test-uuid-123'; + +Object.defineProperty(globalThis, 'crypto', { + value: { + randomUUID: () => mockUUID, + }, + writable: true, +}); + +// Mock structuredClone +(globalThis as any).structuredClone = jest.fn((val) => JSON.parse(JSON.stringify(val))); + +// Mock HTMLDialogElement methods +const mockDialogMethods = { + showModal: jest.fn(), + close: jest.fn(), +}; + +describe('PlanEditorDialog', () => { + let user: ReturnType; + const mockOnSave = jest.fn(); + + beforeEach(() => { + user = userEvent.setup(); + jest.clearAllMocks(); + // Mock dialog element methods + HTMLDialogElement.prototype.showModal = mockDialogMethods.showModal; + HTMLDialogElement.prototype.close = mockDialogMethods.close; + }); + + const defaultPlan: Plan = { + id: 'plan-1', + name: 'Test Plan', + steps: [], + }; + + const planWithSteps: Plan = { + id: 'plan-2', + name: 'Existing Plan', + steps: [ + { id: 'step-1', text: 'Hello world', type: 'speech' as const }, + { id: 'step-2', gesture: 'Wave', type: 'gesture' as const }, + ], + }; + + const renderDialog = (props: Partial> = {}) => { + const defaultProps = { + plan: undefined, + onSave: mockOnSave, + description: undefined, + }; + + return renderWithProviders(); + }; + + describe('Rendering', () => { + it('should show "Create Plan" button when no plan is provided', () => { + renderDialog(); + // The button should be visible + expect(screen.getByRole('button', { name: 'Create Plan' })).toBeInTheDocument(); + // The dialog content should NOT be visible initially + expect(screen.queryByText(/Add Action/i)).not.toBeInTheDocument(); + }); + + it('should show "Edit Plan" button when a plan is provided', () => { + renderDialog({ plan: defaultPlan }); + expect(screen.getByRole('button', { name: 'Edit Plan' })).toBeInTheDocument(); + }); + + it('should not show "Create Plan" button when a plan exists', () => { + renderDialog({ plan: defaultPlan }); + // Query for the button text specifically, not dialog title + expect(screen.queryByRole('button', { name: 'Create Plan' })).not.toBeInTheDocument(); + }); + }); + + describe('Dialog Interactions', () => { + it('should open dialog with "Create Plan" title when creating new plan', async () => { + renderDialog(); + + await user.click(screen.getByRole('button', { name: 'Create Plan' })); + + expect(mockDialogMethods.showModal).toHaveBeenCalled(); + + // One for button, one for dialog. + expect(screen.getAllByText('Create Plan').length).toEqual(2); + }); + + it('should open dialog with "Edit Plan" title when editing existing plan', async () => { + renderDialog({ plan: defaultPlan }); + + await user.click(screen.getByRole('button', { name: 'Edit Plan' })); + + expect(mockDialogMethods.showModal).toHaveBeenCalled(); + // One for button, one for dialog + expect(screen.getAllByText('Edit Plan').length).toEqual(2); + }); + + it('should pre-fill plan name when editing', async () => { + renderDialog({ plan: defaultPlan }); + + await user.click(screen.getByRole('button', { name: 'Edit Plan' })); + + const nameInput = screen.getByPlaceholderText('Plan name') as HTMLInputElement; + expect(nameInput.value).toBe(defaultPlan.name); + }); + + it('should close dialog when cancel button is clicked', async () => { + renderDialog(); + + await user.click(screen.getByRole('button', { name: 'Create Plan' })); + await user.click(screen.getByText('Cancel')); + + expect(mockDialogMethods.close).toHaveBeenCalled(); + }); + }); + + describe('Plan Creation', () => { + it('should create a new plan with default values', async () => { + renderDialog(); + + await user.click(screen.getByRole('button', { name: 'Create Plan' })); + + // One for the button, one for the dialog + expect(screen.getAllByText('Create Plan').length).toEqual(2); + + const nameInput = screen.getByPlaceholderText('Plan name'); + expect(nameInput).toBeInTheDocument(); + }); + + it('should auto-fill with description when provided', async () => { + const description = 'Achieve world peace'; + renderDialog({ description }); + + await user.click(screen.getByRole('button', { name: 'Create Plan' })); + + // Check if plan name is pre-filled with description + const nameInput = screen.getByPlaceholderText('Plan name') as HTMLInputElement; + expect(nameInput.value).toBe(description); + + // Check if action type is set to LLM + const actionTypeSelect = screen.getByLabelText(/Action Type/i) as HTMLSelectElement; + expect(actionTypeSelect.value).toBe('llm'); + + // Check if suggestion text is shown + expect(screen.getByText('Filled in as a suggestion!')).toBeInTheDocument(); + expect(screen.getByText('Feel free to change!')).toBeInTheDocument(); + }); + + it('should allow changing plan name', async () => { + renderDialog(); + + await user.click(screen.getByRole('button', { name: 'Create Plan' })); + + const nameInput = screen.getByPlaceholderText('Plan name') as HTMLInputElement; + const newName = 'My Custom Plan'; + + // Instead of clear(), select all text and type new value + await user.click(nameInput); + await user.keyboard('{Control>}a{/Control}'); // Select all (Ctrl+A) + await user.keyboard(newName); + + expect(nameInput.value).toBe(newName); + }); + }); + + describe('Action Management', () => { + it('should add a speech action to the plan', async () => { + renderDialog({ plan: defaultPlan }); + + await user.click(screen.getByRole('button', { name: 'Edit Plan' })); + + const actionTypeSelect = screen.getByLabelText(/Action Type/i); + const actionValueInput = screen.getByPlaceholderText("Speech text") + const addButton = screen.getByText('Add Step'); + + // Set up a speech action + await user.selectOptions(actionTypeSelect, 'speech'); + await user.type(actionValueInput, 'Hello there!'); + + await user.click(addButton); + + // Check if step was added + expect(screen.getByText('speech:')).toBeInTheDocument(); + expect(screen.getByText('Hello there!')).toBeInTheDocument(); + }); + + it('should add a gesture action to the plan', async () => { + renderDialog({ plan: defaultPlan }); + + await user.click(screen.getByRole('button', { name: 'Edit Plan' })); + + const actionTypeSelect = screen.getByLabelText(/Action Type/i); + const addButton = screen.getByText('Add Step'); + + // Set up a gesture action + await user.selectOptions(actionTypeSelect, 'gesture'); + + // Find the input field after type change + const gestureInput = screen.getByPlaceholderText(/Gesture name|text/i); + await user.type(gestureInput, 'Wave hand'); + + await user.click(addButton); + + // Check if step was added + expect(screen.getByText('gesture:')).toBeInTheDocument(); + expect(screen.getByText('Wave hand')).toBeInTheDocument(); + }); + + it('should add an LLM action to the plan', async () => { + renderDialog({ plan: defaultPlan }); + + await user.click(screen.getByRole('button', { name: 'Edit Plan' })); + + const actionTypeSelect = screen.getByLabelText(/Action Type/i); + const addButton = screen.getByText('Add Step'); + + // Set up an LLM action + await user.selectOptions(actionTypeSelect, 'llm'); + + // Find the input field after type change + const llmInput = screen.getByPlaceholderText(/LLM goal|text/i); + await user.type(llmInput, 'Generate a story'); + + await user.click(addButton); + + // Check if step was added + expect(screen.getByText('llm:')).toBeInTheDocument(); + expect(screen.getByText('Generate a story')).toBeInTheDocument(); + }); + + it('should disable "Add Step" button when action value is empty', async () => { + renderDialog({ plan: defaultPlan }); + + await user.click(screen.getByRole('button', { name: 'Edit Plan' })); + + const addButton = screen.getByText('Add Step'); + expect(addButton).toBeDisabled(); + }); + + it('should reset action form after adding a step', async () => { + renderDialog({ plan: defaultPlan }); + + await user.click(screen.getByRole('button', { name: 'Edit Plan' })); + + + const actionValueInput = screen.getByPlaceholderText("Speech text") + const addButton = screen.getByText('Add Step'); + + await user.type(actionValueInput, 'Test speech'); + await user.click(addButton); + + // Action value should be cleared + expect(actionValueInput).toHaveValue(''); + // Action type should be reset to speech (default) + const actionTypeSelect = screen.getByLabelText(/Action Type/i) as HTMLSelectElement; + expect(actionTypeSelect.value).toBe('speech'); + }); + }); + + describe('Step Management', () => { + it('should show existing steps when editing a plan', async () => { + renderDialog({ plan: planWithSteps }); + + await user.click(screen.getByRole('button', { name: 'Edit Plan' })); + + // Check if existing steps are shown + expect(screen.getByText('speech:')).toBeInTheDocument(); + expect(screen.getByText('Hello world')).toBeInTheDocument(); + expect(screen.getByText('gesture:')).toBeInTheDocument(); + expect(screen.getByText('Wave')).toBeInTheDocument(); + }); + + it('should show "No steps yet" message when plan has no steps', async () => { + renderDialog({ plan: defaultPlan }); + + await user.click(screen.getByRole('button', { name: 'Edit Plan' })); + + expect(screen.getByText('No steps yet')).toBeInTheDocument(); + }); + + it('should remove a step when clicked', async () => { + renderDialog({ plan: planWithSteps }); + + await user.click(screen.getByRole('button', { name: 'Edit Plan' })); + + // Initially have 2 steps + expect(screen.getByText('speech:')).toBeInTheDocument(); + expect(screen.getByText('gesture:')).toBeInTheDocument(); + + // Click on the first step to remove it + await user.click(screen.getByText('Hello world')); + + // First step should be removed + expect(screen.queryByText('Hello world')).not.toBeInTheDocument(); + // Second step should still exist + expect(screen.getByText('Wave')).toBeInTheDocument(); + }); + }); + + describe('Save Functionality', () => { + it('should call onSave with new plan when creating', async () => { + renderDialog(); + + await user.click(screen.getByRole('button', { name: 'Create Plan' })); + + // Set plan name + const nameInput = screen.getByPlaceholderText('Plan name') as HTMLInputElement; + await user.click(nameInput); + await user.keyboard('{Control>}a{/Control}'); + await user.keyboard('My New Plan'); + + // Add a step + const actionValueInput = screen.getByPlaceholderText(/text/i); + await user.type(actionValueInput, 'First step'); + await user.click(screen.getByText('Add Step')); + + // Save the plan + await user.click(screen.getByText('Create')); + + expect(mockOnSave).toHaveBeenCalledWith({ + id: mockUUID, + name: 'My New Plan', + steps: [ + { + id: mockUUID, + text: 'First step', + type: 'speech', + }, + ], + }); + expect(mockDialogMethods.close).toHaveBeenCalled(); + }); + + it('should call onSave with updated plan when editing', async () => { + renderDialog({ plan: defaultPlan }); + + await user.click(screen.getByRole('button', { name: 'Edit Plan' })); + + // Change plan name + const nameInput = screen.getByPlaceholderText('Plan name') as HTMLInputElement; + await user.click(nameInput); + await user.keyboard('{Control>}a{/Control}'); + await user.keyboard('Updated Plan Name'); + + // Add a step + const actionValueInput = screen.getByPlaceholderText(/text/i); + await user.type(actionValueInput, 'New speech action'); + await user.click(screen.getByText('Add Step')); + + // Save the plan + await user.click(screen.getByText('Confirm')); + + expect(mockOnSave).toHaveBeenCalledWith({ + id: defaultPlan.id, + name: 'Updated Plan Name', + steps: [ + { + id: mockUUID, + text: 'New speech action', + type: 'speech', + }, + ], + }); + expect(mockDialogMethods.close).toHaveBeenCalled(); + }); + + it('should call onSave with undefined when reset button is clicked', async () => { + renderDialog({ plan: defaultPlan }); + + await user.click(screen.getByRole('button', { name: 'Edit Plan' })); + await user.click(screen.getByText('Reset')); + + expect(mockOnSave).toHaveBeenCalledWith(undefined); + expect(mockDialogMethods.close).toHaveBeenCalled(); + }); + + it('should disable save button when no draft plan exists', async () => { + renderDialog(); + + await user.click(screen.getByRole('button', { name: 'Create Plan' })); + + // The save button should be enabled since draftPlan exists after clicking Create Plan + const saveButton = screen.getByText('Create'); + expect(saveButton).not.toBeDisabled(); + }); + }); + + describe('Step Indexing', () => { + it('should show correct step numbers', async () => { + renderDialog({ plan: defaultPlan }); + + await user.click(screen.getByRole('button', { name: 'Edit Plan' })); + + // Add multiple steps + const actionValueInput = screen.getByPlaceholderText(/text/i); + const addButton = screen.getByText('Add Step'); + + await user.type(actionValueInput, 'First'); + await user.click(addButton); + + await user.type(actionValueInput, 'Second'); + await user.click(addButton); + + await user.type(actionValueInput, 'Third'); + await user.click(addButton); + + // Check step numbers + expect(screen.getByText('1.')).toBeInTheDocument(); + expect(screen.getByText('2.')).toBeInTheDocument(); + expect(screen.getByText('3.')).toBeInTheDocument(); + }); + }); + + describe('Action Type Switching', () => { + it('should update placeholder text when action type changes', async () => { + renderDialog(); + + await user.click(screen.getByRole('button', { name: 'Create Plan' })); + + const actionTypeSelect = screen.getByLabelText(/Action Type/i); + + // Check speech placeholder + await user.selectOptions(actionTypeSelect, 'speech'); + // The placeholder might be set dynamically, so we need to check the input + const speechInput = screen.getByPlaceholderText(/text/i); + expect(speechInput).toBeInTheDocument(); + + // Check gesture placeholder + await user.selectOptions(actionTypeSelect, 'gesture'); + const gestureInput = screen.getByPlaceholderText(/Gesture|text/i); + expect(gestureInput).toBeInTheDocument(); + + // Check LLM placeholder + await user.selectOptions(actionTypeSelect, 'llm'); + const llmInput = screen.getByPlaceholderText(/LLM|text/i); + expect(llmInput).toBeInTheDocument(); + }); + }); +}); \ No newline at end of file diff --git a/test/pages/visProgPage/visualProgrammingUI/nodes/NormNode.test.tsx b/test/pages/visProgPage/visualProgrammingUI/nodes/NormNode.test.tsx index c762fff..29e6a0c 100644 --- a/test/pages/visProgPage/visualProgrammingUI/nodes/NormNode.test.tsx +++ b/test/pages/visProgPage/visualProgrammingUI/nodes/NormNode.test.tsx @@ -425,7 +425,6 @@ describe('NormNode', () => { label: 'Safety Norm', norm: 'Never harm humans', critical: false, - basic_beliefs: [], }); }); @@ -917,17 +916,8 @@ describe('NormNode', () => { } }; - const mockBelief2: Node = { - id: 'basic_belief-2', - type: 'basic_belief', - position: {x:300, y:300}, - data: { - ...JSON.parse(JSON.stringify(BasicBeliefNodeDefaults)) - } - }; - useFlowStore.setState({ - nodes: [mockNode, mockBelief1, mockBelief2], + nodes: [mockNode, mockBelief1], edges: [], }); @@ -938,16 +928,11 @@ describe('NormNode', () => { sourceHandle: null, targetHandle: null, }); - useFlowStore.getState().onConnect({ - source: 'basic_belief-2', - target: 'norm-1', - sourceHandle: null, - targetHandle: null, - }); - + + const state = useFlowStore.getState(); const updatedNorm = state.nodes.find(n => n.id === 'norm-1'); - expect(updatedNorm?.data.conditions).toEqual(["basic_belief-1", "basic_belief-2"]); + expect(updatedNorm?.data.condition).toEqual("basic_belief-1"); }); }); }); \ No newline at end of file diff --git a/test/pages/visProgPage/visualProgrammingUI/nodes/TriggerNode.test.tsx b/test/pages/visProgPage/visualProgrammingUI/nodes/TriggerNode.test.tsx index e3c40e0..6313258 100644 --- a/test/pages/visProgPage/visualProgrammingUI/nodes/TriggerNode.test.tsx +++ b/test/pages/visProgPage/visualProgrammingUI/nodes/TriggerNode.test.tsx @@ -1,6 +1,5 @@ import { describe, it, beforeEach } from '@jest/globals'; -import { screen, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; +import { screen } from '@testing-library/react'; import { renderWithProviders } from '../../../../test-utils/test-utils.tsx'; import TriggerNode, { TriggerReduce, @@ -11,12 +10,15 @@ import TriggerNode, { import useFlowStore from '../../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores'; import type { Node } from '@xyflow/react'; import '@testing-library/jest-dom'; +import { TriggerNodeDefaults } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.default.ts'; +import { BasicBeliefNodeDefaults } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.default.ts'; +import { defaultPlan } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/components/Plan.default.ts'; +import { NormNodeDefaults } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.default.ts'; describe('TriggerNode', () => { - let user: ReturnType; beforeEach(() => { - user = userEvent.setup(); + jest.clearAllMocks(); }); describe('Rendering', () => { @@ -26,11 +28,7 @@ describe('TriggerNode', () => { type: 'trigger', position: { x: 0, y: 0 }, data: { - label: 'Keyword Trigger', - droppable: true, - triggerType: 'keywords', - triggers: [], - hasReduce: true, + ...JSON.parse(JSON.stringify(TriggerNodeDefaults)), }, }; @@ -51,161 +49,59 @@ describe('TriggerNode', () => { /> ); - expect(screen.getByText(/Triggers when the keyword is spoken/i)).toBeInTheDocument(); - expect(screen.getByPlaceholderText('...')).toBeInTheDocument(); - }); - - it('should render TriggerNode with emotion type', () => { - const mockNode: Node = { - id: 'trigger-2', - type: 'trigger', - position: { x: 0, y: 0 }, - data: { - label: 'Emotion Trigger', - droppable: true, - triggerType: 'emotion', - triggers: [], - hasReduce: true, - }, - }; - - renderWithProviders( - - ); - - expect(screen.getByText(/Emotion\?/i)).toBeInTheDocument(); - }); - }); - - describe('User Interactions', () => { - it('should add a new keyword', async () => { - const mockNode: Node = { - id: 'trigger-1', - type: 'trigger', - position: { x: 0, y: 0 }, - data: { - label: 'Keyword Trigger', - droppable: true, - triggerType: 'keywords', - triggers: [], - hasReduce: true, - }, - }; - - useFlowStore.setState({ nodes: [mockNode], edges: [] }); - - renderWithProviders( - - ); - - const input = screen.getByPlaceholderText('...'); - await user.type(input, 'hello{enter}'); - - await waitFor(() => { - const node = useFlowStore.getState().nodes.find(n => n.id === 'trigger-1') as Node | undefined; - expect(node?.data.triggers.length).toBe(1); - expect(node?.data.triggers[0].keyword).toBe('hello'); - }); - - }); - - it('should remove a keyword when cleared', async () => { - const mockNode: Node = { - id: 'trigger-1', - type: 'trigger', - position: { x: 0, y: 0 }, - data: { - label: 'Keyword Trigger', - droppable: true, - triggerType: 'keywords', - triggers: [{ id: 'kw1', keyword: 'hello' }], - hasReduce: true, - }, - }; - - useFlowStore.setState({ nodes: [mockNode], edges: [] }); - - renderWithProviders( - - ); - - const input = screen.getByDisplayValue('hello'); - for (let i = 0; i < 'hello'.length; i++) { - await user.type(input, '{backspace}'); - } - await user.type(input, '{enter}'); - - await waitFor(() => { - const node = useFlowStore.getState().nodes.find(n => n.id === 'trigger-1') as Node | undefined; - expect(node?.data.triggers.length).toBe(0); - }); - + expect(screen.getByText(/Triggers when the condition is met/i)).toBeInTheDocument(); + expect(screen.getByText(/Belief is currently/i)).toBeInTheDocument(); + expect(screen.getByText(/Plan is currently/i)).toBeInTheDocument(); }); }); describe('TriggerReduce Function', () => { it('should reduce a trigger node to its essential data', () => { + const conditionNode: Node = { + id: 'belief-1', + type: 'basic_belief', + position: { x: 0, y: 0 }, + data: { + ...JSON.parse(JSON.stringify(BasicBeliefNodeDefaults)), + }, + }; + const triggerNode: Node = { id: 'trigger-1', type: 'trigger', position: { x: 0, y: 0 }, data: { - label: 'Keyword Trigger', - droppable: true, - triggerType: 'keywords', - triggers: [{ id: 'kw1', keyword: 'hello' }], - hasReduce: true, + ...JSON.parse(JSON.stringify(TriggerNodeDefaults)), + condition: "belief-1", + plan: defaultPlan }, }; - const allNodes: Node[] = [triggerNode]; - const result = TriggerReduce(triggerNode, allNodes); + useFlowStore.setState({ + nodes: [conditionNode, triggerNode], + edges: [], + }); + + useFlowStore.getState().onConnect({ + source: 'belief-1', + target: 'trigger-1', + sourceHandle: null, + targetHandle: null, + }); + + const result = TriggerReduce(triggerNode, useFlowStore.getState().nodes); expect(result).toEqual({ id: 'trigger-1', - type: 'keywords', - label: 'Keyword Trigger', - keywords: [{ id: 'kw1', keyword: 'hello' }], - }); + condition: { + id: "belief-1", + keyword: "help", + }, + plan: { + name: "Default Plan", + id: expect.anything(), + steps: [], + },}); }); }); @@ -217,11 +113,8 @@ describe('TriggerNode', () => { type: 'trigger', position: { x: 0, y: 0 }, data: { + ...JSON.parse(JSON.stringify(TriggerNodeDefaults)), label: 'Trigger 1', - droppable: true, - triggerType: 'keywords', - triggers: [], - hasReduce: true, }, }; @@ -230,10 +123,8 @@ describe('TriggerNode', () => { type: 'norm', position: { x: 100, y: 0 }, data: { + ...JSON.parse(JSON.stringify(NormNodeDefaults)), label: 'Norm 1', - droppable: true, - norm: 'test', - hasReduce: true, }, }; diff --git a/test/pages/visProgPage/visualProgrammingUI/nodes/UniversalNodes.test.tsx b/test/pages/visProgPage/visualProgrammingUI/nodes/UniversalNodes.test.tsx index 48a3fb9..b2d6373 100644 --- a/test/pages/visProgPage/visualProgrammingUI/nodes/UniversalNodes.test.tsx +++ b/test/pages/visProgPage/visualProgrammingUI/nodes/UniversalNodes.test.tsx @@ -187,7 +187,7 @@ describe('Universal Nodes', () => { // Verify the correct structure is present using NodesInPhase expect(result).toHaveLength(nodeType !== 'phase' ? 1 : 2); expect(result[0]).toHaveProperty('id', 'phase-1'); - expect(result[0]).toHaveProperty('label', 'Test Phase'); + expect(result[0]).toHaveProperty('name', 'Test Phase'); // Restore mocks phaseReduceSpy.mockRestore(); From c5f44536b78469f416bd44c40535232e4ef4f81b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Otgaar?= Date: Sun, 4 Jan 2026 15:18:07 +0100 Subject: [PATCH 07/27] feat: seperation of concerns for gesture value editor, adjusting output of nodes, integration testing, css file changes, and probably much more. ref: N25B-412 --- src/index.css | 13 + src/pages/VisProgPage/VisProg.module.css | 14 +- .../visualProgrammingUI/VisProgStores.tsx | 26 +- .../components/DragDropSidebar.tsx | 16 +- .../components/GestureValueEditor.module.css | 164 +++++ .../components/GestureValueEditor.tsx | 579 ++++++++++++++++++ .../components/PlanEditor.tsx | 71 +-- .../visualProgrammingUI/nodes/NormNode.tsx | 2 +- 8 files changed, 809 insertions(+), 76 deletions(-) create mode 100644 src/pages/VisProgPage/visualProgrammingUI/components/GestureValueEditor.module.css create mode 100644 src/pages/VisProgPage/visualProgrammingUI/components/GestureValueEditor.tsx diff --git a/src/index.css b/src/index.css index 6e28fe5..f4e6ffe 100644 --- a/src/index.css +++ b/src/index.css @@ -59,8 +59,21 @@ button:focus-visible { background-color: #ffffff; --accent-color: #00AAAA; + --select-color: rgba(gray); + + --dropdown-menu-background-color: rgb(247, 247, 247); + --dropdown-menu-border: rgba(207, 207, 207, 0.986); } button { background-color: #f9f9f9; } } + +@media (prefers-color-scheme: dark) { + :root { + color: #ffffff; + --select-color: rgba(gray); + --dropdown-menu-background-color: rgba(39, 39, 39, 0.986); + --dropdown-menu-border: rgba(65, 65, 65, 0.986); + } +} \ No newline at end of file diff --git a/src/pages/VisProgPage/VisProg.module.css b/src/pages/VisProgPage/VisProg.module.css index 7731e42..e15db1f 100644 --- a/src/pages/VisProgPage/VisProg.module.css +++ b/src/pages/VisProgPage/VisProg.module.css @@ -141,13 +141,13 @@ } .planDialog { - width: 80vw; - max-width: 900px; - padding: 1rem; - border: none; - border-radius: 8px; + overflow:visible; + width: 80vw; + max-width: 900px; + transition: width 0.25s ease; } + .planDialog::backdrop { background: rgba(0, 0, 0, 0.4); } @@ -160,6 +160,7 @@ } .planEditorLeft { + position: relative; display: flex; flex-direction: column; gap: 0.75rem; @@ -212,4 +213,5 @@ opacity: 0.5; font-style: italic; text-decoration: line-through; -} \ No newline at end of file +} + diff --git a/src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx b/src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx index 1decf8e..676019a 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx @@ -40,21 +40,21 @@ function createNode(id: string, type: string, position: XYPosition, data: Record ...data, }, } - } +} -//* Initial nodes, created by using createNode. */ -const initialNodes : Node[] = [ - createNode('start', 'start', {x: 100, y: 100}, {label: "Start"}, false), - createNode('end', 'end', {x: 500, y: 100}, {label: "End"}, false), - 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"], critical:false}), -]; + //* Initial nodes, created by using createNode. */ + // Start and End don't need to apply the UUID, since they are technically never compiled into a program. + const startNode = createNode('start', 'start', {x: 100, y: 100}, {label: "Start"}, false) + const endNode = createNode('end', 'end', {x: 500, y: 100}, {label: "End"}, false) + const initialPhaseNode = createNode(crypto.randomUUID(), 'phase', {x:200, y:100}, {label: "Phase 1", children : []}) -// * Initial edges * / -const initialEdges: Edge[] = [ - { id: 'start-phase-1', source: 'start', target: 'phase-1' }, - { id: 'phase-1-end', source: 'phase-1', target: 'end' }, -]; + const initialNodes : Node[] = [startNode, endNode, initialPhaseNode,]; + + // * Initial edges * / + const initialEdges: Edge[] = [ + { id: 'start-phase-1', source: startNode.id, target: initialPhaseNode.id }, + { id: 'phase-1-end', source: initialPhaseNode.id, target: endNode.id }, + ]; /** diff --git a/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx b/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx index 0401da9..01e222e 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx @@ -69,23 +69,11 @@ function DraggableNode({ className, children, nodeType, onDrop }: DraggableNodeP * @param position - The XY position in the flow canvas where the node will appear. */ function addNodeToFlow(nodeType: keyof typeof NodeTypes, position: XYPosition) { - const { nodes, addNode } = useFlowStore.getState(); + const { addNode } = useFlowStore.getState(); // Load any predefined data for this node type. const defaultData = NodeDefaults[nodeType] ?? {} - - // Currently, we find out what the Id is by checking the last node and adding one. - const sameTypeNodes = nodes.filter((node) => node.type === nodeType); - const nextNumber = - sameTypeNodes.length > 0 - ? (() => { - const lastNode = sameTypeNodes[sameTypeNodes.length - 1]; - const parts = lastNode.id.split('-'); - const lastNum = Number(parts[1]); - return Number.isNaN(lastNum) ? sameTypeNodes.length + 1 : lastNum + 1; - })() - : 1; - const id = `${nodeType}-${nextNumber}`; + const id = crypto.randomUUID(); // Create new node const newNode = { diff --git a/src/pages/VisProgPage/visualProgrammingUI/components/GestureValueEditor.module.css b/src/pages/VisProgPage/visualProgrammingUI/components/GestureValueEditor.module.css new file mode 100644 index 0000000..9d0f3e6 --- /dev/null +++ b/src/pages/VisProgPage/visualProgrammingUI/components/GestureValueEditor.module.css @@ -0,0 +1,164 @@ + +.gestureEditor { + display: flex; + flex-direction: column; + gap: 12px; + width: 100%; +} + +.modeSelector { + display: flex; + align-items: center; + gap: 12px; +} + +.modeLabel { + font-size: 14px; + font-weight: 500; + color: var(--text-secondary); + white-space: nowrap; +} + +.toggleContainer { + display: flex; + background: rgba(78, 78, 78, 0.411); + border-radius: 6px; + padding: 2px; + border: 1px solid var(--border-color); +} + +.toggleButton { + padding: 6px 12px; + background: none; + border: none; + border-radius: 4px; + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; + color: var(--text-secondary); +} + +.toggleButton:hover { + background: none; +} + +.toggleButton.active { + box-shadow: 0 0 1px 0 rgba(9, 255, 0, 0.733); +} + +.valueEditor { + width: 100%; +} + +.textInput { + width: 100%; + padding: 8px 12px; + border: 1px solid var(--border-color); + border-radius: 4px; + font-size: 14px; + transition: border-color 0.2s ease; +} + +.textInput:focus { + outline: none; + border-color: var(--primary-color); + box-shadow: 0 0 0 2px rgba(var(--primary-rgb), 0.1); +} + +.tagSelector { + display: flex; + flex-direction: column; + gap: 8px; +} + +.tagSelect { + width: 100%; + padding: 8px 12px; + border: 1px solid var(--border-color); + border-radius: 4px; + font-size: 14px; + background-color: rgba(135, 135, 135, 0.296); + cursor: pointer; +} + +.tagSelect:focus { + outline: none; + border-color: rgb(0, 149, 25); +} + +.tagList { + display: flex; + flex-wrap: wrap; + gap: 6px; + max-height: 120px; + overflow-y: auto; + padding: 4px; + border: 1px solid rgba(var(--primary-rgb), 0.1); + border-radius: 4px; + background: var(--primary-color); +} + +.tagButton { + padding: 4px 8px; + border: 1px solid gray; + border-radius: 4px; + background: var(--primary-rgb); + font-size: 12px; + cursor: pointer; + transition: all 0.2s ease; + white-space: nowrap; +} + +.tagButton:hover { + background: gray; + border-color: gray; +} + +.tagButton.selected { + background: rgba(var(--primary-rgb), 0.5); + color: var(--primary-rgb); + border-color: rgb(27, 223, 60); +} + +.suggestionsDropdownLeft { + position: absolute; + left: -220px; + top: 120px; + + width: 200px; + max-height: 20vh; + overflow-y: auto; + + background: var(--dropdown-menu-background-color); + border-radius: 12px; + box-shadow: 0 8px 24px var(--dropdown-menu-border); +} + +.suggestionsDropdownLeft::before { + content: "Gesture Suggestions"; + display: block; + padding: 8px 12px; + font-weight: 600; + border-bottom: 1px solid var(--border-light); +} + +.suggestionItem { + padding: 8px 12px; + cursor: pointer; + transition: background-color 0.2s ease; + font-size: 14px; + border-bottom: 1px solid var(--border-light); +} + +.suggestionItem:last-child { + border-bottom: none; +} + +.suggestionItem:hover { + background-color: var(--background-hover); +} + +.suggestionItem:active { + background-color: var(--primary-color-light); +} \ No newline at end of file diff --git a/src/pages/VisProgPage/visualProgrammingUI/components/GestureValueEditor.tsx b/src/pages/VisProgPage/visualProgrammingUI/components/GestureValueEditor.tsx new file mode 100644 index 0000000..5cb76a4 --- /dev/null +++ b/src/pages/VisProgPage/visualProgrammingUI/components/GestureValueEditor.tsx @@ -0,0 +1,579 @@ +import { useState, useEffect, useRef } from "react"; +import styles from './GestureValueEditor.module.css' + +type GestureValueEditorProps = { + value: string; + setValue: (value: string) => void; + placeholder?: string; +}; + +// Define your gesture tags here +const GESTURE_TAGS = ["above", "affirmative", "afford", "agitated", "all", "allright", "alright", "any", + "assuage", "attemper", "back", "bashful", "beg", "beseech", "blank", + "body language", "bored", "bow", "but", "call", "calm", "choose", "choice", "cloud", + "cogitate", "cool", "crazy", "disappointed", "down", "earth", "empty", "embarrassed", + "enthusiastic", "entire", "estimate", "except", "exalted", "excited", "explain", "far", + "field", "floor", "forlorn", "friendly", "front", "frustrated", "gentle", "gift", + "give", "ground", "happy", "hello", "her", "here", "hey", "hi", "him", "hopeless", + "hysterical", "I", "implore", "indicate", "joyful", "me", "meditate", "modest", + "negative", "nervous", "no", "not know", "nothing", "offer", "ok", "once upon a time", + "oppose", "or", "pacify", "pick", "placate", "please", "present", "proffer", "quiet", + "reason", "refute", "reject", "rousing", "sad", "select", "shamefaced", "show", + "show sky", "sky", "soothe", "sun", "supplicate", "tablet", "tall", "them", "there", + "think", "timid", "top", "unless", "up", "upstairs", "void", "warm", "winner", "yeah", + "yes", "yoo-hoo", "you", "your", "zero", "zestful"]; + +const GESTURE_SINGLES = [ + "animations/Stand/BodyTalk/Listening/Listening_1", + "animations/Stand/BodyTalk/Listening/Listening_2", + "animations/Stand/BodyTalk/Listening/Listening_3", + "animations/Stand/BodyTalk/Listening/Listening_4", + "animations/Stand/BodyTalk/Listening/Listening_5", + "animations/Stand/BodyTalk/Listening/Listening_6", + "animations/Stand/BodyTalk/Listening/Listening_7", + "animations/Stand/BodyTalk/Speaking/BodyTalk_1", + "animations/Stand/BodyTalk/Speaking/BodyTalk_10", + "animations/Stand/BodyTalk/Speaking/BodyTalk_11", + "animations/Stand/BodyTalk/Speaking/BodyTalk_12", + "animations/Stand/BodyTalk/Speaking/BodyTalk_13", + "animations/Stand/BodyTalk/Speaking/BodyTalk_14", + "animations/Stand/BodyTalk/Speaking/BodyTalk_15", + "animations/Stand/BodyTalk/Speaking/BodyTalk_16", + "animations/Stand/BodyTalk/Speaking/BodyTalk_2", + "animations/Stand/BodyTalk/Speaking/BodyTalk_3", + "animations/Stand/BodyTalk/Speaking/BodyTalk_4", + "animations/Stand/BodyTalk/Speaking/BodyTalk_5", + "animations/Stand/BodyTalk/Speaking/BodyTalk_6", + "animations/Stand/BodyTalk/Speaking/BodyTalk_7", + "animations/Stand/BodyTalk/Speaking/BodyTalk_8", + "animations/Stand/BodyTalk/Speaking/BodyTalk_9", + "animations/Stand/BodyTalk/Thinking/Remember_1", + "animations/Stand/BodyTalk/Thinking/Remember_2", + "animations/Stand/BodyTalk/Thinking/Remember_3", + "animations/Stand/BodyTalk/Thinking/ThinkingLoop_1", + "animations/Stand/BodyTalk/Thinking/ThinkingLoop_2", + "animations/Stand/Emotions/Negative/Angry_1", + "animations/Stand/Emotions/Negative/Angry_2", + "animations/Stand/Emotions/Negative/Angry_3", + "animations/Stand/Emotions/Negative/Angry_4", + "animations/Stand/Emotions/Negative/Anxious_1", + "animations/Stand/Emotions/Negative/Bored_1", + "animations/Stand/Emotions/Negative/Bored_2", + "animations/Stand/Emotions/Negative/Disappointed_1", + "animations/Stand/Emotions/Negative/Exhausted_1", + "animations/Stand/Emotions/Negative/Exhausted_2", + "animations/Stand/Emotions/Negative/Fear_1", + "animations/Stand/Emotions/Negative/Fear_2", + "animations/Stand/Emotions/Negative/Fearful_1", + "animations/Stand/Emotions/Negative/Frustrated_1", + "animations/Stand/Emotions/Negative/Humiliated_1", + "animations/Stand/Emotions/Negative/Hurt_1", + "animations/Stand/Emotions/Negative/Hurt_2", + "animations/Stand/Emotions/Negative/Late_1", + "animations/Stand/Emotions/Negative/Sad_1", + "animations/Stand/Emotions/Negative/Sad_2", + "animations/Stand/Emotions/Negative/Shocked_1", + "animations/Stand/Emotions/Negative/Sorry_1", + "animations/Stand/Emotions/Negative/Surprise_1", + "animations/Stand/Emotions/Negative/Surprise_2", + "animations/Stand/Emotions/Negative/Surprise_3", + "animations/Stand/Emotions/Neutral/Alienated_1", + "animations/Stand/Emotions/Neutral/AskForAttention_1", + "animations/Stand/Emotions/Neutral/AskForAttention_2", + "animations/Stand/Emotions/Neutral/AskForAttention_3", + "animations/Stand/Emotions/Neutral/Cautious_1", + "animations/Stand/Emotions/Neutral/Confused_1", + "animations/Stand/Emotions/Neutral/Determined_1", + "animations/Stand/Emotions/Neutral/Embarrassed_1", + "animations/Stand/Emotions/Neutral/Hesitation_1", + "animations/Stand/Emotions/Neutral/Innocent_1", + "animations/Stand/Emotions/Neutral/Lonely_1", + "animations/Stand/Emotions/Neutral/Mischievous_1", + "animations/Stand/Emotions/Neutral/Puzzled_1", + "animations/Stand/Emotions/Neutral/Sneeze", + "animations/Stand/Emotions/Neutral/Stubborn_1", + "animations/Stand/Emotions/Neutral/Suspicious_1", + "animations/Stand/Emotions/Positive/Amused_1", + "animations/Stand/Emotions/Positive/Confident_1", + "animations/Stand/Emotions/Positive/Ecstatic_1", + "animations/Stand/Emotions/Positive/Enthusiastic_1", + "animations/Stand/Emotions/Positive/Excited_1", + "animations/Stand/Emotions/Positive/Excited_2", + "animations/Stand/Emotions/Positive/Excited_3", + "animations/Stand/Emotions/Positive/Happy_1", + "animations/Stand/Emotions/Positive/Happy_2", + "animations/Stand/Emotions/Positive/Happy_3", + "animations/Stand/Emotions/Positive/Happy_4", + "animations/Stand/Emotions/Positive/Hungry_1", + "animations/Stand/Emotions/Positive/Hysterical_1", + "animations/Stand/Emotions/Positive/Interested_1", + "animations/Stand/Emotions/Positive/Interested_2", + "animations/Stand/Emotions/Positive/Laugh_1", + "animations/Stand/Emotions/Positive/Laugh_2", + "animations/Stand/Emotions/Positive/Laugh_3", + "animations/Stand/Emotions/Positive/Mocker_1", + "animations/Stand/Emotions/Positive/Optimistic_1", + "animations/Stand/Emotions/Positive/Peaceful_1", + "animations/Stand/Emotions/Positive/Proud_1", + "animations/Stand/Emotions/Positive/Proud_2", + "animations/Stand/Emotions/Positive/Proud_3", + "animations/Stand/Emotions/Positive/Relieved_1", + "animations/Stand/Emotions/Positive/Shy_1", + "animations/Stand/Emotions/Positive/Shy_2", + "animations/Stand/Emotions/Positive/Sure_1", + "animations/Stand/Emotions/Positive/Winner_1", + "animations/Stand/Emotions/Positive/Winner_2", + "animations/Stand/Gestures/Angry_1", + "animations/Stand/Gestures/Angry_2", + "animations/Stand/Gestures/Angry_3", + "animations/Stand/Gestures/BowShort_1", + "animations/Stand/Gestures/BowShort_2", + "animations/Stand/Gestures/BowShort_3", + "animations/Stand/Gestures/But_1", + "animations/Stand/Gestures/CalmDown_1", + "animations/Stand/Gestures/CalmDown_2", + "animations/Stand/Gestures/CalmDown_3", + "animations/Stand/Gestures/CalmDown_4", + "animations/Stand/Gestures/CalmDown_5", + "animations/Stand/Gestures/CalmDown_6", + "animations/Stand/Gestures/Choice_1", + "animations/Stand/Gestures/ComeOn_1", + "animations/Stand/Gestures/Confused_1", + "animations/Stand/Gestures/Confused_2", + "animations/Stand/Gestures/CountFive_1", + "animations/Stand/Gestures/CountFour_1", + "animations/Stand/Gestures/CountMore_1", + "animations/Stand/Gestures/CountOne_1", + "animations/Stand/Gestures/CountThree_1", + "animations/Stand/Gestures/CountTwo_1", + "animations/Stand/Gestures/Desperate_1", + "animations/Stand/Gestures/Desperate_2", + "animations/Stand/Gestures/Desperate_3", + "animations/Stand/Gestures/Desperate_4", + "animations/Stand/Gestures/Desperate_5", + "animations/Stand/Gestures/DontUnderstand_1", + "animations/Stand/Gestures/Enthusiastic_3", + "animations/Stand/Gestures/Enthusiastic_4", + "animations/Stand/Gestures/Enthusiastic_5", + "animations/Stand/Gestures/Everything_1", + "animations/Stand/Gestures/Everything_2", + "animations/Stand/Gestures/Everything_3", + "animations/Stand/Gestures/Everything_4", + "animations/Stand/Gestures/Everything_6", + "animations/Stand/Gestures/Excited_1", + "animations/Stand/Gestures/Explain_1", + "animations/Stand/Gestures/Explain_10", + "animations/Stand/Gestures/Explain_11", + "animations/Stand/Gestures/Explain_2", + "animations/Stand/Gestures/Explain_3", + "animations/Stand/Gestures/Explain_4", + "animations/Stand/Gestures/Explain_5", + "animations/Stand/Gestures/Explain_6", + "animations/Stand/Gestures/Explain_7", + "animations/Stand/Gestures/Explain_8", + "animations/Stand/Gestures/Far_1", + "animations/Stand/Gestures/Far_2", + "animations/Stand/Gestures/Far_3", + "animations/Stand/Gestures/Follow_1", + "animations/Stand/Gestures/Give_1", + "animations/Stand/Gestures/Give_2", + "animations/Stand/Gestures/Give_3", + "animations/Stand/Gestures/Give_4", + "animations/Stand/Gestures/Give_5", + "animations/Stand/Gestures/Give_6", + "animations/Stand/Gestures/Great_1", + "animations/Stand/Gestures/HeSays_1", + "animations/Stand/Gestures/HeSays_2", + "animations/Stand/Gestures/HeSays_3", + "animations/Stand/Gestures/Hey_1", + "animations/Stand/Gestures/Hey_10", + "animations/Stand/Gestures/Hey_2", + "animations/Stand/Gestures/Hey_3", + "animations/Stand/Gestures/Hey_4", + "animations/Stand/Gestures/Hey_6", + "animations/Stand/Gestures/Hey_7", + "animations/Stand/Gestures/Hey_8", + "animations/Stand/Gestures/Hey_9", + "animations/Stand/Gestures/Hide_1", + "animations/Stand/Gestures/Hot_1", + "animations/Stand/Gestures/Hot_2", + "animations/Stand/Gestures/IDontKnow_1", + "animations/Stand/Gestures/IDontKnow_2", + "animations/Stand/Gestures/IDontKnow_3", + "animations/Stand/Gestures/IDontKnow_4", + "animations/Stand/Gestures/IDontKnow_5", + "animations/Stand/Gestures/IDontKnow_6", + "animations/Stand/Gestures/Joy_1", + "animations/Stand/Gestures/Kisses_1", + "animations/Stand/Gestures/Look_1", + "animations/Stand/Gestures/Look_2", + "animations/Stand/Gestures/Maybe_1", + "animations/Stand/Gestures/Me_1", + "animations/Stand/Gestures/Me_2", + "animations/Stand/Gestures/Me_4", + "animations/Stand/Gestures/Me_7", + "animations/Stand/Gestures/Me_8", + "animations/Stand/Gestures/Mime_1", + "animations/Stand/Gestures/Mime_2", + "animations/Stand/Gestures/Next_1", + "animations/Stand/Gestures/No_1", + "animations/Stand/Gestures/No_2", + "animations/Stand/Gestures/No_3", + "animations/Stand/Gestures/No_4", + "animations/Stand/Gestures/No_5", + "animations/Stand/Gestures/No_6", + "animations/Stand/Gestures/No_7", + "animations/Stand/Gestures/No_8", + "animations/Stand/Gestures/No_9", + "animations/Stand/Gestures/Nothing_1", + "animations/Stand/Gestures/Nothing_2", + "animations/Stand/Gestures/OnTheEvening_1", + "animations/Stand/Gestures/OnTheEvening_2", + "animations/Stand/Gestures/OnTheEvening_3", + "animations/Stand/Gestures/OnTheEvening_4", + "animations/Stand/Gestures/OnTheEvening_5", + "animations/Stand/Gestures/Please_1", + "animations/Stand/Gestures/Please_2", + "animations/Stand/Gestures/Please_3", + "animations/Stand/Gestures/Reject_1", + "animations/Stand/Gestures/Reject_2", + "animations/Stand/Gestures/Reject_3", + "animations/Stand/Gestures/Reject_4", + "animations/Stand/Gestures/Reject_5", + "animations/Stand/Gestures/Reject_6", + "animations/Stand/Gestures/Salute_1", + "animations/Stand/Gestures/Salute_2", + "animations/Stand/Gestures/Salute_3", + "animations/Stand/Gestures/ShowFloor_1", + "animations/Stand/Gestures/ShowFloor_2", + "animations/Stand/Gestures/ShowFloor_3", + "animations/Stand/Gestures/ShowFloor_4", + "animations/Stand/Gestures/ShowFloor_5", + "animations/Stand/Gestures/ShowSky_1", + "animations/Stand/Gestures/ShowSky_10", + "animations/Stand/Gestures/ShowSky_11", + "animations/Stand/Gestures/ShowSky_12", + "animations/Stand/Gestures/ShowSky_2", + "animations/Stand/Gestures/ShowSky_3", + "animations/Stand/Gestures/ShowSky_4", + "animations/Stand/Gestures/ShowSky_5", + "animations/Stand/Gestures/ShowSky_6", + "animations/Stand/Gestures/ShowSky_7", + "animations/Stand/Gestures/ShowSky_8", + "animations/Stand/Gestures/ShowSky_9", + "animations/Stand/Gestures/ShowTablet_1", + "animations/Stand/Gestures/ShowTablet_2", + "animations/Stand/Gestures/ShowTablet_3", + "animations/Stand/Gestures/Shy_1", + "animations/Stand/Gestures/Stretch_1", + "animations/Stand/Gestures/Stretch_2", + "animations/Stand/Gestures/Surprised_1", + "animations/Stand/Gestures/TakePlace_1", + "animations/Stand/Gestures/TakePlace_2", + "animations/Stand/Gestures/Take_1", + "animations/Stand/Gestures/Thinking_1", + "animations/Stand/Gestures/Thinking_2", + "animations/Stand/Gestures/Thinking_3", + "animations/Stand/Gestures/Thinking_4", + "animations/Stand/Gestures/Thinking_5", + "animations/Stand/Gestures/Thinking_6", + "animations/Stand/Gestures/Thinking_7", + "animations/Stand/Gestures/Thinking_8", + "animations/Stand/Gestures/This_1", + "animations/Stand/Gestures/This_10", + "animations/Stand/Gestures/This_11", + "animations/Stand/Gestures/This_12", + "animations/Stand/Gestures/This_13", + "animations/Stand/Gestures/This_14", + "animations/Stand/Gestures/This_15", + "animations/Stand/Gestures/This_2", + "animations/Stand/Gestures/This_3", + "animations/Stand/Gestures/This_4", + "animations/Stand/Gestures/This_5", + "animations/Stand/Gestures/This_6", + "animations/Stand/Gestures/This_7", + "animations/Stand/Gestures/This_8", + "animations/Stand/Gestures/This_9", + "animations/Stand/Gestures/WhatSThis_1", + "animations/Stand/Gestures/WhatSThis_10", + "animations/Stand/Gestures/WhatSThis_11", + "animations/Stand/Gestures/WhatSThis_12", + "animations/Stand/Gestures/WhatSThis_13", + "animations/Stand/Gestures/WhatSThis_14", + "animations/Stand/Gestures/WhatSThis_15", + "animations/Stand/Gestures/WhatSThis_16", + "animations/Stand/Gestures/WhatSThis_2", + "animations/Stand/Gestures/WhatSThis_3", + "animations/Stand/Gestures/WhatSThis_4", + "animations/Stand/Gestures/WhatSThis_5", + "animations/Stand/Gestures/WhatSThis_6", + "animations/Stand/Gestures/WhatSThis_7", + "animations/Stand/Gestures/WhatSThis_8", + "animations/Stand/Gestures/WhatSThis_9", + "animations/Stand/Gestures/Whisper_1", + "animations/Stand/Gestures/Wings_1", + "animations/Stand/Gestures/Wings_2", + "animations/Stand/Gestures/Wings_3", + "animations/Stand/Gestures/Wings_4", + "animations/Stand/Gestures/Wings_5", + "animations/Stand/Gestures/Yes_1", + "animations/Stand/Gestures/Yes_2", + "animations/Stand/Gestures/Yes_3", + "animations/Stand/Gestures/YouKnowWhat_1", + "animations/Stand/Gestures/YouKnowWhat_2", + "animations/Stand/Gestures/YouKnowWhat_3", + "animations/Stand/Gestures/YouKnowWhat_4", + "animations/Stand/Gestures/YouKnowWhat_5", + "animations/Stand/Gestures/YouKnowWhat_6", + "animations/Stand/Gestures/You_1", + "animations/Stand/Gestures/You_2", + "animations/Stand/Gestures/You_3", + "animations/Stand/Gestures/You_4", + "animations/Stand/Gestures/You_5", + "animations/Stand/Gestures/Yum_1", + "animations/Stand/Reactions/EthernetOff_1", + "animations/Stand/Reactions/EthernetOn_1", + "animations/Stand/Reactions/Heat_1", + "animations/Stand/Reactions/Heat_2", + "animations/Stand/Reactions/LightShine_1", + "animations/Stand/Reactions/LightShine_2", + "animations/Stand/Reactions/LightShine_3", + "animations/Stand/Reactions/LightShine_4", + "animations/Stand/Reactions/SeeColor_1", + "animations/Stand/Reactions/SeeColor_2", + "animations/Stand/Reactions/SeeColor_3", + "animations/Stand/Reactions/SeeSomething_1", + "animations/Stand/Reactions/SeeSomething_3", + "animations/Stand/Reactions/SeeSomething_4", + "animations/Stand/Reactions/SeeSomething_5", + "animations/Stand/Reactions/SeeSomething_6", + "animations/Stand/Reactions/SeeSomething_7", + "animations/Stand/Reactions/SeeSomething_8", + "animations/Stand/Reactions/ShakeBody_1", + "animations/Stand/Reactions/ShakeBody_2", + "animations/Stand/Reactions/ShakeBody_3", + "animations/Stand/Reactions/TouchHead_1", + "animations/Stand/Reactions/TouchHead_2", + "animations/Stand/Reactions/TouchHead_3", + "animations/Stand/Reactions/TouchHead_4", + "animations/Stand/Waiting/AirGuitar_1", + "animations/Stand/Waiting/BackRubs_1", + "animations/Stand/Waiting/Bandmaster_1", + "animations/Stand/Waiting/Binoculars_1", + "animations/Stand/Waiting/BreathLoop_1", + "animations/Stand/Waiting/BreathLoop_2", + "animations/Stand/Waiting/BreathLoop_3", + "animations/Stand/Waiting/CallSomeone_1", + "animations/Stand/Waiting/Drink_1", + "animations/Stand/Waiting/DriveCar_1", + "animations/Stand/Waiting/Fitness_1", + "animations/Stand/Waiting/Fitness_2", + "animations/Stand/Waiting/Fitness_3", + "animations/Stand/Waiting/FunnyDancer_1", + "animations/Stand/Waiting/HappyBirthday_1", + "animations/Stand/Waiting/Helicopter_1", + "animations/Stand/Waiting/HideEyes_1", + "animations/Stand/Waiting/HideHands_1", + "animations/Stand/Waiting/Innocent_1", + "animations/Stand/Waiting/Knight_1", + "animations/Stand/Waiting/KnockEye_1", + "animations/Stand/Waiting/KungFu_1", + "animations/Stand/Waiting/LookHand_1", + "animations/Stand/Waiting/LookHand_2", + "animations/Stand/Waiting/LoveYou_1", + "animations/Stand/Waiting/Monster_1", + "animations/Stand/Waiting/MysticalPower_1", + "animations/Stand/Waiting/PlayHands_1", + "animations/Stand/Waiting/PlayHands_2", + "animations/Stand/Waiting/PlayHands_3", + "animations/Stand/Waiting/Relaxation_1", + "animations/Stand/Waiting/Relaxation_2", + "animations/Stand/Waiting/Relaxation_3", + "animations/Stand/Waiting/Relaxation_4", + "animations/Stand/Waiting/Rest_1", + "animations/Stand/Waiting/Robot_1", + "animations/Stand/Waiting/ScratchBack_1", + "animations/Stand/Waiting/ScratchBottom_1", + "animations/Stand/Waiting/ScratchEye_1", + "animations/Stand/Waiting/ScratchHand_1", + "animations/Stand/Waiting/ScratchHead_1", + "animations/Stand/Waiting/ScratchLeg_1", + "animations/Stand/Waiting/ScratchTorso_1", + "animations/Stand/Waiting/ShowMuscles_1", + "animations/Stand/Waiting/ShowMuscles_2", + "animations/Stand/Waiting/ShowMuscles_3", + "animations/Stand/Waiting/ShowMuscles_4", + "animations/Stand/Waiting/ShowMuscles_5", + "animations/Stand/Waiting/ShowSky_1", + "animations/Stand/Waiting/ShowSky_2", + "animations/Stand/Waiting/SpaceShuttle_1", + "animations/Stand/Waiting/Stretch_1", + "animations/Stand/Waiting/Stretch_2", + "animations/Stand/Waiting/TakePicture_1", + "animations/Stand/Waiting/Taxi_1", + "animations/Stand/Waiting/Think_1", + "animations/Stand/Waiting/Think_2", + "animations/Stand/Waiting/Think_3", + "animations/Stand/Waiting/Think_4", + "animations/Stand/Waiting/Waddle_1", + "animations/Stand/Waiting/Waddle_2", + "animations/Stand/Waiting/WakeUp_1", + "animations/Stand/Waiting/Zombie_1"] + + +export default function GestureValueEditor({ + value, + setValue, + placeholder = "Gesture name", +}: GestureValueEditorProps) { + const [mode, setMode] = useState<"single" | "tag">("tag"); + const [customValue, setCustomValue] = useState(""); + const [showSuggestions, setShowSuggestions] = useState(true); + const [filteredSuggestions, setFilteredSuggestions] = useState([]); + const containerRef = useRef(null); + + const handleModeChange = (newMode: "single" | "tag") => { + setMode(newMode); + if (newMode === "single") { + // When switching to single, use custom value or existing value + setValue(customValue || value); + setFilteredSuggestions(GESTURE_SINGLES); + setShowSuggestions(true); + } else { + // When switching to tag, clear value if not a valid tag + const isCurrentValueTag = GESTURE_TAGS.some(tag => + tag.toLowerCase() === value.toLowerCase() + ); + if (!isCurrentValueTag) { + setValue(""); + } + setShowSuggestions(false); + } + }; + + const handleTagSelect = (tag: string) => { + setValue(tag); + }; + + const handleCustomChange = (newValue: string) => { + setCustomValue(newValue); + setValue(newValue); + + // Filter suggestions based on input + if (newValue.trim() === "") { + setShowSuggestions(true) + setFilteredSuggestions(GESTURE_SINGLES); + } else { + const filtered = GESTURE_SINGLES.filter(single => + single.toLowerCase().includes(newValue.toLowerCase()) + ); + setFilteredSuggestions(filtered); + setShowSuggestions(filtered.length > 0); + } + }; + + const handleSuggestionSelect = (suggestion: string) => { + setCustomValue(suggestion); + setValue(suggestion); + setShowSuggestions(false); + }; + + const handleInputFocus = () => { + if (customValue.trim() !== "") { + const filtered = GESTURE_SINGLES.filter(tag => + tag.toLowerCase().includes(customValue.toLowerCase()) + ); + setFilteredSuggestions(filtered); + setShowSuggestions(filtered.length > 0); + } + }; + + const handleInputBlur = (_e: React.FocusEvent) => { + // Delay hiding suggestions to allow clicking on them + + }; + + return ( +
+ {/* Mode selector */} +
+ +
+ + +
+
+ + {/* Value editor based on mode */} +
+ {mode === "single" ? ( +
+ {showSuggestions && ( +
+ {filteredSuggestions.map((suggestion) => ( +
handleSuggestionSelect(suggestion)} + onMouseDown={(e) => e.preventDefault()} + > + {suggestion} +
+ ))} +
+ )} + handleCustomChange(e.target.value)} + onFocus={handleInputFocus} + onBlur={handleInputBlur} + placeholder={placeholder} + className={`${styles.textInput} ${showSuggestions ? styles.textInputWithSuggestions : ''}`} + autoComplete="off" + /> +
+ ) : ( +
+ +
+ {GESTURE_TAGS.map((tag) => ( + + ))} +
+
+ )} +
+
+ ); +} \ No newline at end of file diff --git a/src/pages/VisProgPage/visualProgrammingUI/components/PlanEditor.tsx b/src/pages/VisProgPage/visualProgrammingUI/components/PlanEditor.tsx index af05310..7092a95 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/components/PlanEditor.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/components/PlanEditor.tsx @@ -3,6 +3,7 @@ import styles from '../../VisProg.module.css'; import { GetActionValue, type Action, type ActionTypes, type Plan } from "../components/Plan"; import { defaultPlan } from "../components/Plan.default"; import { TextField } from "../../../../components/TextField"; +import GestureValueEditor from "./GestureValueEditor"; // Add this import type PlanEditorDialogProps = { plan?: Plan; @@ -10,25 +11,6 @@ type PlanEditorDialogProps = { description? : string; }; -/** - * Adds an element to a React.JSX.Element that allows for the creation and editing of plans. - * Renders a dialog in the current screen with buttons and text fields for names, actions and other configurability. - * @param param0: Takes in a current plan, which can be undefined and a function which is called on saving with the potential plan. - * @returns: JSX.Element - * @example - * ``` - * // Within a Node's default JSX Element function - * { - * updateNodeData(props.id, { - * ...data, - * plan, - * }); - * }} - * /> - * ``` - */ export default function PlanEditorDialog({ plan, onSave, @@ -53,8 +35,6 @@ export default function PlanEditorDialog({ dialogRef.current?.showModal(); } - - const openEdit = () => { if (!plan) return; setDraftPlan(structuredClone(plan)); @@ -93,9 +73,9 @@ export default function PlanEditorDialog({ {/* Start of dialog (plan editor) */} e.preventDefault()} + ref={dialogRef} + className={`${styles.planDialog}`} + onWheel={(e) => e.stopPropagation()} >

{draftPlan?.id === plan?.id ? "Edit Plan" : "Create Plan"}

@@ -124,26 +104,34 @@ export default function PlanEditorDialog({ {/* Type selection */} - {/* Action value editor */} - + {/* Action value editor - UPDATED SECTION */} + {newActionType === "gesture" ? ( + + ) : ( + + )} {/* Adding steps */}
); -} +} \ No newline at end of file diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.tsx index ef65215..fb7a251 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.tsx @@ -102,7 +102,7 @@ export function NormReduce(node: Node, nodes: Node[]) { const conditionNode = nodes.find((node) => node.id === data.condition); // In case something went wrong, and our condition doesn't actually exist; if (conditionNode == undefined) return result; - result["belief"] = reducer(conditionNode, nodes) + result["condition"] = reducer(conditionNode, nodes) } return result From 149b82cb66630e3589960a7bad87513f9f04dbbb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Otgaar?= Date: Sun, 4 Jan 2026 18:29:19 +0100 Subject: [PATCH 08/27] feat: create tests, more integration testing, fix ID tests, use UUID (almost) everywhere ref: N25B-412 --- src/pages/VisProgPage/VisProg.module.css | 72 +--------- .../components/GestureValueEditor.tsx | 90 +++++++----- .../visualProgrammingUI/components/Plan.tsx | 1 - .../components/PlanEditor.module.css | 71 ++++++++++ .../components/PlanEditor.tsx | 19 ++- .../components/DragDropSidebar.test.tsx | 6 +- .../components/GestureValueEditor.test.tsx | 131 ++++++++++++++++++ .../components/PlanEditor.test.tsx | 50 +++---- .../nodes/NormNode.test.tsx | 18 ++- .../nodes/PhaseNode.test.tsx | 6 +- test/setupFlowTests.ts | 14 ++ 11 files changed, 332 insertions(+), 146 deletions(-) create mode 100644 src/pages/VisProgPage/visualProgrammingUI/components/PlanEditor.module.css create mode 100644 test/pages/visProgPage/visualProgrammingUI/components/GestureValueEditor.test.tsx diff --git a/src/pages/VisProgPage/VisProg.module.css b/src/pages/VisProgPage/VisProg.module.css index e15db1f..429e740 100644 --- a/src/pages/VisProgPage/VisProg.module.css +++ b/src/pages/VisProgPage/VisProg.module.css @@ -140,78 +140,8 @@ filter: drop-shadow(0 0 0.25rem plum); } -.planDialog { - overflow:visible; - width: 80vw; - max-width: 900px; - transition: width 0.25s ease; -} - - -.planDialog::backdrop { - background: rgba(0, 0, 0, 0.4); -} - -.planEditor { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 1rem; - min-width: 600px; -} - -.planEditorLeft { - position: relative; - display: flex; - flex-direction: column; - gap: 0.75rem; -} - -.planEditorRight { - display: flex; - flex-direction: column; - gap: 0.5rem; - border-left: 1px solid var(--border-color, #ccc); - padding-left: 1rem; - max-height: 300px; - overflow-y: auto; -} - -.planStep { - display: flex; - align-items: center; - gap: 0.5rem; - cursor: pointer; - transition: text-decoration 0.2s; -} - - -.planStep:hover { - text-decoration: line-through; -} - -.stepType { - opacity: 0.7; - font-size: 0.85em; -} - - -.stepIndex { - opacity: 0.6; -} - -.emptySteps { - opacity: 0.5; - font-style: italic; -} - -.stepSuggestion { - opacity: 0.5; - font-style: italic; -} - .planNoIterate { opacity: 0.5; font-style: italic; text-decoration: line-through; -} - +} \ No newline at end of file diff --git a/src/pages/VisProgPage/visualProgrammingUI/components/GestureValueEditor.tsx b/src/pages/VisProgPage/visualProgrammingUI/components/GestureValueEditor.tsx index 5cb76a4..67f9f16 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/components/GestureValueEditor.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/components/GestureValueEditor.tsx @@ -1,13 +1,23 @@ -import { useState, useEffect, useRef } from "react"; +import { useState, useRef } from "react"; import styles from './GestureValueEditor.module.css' +/** + * Props for the GestureValueEditor component. + * - value: current gesture value (controlled by parent) + * - setValue: callback to update the gesture value in parent state + * - placeholder: optional placeholder text for the input field + */ type GestureValueEditorProps = { value: string; setValue: (value: string) => void; placeholder?: string; }; -// Define your gesture tags here +/** + * List of high-level gesture "tags". + * These are human-readable categories or semantic labels. + * In a real app, these would likely be loaded from an external source. + */ const GESTURE_TAGS = ["above", "affirmative", "afford", "agitated", "all", "allright", "alright", "any", "assuage", "attemper", "back", "bashful", "beg", "beseech", "blank", "body language", "bored", "bow", "but", "call", "calm", "choose", "choice", "cloud", @@ -23,6 +33,11 @@ const GESTURE_TAGS = ["above", "affirmative", "afford", "agitated", "all", "allr "think", "timid", "top", "unless", "up", "upstairs", "void", "warm", "winner", "yeah", "yes", "yoo-hoo", "you", "your", "zero", "zestful"]; +/** + * List of concrete gesture animation paths. + * These represent specific animation assets and are used in "single" mode + * with autocomplete-style selection, also would be loaded from an external source. + */ const GESTURE_SINGLES = [ "animations/Stand/BodyTalk/Listening/Listening_1", "animations/Stand/BodyTalk/Listening/Listening_2", @@ -421,50 +436,62 @@ const GESTURE_SINGLES = [ "animations/Stand/Waiting/Zombie_1"] +/** + * Returns a gesture value editor component. + * @returns JSX.Element + */ export default function GestureValueEditor({ value, setValue, placeholder = "Gesture name", }: GestureValueEditorProps) { + + /** Input mode: semantic tag vs concrete animation path */ const [mode, setMode] = useState<"single" | "tag">("tag"); + + /** Raw text value for single-gesture input */ const [customValue, setCustomValue] = useState(""); + + /** Autocomplete dropdown state */ const [showSuggestions, setShowSuggestions] = useState(true); const [filteredSuggestions, setFilteredSuggestions] = useState([]); + + /** Reserved for future click-outside / positioning logic */ const containerRef = useRef(null); + /** Switch between tag and single input modes */ const handleModeChange = (newMode: "single" | "tag") => { setMode(newMode); + if (newMode === "single") { - // When switching to single, use custom value or existing value setValue(customValue || value); setFilteredSuggestions(GESTURE_SINGLES); setShowSuggestions(true); } else { - // When switching to tag, clear value if not a valid tag - const isCurrentValueTag = GESTURE_TAGS.some(tag => - tag.toLowerCase() === value.toLowerCase() + // Clear value if it does not match a valid tag + const isValidTag = GESTURE_TAGS.some( + tag => tag.toLowerCase() === value.toLowerCase() ); - if (!isCurrentValueTag) { - setValue(""); - } + if (!isValidTag) setValue(""); setShowSuggestions(false); } }; + /** Select a semantic gesture tag */ const handleTagSelect = (tag: string) => { setValue(tag); }; + /** Update single-gesture input and filter suggestions */ const handleCustomChange = (newValue: string) => { setCustomValue(newValue); setValue(newValue); - - // Filter suggestions based on input + if (newValue.trim() === "") { - setShowSuggestions(true) setFilteredSuggestions(GESTURE_SINGLES); + setShowSuggestions(true); } else { - const filtered = GESTURE_SINGLES.filter(single => + const filtered = GESTURE_SINGLES.filter(single => single.toLowerCase().includes(newValue.toLowerCase()) ); setFilteredSuggestions(filtered); @@ -472,30 +499,32 @@ export default function GestureValueEditor({ } }; + /** Commit autocomplete selection */ const handleSuggestionSelect = (suggestion: string) => { setCustomValue(suggestion); setValue(suggestion); setShowSuggestions(false); }; + /** Refresh suggestions on refocus */ const handleInputFocus = () => { - if (customValue.trim() !== "") { - const filtered = GESTURE_SINGLES.filter(tag => - tag.toLowerCase().includes(customValue.toLowerCase()) - ); - setFilteredSuggestions(filtered); - setShowSuggestions(filtered.length > 0); - } + if (!customValue.trim()) return; + + const filtered = GESTURE_SINGLES.filter(single => + single.toLowerCase().includes(customValue.toLowerCase()) + ); + setFilteredSuggestions(filtered); + setShowSuggestions(filtered.length > 0); }; - const handleInputBlur = (_e: React.FocusEvent) => { - // Delay hiding suggestions to allow clicking on them + /** Exists to allow delayed blur handling if needed */ + const handleInputBlur = (_e: React.FocusEvent) => {}; - }; + /** Build the JSX component */ return (
- {/* Mode selector */} + {/* Mode toggle */}
@@ -516,8 +545,7 @@ export default function GestureValueEditor({
- {/* Value editor based on mode */} -
+
{mode === "single" ? (
{showSuggestions && ( @@ -527,7 +555,7 @@ export default function GestureValueEditor({ key={suggestion} className={styles.suggestionItem} onClick={() => handleSuggestionSelect(suggestion)} - onMouseDown={(e) => e.preventDefault()} + onMouseDown={(e) => e.preventDefault()} // prevent blur before click > {suggestion}
@@ -551,14 +579,14 @@ export default function GestureValueEditor({ value={value} onChange={(e) => handleTagSelect(e.target.value)} className={styles.tagSelect} + data-testid={"tagSelectorTestID"} > - + {GESTURE_TAGS.map((tag) => ( - + ))} +
{GESTURE_TAGS.map((tag) => ( + )} + {plan && ( + + )} - const close = () => { - dialogRef.current?.close(); - setDraftPlan(null); - }; + {/* Start of dialog (plan editor) */} + e.stopPropagation()} + data-testid={"PlanEditorDialogTestID"} + > + +

{draftPlan?.id === plan?.id ? "Edit Plan" : "Create Plan"}

+ {/* Plan name text field */} + {draftPlan && ( + + setDraftPlan({ ...draftPlan, name })} + placeholder="Plan name" + data-testid="name_text_field"/> + )} - const buildAction = (): Action => { - const id = crypto.randomUUID(); - switch (newActionType) { - case "speech": - return { id, text: newActionValue, type: "speech" }; - case "gesture": - return { id, gesture: newActionValue, type: "gesture" }; - case "llm": - return { id, goal: newActionValue, type: "llm" }; - } - }; + {/* Entire "bottom" part (adder and steps) without cancel, confirm and reset */} + {draftPlan && (
+
+ {/* Left Side (Action Adder) */} +

Add Action

+ {(!plan && description && draftPlan.steps.length === 0) && (
+ + +
)} + - return (<> - {/* Create and edit buttons */} - {!plan && ( - - )} - {plan && ( - - )} - - {/* Start of dialog (plan editor) */} - e.stopPropagation()} - data-testid={"PlanEditorDialogTestID"} - > - -

{draftPlan?.id === plan?.id ? "Edit Plan" : "Create Plan"}

- {/* Plan name text field */} - {draftPlan && ( - - setDraftPlan({ ...draftPlan, name })} - placeholder="Plan name" - data-testid="name_text_field"/> - )} - - {/* Entire "bottom" part (adder and steps) without cancel, confirm and reset */} - {draftPlan && (
-
- {/* Left Side (Action Adder) */} -

Add Action

- {(!plan && description && draftPlan.steps.length === 0) && (
- - -
)} - - - {/* Action value editor*/} - {newActionType === "gesture" ? ( - // Gesture get their own editor component - + ) : ( + - ) : ( - - )} - - {/* Adding steps */} - -
- - {/* Right Side (Steps shown) */} -
-

Steps

+ placeholder={ + newActionType === "speech" ? "Speech text" + : "LLM goal" + } + /> + )} - {/* Show if there are no steps yet */} - {draftPlan.steps.length === 0 && ( -
- No steps yet -
- )} - - - {/* Map over all steps */} - {draftPlan.steps.map((step, index) => ( -
{ - if (e.key === "Enter" || e.key === " ") { - setDraftPlan({ - ...draftPlan, - steps: draftPlan.steps.filter((s) => s.id !== step.id),}); - }}} - onClick={() => { - setDraftPlan({ - ...draftPlan, - steps: draftPlan.steps.filter((s) => s.id !== step.id),}); - }}> - - {index + 1}. - {step.type}: - { - step.type == "goal" ? ""/* TODO: Add support for goals */ - : GetActionValue(step)} - -
- ))} -
+ {/* Adding steps */} +
- )} - {/* Buttons */} -
- {/* Close button */} - + {/* Right Side (Steps shown) */} +
+

Steps

- {/* Confirm/ Create button */} - + {/* Show if there are no steps yet */} + {draftPlan.steps.length === 0 && ( +
+ No steps yet +
+ )} - {/* Reset button */} - -
- -
- - ); + + {/* Map over all steps */} + {draftPlan.steps.map((step, index) => ( +
{ + if (e.key === "Enter" || e.key === " ") { + setDraftPlan({ + ...draftPlan, + steps: draftPlan.steps.filter((s) => s.id !== step.id),}); + }}} + onClick={() => { + setDraftPlan({ + ...draftPlan, + steps: draftPlan.steps.filter((s) => s.id !== step.id),}); + }}> + + {index + 1}. + {step.type}: + { + step.type == "goal" ? ""/* TODO: Add support for goals */ + : GetActionValue(step)} + +
+ ))} +
+
+ )} + + {/* Buttons */} +
+ {/* Close button */} + + + {/* Confirm/ Create button */} + + + {/* Reset button */} + +
+ +
+ +); } \ No newline at end of file From 216b136a759e2b1252ce260835c84bde2eb5d2f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Otgaar?= Date: Mon, 5 Jan 2026 16:38:06 +0100 Subject: [PATCH 10/27] chore: change goal text, correct output for gestures, allow step specific reducing, fix tests/ add tests for new things --- .../components/GestureValueEditor.tsx | 4 + .../visualProgrammingUI/components/Plan.tsx | 37 ++++++++- .../components/PlanEditor.tsx | 4 +- .../visualProgrammingUI/nodes/GoalNode.tsx | 2 +- .../components/GestureValueEditor.test.tsx | 5 +- .../components/PlanEditor.test.tsx | 78 ++++++++++++++++++- 6 files changed, 122 insertions(+), 8 deletions(-) diff --git a/src/pages/VisProgPage/visualProgrammingUI/components/GestureValueEditor.tsx b/src/pages/VisProgPage/visualProgrammingUI/components/GestureValueEditor.tsx index 67f9f16..3b5863a 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/components/GestureValueEditor.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/components/GestureValueEditor.tsx @@ -10,6 +10,7 @@ import styles from './GestureValueEditor.module.css' type GestureValueEditorProps = { value: string; setValue: (value: string) => void; + setType: (value: boolean) => void; placeholder?: string; }; @@ -443,6 +444,7 @@ const GESTURE_SINGLES = [ export default function GestureValueEditor({ value, setValue, + setType, placeholder = "Gesture name", }: GestureValueEditorProps) { @@ -465,10 +467,12 @@ export default function GestureValueEditor({ if (newMode === "single") { setValue(customValue || value); + setType(false); setFilteredSuggestions(GESTURE_SINGLES); setShowSuggestions(true); } else { // Clear value if it does not match a valid tag + setType(true); const isValidTag = GESTURE_TAGS.some( tag => tag.toLowerCase() === value.toLowerCase() ); diff --git a/src/pages/VisProgPage/visualProgrammingUI/components/Plan.tsx b/src/pages/VisProgPage/visualProgrammingUI/components/Plan.tsx index 864c27a..00fa88f 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/components/Plan.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/components/Plan.tsx @@ -17,7 +17,7 @@ export type Goal = { // Actions export type Action = SpeechAction | GestureAction | LLMAction export type SpeechAction = { id: string, text: string, type:"speech" } -export type GestureAction = { id: string, gesture: string, type:"gesture" } +export type GestureAction = { id: string, gesture: string, isTag: boolean, type:"gesture" } export type LLMAction = { id: string, goal: string, type:"llm" } export type ActionTypes = "speech" | "gesture" | "llm"; @@ -29,7 +29,40 @@ export function PlanReduce(plan?: Plan) { return { name: plan.name, id: plan.id, - steps: plan.steps, + steps: plan.steps.map((x) => StepReduce(x)) + } +} + + +// Extract the wanted information from a plan element. +function StepReduce(planElement: PlanElement) { + // We have different types of plan elements, requiring differnt types of output + switch (planElement.type) { + case ("speech"): + return { + id: planElement.id, + text: planElement.text, + } + case ("gesture"): + return { + id: planElement.id, + gesture: { + type: planElement.isTag ? "tag" : "single", + name: planElement.gesture + }, + } + case ("llm"): + return { + id: planElement.id, + goal: planElement.goal, + } + case ("goal"): + return { + id: planElement.id, + plan: planElement.plan, + can_fail: planElement.can_fail, + }; + default: } } diff --git a/src/pages/VisProgPage/visualProgrammingUI/components/PlanEditor.tsx b/src/pages/VisProgPage/visualProgrammingUI/components/PlanEditor.tsx index ac41241..19b590b 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/components/PlanEditor.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/components/PlanEditor.tsx @@ -21,6 +21,7 @@ export default function PlanEditorDialog({ const dialogRef = useRef(null); const [draftPlan, setDraftPlan] = useState(null); const [newActionType, setNewActionType] = useState("speech"); + const [newActionGestureType, setNewActionGestureType] = useState(true); const [newActionValue, setNewActionValue] = useState(""); const { setScrollable } = useFlowStore(); @@ -58,7 +59,7 @@ export default function PlanEditorDialog({ case "speech": return { id, text: newActionValue, type: "speech" }; case "gesture": - return { id, gesture: newActionValue, type: "gesture" }; + return { id, gesture: newActionValue, isTag: newActionGestureType, type: "gesture" }; case "llm": return { id, goal: newActionValue, type: "llm" }; } @@ -127,6 +128,7 @@ export default function PlanEditorDialog({ ) : ( diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.tsx index 75b8b99..ad48a7d 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.tsx @@ -65,7 +65,7 @@ export default function GoalNode({id, data}: NodeProps) {
- +
{data.plan && (
{planIterate ? "" : } diff --git a/test/pages/visProgPage/visualProgrammingUI/components/GestureValueEditor.test.tsx b/test/pages/visProgPage/visualProgrammingUI/components/GestureValueEditor.test.tsx index fc67f25..3bbc205 100644 --- a/test/pages/visProgPage/visualProgrammingUI/components/GestureValueEditor.test.tsx +++ b/test/pages/visProgPage/visualProgrammingUI/components/GestureValueEditor.test.tsx @@ -3,10 +3,11 @@ import userEvent from '@testing-library/user-event'; import { renderWithProviders, screen } from '../../../../test-utils/test-utils.tsx'; import GestureValueEditor from '../../../../../src/pages/VisProgPage/visualProgrammingUI/components/GestureValueEditor'; -function TestHarness({ initialValue = '', placeholder = 'Gesture name' } : { initialValue?: string, placeholder?: string }) { +function TestHarness({ initialValue = '', initialType=true, placeholder = 'Gesture name' } : { initialValue?: string, initialType?: boolean, placeholder?: string }) { const [value, setValue] = useState(initialValue); + const [_, setType] = useState(initialType) return ( - + ); } diff --git a/test/pages/visProgPage/visualProgrammingUI/components/PlanEditor.test.tsx b/test/pages/visProgPage/visualProgrammingUI/components/PlanEditor.test.tsx index a4979a5..63d5cfa 100644 --- a/test/pages/visProgPage/visualProgrammingUI/components/PlanEditor.test.tsx +++ b/test/pages/visProgPage/visualProgrammingUI/components/PlanEditor.test.tsx @@ -4,7 +4,7 @@ import { screen, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { renderWithProviders } from '../../../../test-utils/test-utils.tsx'; import PlanEditorDialog from '../../../../../src/pages/VisProgPage/visualProgrammingUI/components/PlanEditor'; -import type { Plan } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/components/Plan'; +import { PlanReduce, type Plan } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/components/Plan'; import '@testing-library/jest-dom'; // Mock structuredClone @@ -28,12 +28,48 @@ describe('PlanEditorDialog', () => { steps: [], }; + const extendedPlan: Plan = { + id: 'extended-plan-1', + name: 'extended test plan', + steps: [ + // Step 1: A wave tag gesture + { + id: 'firststep', + type: 'gesture', + isTag: true, + gesture: "hello" + }, + + // Step 2: A single tag gesture + { + id: 'secondstep', + type: 'gesture', + isTag: false, + gesture: "somefolder/somegesture" + }, + + // Step 3: A LLM action + { + id: 'thirdstep', + type: 'llm', + goal: 'ask the user something or whatever' + }, + + // Step 4: A speech action + { + id: 'fourthstep', + type: 'speech', + text: "I'm a cyborg ninja :>" + }, + ] + } + const planWithSteps: Plan = { id: 'plan-2', name: 'Existing Plan', steps: [ { id: 'step-1', text: 'Hello world', type: 'speech' as const }, - { id: 'step-2', gesture: 'Wave', type: 'gesture' as const }, + { id: 'step-2', gesture: 'Wave', isTag:true, type: 'gesture' as const }, ], }; @@ -429,4 +465,42 @@ describe('PlanEditorDialog', () => { expect(llmInput).toBeInTheDocument(); }); }); + + describe('Plan reducing', () => { + it('should correctly reduce the plan given the elements of the plan', () => { + const testplan = extendedPlan + const expectedResult = { + name: "extended test plan", + id: "extended-plan-1", + steps: [ + { + id: "firststep", + gesture: { + type: "tag", + name: "hello" + } + }, + { + id: "secondstep", + gesture: { + type: "single", + name: "somefolder/somegesture" + } + }, + { + id: "thirdstep", + goal: "ask the user something or whatever" + }, + { + id: "fourthstep", + text: "I'm a cyborg ninja :>" + } + ] + } + + const actualResult = PlanReduce(testplan) + + expect(actualResult).toEqual(expectedResult) + }); + }) }); \ No newline at end of file From 508fa48be67c4256f79409f7753d0e0ee6cdf923 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Otgaar?= Date: Tue, 6 Jan 2026 14:47:56 +0100 Subject: [PATCH 11/27] fix: fix the goal node's "can_fail" to have the correct property. --- .../visualProgrammingUI/nodes/GoalNode.default.ts | 2 +- .../visualProgrammingUI/nodes/GoalNode.tsx | 11 ++++++----- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.default.ts b/src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.default.ts index 4cf314c..88e4951 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.default.ts +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.default.ts @@ -9,5 +9,5 @@ export const GoalNodeDefaults: GoalNodeData = { description: "", achieved: false, hasReduce: true, - can_fail: true, + can_fail: false, }; \ No newline at end of file diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.tsx index ad48a7d..42f9bde 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.tsx @@ -17,6 +17,8 @@ import PlanEditorDialog from '../components/PlanEditor'; * @param droppable: whether this node is droppable from the drop bar (initialized as true) * @param desciption: description of the goal * @param hasReduce: whether this node has reducing functionality (true by default) + * @param can_fail: whether this plan should be checked- this plan could possible fail + * @param plan: The (possible) attached plan to this goal */ export type GoalNodeData = { label: string; @@ -69,13 +71,13 @@ export default function GoalNode({id, data}: NodeProps) {
{data.plan && (
{planIterate ? "" : } - + planIterate ? setFailable(e.target.checked) : undefined} + onChange={(e) => planIterate ? setFailable(e.target.checked) : setFailable(false)} />
)} @@ -107,9 +109,8 @@ export function GoalReduce(node: Node, _nodes: Node[]) { const data = node.data as GoalNodeData; return { id: node.id, - name: data.label, - description: data.description, - can_fail: data.can_fail, + name: data.description, + can_fail: data.can_fail, plan: data.plan ? PlanReduce(data.plan) : "", } } From f4745c736f33e75bbda57fcb13649907dad9c5b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Otgaar?= Date: Tue, 6 Jan 2026 15:28:31 +0100 Subject: [PATCH 12/27] refactor: update the goal node to have a description for plans that need to be checked, and correctly give the value to the CB. ref: N25B-412 --- src/components/MultilineTextField.tsx | 75 +++++++++++++++++++ src/components/TextField.module.css | 10 +++ .../nodes/GoalNode.default.ts | 1 + .../visualProgrammingUI/nodes/GoalNode.tsx | 32 ++++++-- 4 files changed, 111 insertions(+), 7 deletions(-) create mode 100644 src/components/MultilineTextField.tsx diff --git a/src/components/MultilineTextField.tsx b/src/components/MultilineTextField.tsx new file mode 100644 index 0000000..ad88513 --- /dev/null +++ b/src/components/MultilineTextField.tsx @@ -0,0 +1,75 @@ +import { useEffect, useRef, useState } from "react"; +import styles from "./TextField.module.css"; + +export function MultilineTextField({ + value = "", + setValue, + placeholder, + className, + id, + ariaLabel, + invalid = false, + minRows = 3, +}: { + value: string; + setValue: (value: string) => void; + placeholder?: string; + className?: string; + id?: string; + ariaLabel?: string; + invalid?: boolean; + minRows?: number; +}) { + const [readOnly, setReadOnly] = useState(true); + const [inputValue, setInputValue] = useState(value); + const textareaRef = useRef(null); + + useEffect(() => { + setInputValue(value); + }, [value]); + + // Auto-grow logic + useEffect(() => { + const el = textareaRef.current; + if (!el) return; + + el.style.height = "auto"; + el.style.height = `${el.scrollHeight}px`; + }, [inputValue]); + + const onCommit = () => { + setReadOnly(true); + setValue(inputValue); + }; + + const onKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) { + e.preventDefault(); + (e.target as HTMLTextAreaElement).blur(); + } + }; + + return ( +