From a80dc45505700ca68b0c78851b0e886133a9f230 Mon Sep 17 00:00:00 2001 From: brysonjbest <103070659+brysonjbest@users.noreply.github.com> Date: Thu, 7 Nov 2024 15:57:38 -0800 Subject: [PATCH 01/18] Add scenario creation on import of json with test scenarios. --- .../RuleViewerEditor/RuleViewerEditor.tsx | 81 ++++++++++++++----- app/utils/ruleScenariosFormat.ts | 66 +++++++++++++++ 2 files changed, 128 insertions(+), 19 deletions(-) create mode 100644 app/utils/ruleScenariosFormat.ts diff --git a/app/components/RuleViewerEditor/RuleViewerEditor.tsx b/app/components/RuleViewerEditor/RuleViewerEditor.tsx index b8a5333..eb803f3 100644 --- a/app/components/RuleViewerEditor/RuleViewerEditor.tsx +++ b/app/components/RuleViewerEditor/RuleViewerEditor.tsx @@ -17,7 +17,8 @@ import { import { SchemaSelectProps, PanelType } from "@/app/types/jdm-editor"; import { Scenario, Variable } from "@/app/types/scenario"; import { downloadFileBlob } from "@/app/utils/utils"; -import { getScenariosByFilename } from "@/app/utils/api"; +import { createRuleJSONWithScenarios, convertTestsToScenarios } from "@/app/utils/ruleScenariosFormat"; +import { createScenario, getScenariosByFilename } from "@/app/utils/api"; import { logError } from "@/app/utils/logger"; import LinkRuleComponent from "./subcomponents/LinkRuleComponent"; import SimulatorPanel from "./subcomponents/SimulatorPanel"; @@ -79,24 +80,7 @@ export default function RuleViewerEditor({ const handleScenarioInsertion = async () => { try { - const scenarios: Scenario[] = await getScenariosByFilename(jsonFilename); - 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 = { - ...ruleContent, - ...scenarioObject, - }; + const updatedJSON = await createRuleJSONWithScenarios(jsonFilename, ruleContent); return downloadJSON(updatedJSON, jsonFilename); } catch (error: any) { logError("Error fetching JSON:", error); @@ -115,6 +99,39 @@ export default function RuleViewerEditor({ } }; + useEffect(() => { + const handleFileSelect = (event: any) => { + if ( + decisionGraphRef.current && + event.target?.accept === "application/json" && + event.target.type === "file" && + event.target.files.length > 0 + ) { + const file = event.target.files[0]; + + // Parse contents of the uploaded file + const reader = new FileReader(); + reader.onload = async (e) => { + try { + if (e.target?.result) { + const fileContent = JSON.parse(e.target.result as string); + await handleFileUpload(event, fileContent); + } + } catch (error) { + console.error("Error parsing JSON file:", error); + } + }; + reader.readAsText(file); + } + }; + + document.addEventListener("change", handleFileSelect, true); + + return () => { + document.removeEventListener("change", handleFileSelect, true); + }; + }, [decisionGraphRef, ruleContent]); + useEffect(() => { const clickHandler = (event: any) => { interceptJSONDownload(event); @@ -128,6 +145,32 @@ export default function RuleViewerEditor({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [ruleContent]); + const getRuleIdFromPath = () => { + const match = window.location.pathname.match(/\/rule\/([^/]+)/); + return match?.[1] ?? null; + }; + + const handleFileUpload = async (_event: any, uploadedContent: { tests?: any[] }) => { + if (!uploadedContent?.tests) return; + + try { + const [existingScenarios, scenarios] = await Promise.all([ + getScenariosByFilename(jsonFilename), + Promise.resolve(convertTestsToScenarios(uploadedContent.tests)), + ]); + + const existingTitles: Set = new Set(existingScenarios.map((scenario: Scenario) => scenario.title)); + const newScenarios = scenarios.filter((scenario) => !existingTitles.has(scenario.title)); + + const ruleId = getRuleIdFromPath(); + await Promise.all( + newScenarios.map((scenario) => createScenario({ ...scenario, ruleID: ruleId, filepath: jsonFilename })) + ); + } catch (error) { + console.error("Failed to process scenarios:", error); + } + }; + const additionalComponents: NodeSpecification[] = useMemo( () => [ // This is to add the decision node - note that this may be added to the DecisionGraph library eventually diff --git a/app/utils/ruleScenariosFormat.ts b/app/utils/ruleScenariosFormat.ts new file mode 100644 index 0000000..1ddcbae --- /dev/null +++ b/app/utils/ruleScenariosFormat.ts @@ -0,0 +1,66 @@ +import { Scenario, Variable } from "../types/scenario"; +import { DecisionGraphType } from "@gorules/jdm-editor"; +import { getScenariosByFilename } from "./api"; +import { DEFAULT_RULE_CONTENT } from "../constants/defaultRuleContent"; +/** + * Prepares scenarios for JSON insertion + * @param scenarios Array of scenarios to format + * @returns Formatted scenario object + */ +export const formatScenariosForJSON = (scenarios: Scenario[]) => { + return { + 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; + }, {}), + })), + }; +}; + +/** + * Converts test data from JSON to scenario format + * @param tests Array of test objects from JSON + * @returns Array of formatted scenarios + */ +export const convertTestsToScenarios = (tests: any[]): Scenario[] => { + return tests.map(test => ({ + title: test.name, + ruleID: test.ruleID || '', + filepath: test.filepath || '', + variables: Object.entries(test.input).map(([name, value]) => ({ + name, + value + })), + expectedResults: Object.entries(test.output).map(([name, value]) => ({ + name, + value + })) + })); +}; + +/** + * Creates a complete rule JSON with scenarios + * @param filename The rule filename + * @param ruleContent Current rule content + * @returns Promise with the complete rule JSON + */ +export const createRuleJSONWithScenarios = async (filename: string, ruleContent: DecisionGraphType) => { + try { + const scenarios = await getScenariosByFilename(filename); + const scenarioObject = formatScenariosForJSON(scenarios); + return { + ...DEFAULT_RULE_CONTENT, + ...ruleContent, + ...scenarioObject, + }; + } catch (error) { + console.error("Error preparing rule JSON:", error); + throw error; + } +}; From d7e38c2e2567eb794c5f966140a2d469d9f8fe01 Mon Sep 17 00:00:00 2001 From: brysonjbest <103070659+brysonjbest@users.noreply.github.com> Date: Fri, 8 Nov 2024 12:08:37 -0800 Subject: [PATCH 02/18] Refactor scenarios and add upload selector for scenarios on rule add. --- app/components/RuleManager/RuleManager.tsx | 13 ++++- .../RuleViewerEditor/RuleViewerEditor.tsx | 42 ++++++++++++-- .../ScenarioSelectionContent.tsx | 56 +++++++++++++++++++ .../ScenariosManager/ScenariosManager.tsx | 17 +++--- app/rule/[ruleId]/embedded/page.tsx | 12 +--- app/rule/[ruleId]/page.tsx | 8 +-- 6 files changed, 114 insertions(+), 34 deletions(-) create mode 100644 app/components/RuleViewerEditor/subcomponents/ScenarioSelectionContent.tsx diff --git a/app/components/RuleManager/RuleManager.tsx b/app/components/RuleManager/RuleManager.tsx index fe6eb19..5e3084a 100644 --- a/app/components/RuleManager/RuleManager.tsx +++ b/app/components/RuleManager/RuleManager.tsx @@ -3,7 +3,7 @@ import React, { useState, useEffect } from "react"; import dynamic from "next/dynamic"; import { Flex, Spin, message } from "antd"; import { Simulation, DecisionGraphType } from "@gorules/jdm-editor"; -import { postDecision, getRuleMap } from "../../utils/api"; +import { postDecision, getRuleMap, getScenariosByFilename } from "../../utils/api"; import { RuleInfo } from "@/app/types/ruleInfo"; import { RuleMap } from "@/app/types/rulemap"; import { Scenario } from "@/app/types/scenario"; @@ -20,7 +20,6 @@ const RuleViewerEditor = dynamic(() => import("../RuleViewerEditor"), { ssr: fal interface RuleManagerProps { ruleInfo: RuleInfo; - scenarios?: Scenario[]; initialRuleContent?: DecisionGraphType; editing?: string | boolean; showAllScenarioTabs?: boolean; @@ -28,7 +27,6 @@ interface RuleManagerProps { export default function RuleManager({ ruleInfo, - scenarios, initialRuleContent = DEFAULT_RULE_CONTENT, editing = false, showAllScenarioTabs = true, @@ -49,6 +47,7 @@ export default function RuleManager({ }; const [isLoading, setIsLoading] = useState(true); + const [scenarios, setScenarios] = useState([]); const [ruleContent, setRuleContent] = useState(); const [rulemap, setRulemap] = useState(); const [simulation, setSimulation] = useState(); @@ -65,8 +64,14 @@ export default function RuleManager({ } }; + const updateScenarios = async () => { + const updatedScenarios: Scenario[] = await getScenariosByFilename(jsonFile); + setScenarios(updatedScenarios); + }; + useEffect(() => { setRuleContent(initialRuleContent); + updateScenarios(); }, [initialRuleContent]); useEffect(() => { @@ -160,6 +165,7 @@ export default function RuleManager({ runSimulation={runSimulation} isEditable={canEditGraph} setLoadingComplete={() => setIsLoading(false)} + updateScenarios={updateScenarios} /> {scenarios && rulemap && ( @@ -169,6 +175,7 @@ export default function RuleManager({ ruleContent={ruleContent} rulemap={rulemap} scenarios={scenarios} + setScenarios={setScenarios} isEditing={canEditScenarios} showAllScenarioTabs={showAllScenarioTabs} createRuleMap={createRuleMap} diff --git a/app/components/RuleViewerEditor/RuleViewerEditor.tsx b/app/components/RuleViewerEditor/RuleViewerEditor.tsx index eb803f3..83d80d5 100644 --- a/app/components/RuleViewerEditor/RuleViewerEditor.tsx +++ b/app/components/RuleViewerEditor/RuleViewerEditor.tsx @@ -2,7 +2,7 @@ import { useState, useMemo, useEffect, useRef } from "react"; import type { ReactFlowInstance } from "reactflow"; import "@gorules/jdm-editor/dist/style.css"; -import { Spin } from "antd"; +import { Spin, Modal } from "antd"; import { ApartmentOutlined, PlayCircleOutlined, LoginOutlined, LogoutOutlined, BookOutlined } from "@ant-design/icons"; import { JdmConfigProvider, @@ -24,6 +24,7 @@ import LinkRuleComponent from "./subcomponents/LinkRuleComponent"; import SimulatorPanel from "./subcomponents/SimulatorPanel"; import RuleInputOutputFieldsComponent from "./subcomponents/RuleInputOutputFieldsComponent"; import NotesComponent from "./subcomponents/NotesComponent"; +import ScenarioSelectionContent from "./subcomponents/ScenarioSelectionContent"; interface RuleViewerEditorProps { jsonFilename: string; @@ -35,6 +36,7 @@ interface RuleViewerEditorProps { runSimulation?: (results: unknown) => void; isEditable?: boolean; setLoadingComplete: () => void; + updateScenarios: () => void; } export default function RuleViewerEditor({ @@ -47,6 +49,7 @@ export default function RuleViewerEditor({ runSimulation, isEditable = true, setLoadingComplete, + updateScenarios, }: RuleViewerEditorProps) { const decisionGraphRef: any = useRef(); const [reactFlowRef, setReactFlowRef] = useState(); @@ -162,10 +165,39 @@ export default function RuleViewerEditor({ const existingTitles: Set = new Set(existingScenarios.map((scenario: Scenario) => scenario.title)); const newScenarios = scenarios.filter((scenario) => !existingTitles.has(scenario.title)); - const ruleId = getRuleIdFromPath(); - await Promise.all( - newScenarios.map((scenario) => createScenario({ ...scenario, ruleID: ruleId, filepath: jsonFilename })) - ); + if (newScenarios.length > 0) { + let selectedScenarios: Scenario[] = []; + + Modal.confirm({ + title: "Import Scenarios", + width: 600, + maskClosable: false, + closable: false, + centered: true, + content: ( + { + selectedScenarios = selected; + }} + /> + ), + okText: "Import Selected", + cancelText: "Cancel", + onOk: async () => { + if (selectedScenarios.length > 0) { + const ruleId = getRuleIdFromPath(); + await Promise.all( + selectedScenarios.map((scenario) => + createScenario({ ...scenario, ruleID: ruleId, filepath: jsonFilename }) + ) + ).then(() => { + updateScenarios(); + }); + } + }, + }); + } } catch (error) { console.error("Failed to process scenarios:", error); } diff --git a/app/components/RuleViewerEditor/subcomponents/ScenarioSelectionContent.tsx b/app/components/RuleViewerEditor/subcomponents/ScenarioSelectionContent.tsx new file mode 100644 index 0000000..d263ba5 --- /dev/null +++ b/app/components/RuleViewerEditor/subcomponents/ScenarioSelectionContent.tsx @@ -0,0 +1,56 @@ +import { useState, useEffect } from "react"; +import { Checkbox, Space } from "antd"; +import { Scenario } from "@/app/types/scenario"; + +interface ScenarioSelectionContentProps { + scenarios: Scenario[]; + onComplete: (selectedScenarios: Scenario[]) => void; +} + +export default function ScenarioSelectionContent({ scenarios, onComplete }: ScenarioSelectionContentProps) { + const [selections, setSelections] = useState(scenarios.map((scenario) => ({ scenario, selected: true }))); + + useEffect(() => { + onComplete(selections.filter((s) => s.selected).map((s) => s.scenario)); + }, [selections, onComplete]); + + return ( +
+ + s.selected)} + indeterminate={selections.some((s) => s.selected) && !selections.every((s) => s.selected)} + onChange={(e) => { + setSelections((prev) => + prev.map((s) => ({ + ...s, + selected: e.target.checked, + })) + ); + }} + > + Select All + +
+ {selections.map((item, index) => ( + { + setSelections((prev) => { + const updated = [...prev]; + updated[index] = { + ...updated[index], + selected: e.target.checked, + }; + return updated; + }); + }} + > + {item.scenario.title} + + ))} + +
+ ); +} diff --git a/app/components/ScenariosManager/ScenariosManager.tsx b/app/components/ScenariosManager/ScenariosManager.tsx index 120e399..37eb35e 100644 --- a/app/components/ScenariosManager/ScenariosManager.tsx +++ b/app/components/ScenariosManager/ScenariosManager.tsx @@ -16,7 +16,8 @@ interface ScenariosManagerProps { jsonFile: string; ruleContent: DecisionGraphType; rulemap: RuleMap; - scenarios?: Scenario[]; + scenarios: Scenario[]; + setScenarios: (scenarios: Scenario[]) => void; isEditing: boolean; showAllScenarioTabs?: boolean; createRuleMap: (array: any[], preExistingContext?: Record) => RuleMap; @@ -31,7 +32,8 @@ export default function ScenariosManager({ jsonFile, ruleContent, rulemap, - scenarios, + scenarios = [], + setScenarios, isEditing, showAllScenarioTabs, createRuleMap, @@ -53,7 +55,6 @@ export default function ScenariosManager({ showAllScenarioTabs ? ScenariosManagerTabs.InputsTab : ScenariosManagerTabs.ScenariosTab ); const [scenarioName, setScenarioName] = useState(""); - const [activeScenarios, setActiveScenarios] = useState(scenarios ? scenarios : []); const handleTabChange = (key: string) => { setActiveTabKey(key); @@ -73,7 +74,7 @@ export default function ScenariosManager({ const scenariosTab = scenarios && rulemap && ( )} + {ruleInfo.name && + process.env.NEXT_PUBLIC_KLAMM_URL && + version !== RULE_VERSION.inProduction && + version !== RULE_VERSION.inDev && ( + + {" "} + + + )} {formatVersionText(version)} diff --git a/app/components/ScenariosManager/ScenarioFormatter/ScenarioFormatter.tsx b/app/components/ScenariosManager/ScenarioFormatter/ScenarioFormatter.tsx index f60b366..6f88616 100644 --- a/app/components/ScenariosManager/ScenarioFormatter/ScenarioFormatter.tsx +++ b/app/components/ScenariosManager/ScenarioFormatter/ScenarioFormatter.tsx @@ -4,7 +4,6 @@ import { Scenario } from "@/app/types/scenario"; import styles from "./ScenarioFormatter.module.css"; import { RuleMap } from "@/app/types/rulemap"; import InputStyler from "../../InputStyler/InputStyler"; -import { parseSchemaTemplate } from "../../InputStyler/InputStyler"; import FieldStyler from "../../InputStyler/subcomponents/FieldStyler"; const COLUMNS = [ @@ -62,25 +61,24 @@ export default function ScenarioFormatter({ title, rawData, setRawData, scenario const newData = Object.entries(updatedRawData) .filter(([field]) => !PROPERTIES_TO_IGNORE.includes(field)) .sort(([propertyA], [propertyB]) => propertyA.localeCompare(propertyB)) - .map(([field, value], index) => ({ - field: FieldStyler( - propertyRuleMap?.find((item) => item.field === field)?.name || - parseSchemaTemplate(field)?.arrayName || + .map(([field, value], index) => { + const propertyRule = propertyRuleMap?.find((item) => item.field === field); + return { + field: FieldStyler( + propertyRule?.name ? propertyRule : { name: field, description: propertyRule?.description } + ), + value: InputStyler( + value, field, - propertyRuleMap?.find((item) => item.field === field)?.description - ), - - value: InputStyler( - value, - field, - editable, - scenarios, - updatedRawData, - setRawData, - rulemap?.inputs.find((item) => item.field === field) - ), - key: index, - })); + editable, + scenarios, + updatedRawData, + setRawData, + rulemap?.inputs.find((item) => item.field === field) + ), + 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."); From 14b242bd5236c130a4639e9a1d72470ab49f805e Mon Sep 17 00:00:00 2001 From: brysonjbest <103070659+brysonjbest@users.noreply.github.com> Date: Thu, 24 Oct 2024 09:59:04 -0700 Subject: [PATCH 05/18] Minor accessibility fixes. --- app/admin/page.tsx | 17 ++++++++++++++-- .../InputStyler/subcomponents/FieldStyler.tsx | 2 +- .../RuleInputOutputFieldsComponent.tsx | 20 ++++++++++++++++--- app/page.tsx | 13 ++++++++++-- 4 files changed, 44 insertions(+), 8 deletions(-) diff --git a/app/admin/page.tsx b/app/admin/page.tsx index 4332706..95eaec5 100644 --- a/app/admin/page.tsx +++ b/app/admin/page.tsx @@ -137,7 +137,11 @@ export default function Admin() { const renderInputField = (fieldName: keyof RuleInfoBasic) => { const Component = (value: string, _: RuleInfo, index: number) => ( - ) => updateRule(e, index, fieldName)} /> + ) => updateRule(e, index, fieldName)} + /> ); Component.displayName = "InputField"; return Component; @@ -156,6 +160,7 @@ export default function Admin() { render: renderInputField("filepath"), }, { + title: "Manage", dataIndex: "delete", width: "60px", render: (value: string, _: RuleInfo, index: number) => { @@ -179,6 +184,7 @@ export default function Admin() { }, }, { + title: "View", dataIndex: "view", width: "60px", render: (_: string, { _id, isPublished }: RuleInfo) => { @@ -221,7 +227,14 @@ export default function Admin() { )} - + {isLoading ? (

Loading...

diff --git a/app/components/InputStyler/subcomponents/FieldStyler.tsx b/app/components/InputStyler/subcomponents/FieldStyler.tsx index 36a8f48..a5808ca 100644 --- a/app/components/InputStyler/subcomponents/FieldStyler.tsx +++ b/app/components/InputStyler/subcomponents/FieldStyler.tsx @@ -43,7 +43,7 @@ export default function FieldStyler({ name, description = "", field }: FieldProp ); const helpDialog = "View Description"; return ( -