diff --git a/.github/workflows/eslint.yml b/.github/workflows/eslint.yml index bdb3b3e..123bac0 100644 --- a/.github/workflows/eslint.yml +++ b/.github/workflows/eslint.yml @@ -32,10 +32,7 @@ jobs: continue-on-error: true - name: Upload ESLint report - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v4 with: name: eslint-report path: eslint-report.html - - - name: Display ESLint report link - run: echo "::set-output name=eslint-report::${{ steps.upload.outputs.artifact_path }}" diff --git a/app/admin/page.tsx b/app/admin/page.tsx index 8dbaf41..51634ce 100644 --- a/app/admin/page.tsx +++ b/app/admin/page.tsx @@ -22,15 +22,7 @@ export default function Admin() { // Get rules that are already defined in the DB const existingRules = await getAllRuleData(); setInitialRules(existingRules); - // Get rules that exist in the rules repository, but aren't yet defined in the DB - const existingRuleDocuments = await getAllRuleDocuments(); - const undefinedRules = existingRuleDocuments - .filter((ruleJSON: string) => { - return !existingRules.find((rule: RuleInfo) => rule.goRulesJSONFilename === ruleJSON); - }) - .map((ruleJSON: string) => ({ goRulesJSONFilename: ruleJSON })); - const ruleData = [...existingRules, ...undefinedRules]; - setRules(JSON.parse(JSON.stringify(ruleData))); // JSON.parse(JSON.stringify(data)) is a hacky way to deep copy the data - needed for comparison later + setRules(JSON.parse(JSON.stringify([...existingRules]))); // JSON.parse(JSON.stringify(data)) is a hacky way to deep copy the data - needed for comparison later setIsLoading(false); }; @@ -38,18 +30,6 @@ export default function Admin() { getOrRefreshRuleList(); }, []); - const addNewRule = async () => { - const newRules = [...rules]; - newRules.push({ - _id: "", - title: "", - goRulesJSONFilename: "", - chefsFormId: "", - chefsFormAPIKey: "", - }); - setRules(newRules); - }; - const updateRule = (e: React.ChangeEvent, index: number, property: keyof RuleInfo) => { const newRules = [...rules]; newRules[index][property] = e.target.value; @@ -72,9 +52,7 @@ export default function Admin() { } else if ( initialRule._id !== rule._id || initialRule.title !== rule.title || - initialRule.goRulesJSONFilename !== rule.goRulesJSONFilename || - initialRule.chefsFormId !== rule.chefsFormId || - initialRule.chefsFormAPIKey !== rule.chefsFormAPIKey + initialRule.goRulesJSONFilename !== rule.goRulesJSONFilename ) { return { rule, action: ACTION_STATUS.UPDATE }; } @@ -92,19 +70,19 @@ export default function Admin() { const entriesToUpdate = getRulesToUpdate(); await Promise.all( entriesToUpdate.map(async ({ rule, action }) => { - try { - if (action === ACTION_STATUS.NEW) { - await postRuleData(rule); - } else if (rule?._id) { - if (action === ACTION_STATUS.UPDATE) { - await updateRuleData(rule._id, rule); - } else if (action === ACTION_STATUS.DELETE) { - await deleteRuleData(rule._id); - } + try { + if (action === ACTION_STATUS.NEW) { + await postRuleData(rule); + } else if (rule?._id) { + if (action === ACTION_STATUS.UPDATE) { + await updateRuleData(rule._id, rule); + } else if (action === ACTION_STATUS.DELETE) { + await deleteRuleData(rule._id); } - } catch (error) { - console.error(`Error performing action ${action} on rule ${rule._id}: ${error}`); } + } catch (error) { + console.error(`Error performing action ${action} on rule ${rule._id}: ${error}`); + } }) ); getOrRefreshRuleList(); @@ -123,28 +101,13 @@ export default function Admin() { title: "Title", dataIndex: "title", render: renderInputField("title"), - width: "220px" - }, - { - title: "GoRules Id", - dataIndex: "_id", - render: renderInputField("_id"), + width: "220px", }, { title: "GoRules JSON Filename", dataIndex: "goRulesJSONFilename", render: renderInputField("goRulesJSONFilename"), - width: "260px" - }, - { - title: "CHEFS Form Id", - dataIndex: "chefsFormId", - render: renderInputField("chefsFormId"), - }, - { - title: "CHEFS Form API Key", - dataIndex: "chefsFormAPIKey", - render: renderInputField("chefsFormAPIKey"), + width: "260px", }, { dataIndex: "delete", @@ -180,15 +143,7 @@ export default function Admin() { {isLoading ? (

Loading...

) : ( - ({ key, ...rule }))} - footer={() => ( - - )} - /> +
({ key, ...rule }))} /> )} ); diff --git a/app/components/InputOutputTable/InputOutputTable.module.css b/app/components/InputOutputTable/InputOutputTable.module.css index b331e48..37f0f90 100644 --- a/app/components/InputOutputTable/InputOutputTable.module.css +++ b/app/components/InputOutputTable/InputOutputTable.module.css @@ -1,4 +1,8 @@ .tableTitle { + display: flex; + gap: 20px; + justify-content: space-between; + align-items: center; margin: 0; background: #f9f9f9; padding: 16px; diff --git a/app/components/InputOutputTable/InputOutputTable.tsx b/app/components/InputOutputTable/InputOutputTable.tsx index 9216565..8a71b88 100644 --- a/app/components/InputOutputTable/InputOutputTable.tsx +++ b/app/components/InputOutputTable/InputOutputTable.tsx @@ -1,5 +1,6 @@ -import { useState, useEffect } from "react"; -import { Table, Tag } from "antd"; +import { useState, useEffect, FocusEvent } from "react"; +import { Table, Tag, Input, Button } from "antd"; +import { RuleMap } from "@/app/types/rulemap"; import styles from "./InputOutputTable.module.css"; const COLUMNS = [ @@ -15,54 +16,128 @@ const COLUMNS = [ }, ]; -const PROPERTIES_TO_IGNORE = ["submit", "lateEntry"]; +const PROPERTIES_TO_IGNORE = ["submit", "lateEntry", "rulemap"]; + +interface rawDataProps { + [key: string]: any; +} interface InputOutputTableProps { title: string; - rawData: object; + rawData: rawDataProps | null | undefined; + setRawData?: (data: rawDataProps) => void; + submitButtonRef?: React.RefObject; + editable?: boolean; + rulemap?: RuleMap; } -export default function InputOutputTable({ title, rawData }: InputOutputTableProps) { +export default function InputOutputTable({ + title, + rawData, + setRawData, + submitButtonRef, + editable = false, + rulemap, +}: InputOutputTableProps) { const [dataSource, setDataSource] = useState([]); + const [columns, setColumns] = useState(COLUMNS); + const [showTable, setShowTable] = useState(true); + + const toggleTableVisibility = () => { + setShowTable(!showTable); + }; + + const convertAndStyleValue = (value: any, property: string, editable: boolean) => { + if (editable) { + return ( + + ); + } - const convertAndStyleValue = (value: any, property: string) => { - // Handle booleans if (typeof value === "boolean") { return value ? TRUE : FALSE; } - // Handle money amounts + if (typeof value === "number" && property.toLowerCase().includes("amount")) { - value = `$${value}`; + return `$${value}`; } + return {value}; }; + const handleValueChange = (e: FocusEvent, property: string) => { + if (!e.target) return; + const newValue = (e.target as HTMLInputElement).value; + let queryValue: any = newValue; + + if (newValue.toLowerCase() === "true") { + queryValue = true; + } else if (newValue.toLowerCase() === "false") { + queryValue = false; + } else if (!isNaN(Number(newValue))) { + queryValue = Number(newValue); + } + + const updatedData = { ...rawData, [property]: queryValue } || {}; + + if (typeof setRawData === "function") { + setRawData(updatedData); + } else { + console.error("setRawData is not a function or is undefined"); + } + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter" && submitButtonRef) { + if (submitButtonRef.current) { + submitButtonRef.current.click(); + } + } + }; + useEffect(() => { if (rawData) { - const newData: object[] = []; - Object.entries(rawData).forEach(([property, value], index) => { - if (!PROPERTIES_TO_IGNORE.includes(property)) { - newData.push({ - property, - value: convertAndStyleValue(value, property), - key: index, - }); - } - }); + const propertyRuleMap = Object.values(rulemap || {}).flat(); + const newData = Object.entries(rawData) + .filter(([property]) => !PROPERTIES_TO_IGNORE.includes(property)) + .sort(([propertyA], [propertyB]) => propertyA.localeCompare(propertyB)) + .map(([property, value], index) => ({ + property: propertyRuleMap?.find((item) => item.property === property)?.name || property, + value: convertAndStyleValue(value, property, editable), + key: index, + })); setDataSource(newData); + const newColumns = COLUMNS.filter((column) => showColumn(newData, column.dataIndex)); + setColumns(newColumns); } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [rawData]); + const showColumn = (data: any[], columnKey: string) => { + return data.some((item) => item[columnKey] !== null && item[columnKey] !== undefined); + }; + return (
-

{title}

-
+

+ {title} {title === "Outputs" && } +

+ {showTable && ( +
+ )} ); } diff --git a/app/components/InputStyler/InputStyler.tsx b/app/components/InputStyler/InputStyler.tsx new file mode 100644 index 0000000..efd843e --- /dev/null +++ b/app/components/InputStyler/InputStyler.tsx @@ -0,0 +1,138 @@ +import { Tag, Input, Radio, AutoComplete, InputNumber, Flex } from "antd"; +import { Scenario } from "@/app/types/scenario"; + +export default function inputStyler( + value: any, + property: string, + editable: boolean, + scenarios: Scenario[] = [], + rawData: object = {}, + setRawData: any +) { + const getAutoCompleteOptions = (property: string) => { + if (!scenarios) return []; + const optionsSet = new Set(); + + scenarios.forEach((scenario) => { + scenario.variables + .filter((variable) => variable.name === property) + .forEach((variable) => optionsSet.add(variable.value)); + }); + + return Array.from(optionsSet).map((value) => ({ value, type: typeof value })); + }; + + const handleValueChange = (value: any, property: string) => { + let queryValue: any = value; + if (typeof value === "string") { + if (value.toLowerCase() === "true") { + queryValue = true; + } else if (value.toLowerCase() === "false") { + queryValue = false; + } else if (!isNaN(Number(value))) { + queryValue = Number(value); + } + } + + const updatedData = { ...rawData, [property]: queryValue } || {}; + + if (typeof setRawData === "function") { + setRawData(updatedData); + } else { + console.error("setRawData is not a function or is undefined"); + } + }; + + const handleInputChange = (val: any, property: string) => { + const updatedData = { ...rawData, [property]: val }; + if (typeof setRawData === "function") { + setRawData(updatedData); + } + }; + + const valuesArray = getAutoCompleteOptions(property); + let type = typeof value; + if (valuesArray.length > 0) { + type = typeof valuesArray[0].value; + } + + if (editable) { + if (type === "boolean" || typeof value === "boolean") { + return ( + + + + ); + } + + if (type === "string" || typeof value === "string") { + return ( + + ); + } + + if (type === "number" || typeof value === "number") { + return ( + + ); + } + + if (value === null || value === undefined) { + return ( + + ); + } + } else { + if (type === "boolean" || typeof value === "boolean") { + return ( + null} value={value}> + Yes + No + + ); + } + + if (type === "string" || typeof value === "string") { + return {value}; + } + + if (type === "number" || typeof value === "number") { + if (property.toLowerCase().includes("amount")) { + return ${value}; + } else { + return {value}; + } + } + + if (value === null || value === undefined) { + return null; + } + } + + return {value}; +} diff --git a/app/components/InputStyler/index.ts b/app/components/InputStyler/index.ts new file mode 100644 index 0000000..e69de29 diff --git a/app/components/RuleHeader/RuleHeader.module.css b/app/components/RuleHeader/RuleHeader.module.css new file mode 100644 index 0000000..9d5367f --- /dev/null +++ b/app/components/RuleHeader/RuleHeader.module.css @@ -0,0 +1,13 @@ +.titleInput { + font-size: inherit; + width: 100%; + padding: 0; +} + +.editButton { + background: none; + border: none; + cursor: pointer; + padding: 0; + font-size: 18px; +} \ No newline at end of file diff --git a/app/components/RuleHeader/RuleHeader.test.js b/app/components/RuleHeader/RuleHeader.test.js new file mode 100644 index 0000000..970b6b5 --- /dev/null +++ b/app/components/RuleHeader/RuleHeader.test.js @@ -0,0 +1,37 @@ +import { render, fireEvent, waitFor } from "@testing-library/react"; +import { toBeInTheDocument } from "@testing-library/jest-dom"; +import RuleHeader from "./RuleHeader"; +import api from "@/app/utils/api"; + +jest.mock("../../utils/api", () => ({ + updateRuleData: jest.fn(), +})); + +describe("RuleHeader - doneEditingTitle", () => { + const ruleInfoMock = { _id: "1", title: "Original Title", goRulesJSONFilename: "filename.json" }; + + it("updates title on success", async () => { + api.updateRuleData.mockResolvedValue({}); // Mock success + const { getByLabelText, getByText } = render(); + fireEvent.click(getByText("Original Title")); // Start editing + fireEvent.change(getByLabelText("Edit title"), { target: { value: "New Title" } }); + fireEvent.blur(getByLabelText("Edit title")); // Done editing + await waitFor(() => expect(getByText("New Title")).toBeInTheDocument()); + }); + + it("reverts title on update failure", async () => { + api.updateRuleData.mockRejectedValue(new Error("Failed to update")); // Mock failure + const { getByLabelText, getByText } = render(); + fireEvent.click(getByText("Original Title")); // Start editing + fireEvent.change(getByLabelText("Edit title"), { target: { value: "Failed Title" } }); + fireEvent.blur(getByLabelText("Edit title")); // Done editing + await waitFor(() => expect(getByText("Original Title")).toBeInTheDocument()); + }); + + it("does nothing if title is unchanged", async () => { + const { getByLabelText, getByText } = render(); + fireEvent.click(getByText("Original Title")); // Start editing + fireEvent.blur(getByLabelText("Edit title")); // Done editing without change + await waitFor(() => expect(getByText("Original Title")).toBeInTheDocument()); + }); +}); diff --git a/app/components/RuleHeader/RuleHeader.tsx b/app/components/RuleHeader/RuleHeader.tsx new file mode 100644 index 0000000..c174e51 --- /dev/null +++ b/app/components/RuleHeader/RuleHeader.tsx @@ -0,0 +1,70 @@ +"use client"; +import { useState, useRef, useEffect } from "react"; +import Link from "next/link"; +import { Flex } from "antd"; +import { HomeOutlined, EditOutlined, CheckOutlined } from "@ant-design/icons"; +import { RuleInfo } from "@/app/types/ruleInfo"; +import { updateRuleData } from "@/app/utils/api"; +import styles from "./RuleHeader.module.css"; + +export default function RuleHeader({ ruleInfo }: { ruleInfo: RuleInfo }) { + const { title, goRulesJSONFilename } = ruleInfo; + + const [savedTitle, setSavedTitle] = useState(title || goRulesJSONFilename); + const [isEditingTitle, setIsEditingTitle] = useState(false); + const [currTitle, setCurrTitle] = useState(savedTitle); + const inputRef = useRef(null); + + useEffect(() => { + if (isEditingTitle) { + inputRef.current?.focus(); + } + }, [isEditingTitle]); + + const startEditingTitle = () => { + setIsEditingTitle(true); + }; + + const updateTitle = (e: React.ChangeEvent) => { + if (e.target.value !== currTitle) { + setCurrTitle(e.target.value); + } + }; + + const doneEditingTitle = async () => { + setIsEditingTitle(false); + const updatedRuleInfo = { ...ruleInfo, title: currTitle }; + try { + await updateRuleData(ruleInfo._id, updatedRuleInfo); + setSavedTitle(currTitle); + } catch (e) { + // If updating fails, revert to previous title name + setCurrTitle(title || goRulesJSONFilename); + } + }; + + return ( + + + + +

+ {isEditingTitle ? ( + + ) : ( + currTitle + )} +

+ +
+ ); +} diff --git a/app/components/RuleHeader/index.ts b/app/components/RuleHeader/index.ts new file mode 100644 index 0000000..6879f98 --- /dev/null +++ b/app/components/RuleHeader/index.ts @@ -0,0 +1 @@ +export { default } from "./RuleHeader"; diff --git a/app/components/RulesDecisionGraph/RulesDecisionGraph.tsx b/app/components/RulesDecisionGraph/RulesDecisionGraph.tsx index 0ea98be..f05c136 100644 --- a/app/components/RulesDecisionGraph/RulesDecisionGraph.tsx +++ b/app/components/RulesDecisionGraph/RulesDecisionGraph.tsx @@ -4,25 +4,27 @@ import "@gorules/jdm-editor/dist/style.css"; import { JdmConfigProvider, DecisionGraph, DecisionGraphRef } from "@gorules/jdm-editor"; import { DecisionGraphType } from "@gorules/jdm-editor/dist/components/decision-graph/context/dg-store.context"; import type { ReactFlowInstance } from "reactflow"; -import { Spin } from "antd"; +import { Spin, message } from "antd"; import { SubmissionData } from "../../types/submission"; -import { getDocument, postDecision } from "../../utils/api"; +import { Scenario, Variable } from "@/app/types/scenario"; +import { getDocument, postDecision, getRuleRunSchema, getScenariosByFilename } from "../../utils/api"; import styles from "./RulesDecisionGraph.module.css"; interface RulesViewerProps { jsonFile: string; contextToSimulate?: SubmissionData | null; setResultsOfSimulation: (results: Record) => void; + setOutputsOfSimulation: (outputs: Record) => void; } export default function RulesDecisionGraph({ jsonFile, contextToSimulate, setResultsOfSimulation, + setOutputsOfSimulation, }: RulesViewerProps) { const decisionGraphRef: any = useRef(); const [graphJSON, setGraphJSON] = useState(); - useEffect(() => { const fetchData = async () => { @@ -42,22 +44,117 @@ export default function RulesDecisionGraph({ }; useEffect(() => { - // Run the simulator when the context updates - decisionGraphRef?.current?.runSimulator(contextToSimulate); + try { + // Run the simulator when the context updates + decisionGraphRef?.current?.runSimulator(contextToSimulate); + } catch (e: any) { + message.error("An error occurred while running the simulator: " + e); + console.error("Error running the simulator:", e); + } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [contextToSimulate]); const simulateRun = async ({ context }: { context: unknown }) => { if (contextToSimulate) { console.info("Simulate:", context); - const data = await postDecision(jsonFile, context); - console.info("Simulation Results:", data, data?.result); - setResultsOfSimulation(data?.result); - return { result: data }; + try { + const data = await postDecision(jsonFile, context); + console.info("Simulation Results:", data, data?.result); + // Check if data.result is an array and throw error as object is required + if (Array.isArray(data?.result)) { + throw new Error("Please update your rule and ensure that outputs are on one line."); + } + + setResultsOfSimulation(data?.result); + const ruleRunSchema = await getRuleRunSchema(data); + // Filter out properties from ruleRunSchema outputs that are also present in data.result + const uniqueOutputs = Object.keys(ruleRunSchema?.result?.output || {}).reduce((acc: any, key: string) => { + if (!(key in data?.result)) { + acc[key] = ruleRunSchema?.result[key]; + } + return acc; + }, {}); + + setOutputsOfSimulation(uniqueOutputs); + return { result: data }; + } catch (e: any) { + message.error("Error during simulation run: " + e); + console.error("Error during simulation run:", e); + return { result: {} }; + } } // Reset the result if there is no contextToSimulate (used to reset the trace) return { result: {} }; }; + const downloadJSON = (jsonData: any, filename: string) => { + const jsonString = JSON.stringify(jsonData, null, 2); + const blob = new Blob([jsonString], { type: "application/json" }); + const url = URL.createObjectURL(blob); + + const link = document.createElement("a"); + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + + // Clean up and remove the link + document.body.removeChild(link); + URL.revokeObjectURL(url); + }; + + const handleScenarioInsertion = async () => { + try { + const jsonData = await getDocument(jsonFile); + const scenarios: Scenario[] = await getScenariosByFilename(jsonFile); + const scenarioObject = { + tests: scenarios.map((scenario: Scenario) => ({ + name: scenario.title || "Default name", + input: scenario.variables.reduce((obj: any, each: Variable) => { + obj[each.name] = each.value; + return obj; + }, {}), + output: scenario.expectedResults.reduce((obj, each) => { + obj[each.name] = each.value; + return obj; + }, {}), + })), + }; + const updatedJSON = { + ...jsonData, + ...scenarioObject, + }; + return downloadJSON(updatedJSON, jsonFile); + } catch (error) { + console.error("Error fetching JSON:", error); + throw error; + } + }; + + const interceptJSONDownload = async (event: any) => { + if (decisionGraphRef.current && event.target?.download === "graph.json") { + event.preventDefault(); + try { + await handleScenarioInsertion(); + } catch (error) { + console.error("Error intercepting JSON download:", error); + } + } + }; + + useEffect(() => { + const clickHandler = (event: any) => { + interceptJSONDownload(event); + }; + + document.addEventListener("click", clickHandler); + + return () => { + document.removeEventListener("click", clickHandler); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + if (!graphJSON) { return ( diff --git a/app/components/ScenarioFormatter/ScenarioFormatter.module.css b/app/components/ScenarioFormatter/ScenarioFormatter.module.css new file mode 100644 index 0000000..37f0f90 --- /dev/null +++ b/app/components/ScenarioFormatter/ScenarioFormatter.module.css @@ -0,0 +1,12 @@ +.tableTitle { + display: flex; + gap: 20px; + justify-content: space-between; + align-items: center; + margin: 0; + background: #f9f9f9; + padding: 16px; + border: 1px solid #f0f0f0; + border-radius: 8px 8px 0 0; + font-weight: normal; +} diff --git a/app/components/ScenarioFormatter/ScenarioFormatter.tsx b/app/components/ScenarioFormatter/ScenarioFormatter.tsx new file mode 100644 index 0000000..63d5b30 --- /dev/null +++ b/app/components/ScenarioFormatter/ScenarioFormatter.tsx @@ -0,0 +1,81 @@ +import { useState, useEffect } from "react"; +import { Table, Button } from "antd"; +import { Scenario } from "@/app/types/scenario"; +import styles from "./ScenarioFormatter.module.css"; +import { RuleMap } from "@/app/types/rulemap"; +import inputStyler from "../InputStyler/InputStyler"; + +const COLUMNS = [ + { + title: "Property", + dataIndex: "property", + key: "property", + }, + { + title: "Value", + dataIndex: "value", + key: "value", + }, +]; + +const PROPERTIES_TO_IGNORE = ["submit", "lateEntry", "rulemap"]; + +interface rawDataProps { + rulemap?: boolean; +} + +interface ScenarioFormatterProps { + title: string; + rawData: rawDataProps | null | undefined; + setRawData?: (data: object) => void; + scenarios?: Scenario[]; + rulemap: RuleMap; +} + +export default function ScenarioFormatter({ title, rawData, setRawData, scenarios, rulemap }: ScenarioFormatterProps) { + const [dataSource, setDataSource] = useState([]); + const [columns, setColumns] = useState(COLUMNS); + const [showTable, setShowTable] = useState(true); + + const toggleTableVisibility = () => { + setShowTable(!showTable); + }; + + const showColumn = (data: any[], columnKey: string) => { + return data.some((item) => item[columnKey] !== null && item[columnKey] !== undefined); + }; + + useEffect(() => { + if (rawData) { + const editable = title === "Inputs" && rawData.rulemap === true; + const propertyRuleMap = Object.values(rulemap || {}).flat(); + const newData = Object.entries(rawData) + .filter(([property]) => !PROPERTIES_TO_IGNORE.includes(property)) + .sort(([propertyA], [propertyB]) => propertyA.localeCompare(propertyB)) + .map(([property, value], index) => ({ + property: propertyRuleMap?.find((item) => item.property === property)?.name || property, + value: inputStyler(value, property, editable, scenarios, rawData, setRawData), + key: index, + })); + // Check if data.result is an array + if (Array.isArray(rawData)) { + throw new Error("Please update your rule and ensure that outputs are on one line."); + } + setDataSource(newData); + const newColumns = COLUMNS.filter((column) => showColumn(newData, column.dataIndex)); + setColumns(newColumns); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [rawData]); + + return ( +
+

+ {title} {title === "Outputs" && } +

+ {showTable && ( +
+ )} + + ); +} diff --git a/app/components/ScenarioFormatter/index.ts b/app/components/ScenarioFormatter/index.ts new file mode 100644 index 0000000..4f9027a --- /dev/null +++ b/app/components/ScenarioFormatter/index.ts @@ -0,0 +1 @@ +export { default } from "./ScenarioFormatter"; diff --git a/app/components/ScenarioGenerator/ScenarioGenerator.tsx b/app/components/ScenarioGenerator/ScenarioGenerator.tsx new file mode 100644 index 0000000..bbd6a2a --- /dev/null +++ b/app/components/ScenarioGenerator/ScenarioGenerator.tsx @@ -0,0 +1,141 @@ +import React, { useState, useEffect } from "react"; +import { Flex, Button, Input } from "antd"; +import InputOutputTable from "../InputOutputTable"; +import { Scenario } from "@/app/types/scenario"; +import { SubmissionData } from "@/app/types/submission"; +import { createScenario } from "@/app/utils/api"; +import ScenarioFormatter from "../ScenarioFormatter"; +import { RuleMap } from "@/app/types/rulemap"; + +interface ScenarioGeneratorProps { + scenarios: Scenario[]; + resultsOfSimulation: Record | null | undefined; + setSelectedSubmissionInputs: (data: any) => void; + runSimulation: () => void; + selectedSubmissionInputs: SubmissionData; + resetTrigger: boolean; + ruleId: string; + jsonFile: string; + rulemap: RuleMap; + editing?: boolean; +} + +export default function ScenarioGenerator({ + scenarios, + resultsOfSimulation, + setSelectedSubmissionInputs, + runSimulation, + selectedSubmissionInputs, + resetTrigger, + ruleId, + jsonFile, + rulemap, + editing = true, +}: ScenarioGeneratorProps) { + const [simulationRun, setSimulationRun] = useState(false); + const [newScenarioName, setNewScenarioName] = useState(""); + const [scenarioExpectedOutput, setScenarioExpectedOutput] = useState({}); + + const handleSaveScenario = async () => { + if (!simulationRun || !selectedSubmissionInputs || !newScenarioName) return; + + const variables = Object.entries(selectedSubmissionInputs) + .filter(([name, value]) => name !== "rulemap" && value !== null && value !== undefined) + .map(([name, value]) => ({ name, value })); + + const expectedResults = Object.entries(scenarioExpectedOutput) + .filter(([name, value]) => name !== "rulemap" && value !== null && value !== undefined) + .map(([name, value]) => ({ name, value })); + + const newScenario: Scenario = { + title: newScenarioName, + ruleID: ruleId, + goRulesJSONFilename: jsonFile, + variables, + expectedResults, + }; + + try { + await createScenario(newScenario); + setNewScenarioName(""); + // Reload the page after the scenario is successfully created + window.location.reload(); + } catch (error) { + console.error("Error creating scenario:", error); + } + }; + + const runScenarioSimulation = () => { + if (!selectedSubmissionInputs) return; + runSimulation(); + setSimulationRun(true); + }; + + useEffect(() => { + setSimulationRun(false); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [resetTrigger]); + + // Update scenarioExpectedOutput on first render to display full rulemap possible results + useEffect(() => { + if (resultsOfSimulation) { + setScenarioExpectedOutput(resultsOfSimulation); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( + + + {selectedSubmissionInputs && ( + + { + setSelectedSubmissionInputs(data); + }} + scenarios={scenarios} + rulemap={rulemap} + /> + + + + {simulationRun && editing && ( + <> + setNewScenarioName(e.target.value)} + placeholder="Enter Scenario Name" + /> + + + )} + + + + )} + + {resultsOfSimulation && } + + + {scenarioExpectedOutput && editing && ( + { + setScenarioExpectedOutput(data); + }} + title="Expected Results" + rawData={scenarioExpectedOutput} + editable + rulemap={rulemap} + /> + )} + + + + ); +} diff --git a/app/components/ScenarioGenerator/index.ts b/app/components/ScenarioGenerator/index.ts new file mode 100644 index 0000000..e163497 --- /dev/null +++ b/app/components/ScenarioGenerator/index.ts @@ -0,0 +1 @@ +export { default } from "./ScenarioGenerator"; diff --git a/app/components/ScenarioTester/ScenarioTester.module.css b/app/components/ScenarioTester/ScenarioTester.module.css new file mode 100644 index 0000000..1646bfb --- /dev/null +++ b/app/components/ScenarioTester/ScenarioTester.module.css @@ -0,0 +1,81 @@ +.instructionsList { + list-style: none; + padding: 20px; + margin: 20px 0; + background-color: #f4f4f9; + border-radius: 8px; + border: 1px solid #eaeaea; +} + +.instructionsList li { + margin-bottom: 15px; + padding: 10px; + background: #fff; + border-radius: 6px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + font-size: 16px; + line-height: 1.5; + position: relative; +} + +.instructionsList li::before { + content: counter(li); + counter-increment: li; + position: absolute; + left: -30px; + top: 50%; + transform: translateY(-50%); + background: #007bff; + color: white; + border-radius: 50%; + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; +} + +.instructionsList a { + color: #007bff; + text-decoration: none; +} + +.instructionsList a:hover { + text-decoration: underline; +} + +.instructionsList { + counter-reset: li; +} +.downloadLink { + font-size: 16px; + color: #007bff; + text-decoration: none; + padding-right: 10px; +} + +.upload { + margin-right: 10px; +} + +.runButton { + margin-left: 10px; +} + +.scenarioContainer { + padding: 20px; + background-color: #f9f9f9; + border-radius: 8px; + max-width: 100%; + overflow: auto; +} + +.scenarioTable { + max-width: 100%; + overflow: auto; +} + +.expectedResultsExpanded { + margin-top: 20px; + margin-bottom: 20px; +} \ No newline at end of file diff --git a/app/components/ScenarioTester/ScenarioTester.tsx b/app/components/ScenarioTester/ScenarioTester.tsx new file mode 100644 index 0000000..8d9f84a --- /dev/null +++ b/app/components/ScenarioTester/ScenarioTester.tsx @@ -0,0 +1,325 @@ +import { useState, useEffect, useRef } from "react"; +import { Table, Tag, Button, TableProps, Flex, Upload, message } from "antd"; +import { CheckCircleOutlined, CloseCircleOutlined } from "@ant-design/icons"; +import { UploadOutlined } from "@ant-design/icons"; +import styles from "./ScenarioTester.module.css"; +import { runDecisionsForScenarios, uploadCSVAndProcess, getCSVForRuleRun } from "@/app/utils/api"; + +interface ScenarioTesterProps { + jsonFile: string; + uploader?: boolean; +} + +export default function ScenarioTester({ jsonFile, uploader }: ScenarioTesterProps) { + const [scenarioResults, setScenarioResults] = useState({}); + const [file, setFile] = useState(null); + const [uploadedFile, setUploadedFile] = useState(false); + const hasError = useRef(false); + + type DataType = { + key: string; + name: string; + [key: string]: any; + }; + + const applyConditionalStyling = (value: any, property: string): React.ReactNode => { + if (value === null || value === undefined) { + return null; + } + + if (typeof value === "boolean" && property === "resultMatch") { + return value ? ( + + }> + + ) : ( + + }> + + ); + } else if (typeof value === "boolean") { + return value ? TRUE : FALSE; + } + + // Handle numbers with "amount" in the property name + let displayValue = value; + if (typeof value === "number" && property.toLowerCase().includes("amount")) { + displayValue = `$${value}`; + } else if (typeof value === "number") { + displayValue = {value}; + } + + // Default formatting for other values + return {displayValue}; + }; + + const generateColumns = (keys: string[], prefix: string) => { + return keys.map((key) => ({ + title: key, + dataIndex: `${prefix.toLowerCase()}_${key}`, + key: `${prefix.toLowerCase()}_${key}`, + render: (value: any) => applyConditionalStyling(value, key), + })); + }; + + const formatData = ( + data: Record< + string, + { + result: Record; + inputs: Record; + outputs: Record; + expectedResults: Record; + resultMatch: boolean; + } + > + ): { formattedData: DataType[]; columns: TableProps["columns"] } => { + const uniqueInputKeys = new Set(); + const uniqueResultKeys = new Set(); + const uniqueExpectedKeys = new Set(); + + // Collect unique input and result keys + for (const entry of Object.values(data)) { + Object.keys(entry.inputs).forEach((key) => uniqueInputKeys.add(key)); + Object.keys(entry.result).forEach((key) => uniqueResultKeys.add(key)); + Object.keys(entry.expectedResults).forEach((key) => uniqueExpectedKeys.add(key)); + } + + const sortKeys = (keys: string[]) => keys.sort((a, b) => a.localeCompare(b)); + + // Convert sets to arrays for easier iteration + const inputKeys = sortKeys(Array.from(uniqueInputKeys)); + const resultKeys = sortKeys(Array.from(uniqueResultKeys)); + const expectedKeys = sortKeys(Array.from(uniqueExpectedKeys)); + + // Format the data + const formattedData: DataType[] = Object.entries(data).map(([name, entry], index) => { + const formattedEntry: DataType = { + key: (index + 1).toString(), + name, + }; + + // Add inputs + inputKeys.forEach((key) => { + formattedEntry[`input_${key}`] = + entry.inputs[key] !== undefined ? applyConditionalStyling(entry.inputs[key], key) : null; + }); + + // Add outputs + resultKeys.forEach((key) => { + formattedEntry[`result_${key}`] = + entry.result[key] !== undefined ? applyConditionalStyling(entry.result[key], key) : null; + }); + + // Add expected results + expectedKeys.forEach((key) => { + formattedEntry[`expected_result_${key}`] = + entry.expectedResults[key] !== undefined ? applyConditionalStyling(entry.expectedResults[key], key) : null; + }); + + formattedEntry.resultMatch = applyConditionalStyling(entry.resultMatch, "resultMatch"); + + return formattedEntry; + }); + + const inputColumns = generateColumns(inputKeys, "input"); + const outputColumns = generateColumns(resultKeys, "result"); + //Unused columns for now, unless we'd like to display the expected results as columns on the frontend + const expectedColumns = generateColumns(expectedKeys, "expected_result"); + + const columns: TableProps["columns"] = [ + { + title: "Status", + dataIndex: "resultMatch", + key: "resultMatch", + fixed: "left", + }, + { + title: "Name", + dataIndex: "name", + key: "name", + render: (text) => {text}, + fixed: "left", + }, + { + title: "Inputs", + children: inputColumns, + }, + { + title: "Results", + children: outputColumns, + }, + ]; + + return { formattedData, columns }; + }; + + const expandedRowRender = (record: { name: string }) => { + const expandedData = Object.entries(record || {}) + .map(([property, value], index) => ({ + key: index.toString(), + property, + value, + })) + .filter((entry) => entry.property.includes("expected_result")); + const expandedDataColumns = expandedData.map((entry) => ({ + title: entry.property.replace("expected_result_", ""), + dataIndex: entry.property, + key: entry.property, + render: (value: any) => { + return applyConditionalStyling(value, entry.property); + }, + })); + + return ( +
+ +
`Expected results for scenario: ${record?.name}`} + columns={expandedDataColumns} + dataSource={[record]} + pagination={false} + bordered + /> + + + ); + }; + + const rowExpandable = (record: { resultMatch: { props: { className: string } } }) => { + const resultStatus = record.resultMatch.props.className === "result-mismatch" ? true : false; + return resultStatus; + }; + + const updateScenarioResults = async (goRulesJSONFilename: string) => { + try { + const results = await runDecisionsForScenarios(goRulesJSONFilename); + // Loop through object and check if data.result is an array + for (const key in results) { + if (Array.isArray(results[key].result)) { + throw new Error( + `Error with results for: ${key}. Please update your rule and ensure that outputs are on one line.` + ); + } + } + const formattedResults = formatData(results); + setScenarioResults(formattedResults); + } catch (error) { + if (!hasError.current) { + hasError.current = true; + message.error("Error fetching scenario results: " + error); + } + } + }; + + useEffect(() => { + hasError.current = false; + updateScenarioResults(jsonFile); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [jsonFile]); + + const handleRunUploadScenarios = async () => { + if (!file) { + message.error("No file uploaded."); + return; + } + try { + const csvContent = await uploadCSVAndProcess(file, jsonFile); + message.success(`Scenarios Test: ${csvContent}`); + } catch (error) { + message.error("Error processing scenarios."); + console.error("Error:", error); + } + }; + + const handleDownloadScenarios = async () => { + try { + const csvContent = await getCSVForRuleRun(jsonFile); + message.success(`Scenario Testing Template: ${csvContent}`); + } catch (error) { + message.error("Error downloading scenarios."); + console.error("Error:", error); + } + }; + + return ( +
+ {uploader ? ( + +
    +
  1. + Download a template CSV file:{" "} + +
  2. +
  3. Add additional scenarios to the CSV file
  4. +
  5. + Upload your edited CSV file with scenarios:{" "} + +
  6. +
  7. + Run the scenarios against the GO Rules JSON file:{" "} + +
  8. +
  9. Receive a csv file with the results! 🎉
  10. +
+
+ ) : ( +
+ + + + +
expandedRowRender(record), + rowExpandable: (record: any) => rowExpandable(record), + columnTitle: "View Expected Results", + }} + className={styles.scenarioTable} + size="middle" + /> + + + )} + + ); +} diff --git a/app/components/ScenarioTester/index.ts b/app/components/ScenarioTester/index.ts new file mode 100644 index 0000000..8aa3a54 --- /dev/null +++ b/app/components/ScenarioTester/index.ts @@ -0,0 +1 @@ +export { default } from "./ScenarioTester"; diff --git a/app/components/ScenarioViewer/ScenarioViewer.module.css b/app/components/ScenarioViewer/ScenarioViewer.module.css new file mode 100644 index 0000000..45a77f1 --- /dev/null +++ b/app/components/ScenarioViewer/ScenarioViewer.module.css @@ -0,0 +1,75 @@ +.scenarioViewer { + display: flex; + gap: 1rem; +} + +.scenarioList, .selectedScenarioDetails, .resultsColumn { + flex: 1 1 auto; /* Initializes with each column taking up equal space */ + box-sizing: border-box; + padding: 1rem; + border-radius: 4px; +} + +.scenarioList { + min-width: 300px; + max-width: 300px; + .selected { + background-color: rgba(209, 230, 255, 0.5); + border-radius: 5px; + } +} + +.scenarioList ol { + list-style-type: none; + counter-reset: item; + padding: 0; +} + +.scenarioList li { + cursor: pointer; + counter-increment: item; + padding-block: 1rem; + padding-inline: 0.75rem; + margin-block: 0.5rem; +} + +.scenarioList li:before { + content: counter(item) ""; + color: white; + background-color: black; + margin-right: 10px; + border-radius: 50%; + width:30px; + height: 20px; + padding-block: 5px; + padding-inline: 10px; +} + +.selectedScenarioDetails { + min-width: 450px; + max-width: 450px; + border: 1px solid #ccc; +} + +.selectedScenarioDetails button { + width: 10rem; + align-self: end; +} + +.resultsColumn { + flex: 1 1 300px; + min-width: 300px; + max-width: 300px; + display: none; + white-space: pre-wrap; + border: 1px solid #6fb1fe; + background-color: rgba(209, 230, 255, 0.5); + border-radius: 5px; +} + +@media (min-width: 768px) { + .resultsColumn { + display: block; + + } +} diff --git a/app/components/ScenarioViewer/ScenarioViewer.tsx b/app/components/ScenarioViewer/ScenarioViewer.tsx new file mode 100644 index 0000000..4c27bff --- /dev/null +++ b/app/components/ScenarioViewer/ScenarioViewer.tsx @@ -0,0 +1,140 @@ +import React, { useState, useEffect } from "react"; +import { Flex, Button, Popconfirm, message } from "antd"; +import type { PopconfirmProps } from "antd"; +import { DeleteOutlined } from "@ant-design/icons"; +import InputOutputTable from "../InputOutputTable"; +import styles from "./ScenarioViewer.module.css"; +import { Scenario } from "@/app/types/scenario"; +import { deleteScenario } from "@/app/utils/api"; +import ScenarioFormatter from "../ScenarioFormatter"; +import { RuleMap } from "@/app/types/rulemap"; + +interface ScenarioViewerProps { + scenarios: Scenario[]; + resultsOfSimulation: Record | null | undefined; + setSelectedSubmissionInputs: (data: any) => void; + runSimulation: () => void; + rulemap: RuleMap; + editing?: boolean; +} + +export default function ScenarioViewer({ + scenarios, + resultsOfSimulation, + setSelectedSubmissionInputs, + runSimulation, + rulemap, + editing = true, +}: ScenarioViewerProps) { + const [scenariosDisplay, setScenariosDisplay] = useState(scenarios); + const [selectedScenario, setSelectedScenario] = useState(null); + const [manageScenarios, setManageScenarios] = useState(false); + + useEffect(() => { + setScenariosDisplay(scenarios); + }, [scenarios]); + + const handleSelectScenario = (scenario: Scenario) => { + setSelectedScenario(scenario); + const submissionInputs = scenario.variables.reduce((acc, variable) => { + acc[variable.name] = variable.value; + return acc; + }, {} as Record); + setSelectedSubmissionInputs(submissionInputs); + }; + + const handleRunScenario = () => { + runSimulation(); + }; + + const handleDeleteScenario = async (scenario: Scenario) => { + const scenarioID = scenario._id || ""; + try { + await deleteScenario(scenarioID); + scenariosDisplay ? setScenariosDisplay(scenariosDisplay.filter((s) => s._id !== scenarioID)) : null; + message.success("Scenario deleted"); + setManageScenarios(false); + } catch (e) { + message.error("Error deleting scenario"); + } + }; + + const cancel: PopconfirmProps["onCancel"] = (e) => { + console.log(e); + }; + + return ( + + + {scenariosDisplay && scenariosDisplay.length > 0 ? ( + <> +
    + {scenariosDisplay.map((scenario, index) => ( +
  1. handleSelectScenario(scenario)} + className={selectedScenario === scenario ? styles.selected : ""} + > + {scenario.title} {" "} + {manageScenarios && ( + <> + handleDeleteScenario(scenario)} + onCancel={cancel} + okText="Yes, delete scenario" + cancelText="No" + > + + + + )} +
  2. + ))} +
+ {editing && } + + ) : ( +
No scenarios available
+ )} +
+ {selectedScenario && ( + + +
+ { + acc[variable.name] = variable.value; + return acc; + }, {} as Record)} + /> +
+
+ +
+ )} + + {!resultsOfSimulation?.rulemap && ( + <> + + <>→ + + + + + + )} +
+ ); +} diff --git a/app/components/ScenarioViewer/index.ts b/app/components/ScenarioViewer/index.ts new file mode 100644 index 0000000..2ef6345 --- /dev/null +++ b/app/components/ScenarioViewer/index.ts @@ -0,0 +1 @@ +export { default } from "./ScenarioViewer"; diff --git a/app/components/SimulationViewer/SimulationViewer.module.css b/app/components/SimulationViewer/SimulationViewer.module.css index 209b335..b74b0ad 100644 --- a/app/components/SimulationViewer/SimulationViewer.module.css +++ b/app/components/SimulationViewer/SimulationViewer.module.css @@ -5,4 +5,8 @@ .contentSection { margin: "0 8px"; +} + +.inputSection button { + width: 10rem; } \ No newline at end of file diff --git a/app/components/SimulationViewer/SimulationViewer.tsx b/app/components/SimulationViewer/SimulationViewer.tsx index 0dfeb33..62114d1 100644 --- a/app/components/SimulationViewer/SimulationViewer.tsx +++ b/app/components/SimulationViewer/SimulationViewer.tsx @@ -1,30 +1,58 @@ "use client"; import React, { useState, useEffect } from "react"; import dynamic from "next/dynamic"; -import Link from "next/link"; -import { Flex, Button } from "antd"; -import { ExportOutlined } from "@ant-design/icons"; +import { Flex, Button, Tabs } from "antd"; +import type { TabsProps } from "antd"; import { SubmissionData } from "../../types/submission"; -import SubmissionSelector from "../SubmissionSelector"; -import InputOutputTable from "../InputOutputTable"; +import { RuleMap } from "../../types/rulemap"; +import { Scenario } from "@/app/types/scenario"; import styles from "./SimulationViewer.module.css"; +import ScenarioViewer from "../ScenarioViewer/ScenarioViewer"; +import ScenarioGenerator from "../ScenarioGenerator/ScenarioGenerator"; +import ScenarioTester from "../ScenarioTester/ScenarioTester"; // Need to disable SSR when loading this component so it works properly const RulesDecisionGraph = dynamic(() => import("../RulesDecisionGraph"), { ssr: false }); interface SimulationViewerProps { + ruleId: string; jsonFile: string; - chefsFormId: string; + rulemap: RuleMap; + scenarios: Scenario[]; + editing?: boolean; } -export default function SimulationViewer({ jsonFile, chefsFormId }: SimulationViewerProps) { - const [selectedSubmissionInputs, setSelectedSubmissionInputs] = useState(); +export default function SimulationViewer({ + ruleId, + jsonFile, + rulemap, + scenarios, + editing = true, +}: SimulationViewerProps) { + const createRuleMap = (array: any[]) => { + return array.reduce( + (acc, obj) => { + acc[obj.property] = null; + return acc; + }, + { rulemap: true } + ); + }; + + const ruleMapInputs = createRuleMap(rulemap.inputs); + const ruleMapOutputs = createRuleMap(rulemap.outputs); + const ruleMapResultOutputs = createRuleMap(rulemap.resultOutputs); + + const [selectedSubmissionInputs, setSelectedSubmissionInputs] = useState(ruleMapInputs); const [contextToSimulate, setContextToSimulate] = useState(); + const [outputSchema, setOutputSchema] = useState | null>(ruleMapOutputs); const [resultsOfSimulation, setResultsOfSimulation] = useState | null>(); + const [resetTrigger, setResetTrigger] = useState(false); const resetContextAndResults = () => { setContextToSimulate(null); - setResultsOfSimulation(null); + setOutputSchema(ruleMapOutputs); + setResultsOfSimulation(ruleMapResultOutputs); }; const runSimulation = () => { @@ -35,8 +63,99 @@ export default function SimulationViewer({ jsonFile, chefsFormId }: SimulationVi useEffect(() => { // reset context/results when a new submission is selected resetContextAndResults(); + // eslint-disable-next-line react-hooks/exhaustive-deps }, [selectedSubmissionInputs]); + useEffect(() => {}, [resultsOfSimulation]); + + const handleTabChange = (key: string) => { + if (key === "1") { + handleReset(); + } + }; + + const handleReset = () => { + setSelectedSubmissionInputs({}); + setTimeout(() => { + setSelectedSubmissionInputs(ruleMapInputs); + }, 0); + setResetTrigger((prev) => !prev); + }; + + const scenarioTab = ( + + + + ); + + const scenarioGeneratorTab = ( + + + + + ); + + const scenarioTestsTab = ( + + + + ); + + const csvScenarioTestsTab = ( + + + + ); + + const items: TabsProps["items"] = [ + { + key: "1", + label: "Simulate pre-defined test scenarios", + children: scenarioTab, + disabled: false, + }, + { + key: "2", + label: "Simulate inputs manually and create new scenarios", + children: scenarioGeneratorTab, + disabled: false, + }, + { + key: "3", + label: "Scenario Results", + children: scenarioTestsTab, + disabled: editing ? false : true, + }, + { + key: "4", + label: "CSV Tests", + children: csvScenarioTestsTab, + disabled: editing ? false : true, + }, + ]; + + const filteredItems = editing ? items : items?.filter((item) => item.disabled !== true) || []; + return (
@@ -44,26 +163,18 @@ export default function SimulationViewer({ jsonFile, chefsFormId }: SimulationVi jsonFile={jsonFile} contextToSimulate={contextToSimulate} setResultsOfSimulation={setResultsOfSimulation} + setOutputsOfSimulation={setOutputSchema} />
- - - {selectedSubmissionInputs && ( - - )} + + - - - - - - {selectedSubmissionInputs && } - {resultsOfSimulation && } ); diff --git a/app/components/SubmissionSelector/SubmissionSelector.tsx b/app/components/SubmissionSelector/SubmissionSelector.tsx index c7b8744..2e04696 100644 --- a/app/components/SubmissionSelector/SubmissionSelector.tsx +++ b/app/components/SubmissionSelector/SubmissionSelector.tsx @@ -7,9 +7,14 @@ import styles from "./SubmissionSelector.module.css"; interface SubmissionSelectorProps { chefsFormId: string; setSelectedSubmissionInputs: (newSelection: SubmissionData) => void; + resetTrigger: boolean; } -export default function SubmissionSelector({ chefsFormId, setSelectedSubmissionInputs }: SubmissionSelectorProps) { +export default function SubmissionSelector({ + chefsFormId, + setSelectedSubmissionInputs, + resetTrigger, +}: SubmissionSelectorProps) { const [options, setOptions] = useState<{ value: string }[]>([]); const [valueToIdMap, setValueToIdMap] = useState>({}); const [searchText, setSearchText] = useState(""); @@ -22,6 +27,7 @@ export default function SubmissionSelector({ chefsFormId, setSelectedSubmissionI }, } = await getSubmissionFromCHEFSById(chefsFormId, valueToIdMap[value]); setSelectedSubmissionInputs(data); + setSearchText(value); }; // Extracted data transformation logic @@ -50,11 +56,16 @@ export default function SubmissionSelector({ chefsFormId, setSelectedSubmissionI fetchData(); }, [chefsFormId]); + useEffect(() => { + setSearchText(""); + }, [resetTrigger]); + return ( diff --git a/app/page.tsx b/app/page.tsx index e2ce8ee..7441e7c 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -22,7 +22,7 @@ export default function Home() { getRules(); }, []); - const mappedRules = rules.map(({ _id, title, goRulesJSONFilename, chefsFormId }) => { + const mappedRules = rules.map(({ _id, title, goRulesJSONFilename }) => { return { key: _id, titleLink: ( @@ -31,11 +31,8 @@ export default function Home() { ), downloadRule: ( - - Download JSON - + Download JSON ), - submissionFormLink: Submission, }; }); @@ -48,10 +45,6 @@ export default function Home() { title: "Download Rule", dataIndex: "downloadRule", }, - { - title: "Submission Form", - dataIndex: "submissionFormLink", - }, ]; return ( diff --git a/app/rule/[ruleId]/embedded/page.tsx b/app/rule/[ruleId]/embedded/page.tsx index 3cfcea5..09600d4 100644 --- a/app/rule/[ruleId]/embedded/page.tsx +++ b/app/rule/[ruleId]/embedded/page.tsx @@ -1,12 +1,16 @@ import SimulationViewer from "../../../components/SimulationViewer"; -import { getRuleDataById } from "../../../utils/api"; +import { getRuleDataById, getRuleMapByName, getScenariosByFilename } from "../../../utils/api"; +import { RuleMap } from "@/app/types/rulemap"; +import { Scenario } from "@/app/types/scenario"; -export default async function RuleEmbedded({ params: { ruleId } }: { params: { ruleId: string } }) { - const { _id, goRulesJSONFilename, chefsFormId } = await getRuleDataById(ruleId); +export default async function Rule({ params: { ruleId } }: { params: { ruleId: string } }) { + const { _id, goRulesJSONFilename } = await getRuleDataById(ruleId); + const rulemap: RuleMap = await getRuleMapByName(goRulesJSONFilename); + const scenarios: Scenario[] = await getScenariosByFilename(goRulesJSONFilename); if (!_id) { return

Rule not found

; } - return ; + return ; } diff --git a/app/rule/[ruleId]/page.tsx b/app/rule/[ruleId]/page.tsx index 74b1d6b..ced8a81 100644 --- a/app/rule/[ruleId]/page.tsx +++ b/app/rule/[ruleId]/page.tsx @@ -1,11 +1,14 @@ -import Link from "next/link"; -import { Flex } from "antd"; -import { HomeOutlined } from "@ant-design/icons"; +import RuleHeader from "@/app/components/RuleHeader"; import SimulationViewer from "../../components/SimulationViewer"; -import { getRuleDataById } from "../../utils/api"; +import { getRuleDataById, getRuleMapByName, getScenariosByFilename } from "../../utils/api"; +import { RuleMap } from "@/app/types/rulemap"; +import { Scenario } from "@/app/types/scenario"; export default async function Rule({ params: { ruleId } }: { params: { ruleId: string } }) { - const { title, _id, goRulesJSONFilename, chefsFormId } = await getRuleDataById(ruleId); + const ruleInfo = await getRuleDataById(ruleId); + const { _id, goRulesJSONFilename } = ruleInfo; + const rulemap: RuleMap = await getRuleMapByName(goRulesJSONFilename); + const scenarios: Scenario[] = await getScenariosByFilename(goRulesJSONFilename); if (!_id) { return

Rule not found

; @@ -13,13 +16,13 @@ export default async function Rule({ params: { ruleId } }: { params: { ruleId: s return ( <> - - - - -

{title || goRulesJSONFilename}

-
- + + ); } diff --git a/app/styles/globals.css b/app/styles/globals.css index 9ce74ab..560220a 100644 --- a/app/styles/globals.css +++ b/app/styles/globals.css @@ -2,3 +2,23 @@ .grl-dg { min-height: 500px !important; } + +.labelsmall { + font-size: 12px; + color: #666; + display: flex; + flex-direction: column; +} + +.labelsmall input { + margin-top: 4px; +} + +.labelsmall:focus-within { + color: #333; +} + +.labelsmall input:focus { + outline: 2px solid #007BFF; + outline-offset: 2px; +} diff --git a/app/types/ruleInfo.d.ts b/app/types/ruleInfo.d.ts index 7c5daf3..a062297 100644 --- a/app/types/ruleInfo.d.ts +++ b/app/types/ruleInfo.d.ts @@ -2,6 +2,6 @@ export interface RuleInfo { _id: string; title: string; goRulesJSONFilename: string; - chefsFormId: string; + chefsFormId?: string; chefsFormAPIKey?: string; } diff --git a/app/types/rulemap.d.ts b/app/types/rulemap.d.ts new file mode 100644 index 0000000..f853ead --- /dev/null +++ b/app/types/rulemap.d.ts @@ -0,0 +1,6 @@ +import { Variable } from "./scenario"; +export interface RuleMap { + inputs: Variable[]; + outputs: Variable[]; + resultOutputs: Variable[]; +} diff --git a/app/types/scenario.d.ts b/app/types/scenario.d.ts new file mode 100644 index 0000000..4d8ac24 --- /dev/null +++ b/app/types/scenario.d.ts @@ -0,0 +1,14 @@ +export interface Scenario { + _id?: string; //optional for create scenario as generated id + title: string; + ruleID: string; + goRulesJSONFilename: string; + variables: Variable[]; + expectedResults: any[]; +} + +export interface Variable { + name: string; + value: any; + type?: string; +} diff --git a/app/utils/api.ts b/app/utils/api.ts index 9353e08..22e7a39 100644 --- a/app/utils/api.ts +++ b/app/utils/api.ts @@ -1,6 +1,7 @@ import { DecisionGraphType } from "@gorules/jdm-editor/dist/components/decision-graph/context/dg-store.context"; import axios from "axios"; import { RuleInfo } from "../types/ruleInfo"; +import { RuleMap } from "../types/rulemap"; const axiosAPIInstance = axios.create({ // For server side calls, need full URL, otherwise can just use /api @@ -177,3 +178,171 @@ export const deleteRuleData = async (ruleId: string) => { throw error; } }; + +/** + * Retrieves a rule map from the API based on the provided json filename. + * @param goRulesJSONFilename The ID of the rule data to retrieve. + * @returns The rule map. + * @throws If an error occurs while retrieving the rule data. + */ +export const getRuleMapByName = async (goRulesJSONFilename: string): Promise => { + try { + const { data } = await axiosAPIInstance.post( + `/rulemap?goRulesJSONFilename=${encodeURIComponent(goRulesJSONFilename)}` + ); + return data; + } catch (error) { + console.error(`Error getting rule data: ${error}`); + throw error; + } +}; + +/** + * Assess the rule response and return the schema based on a run of the rule. + * @param ruleResponse The response from the rule evaluation. Assesses the trace response. + * @returns The inputs and outputs schema. + * @throws If an error occurs while retrieving the rule data. + */ +export const getRuleRunSchema = async (ruleResponse: unknown) => { + try { + const { data } = await axiosAPIInstance.post(`/rulemap/rulerunschema`, ruleResponse); + return data; + } catch (error) { + console.error(`Error posting output schema: ${error}`); + throw error; + } +}; + +/** + * Retrieves the scenarios for a rule from the API based on the provided filename + * @param goRulesJSONFilename The name of the rule data to retrieve. + * @returns The scenarios for the rule. + * @throws If an error occurs while retrieving the rule data. + */ +export const getScenariosByFilename = async (goRulesJSONFilename: string) => { + try { + const { data } = await axiosAPIInstance.post("/scenario/by-filename/", { goRulesJSONFilename }); + return data; + } catch (error) { + console.error(`Error posting output schema: ${error}`); + throw error; + } +}; + +/** + * Creates a new scenario for a rule + * @param scenarioResponse The response from scenario creation. + * @returns The confirmation of rule posting. + * @throws If an error occurs while retrieving the rule data. + */ +export const createScenario = async (scenarioResponse: unknown) => { + try { + const { data } = await axiosAPIInstance.post(`/scenario`, scenarioResponse); + return data; + } catch (error) { + console.error(`Error posting output schema: ${error}`); + throw error; + } +}; + +/** + * Deletes a scenario by its ID + * @param scenarioId The ID of the scenario to delete. + * @returns The confirmation of scenario deletion. + * @throws If an error occurs while deleting the scenario. + */ +export const deleteScenario = async (scenarioId: string) => { + try { + const { data } = await axiosAPIInstance.delete(`/scenario/${scenarioId}`); + return data; + } catch (error) { + console.error(`Error deleting scenario: ${error}`); + throw error; + } +}; +/** + * Runs all scenarios against a rule and exports the results as a CSV. + * @param goRulesJSONFilename The filename of the rule to evaluate scenarios against. + * @returns The CSV data containing the results of the scenario evaluations. + * @throws If an error occurs while running the scenarios or generating the CSV. + */ +export const runDecisionsForScenarios = async (goRulesJSONFilename: string) => { + try { + const { data } = await axiosAPIInstance.post("/scenario/run-decisions", { goRulesJSONFilename }); + return data; + } catch (error) { + console.error(`Error running scenarios: ${error}`); + throw error; + } +}; + +/** + * Downloads a CSV file containing scenarios for a rule run. + * @param goRulesJSONFilename The filename for the JSON rule. + * @returns The processed CSV content as a string. + * @throws If an error occurs during file upload or processing. + */ +export const getCSVForRuleRun = async (goRulesJSONFilename: string): Promise => { + try { + const response = await axiosAPIInstance.post( + "/scenario/evaluation", + { goRulesJSONFilename: goRulesJSONFilename }, + { + responseType: "blob", + headers: { "Content-Type": "application/json" }, + } + ); + + const blob = new Blob([response.data], { type: "text/csv" }); + const url = window.URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `${goRulesJSONFilename.replace(/\.json$/, ".csv")}`; + a.click(); + window.URL.revokeObjectURL(url); + + return "CSV downloaded successfully"; + } catch (error) { + console.error(`Error getting CSV for rule run: ${error}`); + throw new Error("Error getting CSV for rule run"); + } +}; + +/** + * Uploads a CSV file containing scenarios and processes the scenarios against the specified rule. + * @param file The file to be uploaded. + * @param goRulesJSONFilename The filename for the JSON rule. + * @returns The processed CSV content as a string. + * @throws If an error occurs during file upload or processing. + */ +export const uploadCSVAndProcess = async (file: File, goRulesJSONFilename: string): Promise => { + const formData = new FormData(); + formData.append("file", file); + formData.append("goRulesJSONFilename", goRulesJSONFilename); + + try { + const response = await axiosAPIInstance.post(`/scenario/evaluation/upload/`, formData, { + headers: { + "Content-Type": "multipart/form-data", + }, + responseType: "blob", + }); + + const blob = new Blob([response.data], { type: "text/csv" }); + const url = window.URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + const timestamp = new Date().toISOString().replace(/:/g, "-").replace(/\.\d+/, ""); + a.download = `${goRulesJSONFilename.replace(".json", "")}_testing_${file.name.replace( + ".csv", + "" + )}_${timestamp}.csv`; + a.click(); + window.URL.revokeObjectURL(url); + + return "File processed successfully"; + } catch (error) { + console.error(`Error processing CSV file: ${error}`); + throw new Error("Error processing CSV file"); + } +};