diff --git a/designer/client/cypress/e2e/__image_snapshots__/electron/Linux/Creator toolbar should allow collapse (persist) and filtering #0.png b/designer/client/cypress/e2e/__image_snapshots__/electron/Linux/Creator toolbar should allow collapse (persist) and filtering #0.png index 6421dbc66af..c5c1c96a2ce 100644 Binary files a/designer/client/cypress/e2e/__image_snapshots__/electron/Linux/Creator toolbar should allow collapse (persist) and filtering #0.png and b/designer/client/cypress/e2e/__image_snapshots__/electron/Linux/Creator toolbar should allow collapse (persist) and filtering #0.png differ diff --git a/designer/client/cypress/e2e/__image_snapshots__/electron/Linux/Fragment should display dead-ended fragment correct #0.png b/designer/client/cypress/e2e/__image_snapshots__/electron/Linux/Fragment should display dead-ended fragment correct #0.png index cc171721195..dc885402122 100644 Binary files a/designer/client/cypress/e2e/__image_snapshots__/electron/Linux/Fragment should display dead-ended fragment correct #0.png and b/designer/client/cypress/e2e/__image_snapshots__/electron/Linux/Fragment should display dead-ended fragment correct #0.png differ diff --git a/designer/client/cypress/e2e/__image_snapshots__/electron/Linux/Process tests from file should properly display results of tests from file #0.png b/designer/client/cypress/e2e/__image_snapshots__/electron/Linux/Process tests from file should properly display results of tests from file #0.png index 2174e23a8e9..c5ffef68d12 100644 Binary files a/designer/client/cypress/e2e/__image_snapshots__/electron/Linux/Process tests from file should properly display results of tests from file #0.png and b/designer/client/cypress/e2e/__image_snapshots__/electron/Linux/Process tests from file should properly display results of tests from file #0.png differ diff --git a/designer/client/cypress/e2e/autoScreenshotChangeDocs.cy.ts b/designer/client/cypress/e2e/autoScreenshotChangeDocs.cy.ts index 1329f4e6b91..0b9efad160e 100644 --- a/designer/client/cypress/e2e/autoScreenshotChangeDocs.cy.ts +++ b/designer/client/cypress/e2e/autoScreenshotChangeDocs.cy.ts @@ -21,12 +21,12 @@ describe("Auto Screenshot Change Docs -", () => { takeGraphScreenshot(); // take screenshot of whole graph cy.get('[model-id="My first variable declaration"]').dblclick(); // click on node - cy.get('[title="Name"]').click(); // click of remove cursor flickering effect + cy.get("[data-testid=window]").find('[title="Name"]').click(); // click of remove cursor flickering effect takeWindowScreenshot(); // take screenshot of node window cy.visitNewProcess(seed, "docsBasicComponentsVariable#1"); // load new scenario cy.get('[model-id="only financial ops"]').dblclick(); // click on node - cy.get('[title="Name"]').click(); // click of remove cursor flickering effect + cy.get("[data-testid=window]").find('[title="Name"]').click(); // click of remove cursor flickering effect takeWindowScreenshot(); // take screenshot of node window }); @@ -34,14 +34,14 @@ describe("Auto Screenshot Change Docs -", () => { cy.visitNewProcess(seed, "docsBasicComponentsRecordVariable#0"); cy.layoutScenario(); cy.get('[model-id="node label goes here"]').dblclick(); - cy.get('[title="Name"]').click(); + cy.get("[data-testid=window]").find('[title="Name"]').click(); takeWindowScreenshot(); cy.get("[data-testid=window]") .contains(/^cancel$/i) .click(); cy.get('[model-id="variable"]').dblclick(); - cy.get('[title="Name"]').click(); + cy.get("[data-testid=window]").find('[title="Name"]').click(); takeWindowScreenshot(); }); @@ -55,7 +55,7 @@ describe("Auto Screenshot Change Docs -", () => { takeGraphScreenshot(); cy.get('[model-id="conditional filter"]').dblclick(); - cy.get('[title="Name"]').click(); + cy.get("[data-testid=window]").find('[title="Name"]').click(); takeWindowScreenshot(); }); @@ -68,7 +68,7 @@ describe("Auto Screenshot Change Docs -", () => { takeGraphScreenshot(); cy.get('[model-id="choice"]').dblclick(); - cy.get('[title="Name"]').click(); + cy.get("[data-testid=window]").find('[title="Name"]').click(); takeWindowScreenshot(); }); @@ -82,7 +82,7 @@ describe("Auto Screenshot Change Docs -", () => { cy.visitNewProcess(seed, "docsBasicComponentsForEach#0"); cy.layoutScenario(); cy.get('[model-id="for-each"]').dblclick(); - cy.get('[title="Name"]').click(); + cy.get("[data-testid=window]").find('[title="Name"]').click(); takeWindowScreenshot(); }); @@ -94,7 +94,7 @@ describe("Auto Screenshot Change Docs -", () => { takeGraphScreenshot(); cy.get('[model-id="union"]').dblclick(); - cy.get('[title="Name"]').click(); + cy.get("[data-testid=window]").find('[title="Name"]').click(); takeWindowScreenshot(); }); @@ -105,7 +105,7 @@ describe("Auto Screenshot Change Docs -", () => { takeGraphScreenshot(); cy.get('[model-id="single-side-join"]').dblclick(); - cy.get('[title="Name"]').click(); + cy.get("[data-testid=window]").find('[title="Name"]').click(); takeWindowScreenshot(); }); @@ -114,7 +114,7 @@ describe("Auto Screenshot Change Docs -", () => { cy.visitNewProcess(seed, "docsAggregatesFullOuterJoin#0"); cy.layoutScenario(); cy.get('[model-id="full-outer-join"]').dblclick(); - cy.get('[title="Name"]').click(); + cy.get("[data-testid=window]").find('[title="Name"]').click(); takeWindowScreenshot(); }); @@ -132,16 +132,16 @@ describe("Auto Screenshot Change Docs -", () => { cy.visitNewProcess(seed, "docsFragmentsInputs#0"); cy.layoutScenario(); cy.get('[model-id="input"]').dblclick(); - cy.get('[title="Name"]').click(); + cy.get("[data-testid=window]").find('[title="Name"]').click(); takeWindowScreenshot(); cy.get('[title="Options"]').eq(0).click(); // open parameter1 options - cy.get('[title="Name"]').click(); + cy.get("[data-testid=window]").find('[title="Name"]').click(); takeWindowScreenshot(); cy.get('[title="Options"]').eq(0).click(); // close parameter1 options cy.get('[title="Options"]').eq(1).click(); // open parameter2 options - cy.get('[title="Name"]').click(); + cy.get("[data-testid=window]").find('[title="Name"]').click(); takeWindowScreenshot(); }); diff --git a/designer/client/cypress/e2e/description.cy.ts b/designer/client/cypress/e2e/description.cy.ts index b4ab27dab68..ff4c3632fa0 100644 --- a/designer/client/cypress/e2e/description.cy.ts +++ b/designer/client/cypress/e2e/description.cy.ts @@ -27,7 +27,8 @@ describe("Description", () => { .dblclick(); cy.get("[data-testid=window]").should("be.visible").as("window"); - cy.contains("Description").next().find(".ace_editor").should("be.visible").click("center").type(`# description header{enter} + cy.get("[data-testid=window]").contains("Description").next().find(".ace_editor").should("be.visible").click("center") + .type(`# description header{enter} *Everything* is going according to **plan**.`); diff --git a/designer/client/cypress/e2e/fragment.cy.ts b/designer/client/cypress/e2e/fragment.cy.ts index 7554cf575af..799c63d169a 100644 --- a/designer/client/cypress/e2e/fragment.cy.ts +++ b/designer/client/cypress/e2e/fragment.cy.ts @@ -241,13 +241,18 @@ describe("Fragment", () => { request.alias = "suggestions"; } }); - cy.get('[title="Value"]').siblings().eq(0).should("be.visible").type("{selectall}#fragmentResult."); + cy.get("[data-testid=window]").find('[title="Value"]').siblings().eq(0).should("be.visible").type("{selectall}#fragmentResult."); // We wait for validation result to be sure that red message below the form field will be visible cy.wait("@validation") .its("response.statusCode") .should("eq", 200) .then(() => { - cy.get("@window").get("[title='Value']").siblings().eq(0).find("[data-testid='form-helper-text']").should("exist"); + cy.get("[data-testid=window]") + .find("[title='Value']") + .siblings() + .eq(0) + .find("[data-testid='form-helper-text']") + .should("exist"); }); cy.wait("@suggestions").its("response.statusCode").should("eq", 200); cy.get(".ace_autocomplete").should("be.visible"); diff --git a/designer/client/cypress/e2e/process.cy.ts b/designer/client/cypress/e2e/process.cy.ts index b7b6038e52c..32b90e6488e 100644 --- a/designer/client/cypress/e2e/process.cy.ts +++ b/designer/client/cypress/e2e/process.cy.ts @@ -44,8 +44,8 @@ describe("Process", () => { .should("be.enabled") .click(); cy.get("[data-testid=window]").should("be.visible"); - cy.get('[title="Name"]').siblings().first().click().type("-renamed"); - cy.get('[title="Description"]').siblings().first().type("RENAMED"); + cy.get("[data-testid=window]").find('[title="Name"]').siblings().first().click().type("-renamed"); + cy.get("[data-testid=window]").find('[title="Description"]').siblings().first().type("RENAMED"); cy.contains(/^apply/i) .should("be.enabled") .click(); @@ -60,7 +60,7 @@ describe("Process", () => { cy.contains(/^properties/i) .should("be.enabled") .click(); - cy.get('[title="Description"]').siblings().first().should("contain", "RENAMED"); + cy.get("[data-testid=window]").find('[title="Description"]').siblings().first().should("contain", "RENAMED"); }); it("should allow archive with redirect to list", function () { diff --git a/designer/client/cypress/e2e/process2.cy.ts b/designer/client/cypress/e2e/process2.cy.ts index 20a987f4105..aada2ee52e8 100644 --- a/designer/client/cypress/e2e/process2.cy.ts +++ b/designer/client/cypress/e2e/process2.cy.ts @@ -22,7 +22,7 @@ describe("Process view", () => { it("should have node search toolbar", () => { cy.get("[data-testid=search-panel]").should("be.visible"); cy.get("[data-testid=search-panel]").contains(/^search$/i); - cy.get("[data-testid=search-panel] input").click(); + cy.get("[data-testid=search-panel]").find("input[data-selector='NODES_IN_SCENARIO']").click(); cy.realType("en"); cy.get("[data-testid=search-panel]").contains(/sms/i).click(); cy.getNode("enricher") @@ -41,7 +41,7 @@ describe("Process view", () => { cy.get("[data-testid=window]") .contains(/^cancel$/i) .click(); - cy.get("[data-testid=search-panel] input").click().clear(); + cy.get("[data-testid=search-panel]").find("input[data-selector='NODES_IN_SCENARIO']").click().clear(); cy.realType("source"); cy.wait(750); //wait for animation cy.getNode("enricher") diff --git a/designer/client/cypress/e2e/search.cy.ts b/designer/client/cypress/e2e/search.cy.ts new file mode 100644 index 00000000000..2ecc7f16e17 --- /dev/null +++ b/designer/client/cypress/e2e/search.cy.ts @@ -0,0 +1,144 @@ +describe("Search Panel View", () => { + const seed = "process"; + + before(() => { + cy.deleteAllTestProcesses({ + filter: seed, + force: true, + }); + }); + + after(() => { + cy.deleteAllTestProcesses({ + filter: seed, + force: true, + }); + }); + + beforeEach(() => { + cy.visitNewProcess(seed, "testProcess"); + }); + + it("should collapse advanced search filters", () => { + cy.get("[data-testid=search-panel]").should("be.visible"); + cy.get("[data-testid=search-panel]").contains(/^search$/i); + cy.get("[data-testid=search-panel]").find("svg[id='advanced-search-icon']").click(); + //cy.realType("se"); + cy.get("[data-testid=search-panel]").contains("Advanced Search"); + cy.get("[data-testid=search-panel]").contains("Name"); + cy.get("[data-testid=search-panel]").contains("Description"); + cy.get("[data-testid=search-panel]").contains("Label"); + cy.get("[data-testid=search-panel]").contains("Value"); + cy.get("[data-testid=search-panel]").contains("Output"); + cy.get("[data-testid=search-panel]").contains("Type"); + cy.get("[data-testid=search-panel]").contains("Edge"); + }); + + it("should filter nodes when typing search query with selectors manually and editing it later in form", () => { + cy.get("[data-testid=search-panel]").find("input[data-selector='NODES_IN_SCENARIO']").click(); + + cy.realType("type:(sink) se"); + + cy.get("[data-testid=search-panel]").contains("dynamicService").should("not.exist"); + cy.get("[data-testid=search-panel]").contains("sendSms"); + + cy.get("[data-testid=search-panel]").find("svg[id='advanced-search-icon']").click(); + cy.get("[data-testid=search-panel]").find("input[name='type']").should("have.value", "sink"); + + cy.get("[data-testid=search-panel]").find("input[name='type']").click(); + cy.realType(",processor"); + + cy.get("[data-testid=search-panel]").find("button[type='submit']").click(); + + cy.get("[data-testid=search-panel]").contains("dynamicService"); + cy.get("[data-testid=search-panel]").contains("sendSms"); + }); + + it("should filter nodes when performing simple search and adding selectors using form", () => { + cy.get("[data-testid=search-panel]").find("input[data-selector='NODES_IN_SCENARIO']").click(); + cy.realType("se"); + + cy.get("[data-testid=search-panel]").find("svg[id='advanced-search-icon']").click(); + cy.get("[data-testid=search-panel]").find("input[name='type']").click(); + + cy.realType("sink,processor"); + + cy.get("[data-testid=search-panel]").find("button[type='submit']").click(); + + cy.get("[data-testid=search-panel]") + .find("input[data-selector='NODES_IN_SCENARIO']") + .should("have.value", "type:(sink,processor) se"); + + cy.get("[data-testid=search-panel]").contains("dynamicService"); + cy.get("[data-testid=search-panel]").contains("sendSms"); + }); + + it("should filter nodes when setting up multiple selectors using form", () => { + cy.get("[data-testid=search-panel]").find("svg[id='advanced-search-icon']").click(); + + cy.get("[data-testid=search-panel]").find("input[name='id']").click(); + cy.realType("bounded,dynamic,send,enricher"); + + cy.get("[data-testid=search-panel]").find("input[name='type']").click(); + cy.realType("sink,enricher"); + + cy.get("[data-testid=search-panel]").find("button[type='submit']").click(); + + cy.get("[data-testid=search-panel]") + .find("input[data-selector='NODES_IN_SCENARIO']") + .should("have.value", "id:(bounded,dynamic,send,enricher) type:(sink,enricher) "); + + cy.get("[data-testid=search-panel]").contains("enricher"); + cy.get("[data-testid=search-panel]").contains("sendSms"); + }); + + it("should synchronize the form input state with manually provided query with selectors", () => { + cy.get("[data-testid=search-panel]").find("input[data-selector='NODES_IN_SCENARIO']").click(); + cy.realType("type:(sink)"); + + cy.get("[data-testid=search-panel]").find("svg[id='advanced-search-icon']").click(); + cy.get("[data-testid=search-panel]").find("input[name='type']").should("have.value", "sink"); + + cy.get("[data-testid=search-panel]") + .find("input[data-selector='NODES_IN_SCENARIO']") + .click() + .type("{moveToEnd}") + .type("{leftArrow}") + .type(",source"); + + cy.get("[data-testid=search-panel]").find("svg[id='advanced-search-icon']").click(); + cy.get("[data-testid=search-panel]").find("input[name='type']").should("have.value", "sink,source"); + }); + + it("should clear search filters", () => { + cy.get("[data-testid=search-panel]").find("input[data-selector='NODES_IN_SCENARIO']").click(); + cy.realType("type:(sink) se"); + + cy.get("[data-testid=search-panel]").find("svg[id='advanced-search-icon']").click(); + cy.get("[data-testid=search-panel]").find("button[type='button']").click(); + + cy.get("[data-testid=search-panel]").find("input[data-selector='NODES_IN_SCENARIO']").should("have.value", "se"); + cy.get("[data-testid=search-panel]").find("input[name='type']").should("have.value", ""); + + cy.get("[data-testid=search-panel]").contains("dynamicService"); + cy.get("[data-testid=search-panel]").contains("sendSms"); + }); + + it("should clear filters when clear all button clicked", () => { + cy.get("[data-testid=search-panel]").find("input[data-selector='NODES_IN_SCENARIO']").click(); + cy.realType("se"); + + cy.get("[data-testid=search-panel]").find("svg[id='advanced-search-icon']").click(); + cy.get("[data-testid=search-panel]").find("input[name='type']").click(); + + cy.realType("sink,processor"); + + cy.get("[data-testid=search-panel]").find("button[type='submit']").click(); + cy.get("[data-testid=search-panel]").find("svg[id='clear-icon']").click(); + + cy.get("[data-testid=search-panel]").find("input[data-selector='NODES_IN_SCENARIO']").should("have.value", ""); + + cy.get("[data-testid=search-panel]").find("svg[id='advanced-search-icon']").click(); + cy.get("[data-testid=search-panel]").find("input[name='type']").should("have.value", ""); + }); +}); diff --git a/designer/client/src/assets/img/advanced-search.svg b/designer/client/src/assets/img/advanced-search.svg new file mode 100644 index 00000000000..645aba94cfc --- /dev/null +++ b/designer/client/src/assets/img/advanced-search.svg @@ -0,0 +1 @@ + diff --git a/designer/client/src/components/sidePanels/SearchLabel.tsx b/designer/client/src/components/sidePanels/SearchLabel.tsx new file mode 100644 index 00000000000..0a98db1efd0 --- /dev/null +++ b/designer/client/src/components/sidePanels/SearchLabel.tsx @@ -0,0 +1,10 @@ +import { FieldLabel } from "../graph/node-modal/FieldLabel"; +import React from "react"; + +interface Props { + label: string; +} + +export const SearchLabel = (props: Props) => { + return ; +}; diff --git a/designer/client/src/components/sidePanels/SearchLabeledInput.tsx b/designer/client/src/components/sidePanels/SearchLabeledInput.tsx new file mode 100644 index 00000000000..23e5354edd9 --- /dev/null +++ b/designer/client/src/components/sidePanels/SearchLabeledInput.tsx @@ -0,0 +1,19 @@ +import { forwardRef, PropsWithChildren } from "react"; +import { FormControl } from "@mui/material"; +import { nodeInput } from "../graph/node-modal/NodeDetailsContent/NodeTableStyled"; +import React from "react"; + +export type SearchLabeledInputProps = PropsWithChildren<{ + name: string; +}>; + +export const SearchLabeledInput = forwardRef(({ children, ...props }, ref) => { + return ( + + {children} + + + ); +}); + +SearchLabeledInput.displayName = "SearchLabeledInput"; diff --git a/designer/client/src/components/table/SearchFilter.tsx b/designer/client/src/components/table/SearchFilter.tsx index 5941c62f254..92d163de624 100644 --- a/designer/client/src/components/table/SearchFilter.tsx +++ b/designer/client/src/components/table/SearchFilter.tsx @@ -1,6 +1,7 @@ import { css, cx } from "@emotion/css"; import React from "react"; import SearchSvg from "../../assets/img/search.svg"; +import AdvancedSearchSvg from "../../assets/img/advanced-search.svg"; import DeleteSvg from "../../assets/img/toolbarButtons/delete.svg"; import { useTheme } from "@mui/material"; @@ -9,6 +10,35 @@ const flex = css({ flex: 1, }); +export function AdvancedOptionsIcon(props: { + isActive?: boolean; + collapseHandler: React.Dispatch>; +}): JSX.Element { + const theme = useTheme(); + + const toggleCollapseHandler = () => { + props.collapseHandler((p) => !p); + }; + + return ( + + ); +} + export function SearchIcon(props: { isEmpty?: boolean }): JSX.Element { const theme = useTheme(); return ( @@ -16,6 +46,7 @@ export function SearchIcon(props: { isEmpty?: boolean }): JSX.Element { className={cx( flex, css({ + transform: "scale(0.8)", ".icon-fill": { fill: props.isEmpty ? theme.palette.text.secondary : theme.palette.primary.main, }, @@ -30,6 +61,7 @@ export function ClearIcon(): JSX.Element { return ( & { onClear?: () => void; onAddonClick?: () => void; + endAdornment?: ReactNode; }; export type Focusable = { @@ -23,6 +24,9 @@ export const InputWithIcon = forwardRef(function InputWithIcon const wrapperWithAddonStyles = css({ position: "relative", + display: "flex", + flexDirection: "row", + alignItems: "center", }); const addonWrapperStyles = css({ @@ -59,6 +63,11 @@ export const InputWithIcon = forwardRef(function InputWithIcon return (
+ {children && ( +
focus())}> + {children} +
+ )}
{!!props.value && onClear && ( @@ -66,11 +75,7 @@ export const InputWithIcon = forwardRef(function InputWithIcon
)} - {children && ( -
focus())}> - {children} -
- )} + {props.endAdornment &&
{props.endAdornment}
}
); diff --git a/designer/client/src/components/themed/SearchInput.tsx b/designer/client/src/components/themed/SearchInput.tsx index 9cbbfc4e3e5..a495d420c38 100644 --- a/designer/client/src/components/themed/SearchInput.tsx +++ b/designer/client/src/components/themed/SearchInput.tsx @@ -3,7 +3,7 @@ import { InputWithIcon } from "./InputWithIcon"; export const SearchInputWithIcon = styled(InputWithIcon)(({ theme }) => ({ ...theme.typography.body2, - width: "100%", + width: "75%", borderRadius: 0, height: "36px !important", color: theme.palette.text.secondary, diff --git a/designer/client/src/components/tips/Styled.tsx b/designer/client/src/components/tips/Styled.tsx index 8ce50317ade..93704643536 100644 --- a/designer/client/src/components/tips/Styled.tsx +++ b/designer/client/src/components/tips/Styled.tsx @@ -38,3 +38,8 @@ export const TipPanelStyled = styled("div")<{ outlineOffset: "-2px", }), })); + +export const SearchPanelStyled = styled("div")(({ theme }) => ({ + width: "100%", + padding: theme.spacing(1, 1.25), +})); diff --git a/designer/client/src/components/toolbars/search/AdvancedSearchFilters.tsx b/designer/client/src/components/toolbars/search/AdvancedSearchFilters.tsx new file mode 100644 index 00000000000..51f0f9bf713 --- /dev/null +++ b/designer/client/src/components/toolbars/search/AdvancedSearchFilters.tsx @@ -0,0 +1,139 @@ +import React, { MutableRefObject, useEffect, useMemo, useRef, useState } from "react"; +import { Button, Box, Typography } from "@mui/material"; +import { useTranslation } from "react-i18next"; +import { SearchLabeledInput } from "../../sidePanels/SearchLabeledInput"; +import { SearchLabel } from "../../sidePanels/SearchLabel"; +import { SearchQuery } from "./SearchResults"; +import { resolveSearchQuery } from "./utils"; + +const transformInput = (input: string, fieldName: string) => { + return input === "" ? "" : `${fieldName}:(${input})`; +}; + +function extractSimpleSearchQuery(text: string): string { + const regex = /(\w+):\(([^)]*)\)/g; + let match: RegExpExecArray | null; + let lastIndex = 0; + + while ((match = regex.exec(text)) !== null) { + lastIndex = regex.lastIndex; + } + + const rest = text.slice(lastIndex).trim(); + + return rest; +} + +export function AdvancedSearchFilters({ + filter, + setFilter, + setCollapsedHandler, + refForm, +}: { + filter: string; + setFilter: React.Dispatch>; + setCollapsedHandler: React.Dispatch>; + refForm: MutableRefObject; +}) { + const { t } = useTranslation(); + //const refForm = useRef(null); + + const displayNames = useMemo( + () => ({ + id: t("panels.search.field.id", "Name"), + description: t("panels.search.field.description", "Description"), + type: t("panels.search.field.type", "Type"), + paramName: t("panels.search.field.paramName", "Label"), + paramValue: t("panels.search.field.paramValue", "Value"), + outputValue: t("panels.search.field.outputValue", "Output"), + edgeExpression: t("panels.search.field.edgeExpression", "Edge"), + }), + [t], + ); + + //Here be dragons: direct DOM manipulation + useEffect(() => { + if (refForm.current) { + const searchQuery = resolveSearchQuery(filter); + const formElements = refForm.current.elements; + + Array.from(formElements).forEach((element: HTMLInputElement) => { + if (element.name in searchQuery) { + element.value = (searchQuery[element.name] || []).join(","); + } else { + element.value = ""; + } + }); + } + }, [filter]); + + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault(); + + const formData = new FormData(event.currentTarget); + + const transformedInputs = Array.from(formData.entries()) + .map(([fieldName, fieldValue]) => { + const input = (fieldValue as string) || ""; + return transformInput(input, fieldName); + }) + .filter((input) => input !== ""); + + const finalText = transformedInputs.join(" ").trim() + " " + extractSimpleSearchQuery(filter); + + setFilter(finalText); + setCollapsedHandler(false); + }; + + const handleClear = () => { + setFilter(extractSimpleSearchQuery(filter)); + + refForm.current.reset(); + }; + + return ( + + {t("search.panel.advancedFilters.label", "Advanced Search")} + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} diff --git a/designer/client/src/components/toolbars/search/SearchPanel.tsx b/designer/client/src/components/toolbars/search/SearchPanel.tsx index 73ab6f4141b..1243ff60559 100644 --- a/designer/client/src/components/toolbars/search/SearchPanel.tsx +++ b/designer/client/src/components/toolbars/search/SearchPanel.tsx @@ -1,26 +1,39 @@ import { isEmpty } from "lodash"; -import React, { ReactElement, useCallback, useRef, useState } from "react"; +import React, { ReactElement, useCallback, useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; -import { SearchIcon } from "../../table/SearchFilter"; +import { AdvancedOptionsIcon, SearchIcon } from "../../table/SearchFilter"; import { Focusable } from "../../themed/InputWithIcon"; import { ToolbarPanelProps } from "../../toolbarComponents/DefaultToolbarPanel"; import { ToolbarWrapper } from "../../toolbarComponents/toolbarWrapper/ToolbarWrapper"; import { SearchResults } from "./SearchResults"; import { SearchInputWithIcon } from "../../themed/SearchInput"; import { EventTrackingSelector, getEventTrackingProps } from "../../../containers/event-tracking"; +import { Collapse } from "@mui/material"; +import { AdvancedSearchFilters } from "./AdvancedSearchFilters"; +import { SearchPanelStyled, TipPanelStyled } from "../../tips/Styled"; export function SearchPanel(props: ToolbarPanelProps): ReactElement { const { t } = useTranslation(); const [filter, setFilter] = useState(""); - const clearFilter = useCallback(() => setFilter(""), []); + const refForm = useRef(null); + const clearFilter = useCallback(() => { + setFilter(""); + refForm.current.reset(); + }, []); + const [advancedOptionsCollapsed, setAdvancedOptionsCollapsed] = useState(false); const searchRef = useRef(); + useEffect(() => { + setAdvancedOptionsCollapsed(false); + }, [filter]); + return ( searchRef.current?.focus()}> } onClear={clearFilter} value={filter} placeholder={t("panels.search.filter.placeholder", "type here to search nodes...")} @@ -28,7 +41,17 @@ export function SearchPanel(props: ToolbarPanelProps): ReactElement { > - + + + + + + ); } diff --git a/designer/client/src/components/toolbars/search/SearchResults.tsx b/designer/client/src/components/toolbars/search/SearchResults.tsx index ca1c7bf3bcb..f5db20a32f8 100644 --- a/designer/client/src/components/toolbars/search/SearchResults.tsx +++ b/designer/client/src/components/toolbars/search/SearchResults.tsx @@ -4,14 +4,26 @@ import { getScenario, getSelectionState } from "../../../reducers/selectors/grap import { MenuItem, MenuList } from "@mui/material"; import { FoundNode } from "./FoundNode"; import React, { useCallback, useEffect, useState } from "react"; -import { useFilteredNodes } from "./utils"; +import { resolveSearchQuery, useFilteredNodes } from "./utils"; import { useGraph } from "../../graph/GraphContext"; import { nodeFound, nodeFoundHover } from "../../graph/graphStyledWrapper"; import { resetSelection } from "../../../actions/nk"; import { useWindows } from "../../../windowManager"; -export function SearchResults({ filterValues = [] }: { filter?: string; filterValues?: string[] }) { - const nodes = useFilteredNodes(filterValues); +export type SearchQuery = { + id?: string[]; + description?: string[]; + type?: string[]; + paramName?: string[]; + paramValue?: string[]; + outputValue?: string[]; + edgeExpression?: string[]; + plainQuery?: string; +}; + +export function SearchResults({ filterRawText }: { filterRawText?: string }) { + const searchQuery: SearchQuery = resolveSearchQuery(filterRawText); + const nodes = useFilteredNodes(searchQuery); const [hasFocus, setHasFocus] = useState(false); const [hoveredNodes, setHoveredNodes] = useState([]); @@ -83,7 +95,7 @@ export function SearchResults({ filterValues = [] }: { filter?: string; filterVa disableGutters divider > - + ))} diff --git a/designer/client/src/components/toolbars/search/utils.ts b/designer/client/src/components/toolbars/search/utils.ts index 0abd8c10221..e379a699978 100644 --- a/designer/client/src/components/toolbars/search/utils.ts +++ b/designer/client/src/components/toolbars/search/utils.ts @@ -1,11 +1,13 @@ import { Edge, NodeType } from "../../../types"; -import { uniq } from "lodash"; +import { concat, keys, uniq } from "lodash"; import { useSelector } from "react-redux"; +import { isEqual } from "lodash"; import { getScenario } from "../../../reducers/selectors/graph"; import NodeUtils from "../../graph/NodeUtils"; import { useMemo } from "react"; import { useTranslation } from "react-i18next"; import { ensureArray } from "../../../common/arrayUtils"; +import { SearchQuery } from "./SearchResults"; type SelectorResult = { expression: string } | string; type Selector = (node: NodeType) => SelectorResult | SelectorResult[]; @@ -52,7 +54,7 @@ const fieldsSelectors: FilterSelector = [ function matchFilters(value: SelectorResult, filterValues: string[]): boolean { const resolved = typeof value === "string" ? value : value?.expression; - return filterValues.length && filterValues.every((filter) => resolved?.toLowerCase().includes(filter.toLowerCase())); + return filterValues.length && filterValues.some((filter) => filter != "" && resolved?.toLowerCase().includes(filter.toLowerCase())); } export const findFields = (filterValues: string[], node: NodeType) => { @@ -65,7 +67,19 @@ export const findFields = (filterValues: string[], node: NodeType) => { ); }; -export function useFilteredNodes(filterValues: string[]): { +const findFieldsUsingSelectorWithName = (selectorName: string, filterValues: string[], node: NodeType) => { + return uniq( + fieldsSelectors + .filter((selector) => selector.name == selectorName) + .flatMap(({ name, selector }) => + ensureArray(selector(node)) + .filter((v) => matchFilters(v, filterValues)) + .map(() => name), + ), + ); +}; + +export function useFilteredNodes(searchQuery: SearchQuery): { groups: string[]; node: NodeType; edges: Edge[]; @@ -75,6 +89,9 @@ export function useFilteredNodes(filterValues: string[]): { const allNodes = NodeUtils.nodesFromScenarioGraph(scenarioGraph); const allEdges = NodeUtils.edgesFromScenarioGraph(scenarioGraph); + const searchKeys = Object.keys(searchQuery).filter((key) => searchQuery[key as keyof SearchQuery] !== undefined); + const isSimpleSearch = searchKeys.length === 1 && searchKeys[0] === "plainQuery"; + const displayNames = useMemo( () => ({ id: t("panels.search.field.id", "Name"), @@ -92,18 +109,94 @@ export function useFilteredNodes(filterValues: string[]): { () => allNodes .map((node) => { - const edges = allEdges - .filter((e) => e.from === node.id) - .filter((e) => matchFilters(e.edgeType?.condition, filterValues)); + let edges: Edge[] = []; + let groups: string[] = []; + + if (isSimpleSearch) { + edges = allEdges + .filter((e) => e.from === node.id) + .filter((e) => matchFilters(e.edgeType?.condition, [searchQuery.plainQuery])); + + groups = findFields([searchQuery.plainQuery], node) + .concat(edges.length ? "edgeExpression" : null) + .map((name) => displayNames[name]) + .filter(Boolean); + + return { node, edges, groups }; + } else { + const edgesAux = allEdges + .filter((e) => e.from === node.id) + .filter((e) => matchFilters(e.edgeType?.condition, [searchQuery.plainQuery])); + + const groupsAux: string[] = findFields([searchQuery.plainQuery], node) + .concat(edgesAux.length ? "edgeExpression" : null) + .map((name) => displayNames[name]) + .filter(Boolean); + + edges = + "edgeExpression" in searchQuery + ? allEdges + .filter((e) => e.from === node.id) + .filter((e) => matchFilters(e.edgeType?.condition, searchQuery.edgeExpression)) + : []; + + const keyNamesRelevantForFiltering = Object.keys(searchQuery).filter( + (key) => key !== "searchType" && key !== "plainQuery", + ); + + const displayKeyNamesRelevantForFiltering: string[] = keyNamesRelevantForFiltering.map( + (name) => displayNames[name], + ); - const groups = findFields(filterValues, node) - .concat(edges.length ? "edgeExpression" : null) - .map((name) => displayNames[name]) - .filter(Boolean); + groups = keyNamesRelevantForFiltering + .map((key) => findFieldsUsingSelectorWithName(key, searchQuery[key], node)) + .flat() + .concat(edges.length ? "edgeExpression" : null) + .map((name) => displayNames[name]) + .filter(Boolean); - return { node, edges, groups }; + const allChecksPassed = isEqual(groups, displayKeyNamesRelevantForFiltering); + + const hasValidQueryOrGroups = searchQuery.plainQuery === "" ? false : groupsAux.length === 0; + + if (!allChecksPassed || hasValidQueryOrGroups) { + edges = []; + groups = []; + } + + return { node, edges, groups }; + } }) .filter(({ groups }) => groups.length), - [displayNames, allEdges, filterValues, allNodes], + [displayNames, allEdges, searchQuery, allNodes], ); } + +export function resolveSearchQuery(filterRawText: string): SearchQuery { + return parseRawTextToSearchQuery(filterRawText); +} + +function splitString(input: string): string[] { + //split string by comma respecting quoted elements + //"a,b,c" -> ["a", "b", "c"] + //"a,\"b,c\",d" -> ["a", "b,c", "d"] + return input.match(/(".*?"|[^",\s]+)(?=\s*,|\s*$)/g) || []; +} + +function parseRawTextToSearchQuery(text: string): SearchQuery { + const result: SearchQuery = {}; + const regex = /(\w+):\(([^)]*)\)/g; + let match: RegExpExecArray | null; + let lastIndex = 0; + + while ((match = regex.exec(text)) !== null) { + const key = match[1] as keyof Omit; + const values = match[2].split(",").map((value) => value.trim().replace(/"/g, "")); + result[key] = values.length > 0 ? values : []; + lastIndex = regex.lastIndex; + } + + result.plainQuery = text.slice(lastIndex).trim(); + + return result; +}