diff --git a/src/AceEditor/madie-ace-editor.tsx b/src/AceEditor/madie-ace-editor.tsx index 9ffa8a8e..8cc86403 100644 --- a/src/AceEditor/madie-ace-editor.tsx +++ b/src/AceEditor/madie-ace-editor.tsx @@ -25,6 +25,7 @@ import { } from "../api/useTerminologyServiceApi"; import { Definition } from "../CqlBuilderPanel/definitionsSection/definitionBuilder/DefinitionBuilder"; import { SelectedLibrary } from "../CqlBuilderPanel/Includes/CqlLibraryDetailsDialog"; +import { Funct } from "../CqlBuilderPanel/functionsSection/functionBuilder/FunctionBuilder"; export interface EditorPropsType { value: string; @@ -45,6 +46,7 @@ export interface EditorPropsType { editedLib: SelectedLibrary ) => void; handleDeleteLibrary?: (lib: SelectedLibrary) => void; + handleApplyFunction?: (funct: Funct) => void; parseDebounceTime?: number; inboundAnnotations?: Ace.Annotation[]; inboundErrorMarkers?: Ace.MarkerLike[]; diff --git a/src/CqlBuilderPanel/CqlBuilderPanel.test.tsx b/src/CqlBuilderPanel/CqlBuilderPanel.test.tsx index 1f4c4079..aabff0a8 100644 --- a/src/CqlBuilderPanel/CqlBuilderPanel.test.tsx +++ b/src/CqlBuilderPanel/CqlBuilderPanel.test.tsx @@ -98,6 +98,7 @@ jest.mock("@madie/madie-util", () => ({ useFeatureFlags: jest.fn().mockReturnValue({ CQLBuilderIncludes: true, CQLBuilderDefinitions: true, + CQLBuilderFunctions: true, QDMValueSetSearch: true, qdmCodeSearch: true, }), @@ -121,6 +122,9 @@ const props = { handleDeleteLibrary: jest.fn(), handleEditLibrary: jest.fn(), handleDefinitionEdit: jest.fn(), + handleApplyFunction: jest.fn(), + handleParameterEdit: jest.fn(), + handleApplyParameter: jest.fn(), resetCql: jest.fn(), getCqlDefinitionReturnTypes: jest.fn(), makeExpanded: jest.fn(), @@ -867,4 +871,40 @@ describe("CqlBuilderPanel", () => { expect(aceEditor.value).toContain("Some more Text"); }); }); + + it("Functions tab exists and it's enabled", async () => { + useFeatureFlags.mockImplementationOnce(() => ({ + CQLBuilderIncludes: true, + QDMValueSetSearch: true, + CQLBuilderDefinitions: true, + qdmCodeSearch: true, + CQLBuilderParameters: true, + CQLBuilderFunctions: true, + })); + const newProps = { ...props, canEdit: false }; + mockedAxios.put.mockResolvedValue({ + data: mockCqlBuilderLookUpData, + }); + render(); + const parameterTab = await screen.queryByText("Functions"); + expect(parameterTab).toBeInTheDocument(); + expect(parameterTab).toBeEnabled(); + }); + + it("Functions tab does not exist", async () => { + useFeatureFlags.mockImplementationOnce(() => ({ + CQLBuilderIncludes: true, + QDMValueSetSearch: true, + CQLBuilderDefinitions: true, + qdmCodeSearch: true, + CQLBuilderParameters: true, + CQLBuilderFunctions: false, + })); + mockedAxios.put.mockResolvedValue({ + data: mockCqlBuilderLookUpData, + }); + render(); + const parameterTab = await screen.queryByText("Functions"); + expect(parameterTab).not.toBeInTheDocument(); + }); }); diff --git a/src/CqlBuilderPanel/CqlBuilderPanel.tsx b/src/CqlBuilderPanel/CqlBuilderPanel.tsx index ac98e4d1..99ce7634 100644 --- a/src/CqlBuilderPanel/CqlBuilderPanel.tsx +++ b/src/CqlBuilderPanel/CqlBuilderPanel.tsx @@ -3,6 +3,7 @@ import CqlBuilderSectionPanelNavTabs from "./CqlBuilderSectionPanelNavTabs"; import ValueSetsSection from "./ValueSets/ValueSets"; import CodesSection from "./codesSection/CodesSection"; import DefinitionsSection from "./definitionsSection/DefinitionsSection"; +import FunctionsSection from "./functionsSection/FunctionsSection"; import { useFeatureFlags } from "@madie/madie-util"; import IncludesTabSection from "./Includes/Includes"; import Parameters from "./Parameters/Parameters"; @@ -34,13 +35,18 @@ export default function CqlBuilderPanel({ handleApplyDefinition, handleDefinitionEdit, handleDefinitionDelete, + handleApplyFunction, resetCql, getCqlDefinitionReturnTypes, makeExpanded, }) { const featureFlags = useFeatureFlags(); - const { CQLBuilderDefinitions, CQLBuilderIncludes, CQLBuilderParameters } = - featureFlags; + const { + CQLBuilderDefinitions, + CQLBuilderIncludes, + CQLBuilderParameters, + CQLBuilderFunctions, + } = featureFlags; // we have multiple flags and need to select a starting value based off of what's available and canEdit. const getStartingPage = (() => { // if cqlBuilderIncludes -> includes @@ -143,6 +149,7 @@ export default function CqlBuilderPanel({ isQDM={measureModel?.includes("QDM")} CQLBuilderParameters={CQLBuilderParameters} CQLBuilderIncludes={CQLBuilderIncludes} + CQLBuilderFunctions={CQLBuilderFunctions} />
)} + + {activeTab === "functions" && ( + + )}
diff --git a/src/CqlBuilderPanel/CqlBuilderSectionPanelNavTabs.tsx b/src/CqlBuilderPanel/CqlBuilderSectionPanelNavTabs.tsx index 73e71c83..7d290a01 100644 --- a/src/CqlBuilderPanel/CqlBuilderSectionPanelNavTabs.tsx +++ b/src/CqlBuilderPanel/CqlBuilderSectionPanelNavTabs.tsx @@ -6,6 +6,7 @@ export interface NavTabProps { CQLBuilderIncludes: boolean; CQLBuilderParameters: boolean; CQLBuilderDefinitions: boolean; + CQLBuilderFunctions: boolean; isQDM: boolean; } @@ -16,6 +17,7 @@ export default function CqlBuilderSectionPanelNavTabs(props: NavTabProps) { CQLBuilderDefinitions, CQLBuilderIncludes, CQLBuilderParameters, + CQLBuilderFunctions, isQDM, } = props; @@ -78,6 +80,16 @@ export default function CqlBuilderSectionPanelNavTabs(props: NavTabProps) { data-testid="definitions-tab" /> )} + {CQLBuilderFunctions && ( + + )} ); } diff --git a/src/CqlBuilderPanel/common/ConfirmationDialog.tsx b/src/CqlBuilderPanel/common/ConfirmationDialog.tsx new file mode 100644 index 00000000..e459c177 --- /dev/null +++ b/src/CqlBuilderPanel/common/ConfirmationDialog.tsx @@ -0,0 +1,39 @@ +import React from "react"; +import { MadieDialog } from "@madie/madie-design-system/dist/react"; +import ErrorIcon from "@mui/icons-material/Error"; + +const ConfirmationDialog = ({ open, onClose, onSubmit }) => { + return ( + +
+
+

You are about to clear this function, including all arguments.

+
+
+ +

This action cannot be undone!

+
+
+
+ ); +}; + +export default ConfirmationDialog; diff --git a/src/CqlBuilderPanel/functionsSection/FunctionSectionNavTabs.tsx b/src/CqlBuilderPanel/functionsSection/FunctionSectionNavTabs.tsx new file mode 100644 index 00000000..b68c3582 --- /dev/null +++ b/src/CqlBuilderPanel/functionsSection/FunctionSectionNavTabs.tsx @@ -0,0 +1,46 @@ +import React from "react"; +import { Tabs, Tab } from "@madie/madie-design-system/dist/react"; + +export interface NavTabProps { + activeTab: string; + setActiveTab: (value: string) => void; + functionCount: number; + loading: boolean; +} + +export default function FunctionSectionNavTabs(props: NavTabProps) { + const { activeTab, setActiveTab, functionCount, loading } = props; + + return ( +
+ { + setActiveTab(v); + }} + type="B" + visibleScrollbar + variant="scrollable" + scrollButtons={false} + > + + + +
+ ); +} diff --git a/src/CqlBuilderPanel/functionsSection/Functions.scss b/src/CqlBuilderPanel/functionsSection/Functions.scss new file mode 100644 index 00000000..1959f358 --- /dev/null +++ b/src/CqlBuilderPanel/functionsSection/Functions.scss @@ -0,0 +1,86 @@ +#function-form { + font-family: Rubik, sans-serif; + grid-column: span 5 / span 14; + display: flex; + flex-direction: column; + padding-top: 32px; + textArea { + box-sizing: border-box; + border-radius: 3px; + border: solid 1px #8c8c8c; + background-color: #fff; + opacity: 1; + height: 100%; + font-size: 14px; + font-weight: 400; + resize: both; + color: #333; + &::placeholder { + color: #717171; + opacity: 1; + } + } + > .content { + display: flex; + flex-direction: column; + flex-grow: 1; + position: relative; + .subTitle { + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: baseline; + padding-bottom: 8px; + margin-bottom: 33px; + border-bottom: solid 1px; + position: relative; + h2 { + font-size: 24px; + border: none; + margin: 0; + padding: 0; + } + } + h3 { + font-weight: 400; + font-size: 32px; + line-height: 48px; + display: flex; + flex-direction: row; + padding-bottom: 8px; + border-bottom: solid 1px #8c8c8c; + } + label:not(.Mui-disabled) { + font-weight: 500; + font-size: 14px; + line-height: 17px; + color: #333333; + text-transform: none; + margin-bottom: 7px; + } + } + .form-actions { + display: flex; + flex-direction: row; + min-width: 100%; + justify-content: end; + > .cancel-button { + margin-top: 1rem; + margin-right: 32px; + } + > button[type="submit"] { + margin-top: 0; + } + } + .left-box { + width: 40%; + float: left; + } + .right-box { + width: 55%; + font-family: Rubik, sans-serif; + margin-left: 30px; + height: 80%; + float: right; + } +} diff --git a/src/CqlBuilderPanel/functionsSection/FunctionsSection.test.tsx b/src/CqlBuilderPanel/functionsSection/FunctionsSection.test.tsx new file mode 100644 index 00000000..914fa42e --- /dev/null +++ b/src/CqlBuilderPanel/functionsSection/FunctionsSection.test.tsx @@ -0,0 +1,44 @@ +import * as React from "react"; +import { render, screen, waitFor, act } from "@testing-library/react"; +import FunctionsSection from "./FunctionsSection"; +import userEvent from "@testing-library/user-event"; + +const props = { + canEdit: true, + loading: false, + handleApplyFunction: jest.fn(), +}; + +describe("FunctionsSection", () => { + it("Should display function section", async () => { + render(); + const funct = await screen.findByTestId("function-tab"); + const savedfunctions = await screen.findByText("Saved Functions (0)"); + expect(funct).toBeInTheDocument(); + expect(savedfunctions).toBeInTheDocument(); + await waitFor(() => { + expect(funct).toHaveAttribute("aria-selected", "true"); + }); + await waitFor(() => { + expect(savedfunctions).toHaveAttribute("aria-selected", "false"); + }); + }); + + it("Should display saved function section", async () => { + render(); + const funct = await screen.findByTestId("function-tab"); + const savedfunctions = await screen.findByText("Saved Functions (0)"); + expect(funct).toBeInTheDocument(); + expect(savedfunctions).toBeInTheDocument(); + await waitFor(() => { + expect(funct).toHaveAttribute("aria-selected", "true"); + }); + await waitFor(() => { + expect(savedfunctions).toHaveAttribute("aria-selected", "false"); + }); + userEvent.click(savedfunctions); + await waitFor(() => { + expect(savedfunctions).toHaveAttribute("aria-selected", "true"); + }); + }); +}); diff --git a/src/CqlBuilderPanel/functionsSection/FunctionsSection.tsx b/src/CqlBuilderPanel/functionsSection/FunctionsSection.tsx new file mode 100644 index 00000000..d1f78d41 --- /dev/null +++ b/src/CqlBuilderPanel/functionsSection/FunctionsSection.tsx @@ -0,0 +1,39 @@ +import React, { useState } from "react"; +import "./Functions.scss"; +import FunctionSectionNavTabs from "./FunctionSectionNavTabs"; +import Functions from "./functions/Functions"; +import FunctionBuilder from "./functionBuilder/FunctionBuilder"; + +interface FunctionProps { + canEdit: boolean; + handleApplyFunction: Function; + loading: boolean; +} + +export default function FunctionsSection({ + canEdit, + handleApplyFunction, + loading, +}: FunctionProps) { + const [activeTab, setActiveTab] = useState("function"); + + return ( + <> + +
+ {activeTab === "function" && ( + + )} + {activeTab === "saved-functions" && } +
+ + ); +} diff --git a/src/CqlBuilderPanel/functionsSection/functionBuilder/FunctionBuilder.test.tsx b/src/CqlBuilderPanel/functionsSection/functionBuilder/FunctionBuilder.test.tsx new file mode 100644 index 00000000..6dc614ab --- /dev/null +++ b/src/CqlBuilderPanel/functionsSection/functionBuilder/FunctionBuilder.test.tsx @@ -0,0 +1,93 @@ +import * as React from "react"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { describe, it } from "@jest/globals"; +import "@testing-library/jest-dom"; +import FunctionBuilder from "./FunctionBuilder"; + +describe("CQL Function Builder Tests", () => { + it("Should display name and comment fields", async () => { + render(); + const functionNameTextBox = await screen.findByRole("textbox", { + name: "Function Name", + }); + expect(functionNameTextBox).toBeInTheDocument(); + + const functionCommentTextBox = await screen.findByRole("textbox", { + name: "Comment", + }); + expect(functionCommentTextBox).toBeInTheDocument(); + + expect( + screen.getByTestId("terminology-section-Expression Editor-sub-heading") + ).toBeInTheDocument(); + expect(screen.queryByTestId("type-selector-input")).not.toBeInTheDocument(); + }); + + it("Should disable Apply button with canEdit being false", async () => { + render(); + + const applyBtn = screen.getByTestId("function-apply-btn"); + expect(applyBtn).toBeInTheDocument(); + expect(applyBtn).toBeDisabled(); + + const clearBtn = screen.getByTestId("clear-function-btn"); + expect(clearBtn).toBeInTheDocument(); + expect(clearBtn).toBeDisabled(); + }); + + it("Should generate pop up when clear button is clicked, Cancel clear", async () => { + render(); + const functionNameInput = (await screen.findByTestId( + "function-name-text-input" + )) as HTMLInputElement; + expect(functionNameInput).toBeInTheDocument(); + expect(functionNameInput.value).toBe(""); + fireEvent.change(functionNameInput, { + target: { value: "Test" }, + }); + expect(functionNameInput.value).toBe("Test"); + + const functionCommentTextBox = await screen.findByRole("textbox", { + name: "Comment", + }); + expect(functionCommentTextBox).toBeInTheDocument(); + + const clearBtn = screen.getByTestId("clear-function-btn"); + expect(clearBtn).toBeEnabled(); + fireEvent.click(clearBtn); + + const confirmationDialog = screen.getByText("Are you sure?"); + const cancelButton = screen.getByTestId("confirmation-cancel-button"); + expect(cancelButton).toBeEnabled(); + fireEvent.click(cancelButton); + expect(functionNameInput.value).toBe("Test"); + }); + + it("Should generate pop up when clear button is clicked, confirm clear", async () => { + render(); + const functionNameInput = (await screen.findByTestId( + "function-name-text-input" + )) as HTMLInputElement; + expect(functionNameInput).toBeInTheDocument(); + expect(functionNameInput.value).toBe(""); + fireEvent.change(functionNameInput, { + target: { value: "Test" }, + }); + expect(functionNameInput.value).toBe("Test"); + + const functionCommentTextBox = await screen.findByRole("textbox", { + name: "Comment", + }); + expect(functionCommentTextBox).toBeInTheDocument(); + + const clearBtn = screen.getByTestId("clear-function-btn"); + expect(clearBtn).toBeEnabled(); + fireEvent.click(clearBtn); + + const confirmationDialog = screen.getByText("Are you sure?"); + const clearButton = screen.getByTestId("confirmation-clear-button"); + expect(clearButton).toBeEnabled(); + fireEvent.click(clearButton); + expect(functionNameInput.value).toBe(""); + }); +}); diff --git a/src/CqlBuilderPanel/functionsSection/functionBuilder/FunctionBuilder.tsx b/src/CqlBuilderPanel/functionsSection/functionBuilder/FunctionBuilder.tsx new file mode 100644 index 00000000..aaa509d8 --- /dev/null +++ b/src/CqlBuilderPanel/functionsSection/functionBuilder/FunctionBuilder.tsx @@ -0,0 +1,152 @@ +import React, { useState, useRef } from "react"; +import "twin.macro"; +import "styled-components/macro"; +import { useFormik } from "formik"; +import { + Button, + TextArea, + TextField, +} from "@madie/madie-design-system/dist/react"; +import "../Functions.scss"; +import ExpandingSection from "../../../common/ExpandingSection"; +import { Checkbox, FormControlLabel } from "@mui/material"; +import { Box } from "@mui/system"; +import ConfirmationDialog from "../../common/ConfirmationDialog"; + +export interface Funct { + functionName?: string; + fluentFunction?: boolean; + comment?: string; +} + +export interface FunctionProps { + canEdit: boolean; + handleApplyFunction: Function; + handleFunctionEdit?: Function; + funct?: Funct; + onClose?: Function; +} + +export default function FunctionBuilder({ + canEdit, + handleApplyFunction, + handleFunctionEdit, + onClose, + funct, +}: FunctionProps) { + const [argumentsEditorOpen, setArgumentsEditorOpen] = + useState(false); + const [expressionEditorOpen, setExpressionEditorOpen] = + useState(false); + const textAreaRef = useRef(null); + const [confirmationDialog, setConfirmationDialog] = useState(false); + + const formik = useFormik({ + initialValues: { + functionName: funct?.functionName || "", + comment: funct?.comment || "", + fluentFunction: funct?.fluentFunction || false, + }, + enableReinitialize: true, + onSubmit: (values) => {}, + }); + const { resetForm } = formik; + + return ( +
+
+
+
+ +
+ + + } + sx={{ textTransform: "none", color: "#515151" }} + label="Fluent Function" + /> + +
+
+