diff --git a/designer/client/cypress/e2e/__image_snapshots__/electron/Linux/Process view should have node search toolbar #1.png b/designer/client/cypress/e2e/__image_snapshots__/electron/Linux/Process view should have node search toolbar #1.png index ced66534bc5..3316f3cb1e3 100644 Binary files a/designer/client/cypress/e2e/__image_snapshots__/electron/Linux/Process view should have node search toolbar #1.png and b/designer/client/cypress/e2e/__image_snapshots__/electron/Linux/Process view should have node search toolbar #1.png differ diff --git a/designer/client/cypress/e2e/search.cy.ts b/designer/client/cypress/e2e/search.cy.ts index 2ecc7f16e17..b454ead3942 100644 --- a/designer/client/cypress/e2e/search.cy.ts +++ b/designer/client/cypress/e2e/search.cy.ts @@ -46,7 +46,7 @@ describe("Search Panel View", () => { 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.realType(",dynamicService"); cy.get("[data-testid=search-panel]").find("button[type='submit']").click(); @@ -61,13 +61,13 @@ describe("Search Panel View", () => { 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.realType("sink,dynamicSe"); 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"); + .should("have.value", "type:(sink,dynamicSe) se"); cy.get("[data-testid=search-panel]").contains("dynamicService"); cy.get("[data-testid=search-panel]").contains("sendSms"); @@ -76,7 +76,7 @@ describe("Search Panel View", () => { 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.get("[data-testid=search-panel]").find("input[name='name']").click(); cy.realType("bounded,dynamic,send,enricher"); cy.get("[data-testid=search-panel]").find("input[name='type']").click(); @@ -86,7 +86,7 @@ describe("Search Panel View", () => { cy.get("[data-testid=search-panel]") .find("input[data-selector='NODES_IN_SCENARIO']") - .should("have.value", "id:(bounded,dynamic,send,enricher) type:(sink,enricher) "); + .should("have.value", "name:(bounded,dynamic,send,enricher) type:(sink,enricher)"); cy.get("[data-testid=search-panel]").contains("enricher"); cy.get("[data-testid=search-panel]").contains("sendSms"); diff --git a/designer/client/src/components/sidePanels/SearchLabeledAutocomplete.tsx b/designer/client/src/components/sidePanels/SearchLabeledAutocomplete.tsx new file mode 100644 index 00000000000..4bd32b9e85f --- /dev/null +++ b/designer/client/src/components/sidePanels/SearchLabeledAutocomplete.tsx @@ -0,0 +1,30 @@ +import { Autocomplete, FormControl } from "@mui/material"; +import { nodeInput } from "../graph/node-modal/NodeDetailsContent/NodeTableStyled"; +import React from "react"; + +export const SearchLabeledAutocomplete = ({ children, name, options, value, setFilterFields }) => { + function handleChange(_, value) { + setFilterFields((prev) => ({ ...prev, [name]: [value] })); + } + + return ( + + {children} + ( +
+ +
+ )} + /> +
+ ); +}; + +SearchLabeledAutocomplete.displayName = "SearchLabeledAutocomplete"; diff --git a/designer/client/src/components/sidePanels/SearchLabeledInput.tsx b/designer/client/src/components/sidePanels/SearchLabeledInput.tsx index 23e5354edd9..96acec5c5b5 100644 --- a/designer/client/src/components/sidePanels/SearchLabeledInput.tsx +++ b/designer/client/src/components/sidePanels/SearchLabeledInput.tsx @@ -1,19 +1,18 @@ -import { forwardRef, PropsWithChildren } from "react"; +import React 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 = ({ children, name, value, setFilterFields }) => { + function handleChange(event) { + setFilterFields((prev) => ({ ...prev, [name]: event.target.value.split(",") })); + } -export const SearchLabeledInput = forwardRef(({ children, ...props }, ref) => { return ( {children} - + ); -}); +}; SearchLabeledInput.displayName = "SearchLabeledInput"; diff --git a/designer/client/src/components/toolbars/search/AdvancedSearchFilters.tsx b/designer/client/src/components/toolbars/search/AdvancedSearchFilters.tsx index 51f0f9bf713..ac33f20ee88 100644 --- a/designer/client/src/components/toolbars/search/AdvancedSearchFilters.tsx +++ b/designer/client/src/components/toolbars/search/AdvancedSearchFilters.tsx @@ -1,99 +1,85 @@ -import React, { MutableRefObject, useEffect, useMemo, useRef, useState } from "react"; -import { Button, Box, Typography } from "@mui/material"; +import React, { useEffect, useMemo } from "react"; +import { Box, Button, Typography } from "@mui/material"; import { useTranslation } from "react-i18next"; import { SearchLabeledInput } from "../../sidePanels/SearchLabeledInput"; import { SearchLabel } from "../../sidePanels/SearchLabel"; +import { resolveSearchQuery, searchQueryToString, selectorByName } from "./utils"; 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; -} +import { SearchLabeledAutocomplete } from "../../sidePanels/SearchLabeledAutocomplete"; +import { useSelector } from "react-redux"; +import { getScenario } from "../../../reducers/selectors/graph"; +import NodeUtils from "../../graph/NodeUtils"; +import { uniq } from "lodash"; +import { getComponentGroups } from "../../../reducers/selectors/settings"; export function AdvancedSearchFilters({ + filterFields, + setFilterFields, filter, setFilter, setCollapsedHandler, - refForm, }: { + filterFields: SearchQuery; + setFilterFields: React.Dispatch>; filter: string; setFilter: React.Dispatch>; setCollapsedHandler: React.Dispatch>; - refForm: MutableRefObject; }) { const { t } = useTranslation(); - //const refForm = useRef(null); + const componentsGroups = useSelector(getComponentGroups); + const { scenarioGraph } = useSelector(getScenario); + const allNodes = NodeUtils.nodesFromScenarioGraph(scenarioGraph); const displayNames = useMemo( () => ({ - id: t("panels.search.field.id", "Name"), + name: 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"), + label: t("panels.search.field.paramName", "Label"), + value: t("panels.search.field.paramValue", "Value"), + output: t("panels.search.field.outputValue", "Output"), + edge: 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; + const componentLabels = useMemo(() => { + return new Set( + componentsGroups.flatMap((componentGroup) => componentGroup.components).map((component) => component.label.toLowerCase()), + ); + }, []); + + const nodeTypes = useMemo(() => { + const availableTypes = allNodes + .flatMap((node) => + selectorByName("type") + .flatMap((s) => s.selector(node)) + .filter((item) => item !== undefined), + ) + .map((selectorResult) => (typeof selectorResult === "string" ? selectorResult : selectorResult?.expression)) + .filter((type) => componentLabels.has(type.toLowerCase())); + + return uniq(availableTypes); + }, [allNodes]); - Array.from(formElements).forEach((element: HTMLInputElement) => { - if (element.name in searchQuery) { - element.value = (searchQuery[element.name] || []).join(","); - } else { - element.value = ""; - } - }); - } + useEffect(() => { + const searchQuery = resolveSearchQuery(filter); + setFilterFields(searchQuery); }, [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); + setFilter(searchQueryToString(filterFields)); setCollapsedHandler(false); }; const handleClear = () => { - setFilter(extractSimpleSearchQuery(filter)); - - refForm.current.reset(); + setFilter(filterFields?.plainQuery); }; 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 1243ff60559..1e5f7528e77 100644 --- a/designer/client/src/components/toolbars/search/SearchPanel.tsx +++ b/designer/client/src/components/toolbars/search/SearchPanel.tsx @@ -5,20 +5,20 @@ 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 { SearchQuery, 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"; +import { SearchPanelStyled } from "../../tips/Styled"; export function SearchPanel(props: ToolbarPanelProps): ReactElement { const { t } = useTranslation(); + const [filterFields, setFilterFields] = useState({}); const [filter, setFilter] = useState(""); - const refForm = useRef(null); const clearFilter = useCallback(() => { setFilter(""); - refForm.current.reset(); + setFilterFields({}); }, []); const [advancedOptionsCollapsed, setAdvancedOptionsCollapsed] = useState(false); @@ -44,7 +44,8 @@ export function SearchPanel(props: ToolbarPanelProps): ReactElement { node.id, }, { @@ -24,30 +23,30 @@ const fieldsSelectors: FilterSelector = [ }, { name: "type", - selector: (node) => node.type, + selector: (node) => [node.nodeType, node.ref?.typ, node.ref?.id, node.type, node.service?.id], }, { - name: "paramValue", + name: "value", selector: (node) => node.ref?.outputVariableNames && Object.values(node.ref?.outputVariableNames), }, { - name: "paramName", + name: "label", selector: (node) => node.ref?.outputVariableNames && Object.keys(node.ref?.outputVariableNames), }, { - name: "paramValue", + name: "value", selector: (node) => [node.expression, node.exprVal, node.value], }, { - name: "outputValue", + name: "output", selector: (node) => [node.outputName, node.output, node.outputVar, node.varName], }, { - name: "paramValue", + name: "value", selector: (node) => [node.parameters, node.ref?.parameters, node.service?.parameters, node.fields].flat().map((p) => p?.expression), }, { - name: "paramName", + name: "label", selector: (node) => [node.parameters, node.ref?.parameters, node.service?.parameters, node.fields].flat().map((p) => p?.name), }, ]; @@ -57,7 +56,7 @@ function matchFilters(value: SelectorResult, filterValues: string[]): boolean { return filterValues.length && filterValues.some((filter) => filter != "" && resolved?.toLowerCase().includes(filter.toLowerCase())); } -export const findFields = (filterValues: string[], node: NodeType) => { +const findFields = (filterValues: string[], node: NodeType) => { return uniq( fieldsSelectors.flatMap(({ name, selector }) => ensureArray(selector(node)) @@ -69,16 +68,18 @@ export const findFields = (filterValues: string[], node: NodeType) => { 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), - ), + selectorByName(selectorName).flatMap(({ name, selector }) => + ensureArray(selector(node)) + .filter((v) => matchFilters(v, filterValues)) + .map(() => name), + ), ); }; +export const selectorByName = (selectorName: string): FilterSelector => { + return fieldsSelectors.filter((selector) => selector.name == selectorName); +}; + export function useFilteredNodes(searchQuery: SearchQuery): { groups: string[]; node: NodeType; @@ -94,13 +95,13 @@ export function useFilteredNodes(searchQuery: SearchQuery): { const displayNames = useMemo( () => ({ - id: t("panels.search.field.id", "Name"), + name: t("panels.search.field.name", "Name"), description: t("panels.search.field.description", "Description"), + label: t("panels.search.field.label", "Label"), + value: t("panels.search.field.value", "Value"), + output: t("panels.search.field.output", "Output"), + edge: t("panels.search.field.edge", "Edge"), 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], ); @@ -118,7 +119,7 @@ export function useFilteredNodes(searchQuery: SearchQuery): { .filter((e) => matchFilters(e.edgeType?.condition, [searchQuery.plainQuery])); groups = findFields([searchQuery.plainQuery], node) - .concat(edges.length ? "edgeExpression" : null) + .concat(edges.length ? "edge" : null) .map((name) => displayNames[name]) .filter(Boolean); @@ -129,15 +130,15 @@ export function useFilteredNodes(searchQuery: SearchQuery): { .filter((e) => matchFilters(e.edgeType?.condition, [searchQuery.plainQuery])); const groupsAux: string[] = findFields([searchQuery.plainQuery], node) - .concat(edgesAux.length ? "edgeExpression" : null) + .concat(edgesAux.length ? "edge" : null) .map((name) => displayNames[name]) .filter(Boolean); edges = - "edgeExpression" in searchQuery + "edge" in searchQuery ? allEdges .filter((e) => e.from === node.id) - .filter((e) => matchFilters(e.edgeType?.condition, searchQuery.edgeExpression)) + .filter((e) => matchFilters(e.edgeType?.condition, searchQuery.edge)) : []; const keyNamesRelevantForFiltering = Object.keys(searchQuery).filter( @@ -151,7 +152,7 @@ export function useFilteredNodes(searchQuery: SearchQuery): { groups = keyNamesRelevantForFiltering .map((key) => findFieldsUsingSelectorWithName(key, searchQuery[key], node)) .flat() - .concat(edges.length ? "edgeExpression" : null) + .concat(edges.length ? "edge" : null) .map((name) => displayNames[name]) .filter(Boolean); @@ -172,18 +173,7 @@ export function useFilteredNodes(searchQuery: SearchQuery): { ); } -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 { +export function resolveSearchQuery(text: string): SearchQuery { const result: SearchQuery = {}; const regex = /(\w+):\(([^)]*)\)/g; let match: RegExpExecArray | null; @@ -200,3 +190,25 @@ function parseRawTextToSearchQuery(text: string): SearchQuery { return result; } + +export function searchQueryToString(query: SearchQuery): string { + const plainQuery = query.plainQuery; + + const formattedParts = Object.entries(query) + .filter(([key]) => key !== "plainQuery") + .map(([key, value]) => { + if (Array.isArray(value) && !(value.length === 1 && value[0] === "")) { + return `${key}:(${value})`; + } else if (typeof value === "string" && value.length > 0) { + return `${key}:(${[value]})`; + } + return []; // Skip undefined or invalid values + }) + .filter(Boolean) // Remove null values + .join(" "); // Join the formatted parts with a space + + // Append plainQuery at the end without a key if it exists + return plainQuery + ? `${formattedParts} ${plainQuery}`.trim() // Ensure no trailing space + : formattedParts; +} diff --git a/designer/client/src/reducers/selectors/settings.ts b/designer/client/src/reducers/selectors/settings.ts index ec562411fb4..e222a76b8ae 100644 --- a/designer/client/src/reducers/selectors/settings.ts +++ b/designer/client/src/reducers/selectors/settings.ts @@ -1,7 +1,7 @@ import { createSelector } from "reselect"; import { MetricsType } from "../../actions/nk"; import { DynamicTabData } from "../../containers/DynamicTab"; -import { ProcessDefinitionData } from "../../types"; +import { ComponentGroup, ProcessDefinitionData } from "../../types"; import { RootState } from "../index"; import { AuthenticationSettings, SettingsState } from "../settings"; import { uniqBy } from "lodash"; @@ -17,6 +17,7 @@ export const getSurveySettings = createSelector(getFeatureSettings, (s) => s?.su export const getLoggedUser = createSelector(getSettings, (s) => s.loggedUser); export const getLoggedUserId = createSelector(getLoggedUser, (s) => s.id); export const getProcessDefinitionData = createSelector(getSettings, (s) => s.processDefinitionData || ({} as ProcessDefinitionData)); +export const getComponentGroups = createSelector(getProcessDefinitionData, (p) => p.componentGroups || ({} as ComponentGroup[])); export const getCategories = createSelector(getLoggedUser, (u) => u.categories || []); export const getWritableCategories = createSelector(getLoggedUser, getCategories, (user, categories) => categories.filter((c) => user.canWrite(c)),