This is an automated email from the ASF dual-hosted git repository. ppawar pushed a commit to branch ATLAS-5166 in repository https://gitbox.apache.org/repos/asf/atlas.git
commit bbb828633d14e32a424f82b3244c50306122fa74 Author: Prasad Pawar <[email protected]> AuthorDate: Wed Dec 10 11:43:53 2025 +0530 ATLAS-5166: React UI: Save search, Quick search, Alignment issue --- dashboard/src/components/FilterQuery.tsx | 32 +++- .../src/components/GlobalSearch/QuickSearch.tsx | 172 ++++++++++++++++----- dashboard/src/styles/classificationForm.scss | 13 ++ .../BusinessMetadata/BusinessMetadataForm.tsx | 2 +- .../views/Classification/ClassificationForm.tsx | 1 - .../PropertiesTab/BMAttributesFields.tsx | 34 ++-- .../RelationshipPropertiesTab.tsx | 38 +++-- dashboard/src/views/Glossary/GlossaryForm.tsx | 2 +- .../src/views/SideBar/SideBarTree/SideBarTree.tsx | 147 ++++++++++++++++-- 9 files changed, 361 insertions(+), 80 deletions(-) diff --git a/dashboard/src/components/FilterQuery.tsx b/dashboard/src/components/FilterQuery.tsx index 644d8e67e..fb14e0288 100644 --- a/dashboard/src/components/FilterQuery.tsx +++ b/dashboard/src/components/FilterQuery.tsx @@ -108,7 +108,32 @@ export const FilterQuery = ({ value }: any) => { searchParams.delete(currentType); - if ([...searchParams]?.length == 1) { + // Check if there are any meaningful filters left after removal + const meaningfulFilterParams = [ + "type", + "tag", + "query", + "term", + "relationshipName", + "entityFilters", + "tagFilters", + "relationshipFilters", + "excludeST", + "excludeSC", + "includeDE" + ]; + + const hasMeaningfulFilters = meaningfulFilterParams.some((param) => + searchParams.has(param) + ); + + // If no meaningful filters remain, navigate to clear all (like Clear button) + if (!hasMeaningfulFilters) { + navigate({ + pathname: "/search" + }); + } else if ([...searchParams]?.length <= 1) { + // Only searchType or other system params remain navigate({ pathname: "/search" }); @@ -440,6 +465,11 @@ export const FilterQuery = ({ value }: any) => { queryArray.push(<div className="group">{queryIncludeDE}</div>); } + // If no filters to display, return null (don't show empty parentheses) + if (queryArray.length === 0) { + return null; + } + return ( <> {queryArray.length === 1 ? ( diff --git a/dashboard/src/components/GlobalSearch/QuickSearch.tsx b/dashboard/src/components/GlobalSearch/QuickSearch.tsx index 5f6fa12b2..1a8700755 100644 --- a/dashboard/src/components/GlobalSearch/QuickSearch.tsx +++ b/dashboard/src/components/GlobalSearch/QuickSearch.tsx @@ -115,9 +115,12 @@ const QuickSearch = () => { }; const onInputChange = (_event: any, value: string) => { - if (value) { + // Sanitize input: remove any potential script tags and validate + const sanitizedValue = value ? value.trim() : ""; + + if (sanitizedValue) { setOpen(true); - getData(value); + getData(sanitizedValue); } else { setOptions([]); setValue(""); @@ -125,27 +128,69 @@ const QuickSearch = () => { } }; - const handleValues = (option: HandleValuesType) => { - const { entityObj, title, types } = option; - setOpen(false); - setOptions([]); - searchParams.set("query", title); - searchParams.set("searchType", "basic"); - - types == "Entities" - ? navigate( - { - pathname: `/detailPage/${entityObj.guid}` - }, - { replace: true } - ) - : navigate( + const handleValues = (option: HandleValuesType | string | null) => { + // Handle case when option is a string (direct search query) + if (typeof option === "string") { + const queryValue = option.trim(); + if (queryValue) { + setOpen(false); + setOptions([]); + // URLSearchParams automatically encodes the value, preventing XSS + // Additional validation: ensure query is not empty after trim + const sanitizedQuery = queryValue || "*"; + searchParams.set("query", sanitizedQuery); + searchParams.set("searchType", "basic"); + navigate( { pathname: `/search/searchResult`, search: searchParams.toString() }, { replace: true } ); + } + return; + } + + // Handle case when option is null or undefined + if (!option || typeof option !== "object") { + return; + } + + const { entityObj, title, types } = option; + + // Validate that title exists and is not undefined + if (!title || title === "undefined" || typeof title !== "string") { + return; + } + + // Sanitize title: trim and validate + const sanitizedTitle = title.trim(); + if (!sanitizedTitle) { + return; + } + + setOpen(false); + setOptions([]); + // URLSearchParams automatically encodes the value, preventing XSS + searchParams.set("query", sanitizedTitle); + searchParams.set("searchType", "basic"); + + if (types === "Entities" && entityObj && entityObj.guid) { + navigate( + { + pathname: `/detailPage/${entityObj.guid}` + }, + { replace: true } + ); + } else { + navigate( + { + pathname: `/search/searchResult`, + search: searchParams.toString() + }, + { replace: true } + ); + } }; const handleClickAway = () => { @@ -172,18 +217,30 @@ const QuickSearch = () => { const code = e.keyCode || e.which; switch (code) { - case 13: + case 13: // Enter key + e.preventDefault(); + const inputValue = (e.target as HTMLInputElement).value.trim(); + + // If input is empty, use "*" for wildcard search (matching classic UI behavior) + const searchQuery = inputValue === "" ? "*" : inputValue; + + // Try to find exact match in options const activeOption = options.find( (option: { title: any }) => typeof option !== "string" && - option.title === (e.target as HTMLInputElement).value + option.title === searchQuery ); + if (activeOption) { + // If exact match found, use that option handleValues(activeOption as HandleValuesType); + } else { + // If no match found, trigger basic search with typed value (matching classic UI behavior) + handleValues(searchQuery); } break; - case 9: - case 27: + case 9: // Tab key + case 27: // Escape key setOpen(false); break; default: @@ -204,9 +261,19 @@ const QuickSearch = () => { }} value={value} onChange={(_event: any, newValue: any) => { - setOptions(newValue ? [newValue, ...options] : options); - setValue(newValue); - handleValues(newValue); + if (newValue) { + // Only update options if newValue is a valid option object + if (typeof newValue === "object" && newValue.title) { + setOptions([newValue, ...options]); + } + setValue(newValue); + // Only call handleValues if newValue is a valid option + if (typeof newValue === "object" && newValue.title && newValue.title !== "undefined") { + handleValues(newValue); + } + } else { + setValue(""); + } }} clearOnBlur={false} autoComplete={true} @@ -214,9 +281,14 @@ const QuickSearch = () => { noOptionsText={"No Entities"} disableClearable onInputChange={onInputChange} - getOptionLabel={(option: string | QuickSearchOptionListType) => - typeof option === "string" ? option : (option as any).title - } + getOptionLabel={(option: string | QuickSearchOptionListType) => { + if (typeof option === "string") { + return option; + } + // Safely extract title, defaulting to empty string if undefined + const title = (option as any)?.title; + return title && typeof title === "string" ? title : ""; + }} renderOption={(props, option, { inputValue }) => { const { entityObj, types, parent } = typeof option !== "string" && @@ -238,21 +310,31 @@ const QuickSearch = () => { typeof option !== "string" && "title" in option ? option.title : option; - const href = `/detailPage/${ - (entityObj as { guid: string })?.guid - }`; + + // Validate and sanitize title to prevent XSS + const safeTitle = typeof title === "string" ? title : ""; + + // Validate guid to prevent XSS in URL + const guid = (entityObj as { guid?: string })?.guid; + const safeGuid = guid && typeof guid === "string" ? guid : ""; + const href = safeGuid ? `/detailPage/${safeGuid}` : "#"; + const { name }: { name: string; found: boolean; key: any } = extractKeyValueFromEntity(entityObj); + + // Safely handle name extraction + const safeName = name && typeof name === "string" ? name : ""; + const matches = match( - types == "Entities" ? (name as string) : (title as string), - inputValue, + types === "Entities" ? safeName : safeTitle, + inputValue || "", { findAllOccurrences: true, insideWords: true } ); const parts = parse( - types == "Entities" ? (name as string) : (title as string), + types === "Entities" ? safeName : safeTitle, matches ); return ( @@ -273,7 +355,7 @@ const QuickSearch = () => { } }} > - {types == "Entities" && !isEmpty(entityObj) ? ( + {types === "Entities" && !isEmpty(entityObj) ? ( <Link className="entity-name text-decoration-none" style={{ @@ -296,10 +378,10 @@ const QuickSearch = () => { } > {" "} - {types == "Entities" && !isEmpty(entityObj) && ( + {types === "Entities" && !isEmpty(entityObj) && ( <DisplayImage entity={entityObj} /> )}{" "} - {types == "Entities" && !isEmpty(entityObj) + {types === "Entities" && !isEmpty(entityObj) ? parts.map((part, index) => ( <Stack flexDirection="row" @@ -308,7 +390,7 @@ const QuickSearch = () => { fontWeight: part.highlight ? "bold" : "regular" }} > - {entityObj?.guid != "-1" && !part.highlight ? ( + {entityObj?.guid !== "-1" && !part.highlight ? ( <Link className="entity-name text-blue text-decoration-none" style={{ @@ -345,7 +427,7 @@ const QuickSearch = () => { {part.text} </Stack> ))} - {types == "Entities" && + {types === "Entities" && !isEmpty(entityObj) && ` (${parent})`} </Link> @@ -410,13 +492,25 @@ const QuickSearch = () => { <CustomButton variant="outlined" size="small" + sx={{ + backgroundColor: "white !important", + color: "#4a90e2 !important", + borderColor: "#dddddd !important", + "&:hover": { + backgroundColor: "rgba(74, 144, 226, 0.08) !important", + // borderColor: "#4a90e2 !important", + color: "#4a90e2 !important" + } + }} onClick={() => { setOpenAdvanceSearch(true); }} > <Typography sx={{ - color: location?.pathname == "/search" ? "blue" : "eeeeee" + color: "#4a90e2 !important", + fontWeight: "600 !important", + fontSize: "0.875rem !important" }} display="inline" > diff --git a/dashboard/src/styles/classificationForm.scss b/dashboard/src/styles/classificationForm.scss index eb81c6810..612ff3665 100644 --- a/dashboard/src/styles/classificationForm.scss +++ b/dashboard/src/styles/classificationForm.scss @@ -22,3 +22,16 @@ .classification-form-editor .ql-container { min-height: 90px !important; } + +/* Prevent duplicate toolbars in ReactQuill */ +.classification-form-editor .ql-toolbar:not(:first-of-type) { + display: none !important; +} + +.classification-form-editor .ql-toolbar { + border-top: 1px solid #ccc; + border-bottom: 1px solid #ccc; + box-sizing: border-box; + font-family: 'Helvetica Neue', 'Helvetica', 'Arial', sans-serif; + padding: 8px; +} diff --git a/dashboard/src/views/BusinessMetadata/BusinessMetadataForm.tsx b/dashboard/src/views/BusinessMetadata/BusinessMetadataForm.tsx index 2f6af49dc..5fcbb22ed 100644 --- a/dashboard/src/views/BusinessMetadata/BusinessMetadataForm.tsx +++ b/dashboard/src/views/BusinessMetadata/BusinessMetadataForm.tsx @@ -448,7 +448,6 @@ const BusinessMetaDataForm = ({ {alignment == "formatted" ? ( <div style={{ position: "relative" }}> <ReactQuill - {...field} theme="snow" placeholder={"Description required"} onChange={(text) => { @@ -456,6 +455,7 @@ const BusinessMetaDataForm = ({ setValue("description", text); }} className="classification-form-editor" + value={field.value || ""} /> </div> ) : ( diff --git a/dashboard/src/views/Classification/ClassificationForm.tsx b/dashboard/src/views/Classification/ClassificationForm.tsx index 61bd4582d..170f9f16c 100644 --- a/dashboard/src/views/Classification/ClassificationForm.tsx +++ b/dashboard/src/views/Classification/ClassificationForm.tsx @@ -314,7 +314,6 @@ const ClassificationForm = ({ {alignment == "formatted" ? ( <div style={{ position: "relative" }}> <ReactQuill - {...field} theme="snow" placeholder={"Description required"} value={descriptionValue} diff --git a/dashboard/src/views/DetailPage/EntityDetailTabs/PropertiesTab/BMAttributesFields.tsx b/dashboard/src/views/DetailPage/EntityDetailTabs/PropertiesTab/BMAttributesFields.tsx index 2cd05d688..76f618e8d 100644 --- a/dashboard/src/views/DetailPage/EntityDetailTabs/PropertiesTab/BMAttributesFields.tsx +++ b/dashboard/src/views/DetailPage/EntityDetailTabs/PropertiesTab/BMAttributesFields.tsx @@ -138,21 +138,22 @@ const BMAttributesFields = ({ obj, control, index }: any) => { required: true }} defaultValue={""} - render={({ field }) => ( - <Stack gap="0.5rem"> - <div style={{ position: "relative", flexBasis: "100%" }}> - {typeName == "string" ? ( - <ReactQuill - {...field} - theme="snow" - placeholder={"Enter String"} - onChange={(text) => { - field.onChange(text); - }} - className="classification-form-editor" - value={typeof field.value === "string" ? field.value : ""} - /> - ) : ( + render={({ field }) => { + return ( + <Stack gap="0.5rem"> + <div style={{ position: "relative", flexBasis: "100%" }}> + {typeName == "string" ? ( + <ReactQuill + key={`quill-${index}-${name}`} + theme="snow" + placeholder={"Enter String"} + onChange={(text) => { + field.onChange(text); + }} + className="classification-form-editor" + value={typeof field.value === "string" ? field.value : ""} + /> + ) : ( <TextField margin="none" fullWidth @@ -176,7 +177,8 @@ const BMAttributesFields = ({ obj, control, index }: any) => { )} </div> </Stack> - )} + ); + }} /> ); } else if (typeName === "boolean") { diff --git a/dashboard/src/views/DetailPage/RelationshipDetails/RelationshipPropertiesTab.tsx b/dashboard/src/views/DetailPage/RelationshipDetails/RelationshipPropertiesTab.tsx index 9d3b5f68a..d0e99794c 100644 --- a/dashboard/src/views/DetailPage/RelationshipDetails/RelationshipPropertiesTab.tsx +++ b/dashboard/src/views/DetailPage/RelationshipDetails/RelationshipPropertiesTab.tsx @@ -190,13 +190,20 @@ const RelationshipPropertiesTab = (props: { spacing={4} marginBottom={1} marginTop={1} + sx={{ + flexWrap: "nowrap", + alignItems: "flex-start" + }} > <div style={{ - flex: 1, - wordBreak: "break-all", + flexBasis: "30%", + flexShrink: 0, + minWidth: "120px", + wordBreak: "break-word", textAlign: "left", - fontWeight: "600" + fontWeight: "600", + paddingRight: "16px" }} > {`${keys} ${ @@ -205,8 +212,9 @@ const RelationshipPropertiesTab = (props: { </div> <div style={{ - // flex: 1, - wordBreak: "break-all", + flex: 1, + minWidth: 0, + wordBreak: "break-word", textAlign: "left" }} > @@ -242,7 +250,7 @@ const RelationshipPropertiesTab = (props: { </AccordionSummary> <AccordionDetails> {" "} - {!isEmpty(end1) + {!isEmpty(end2) ? Object.entries(end2) .sort() .map(([keys, value]: [string, any]) => { @@ -253,13 +261,20 @@ const RelationshipPropertiesTab = (props: { spacing={4} marginBottom={1} marginTop={1} + sx={{ + flexWrap: "nowrap", + alignItems: "flex-start" + }} > <div style={{ - flex: 1, - wordBreak: "break-all", + flexBasis: "30%", + flexShrink: 0, + minWidth: "120px", + wordBreak: "break-word", textAlign: "left", - fontWeight: "600" + fontWeight: "600", + paddingRight: "16px" }} > {`${keys} ${ @@ -268,8 +283,9 @@ const RelationshipPropertiesTab = (props: { </div> <div style={{ - // flex: 1, - wordBreak: "break-all", + flex: 1, + minWidth: 0, + wordBreak: "break-word", textAlign: "left" }} > diff --git a/dashboard/src/views/Glossary/GlossaryForm.tsx b/dashboard/src/views/Glossary/GlossaryForm.tsx index bdbae34c8..3ab9a82fd 100644 --- a/dashboard/src/views/Glossary/GlossaryForm.tsx +++ b/dashboard/src/views/Glossary/GlossaryForm.tsx @@ -142,7 +142,6 @@ const GlossaryForm = (props: { {alignment == "formatted" ? ( <div style={{ position: "relative" }}> <ReactQuill - {...field} theme="snow" placeholder={"Description required"} onChange={(text) => { @@ -150,6 +149,7 @@ const GlossaryForm = (props: { setValue("description", text); }} className="classification-form-editor" + value={field.value || ""} /> </div> ) : ( diff --git a/dashboard/src/views/SideBar/SideBarTree/SideBarTree.tsx b/dashboard/src/views/SideBar/SideBarTree/SideBarTree.tsx index 2d75117cd..fd4bf92f9 100644 --- a/dashboard/src/views/SideBar/SideBarTree/SideBarTree.tsx +++ b/dashboard/src/views/SideBar/SideBarTree/SideBarTree.tsx @@ -61,6 +61,8 @@ import { import Stack from "@mui/material/Stack"; import { globalSearchFilterInitialQuery, isEmpty } from "@utils/Utils"; +import { attributeFilter } from "@utils/CommonViewFunction"; +import { cloneDeep } from "@utils/Helper"; import LaunchOutlinedIcon from "@mui/icons-material/LaunchOutlined"; import { getGlossaryImportTmpl } from "@api/apiMethods/glossaryApiMethod"; import { toast } from "react-toastify"; @@ -593,12 +595,13 @@ const BarTreeView: FC<{ break; } - searchParams.delete("attributes"); - searchParams.delete("entityFilters"); - searchParams.delete("tagFilters"); - searchParams.delete("relationshipFilters"); - // Always reset pagination defaults on tree navigation + // Note: Don't delete filters here for CustomFilters - they will be set by setCustomFiltersSearchParams if (treeName !== "CustomFilters") { + searchParams.delete("attributes"); + searchParams.delete("entityFilters"); + searchParams.delete("tagFilters"); + searchParams.delete("relationshipFilters"); + // Always reset pagination defaults on tree navigation searchParams.set("pageLimit", "25"); searchParams.set("pageOffset", "0"); } @@ -633,6 +636,7 @@ const BarTreeView: FC<{ searchParams: URLSearchParams, savedSearchData: any[] ) => { + // Clear all existing params except searchType const keys = Array.from(searchParams.keys()); for (let i = 0; i < keys.length; i++) { if (keys[i] !== "searchType") { @@ -640,18 +644,137 @@ const BarTreeView: FC<{ } } + // Clear globalSearchFilterInitialQuery when applying new saved search + globalSearchFilterInitialQuery.setQuery({}); + if (treeName === "CustomFilters") { const params = savedSearchData.find((obj) => obj.name === node.id); - for (const key in params?.searchParameters) { - if (shouldSetCustomFilterParam(node, key)) { - setCustomFilterParam(searchParams, key, params.searchParameters[key]); + if (params) { + const searchParamsObj = params?.searchParameters || {}; + + // Step 1: Set searchType based on saved search type + if (params.searchType) { + const searchTypeValue = params.searchType.toLowerCase() === "advanced" ? "advanced" : "basic"; + searchParams.set("searchType", searchTypeValue); + } + + // Step 2: Apply basic search parameters (excluding filters which are handled separately) + for (const key in searchParamsObj) { + if (shouldSetCustomFilterParam(node, key) && + !["entityFilters", "tagFilters", "relationshipFilters"].includes(key)) { + setCustomFilterParam(searchParams, key, searchParamsObj[key]); + } + } + + // Step 3: Convert and apply entityFilters from API format to URL string format + if (searchParamsObj.entityFilters && !isEmpty(searchParamsObj.entityFilters)) { + const clonedFilter = cloneDeep(searchParamsObj.entityFilters); + const ruleUrl = attributeFilter.generateUrl({ + value: clonedFilter, + formatedDateToLong: true + }); + + if (ruleUrl && !isEmpty(ruleUrl) && typeof ruleUrl === "string") { + searchParams.set("entityFilters", ruleUrl); + + // Convert API format to query builder format for Filters component UI + const qbFilter = convertApiToQueryBuilder(searchParamsObj.entityFilters); + if (qbFilter && (qbFilter.rules || qbFilter.combinator)) { + globalSearchFilterInitialQuery.setQuery({ + entityFilters: qbFilter + }); + } + } + } + + // Step 4: Convert and apply tagFilters from API format to URL string format + if (searchParamsObj.tagFilters && !isEmpty(searchParamsObj.tagFilters)) { + const clonedFilter = cloneDeep(searchParamsObj.tagFilters); + const ruleUrl = attributeFilter.generateUrl({ + value: clonedFilter, + formatedDateToLong: true + }); + + if (ruleUrl && !isEmpty(ruleUrl) && typeof ruleUrl === "string") { + searchParams.set("tagFilters", ruleUrl); + + // Convert API format to query builder format for Filters component UI + const qbFilter = convertApiToQueryBuilder(searchParamsObj.tagFilters); + if (qbFilter && (qbFilter.rules || qbFilter.combinator)) { + globalSearchFilterInitialQuery.setQuery({ + tagFilters: qbFilter + }); + } + } + } + + // Step 5: Convert and apply relationshipFilters from API format to URL string format + if (searchParamsObj.relationshipFilters && !isEmpty(searchParamsObj.relationshipFilters)) { + const clonedFilter = cloneDeep(searchParamsObj.relationshipFilters); + const ruleUrl = attributeFilter.generateUrl({ + value: clonedFilter, + formatedDateToLong: true + }); + + if (ruleUrl && !isEmpty(ruleUrl) && typeof ruleUrl === "string") { + searchParams.set("relationshipFilters", ruleUrl); + + // Convert API format to query builder format for Filters component UI + const qbFilter = convertApiToQueryBuilder(searchParamsObj.relationshipFilters); + if (qbFilter && (qbFilter.rules || qbFilter.combinator)) { + globalSearchFilterInitialQuery.setQuery({ + relationshipFilters: qbFilter + }); + } + } } + + searchParams.set("isCF", "true"); } - searchParams.set("isCF", "true"); } else { searchParams.set("relationshipName", node.id); } }; + + // Helper function to convert API format filter (criterion/condition) to query builder format (rules/combinator) + const convertApiToQueryBuilder = (apiFilter: any): any => { + if (!apiFilter || typeof apiFilter !== "object") { + return null; + } + + const result: any = {}; + + // Convert condition to combinator + if (apiFilter.condition) { + result.combinator = apiFilter.condition.toLowerCase(); + } else { + result.combinator = "and"; // default + } + + // Convert criterion to rules + if (apiFilter.criterion && Array.isArray(apiFilter.criterion)) { + result.rules = apiFilter.criterion.map((rule: any) => { + // If nested condition, recurse + if (rule.condition || rule.criterion) { + return convertApiToQueryBuilder(rule); + } + // Convert API rule format to query builder format + return { + field: rule.attributeName || rule.id, + operator: rule.operator, + value: rule.attributeValue || rule.value, + type: rule.type || rule.attributeType + }; + }); + } else if (apiFilter.rules && Array.isArray(apiFilter.rules)) { + // Already in query builder format + result.rules = apiFilter.rules.map((rule: any) => + rule.condition || rule.criterion ? convertApiToQueryBuilder(rule) : rule + ); + } + + return Object.keys(result).length > 0 ? result : null; + }; const shouldSetCustomFilterParam = (node: TreeNode, key: string) => { return ( @@ -673,7 +796,11 @@ const BarTreeView: FC<{ searchParams.set("pageOffset", value); } else if (key === "typeName") { searchParams.set("type", value); - } else { + } else if (key === "classification") { + // Map classification to tag parameter for URL (matching classic UI) + searchParams.set("tag", value); + } else if (value !== null && value !== undefined && value !== "") { + // Only set parameter if value is not null, undefined, or empty string searchParams.set(key, value); } };
