diff --git a/dashboard/src/components/FilterQuery.tsx b/dashboard/src/components/FilterQuery.tsx index 69ef98fd29d..20351c33a6f 100644 --- a/dashboard/src/components/FilterQuery.tsx +++ b/dashboard/src/components/FilterQuery.tsx @@ -22,8 +22,12 @@ import { attributeFilter } from "@utils/CommonViewFunction"; import { queryBuilderDateRangeUIValueToAPI, systemAttributes, - filterQueryValue + filterQueryValue, + getDisplayOperator } from "@utils/Enum"; +import { removeCriterionFromFilterUrl } from "@utils/filterUrlCriterionRemoval"; +import type { ParsedApiFilter } from "@utils/filterUrlCriterionRemoval"; +import { dateTimeFormat } from "@utils/Global"; import moment from "moment"; import { useLocation, useNavigate } from "react-router-dom"; import { globalSearchFilterInitialQuery } from "@utils/Utils"; @@ -45,13 +49,18 @@ export const FilterQuery = ({ value }: any) => { ); } else { - if (obj.type === "date" || obj.id == "createTime") { + const isDateAttr = + obj.type === "date" || + obj.id === "createTime" || + obj.id === "__timestamp" || + obj.id === "__modificationTimestamp"; + if (isDateAttr) { if (queryBuilderDateRangeUIValueToAPI[obj.value]) { obj.value = queryBuilderDateRangeUIValueToAPI[obj.value]; + } else if (/^\d+$/.test(String(obj.value))) { + obj.value = `${moment(Number(obj.value)).format(dateTimeFormat)} (${moment.tz(moment.tz.guess()).zoneAbbr()})`; } else { - obj.value = `${obj.value} (${moment - .tz(moment.tz.guess()) - .zoneAbbr()})`; + obj.value = `${obj.value} (${moment.tz(moment.tz.guess()).zoneAbbr()})`; } } @@ -60,6 +69,7 @@ export const FilterQuery = ({ value }: any) => { color="primary" className="chip-items" data-type={type} + data-rule-key={key} data-id={`${obj.id}${key}`} data-cy={`${obj.id}${key}`} label={ @@ -71,7 +81,7 @@ export const FilterQuery = ({ value }: any) => { : obj.id} - {obj.operator}{" "} + {getDisplayOperator(obj.operator)}{" "} {filterQueryValue[obj.id] @@ -97,9 +107,138 @@ export const FilterQuery = ({ value }: any) => { const clearQueryAttr = (e: any) => { const searchParams = new URLSearchParams(location.search); - const currentType: any = e?.target - ?.closest("[data-type]") - ?.getAttribute("data-type"); + const chipEl = (e?.target as HTMLElement)?.closest?.( + "[data-type]" + ) as HTMLElement | null; + const currentType = chipEl?.getAttribute("data-type"); + const ruleKeyRaw = chipEl?.getAttribute("data-rule-key"); + + const navigateAfterChange = (sp: URLSearchParams) => { + const meaningfulFilterParams = [ + "type", + "tag", + "query", + "term", + "relationshipName", + "entityFilters", + "tagFilters", + "relationshipFilters", + "excludeST", + "excludeSC", + "includeDE" + ]; + const hasMeaningfulFilters = meaningfulFilterParams.some((param) => + sp.has(param) + ); + if (!hasMeaningfulFilters) { + navigate({ + pathname: "/search" + }); + } else if ([...sp]?.length <= 1) { + navigate({ + pathname: "/search" + }); + } else { + navigate({ + pathname: "/search/searchResult", + search: sp.toString() + }); + } + }; + + const syncFilterParamGlobalState = ( + paramKey: "entityFilters" | "tagFilters" | "relationshipFilters", + urlValue: string | null + ) => { + if (!urlValue) { + globalSearchFilterInitialQuery.setQuery({ [paramKey]: [] }); + return; + } + const api = attributeFilter.extractUrl({ + value: urlValue, + apiObj: true + }) as ParsedApiFilter | null; + if (!api?.criterion || !Array.isArray(api.criterion)) { + globalSearchFilterInitialQuery.setQuery({ [paramKey]: [] }); + return; + } + globalSearchFilterInitialQuery.setQuery({ + [paramKey]: { + combinator: String(api.condition || "AND").toLowerCase(), + rules: api.criterion.map((rule: any, i: number) => ({ + id: `url-rule-${i}`, + field: rule.attributeName, + operator: getDisplayOperator(rule.operator) || rule.operator, + value: rule.attributeValue, + ...(rule.type ? { type: rule.type } : {}) + })) + } + }); + }; + + const isFilterCriterionChip = + ruleKeyRaw !== null && + ruleKeyRaw !== "" && + (currentType === "entityFilters" || + currentType === "tagFilters" || + currentType === "relationshipFilters"); + + if (isFilterCriterionChip) { + const idx = Number.parseInt(ruleKeyRaw as string, 10); + if (Number.isNaN(idx)) { + return; + } + const paramKey = currentType as + | "entityFilters" + | "tagFilters" + | "relationshipFilters"; + const raw = searchParams.get(paramKey); + if (!raw) { + return; + } + const allowSimplify = paramKey === "entityFilters"; + const result = removeCriterionFromFilterUrl(raw, idx, allowSimplify); + if (!result) { + return; + } + if (result.kind === "empty") { + searchParams.delete(paramKey); + globalSearchFilterInitialQuery.setQuery({ [paramKey]: [] }); + navigateAfterChange(searchParams); + return; + } + if (result.kind === "singleTypeName") { + searchParams.set("type", result.typeName); + searchParams.delete("entityFilters"); + if (searchParams.get("includeDE") === "true") { + const delUrl = attributeFilter.generateUrl({ + value: { + condition: "AND", + criterion: [ + { + attributeName: "__state", + operator: "eq", + attributeValue: "DELETED" + } + ] + } + }); + if (delUrl) { + searchParams.set("entityFilters", delUrl); + } + } + syncFilterParamGlobalState( + "entityFilters", + searchParams.get("entityFilters") + ); + navigateAfterChange(searchParams); + return; + } + searchParams.set(paramKey, result.url); + syncFilterParamGlobalState(paramKey, result.url); + navigateAfterChange(searchParams); + return; + } if (currentType == "term") { searchParams.delete("gtype"); @@ -127,43 +266,11 @@ export const FilterQuery = ({ value }: any) => { globalSearchFilterInitialQuery.setQuery({ [currentType]: [] }); } - searchParams.delete(currentType); - - // 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" - }); - } else { - navigate({ - pathname: "/search/searchResult", - search: searchParams.toString() - }); + if (currentType) { + searchParams.delete(currentType); } + + navigateAfterChange(searchParams); }; if (value.type) { diff --git a/dashboard/src/utils/CommonViewFunction.ts b/dashboard/src/utils/CommonViewFunction.ts index 3760de5a538..d58357bfd42 100644 --- a/dashboard/src/utils/CommonViewFunction.ts +++ b/dashboard/src/utils/CommonViewFunction.ts @@ -193,13 +193,19 @@ export const attributeFilter = { let temp = obj.split("::") || obj.split("|" + spliter + "|"); let rule = {}; if (apiObj) { + const attrName = temp[0]; + const isDateAttr = + temp[3] === "date" || + attrName === "__timestamp" || + attrName === "__modificationTimestamp" || + attrName === "createTime"; rule = { - attributeName: temp[0], + attributeName: attrName, operator: mapUiOperatorToAPI(temp[1]), attributeValue: temp[2]?.trim() }; rule.attributeValue = - rule.type === "date" && formatDate && rule.attributeValue.length + isDateAttr && formatDate && rule.attributeValue?.length ? formatedDate({ date: parseInt(rule.attributeValue), zone: false @@ -231,9 +237,12 @@ export const attributeFilter = { rule.value; } } else if ( - (rule.type === "date" || rule.attributeName == "createTime") && + (rule.type === "date" || + rule.attributeName === "createTime" || + rule.id === "__timestamp" || + rule.id === "__modificationTimestamp") && formatDate && - rule.value.length + rule.value?.length ) { rule.value = formatedDate({ date: parseInt(rule.value), diff --git a/dashboard/src/utils/filterUrlCriterionRemoval.ts b/dashboard/src/utils/filterUrlCriterionRemoval.ts new file mode 100644 index 00000000000..746fe7998f2 --- /dev/null +++ b/dashboard/src/utils/filterUrlCriterionRemoval.ts @@ -0,0 +1,81 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { attributeFilter } from "./CommonViewFunction"; + +export type RemoveCriterionResult = + | { kind: "empty" } + | { kind: "url"; url: string } + | { kind: "singleTypeName"; typeName: string }; + +type ApiFilterCriterionRow = { + attributeName: string; + operator: string; + attributeValue: string; + type?: string; +}; + +/** Narrowing shape for `attributeFilter.extractUrl(..., { apiObj: true })`. */ +export type ParsedApiFilter = { + condition: string; + criterion: ApiFilterCriterionRow[]; +}; + +/** + * Drops one criterion from a serialized filter URL (entity / tag / relationship). + * When allowTypeParamSimplification is true and exactly one __typeName eq remains, + * returns singleTypeName so the UI can use type= (matches sidebar search). + */ +export const removeCriterionFromFilterUrl = ( + filterUrl: string, + index: number, + allowTypeParamSimplification = false +): RemoveCriterionResult | null => { + if (!filterUrl || index < 0) { + return null; + } + const parsed = attributeFilter.extractUrl({ + value: filterUrl, + apiObj: true + }) as ParsedApiFilter | null; + if (!parsed?.criterion || !Array.isArray(parsed.criterion)) { + return null; + } + const crit = [...parsed.criterion]; + if (index >= crit.length) { + return null; + } + crit.splice(index, 1); + if (crit.length === 0) { + return { kind: "empty" }; + } + if ( + allowTypeParamSimplification && + crit.length === 1 && + crit[0].attributeName === "__typeName" && + String(crit[0].operator).toLowerCase() === "eq" + ) { + return { kind: "singleTypeName", typeName: String(crit[0].attributeValue) }; + } + const url = attributeFilter.generateUrl({ + value: { condition: parsed.condition, criterion: crit } + }); + if (!url) { + return { kind: "empty" }; + } + return { kind: "url", url }; +};