diff --git a/dashboard/src/hooks/__tests__/useVirtualization.test.ts b/dashboard/src/hooks/__tests__/useVirtualization.test.ts new file mode 100644 index 00000000000..62ce583568f --- /dev/null +++ b/dashboard/src/hooks/__tests__/useVirtualization.test.ts @@ -0,0 +1,89 @@ +/* + * 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 { renderHook } from '@testing-library/react'; +import { useVirtualization } from '../useVirtualization'; + +describe('useVirtualization', () => { + it('should return empty values when items array is empty', () => { + const { result } = renderHook(() => + useVirtualization({ items: [], scrollTop: 0 }) + ); + + expect(result.current).toEqual({ + visibleItems: [], + paddingTop: 0, + paddingBottom: 0, + startIndex: 0, + }); + }); + + it('should calculate visible items and padding correctly for initial state', () => { + const items = Array.from({ length: 100 }, (_, i) => i); + const { result } = renderHook(() => + useVirtualization({ items, scrollTop: 0, itemHeight: 37, overscan: 10, visibleCount: 40 }) + ); + + // Initial state (scrollTop = 0) + // startIndex = max(0, 0 - 10) = 0 + // endIndex = min(99, 0 + 40 + 10) = 50 + // visibleItems = items.slice(0, 51) + + expect(result.current.startIndex).toBe(0); + expect(result.current.visibleItems.length).toBe(51); + expect(result.current.paddingTop).toBe(0); + + // paddingBottom = (100 - 1 - 50) * 37 = 49 * 37 = 1813 + expect(result.current.paddingBottom).toBe(1813); + }); + + it('should calculate visible items correctly when scrolled down', () => { + const items = Array.from({ length: 100 }, (_, i) => i); + // Scrolled 20 items down: 20 * 37 = 740 + const { result } = renderHook(() => + useVirtualization({ items, scrollTop: 740, itemHeight: 37, overscan: 10, visibleCount: 40 }) + ); + + // startIndex = max(0, 20 - 10) = 10 + // endIndex = min(99, 20 + 40 + 10) = 70 + + expect(result.current.startIndex).toBe(10); + expect(result.current.visibleItems.length).toBe(61); // 70 - 10 + 1 + + // paddingTop = 10 * 37 = 370 + expect(result.current.paddingTop).toBe(370); + + // paddingBottom = (100 - 1 - 70) * 37 = 29 * 37 = 1073 + expect(result.current.paddingBottom).toBe(1073); + }); + + it('should cap end index at total items length', () => { + const items = Array.from({ length: 50 }, (_, i) => i); + // Scrolled way past the bottom + const { result } = renderHook(() => + useVirtualization({ items, scrollTop: 5000, itemHeight: 37, overscan: 10, visibleCount: 40 }) + ); + + // startIndex = max(0, 135 - 10) = 125 + // endIndex = min(49, 135 + 40 + 10) = 49 + // Wait, if startIndex > endIndex, slice will return empty array + + expect(result.current.startIndex).toBe(125); + expect(result.current.visibleItems.length).toBe(0); + expect(result.current.paddingBottom).toBe(0); + }); +}); diff --git a/dashboard/src/hooks/useVirtualization.ts b/dashboard/src/hooks/useVirtualization.ts new file mode 100644 index 00000000000..20aa20df420 --- /dev/null +++ b/dashboard/src/hooks/useVirtualization.ts @@ -0,0 +1,62 @@ +/* + * 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 { useMemo } from 'react'; + +interface UseVirtualizationProps { + items: T[]; + scrollTop: number; + itemHeight?: number; + overscan?: number; + visibleCount?: number; +} + +export const useVirtualization = ({ + items, + scrollTop, + itemHeight = 37, + overscan = 10, + visibleCount = 40, +}: UseVirtualizationProps) => { + return useMemo(() => { + const totalItems = items.length; + + if (totalItems === 0) { + return { + visibleItems: [], + paddingTop: 0, + paddingBottom: 0, + startIndex: 0, + }; + } + + const startIndex = Math.max(0, Math.floor(scrollTop / itemHeight) - overscan); + const endIndex = Math.min(totalItems - 1, Math.floor(scrollTop / itemHeight) + visibleCount + overscan); + + const visibleItems = items.slice(startIndex, endIndex + 1); + + const paddingTop = startIndex * itemHeight; + const paddingBottom = Math.max(0, (totalItems - 1 - endIndex) * itemHeight); + + return { + visibleItems, + paddingTop, + paddingBottom, + startIndex, + }; + }, [items, scrollTop, itemHeight, overscan, visibleCount]); +}; diff --git a/dashboard/src/utils/Enum.ts b/dashboard/src/utils/Enum.ts index 50644400b87..a67463a1f4f 100644 --- a/dashboard/src/utils/Enum.ts +++ b/dashboard/src/utils/Enum.ts @@ -111,6 +111,20 @@ export const auditAction: { [key: string]: string } = { AUTO_PURGE : "Auto Purged Entities" }; + +export enum AuditOperation { + PURGE = "PURGE", + AUTO_PURGE = "AUTO_PURGE", + IMPORT = "IMPORT", + EXPORT = "EXPORT" +} + +export enum PurgeActiveView { + NONE = "none", + REQUESTED = "requested", + PURGED = "purged" +} + export const stats: any = { generalData: { collectionTime: "day" diff --git a/dashboard/src/views/Administrator/Audits/AdminAuditTable.tsx b/dashboard/src/views/Administrator/Audits/AdminAuditTable.tsx index 86732832284..a1558e2d380 100644 --- a/dashboard/src/views/Administrator/Audits/AdminAuditTable.tsx +++ b/dashboard/src/views/Administrator/Audits/AdminAuditTable.tsx @@ -54,8 +54,33 @@ const AdminAuditTable = () => { const limit = pageSize || 25; const offset = (pageIndex || 0) * limit; - let params: any = { - auditFilters: !isEmpty(queryApiObj) ? queryApiObj : null, + let auditFilters = !isEmpty(queryApiObj) ? JSON.parse(JSON.stringify(queryApiObj)) : null; + + if (auditFilters) { + const filtersStr = JSON.stringify(auditFilters); + if (filtersStr.includes('"attributeName":"runId"')) { + // Remove any existing auditRowKind to prevent duplicates/conflicts + const removeAuditRowKind = (node: Record) => { + if (node && node.criterion) { + node.criterion = node.criterion.filter((c: Record) => c.attributeName !== 'auditRowKind'); + node.criterion.forEach(removeAuditRowKind); + } + }; + removeAuditRowKind(auditFilters); + + // Force append SUMMARY by wrapping the existing filter + auditFilters = { + condition: "AND", + criterion: [ + auditFilters, + { attributeName: "auditRowKind", operator: "eq", attributeValue: "SUMMARY" } + ] + }; + } + } + + let params: Record = { + auditFilters: auditFilters, limit: limit, sortOrder: "DESCENDING", offset: offset, @@ -65,10 +90,10 @@ const AdminAuditTable = () => { try { setLoader(true); let searchResp = await getAuditData(params); - setAuditData(searchResp.data); + setAuditData(searchResp.data || []); setLoader(false); - } catch (error: any) { - console.error("Error fetching data:", error.response.data.errorMessage); + } catch (error: unknown) { + console.error("Error fetching data:", (error as any)?.response?.data?.errorMessage || (error as any)?.message); toast.dismiss(toastId.current); serverError(error, toastId); setLoader(false); diff --git a/dashboard/src/views/Administrator/Audits/AuditResults.scss b/dashboard/src/views/Administrator/Audits/AuditResults.scss new file mode 100644 index 00000000000..86f8e547a5f --- /dev/null +++ b/dashboard/src/views/Administrator/Audits/AuditResults.scss @@ -0,0 +1,322 @@ +/* + * 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. + */ + +.purge-audit-view { + margin-top: 16px; + margin-bottom: 16px; +} + +.purge-summary-container { + padding: 20px; + border-radius: 8px; + background-color: #f0f4f8; + border: 1px solid rgba(0, 0, 0, 0.08); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.02); +} + +.purge-runid-header { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 16px; +} + +.runid-text { + font-family: monospace; +} + +.card-title { + text-transform: uppercase; + font-weight: bold; + letter-spacing: 0.5px; + font-size: 11px; + display: block; +} + +.card-count { + font-weight: bold; + margin-top: 4px; +} + +.purge-card { + padding: 12px; + border-radius: 8px; + transition: all 0.2s ease-in-out; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04); +} + +.purge-card-requested { + background-color: #eff6ff; + border: 1px solid #bfdbfe; + cursor: pointer; + + &:hover { + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(59, 130, 246, 0.15); + border-color: #60a5fa; + } +} + +.purge-card-purged { + background-color: #f0fdf4; + border: 1px solid #bbf7d0; + + &.clickable { + cursor: pointer; + + &:hover { + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(34, 197, 94, 0.15); + border-color: #4ade80; + } + } +} + +.purge-card-failed { + background-color: #fef2f2; + border: 1px solid #fecaca; + cursor: default; +} + +.purge-card-failed-empty { + background-color: #fafafa; + border: 1px solid rgba(0, 0, 0, 0.08); + cursor: default; +} + +.purge-card-skipped { + background-color: #fffbeb; + border: 1px solid #fef08a; + cursor: default; +} + +.purge-card-skipped-empty { + background-color: #fafafa; + border: 1px solid rgba(0, 0, 0, 0.08); + cursor: default; +} + +.purge-alert-container { + margin-top: 16px; +} + +.purge-alert-title { + font-weight: bold; + font-size: 13px; +} + +.drawer-paper { + width: 400px; +} + +.drawer-content-wrapper { + display: flex; + flex-direction: column; + height: 100%; + overflow: hidden; + box-sizing: border-box; +} + +.drawer-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px 12px; +} + +.drawer-title { + font-weight: 600; + color: rgba(0, 0, 0, 0.87); + font-size: 16px; +} + +.drawer-runid-container { + display: flex; + align-items: center; + gap: 8px; + margin: 4px 12px; + padding: 4px 0; +} + +.drawer-runid-text { + font-size: 12px; +} + +.drawer-search-container { + margin: 4px 12px; + padding-bottom: 8px; +} + +.drawer-search-input { + width: 100%; + border-bottom: 1px solid #d1d5db; + padding-bottom: 4px; +} + +.drawer-loader { + display: flex; + justify-content: center; + padding: 40px 0; +} + +.drawer-list-container { + overflow-y: scroll; + flex-grow: 1; + min-height: 0; + margin: 0 15px; + padding-right: 5px; +} + +.drawer-list-empty { + padding: 24px 0; + text-align: center; +} + +.drawer-list-item { + border-bottom: 1px solid rgba(0, 0, 0, 0.04); + padding: 0 0 0 10px; + height: 24px; + box-sizing: border-box; + display: flex; + align-items: center; +} + +.drawer-list-index { + margin-right: 8px; + min-width: 24px; + color: rgba(0, 0, 0, 0.6); + text-align: right; +} + +.drawer-list-link { + display: inline-block; + max-width: 100%; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + text-align: left; + margin-left: 12px; +} + +.drawer-footer { + margin-top: auto; + padding: 8px 12px; + border-top: 1px solid #e2e8f0; + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: nowrap; + flex-shrink: 0; + background-color: #fff; +} + +.drawer-footer-count { + white-space: nowrap; + margin-right: 8px; + font-size: 13px; +} + +.drawer-pagination-container { + display: flex; + align-items: center; + gap: 8px; +} + +.drawer-limit-container { + display: flex; + align-items: center; + gap: 4px; +} + +.drawer-limit-label { + font-size: 13px; +} + +.drawer-limit-input { + width: 48px; + height: 24px; + box-sizing: border-box; + font-size: 13px; + border: 1px solid #cbd5e1; + border-radius: 4px; + padding: 0 4px; + text-align: center; + outline: none; + + &:focus { + border-color: #90caf9; + } +} + +.audit-list-header { + padding: 16px 0 0 16px; + text-align: left; +} + +.audit-list-link { + display: inline-block; + max-width: 100%; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + text-align: left; + vertical-align: bottom; +} + +.copy-icon { + font-size: 15px; + color: rgba(0, 0, 0, 0.6); + + &.copied { + color: #2e7d32; + } +} + +.drawer-copy-btn { + padding: 2px; +} + +.drawer-copy-icon { + font-size: 13px; + color: rgba(0, 0, 0, 0.6); + + &.copied { + color: #2e7d32; + } +} + +.drawer-search-icon { + font-size: 16px; + color: rgba(0, 0, 0, 0.6); +} + +.purge-pagination { + .MuiPaginationItem-root { + min-width: 24px; + height: 24px; + margin: 0 2px; + font-size: 13px; + } + + .MuiPaginationItem-ellipsis { + display: none; + } + + .MuiPaginationItem-page:not(.Mui-selected) { + display: none; + } +} \ No newline at end of file diff --git a/dashboard/src/views/Administrator/Audits/AuditResults.tsx b/dashboard/src/views/Administrator/Audits/AuditResults.tsx index acde90ff397..d0e4bb90fc8 100644 --- a/dashboard/src/views/Administrator/Audits/AuditResults.tsx +++ b/dashboard/src/views/Administrator/Audits/AuditResults.tsx @@ -15,227 +15,730 @@ * limitations under the License. */ -import { Grid, Link, List, ListItem, ListItemText, Typography } from "@mui/material"; -import { auditAction, category } from "@utils/Enum"; +import { Grid, Link, List, ListItem, ListItemText, Typography, Box, Drawer, IconButton, Stack, Tooltip, TextField, InputAdornment, CircularProgress, Pagination, PaginationItem, Skeleton } from "@mui/material"; +import KeyboardDoubleArrowLeftIcon from "@mui/icons-material/KeyboardDoubleArrowLeft"; +import KeyboardDoubleArrowRightIcon from "@mui/icons-material/KeyboardDoubleArrowRight"; +import ContentCopyIcon from "@mui/icons-material/ContentCopy"; +import SearchIcon from "@mui/icons-material/Search"; +import { auditAction, category, AuditOperation, PurgeActiveView } from "@utils/Enum"; import { isEmpty, jsonParse } from "@utils/Utils"; +import { useVirtualization } from "@hooks/useVirtualization"; import CustomModal from "@components/Modal"; import TypeDefAuditDetailModal from "@components/TypeDefAuditDetailModal"; -import { useState } from "react"; -import { Item } from "@utils/Muiutils"; +import { useRef, useState, useEffect } from "react"; import AuditsTab from "@views/DetailPage/EntityDetailTabs/AuditsTab"; import ImportExportAudits from "./ImportExportAudits"; +import { LightTooltip } from "@components/muiComponents"; +import { fetchApi } from "@api/apiMethods/fetchApi"; +import "./AuditResults.scss"; +interface AuditEntry { + guid: string; + operation: string; + params?: string; + result?: string; + runId?: string; + [key: string]: unknown; +} -const AuditResults = ({ componentProps, row }: any) => { +interface AuditResultsProps { + componentProps?: { + auditData?: AuditEntry[]; + }; + row: { + original: { + guid: string; + runId?: string; + [key: string]: unknown; + }; + }; +} + +const AuditResults = ({ componentProps, row }: AuditResultsProps) => { const { auditData } = componentProps || {}; const [openModal, setOpenModal] = useState(false); const [openPurgeModal, setOpenPurgeModal] = useState(false); - const [currentResultObj, setCurrentObj] = useState({}); - const [currentPurgeResultObj, setCurrentPurgeResultObj] = useState(""); + const [currentResultObj, setCurrentObj] = useState | undefined>(); + // Stores the guid of the clicked purged entity + const [currentPurgeResultObj, setCurrentPurgeResultObj] = useState(); + const [activePurgeView, setActivePurgeView] = useState(PurgeActiveView.NONE); + const [drawerSearchText, setDrawerSearchText] = useState(''); + const [drawerPage, setDrawerPage] = useState(1); + const [drawerPageSize, setDrawerPageSize] = useState(25); + const [drawerPageSizeInput, setDrawerPageSizeInput] = useState('25'); + const [scrollTop, setScrollTop] = useState(0); + const [copiedRunId, setCopiedRunId] = useState(false); + const [purgedApiGuids, setPurgedApiGuids] = useState([]); + const [loadingPurgedApi, setLoadingPurgedApi] = useState(false); + const [purgedTotalCount, setPurgedTotalCount] = useState(0); + const [summaryData, setSummaryData] = useState | null>(null); + const [loadingSummary, setLoadingSummary] = useState(false); + const drawerScrollTimerRef = useRef | null>(null); + + const handleCloseModal = () => { setOpenModal(false); }; const handleClosePurgeModal = () => { setOpenPurgeModal(false); }; - const auditObj = !isEmpty(auditData) - ? auditData.find((obj: { guid: string }) => obj.guid == row.original.guid) - : {}; - const { operation, params, result } = auditObj; + const auditObj: AuditEntry | undefined = !isEmpty(auditData) + ? (auditData as AuditEntry[]).find((obj) => obj.guid === row.original.guid) + : undefined; + + const operation = auditObj?.operation ?? ''; + const params = auditObj?.params; + const result = auditObj?.result; + + let isPurgeOperation = operation === AuditOperation.PURGE || operation === AuditOperation.AUTO_PURGE; + const summaryGuid = auditObj?.guid ?? row.original.guid; + + useEffect(() => { + if (isPurgeOperation && summaryGuid) { + setLoadingSummary(true); + fetchApi(`/api/atlas/admin/audit/${summaryGuid}/summary`, { + method: "GET", + headers: { 'Accept': 'application/json', 'Content-Type': 'application/json' } + }) + .then(res => { + if (res.data && typeof res.data === 'object') { + setSummaryData(res.data); + } + }) + .catch(err => { + console.error("Failed to fetch purge summary", err); + }) + .finally(() => { + setLoadingSummary(false); + }); + } + }, [isPurgeOperation, summaryGuid]); + + let summary: Record = summaryData || {}; + let requestedEntitiesList: string[] = []; + let legacyPurgedList: string[] = []; + + if (isPurgeOperation) { + if (!summaryData) { + try { + const parsed = typeof result === "string" ? JSON.parse(result) : result; + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + summary = (parsed as Record).summary + ? (parsed as Record).summary as Record + : parsed as Record; + } else if (Array.isArray(parsed)) { + legacyPurgedList = (parsed as unknown[]).map((item) => + typeof item === "string" ? item : (item as { guid?: string }).guid || String(item) + ); + } + } catch (_e) { + if (typeof result === "string" && !result.startsWith("{")) { + legacyPurgedList = result.replace(/^\[|\]$/g, "").split(",").map(s => s.trim()).filter(Boolean); + } + } + } else { + if (typeof result === "string" && !result.startsWith("{")) { + legacyPurgedList = result.replace(/^\[|\]$/g, "").split(",").map(s => s.trim()).filter(Boolean); + } + } + + if (params) { + try { + const parsedParams = JSON.parse(params); + if (Array.isArray(parsedParams)) { + requestedEntitiesList = parsedParams as string[]; + } else if (typeof params === "string") { + requestedEntitiesList = params.replace(/^\[|\]$/g, "").split(",").map(s => s.trim()).filter(Boolean); + } + } catch (_e) { + requestedEntitiesList = typeof params === "string" + ? params.replace(/^\[|\]$/g, "").split(",").map(s => s.trim()).filter(Boolean) + : []; + } + } + } else { + try { + summary = jsonParse(result) as Record; + } catch (_e) { + summary = {}; + } + } + + const runId = (row.original.runId as string | undefined) + ?? (summary?.runId as string | undefined) + ?? (auditObj?.runId as string | undefined) + ?? 'N/A'; - const resultObj = - (operation == "PURGE" || operation == "AUTO_PURGE") - ? result.replace("[", "").replace("]", "").split(",") - : jsonParse(result); + const isSummaryRow = (runId !== 'N/A') && isPurgeOperation; + + const requestedCount = (summary?.requestedCount as number | undefined) ?? requestedEntitiesList.length; + const purgedCount = (summary?.purgedCount as number | undefined) ?? legacyPurgedList.length; + const purgedDependenciesCount = (summary?.purgedDependenciesCount as number | undefined) ?? 0; + const totalPurgedCount = (purgedCount as number) + (purgedDependenciesCount as number); + const failedCount = (summary?.failedCount as number | undefined) ?? 0; + const failedDependenciesCount = (summary?.failedDependenciesCount as number | undefined) ?? 0; + const totalFailedCount = failedCount + failedDependenciesCount; + const skippedCount = (summary?.skippedCount as number | undefined) ?? 0; + const executionFailed = (summary?.executionFailed as boolean | undefined) || (totalFailedCount) > 0; + + // Fetching purged entities from an API is disabled for now. + // We simply use the raw `result` string as requested. + const fetchPurged = () => { + // Disabled. The UI will just use the `result` string. + }; + + // Handle clicking Total Purged card: opens drawer and fetches first page + const handleOpenPurgedDrawer = () => { + if (totalPurgedCount === 0) return; + setPurgedTotalCount(totalPurgedCount); + setActivePurgeView(PurgeActiveView.PURGED); + setDrawerPage(1); + setScrollTop(0); + // As requested, Total Purged simply uses the raw `result` object string (legacyPurgedList) + setPurgedApiGuids(legacyPurgedList); + setLoadingPurgedApi(false); + }; return ( <> - {operation != "PURGE" && - operation != "AUTO_PURGE" && - operation != "IMPORT" && - operation != "EXPORT" && - !isEmpty(resultObj) ? ( - - {params.split(",").length > 1 ? ( - <> - {params.split(",")?.map((param: { param: string }) => { - return ( - - - {`${category[param as any]} ${auditAction[operation] - }`} - - - {resultObj[param as any].map( - (obj: { name: string }) => { - const { name } = obj; - return ( - <> - - { - setOpenModal(true); - setCurrentObj(obj); - }} - title={name} - sx={{ - display: "inline-block", - maxWidth: "100%", - textOverflow: "ellipsis", - overflow: "hidden", - whiteSpace: "nowrap", - textAlign: "left", - verticalAlign: "bottom" - }} - > - {name} - - - - ); - } - )} - - - - ); - })} - - ) : ( - <> - - - {`${category[params as any]} ${auditAction[operation] - }`} - - {resultObj[params].map((obj: { name: string }) => { - const { name } = obj; - return ( - <> - + + + + + + + {operation === "TYPE_DEF_CREATE" || + operation === "TYPE_DEF_UPDATE" || + operation === "TYPE_DEF_DELETE" ? ( + + {summary && + Object.keys(summary).map((key: string) => { + const rawItems = summary[key]; + const items: Array | string> = Array.isArray(rawItems) + ? (rawItems as Array | string>) + : []; + return ( +
+ + {`${category[key as keyof typeof category] || key} ${auditAction[operation as keyof typeof auditAction] || operation}`} + + {items.map((obj: Record | string, idx: number) => { + const name = typeof obj === 'object' && obj !== null + ? (obj.name as string) || String(obj) + : String(obj); + return ( + + { setOpenModal(true); - setCurrentObj(obj); + setCurrentObj(typeof obj === "object" ? obj : { name: obj }); }} title={name} - sx={{ - display: "inline-block", - maxWidth: "100%", - textOverflow: "ellipsis", - overflow: "hidden", - whiteSpace: "nowrap", - textAlign: "left", - verticalAlign: "bottom" - }} > {name} - - - ); - })} - - + } + /> + + ); + })} +
+ ); + })} +
+ ) : operation === "IMPORT" || operation === "EXPORT" ? ( + + ) : !isPurgeOperation ? ( + No Results Found + ) : null} + + {/* Purge Audit View */} + {isPurgeOperation ? ( + + {loadingSummary && Object.keys(summary).length === 0 && legacyPurgedList.length === 0 && !result ? ( + + + + + + + + + + ) : ( + + + {/* Run Id Header with Copy Action */} + {runId !== 'N/A' && ( + + + Run Id: {runId} + + + { + if (navigator.clipboard) { + navigator.clipboard.writeText(runId); + } else { + const textField = document.createElement('textarea'); + textField.innerText = runId; + document.body.appendChild(textField); + textField.select(); + document.execCommand('copy'); + textField.remove(); + } + setCopiedRunId(true); + setTimeout(() => setCopiedRunId(false), 2000); + }} + className="purge-runid-copy" + > + + + + + )} + + {/* 4 Cards Grid: Requested, Total Purged, Failed (Display Only), Skipped (Display Only) */} + + {/* 1. Clickable Requested Card */} + {isSummaryRow && ( + + { + setActivePurgeView(PurgeActiveView.REQUESTED); + setDrawerPage(1); + setScrollTop(0); + }} + className="purge-card purge-card-requested" + > + + Requested + + + {requestedCount} + + + + )} + + {/* 2. Clickable Total Purged Card */} + + 0 ? "clickable" : ""}`} + > + + PURGED + + + {totalPurgedCount} + + + + + {/* 3 & 4. Display-Only Failed and Skipped Cards */} + {isSummaryRow && ( + <> + + 0 || executionFailed + ? "Some entities failed to purge. Please check ${atlas.log.dir}/purgefailure.log for details." + : "No failed entities during this purge operation." + } + arrow + placement="top" + > + 0 ? "purge-card-failed" : "purge-card-failed-empty"}`} + > + 0 ? "error.main" : "textSecondary"} display="block" className="card-title"> + Failed + + 0 ? "error.main" : "textPrimary"} className="card-count"> + {totalFailedCount} + + + + + + {/* 4. Display-Only Skipped Card */} + + 0 || executionFailed + ? "Some entities were skipped during purge. Please check ${atlas.log.dir}/purgefailure.log for details." + : "No skipped entities during this purge operation." + } + arrow + placement="top" + > + 0 ? "purge-card-skipped" : "purge-card-skipped-empty"}`} + > + 0 ? "warning.main" : "textSecondary"} display="block" className="card-title"> + Skipped + + 0 ? "warning.main" : "textPrimary"} className="card-count"> + {skippedCount} + + + + + + )} - + )} -
- ) : ( - operation != "PURGE" && - operation != "AUTO_PURGE" && - operation != "IMPORT" && - operation != "EXPORT" && No Results Found - )} - - {(operation == "PURGE" || operation == "AUTO_PURGE") && !isEmpty(resultObj) ? ( - <> - {`${category[operation]}`} - - {resultObj.map((obj: string) => { + + {/* Right Side Drawer — server-side pagination for Purged, client-side for Requested */} + + + ) : null} + + ); +}; + + +interface PurgeEntitiesDrawerProps { + activePurgeView: PurgeActiveView; + setActivePurgeView: (view: PurgeActiveView) => void; + isSummaryRow: boolean; +requestedEntitiesList: string[]; + purgedApiGuids: string[]; + drawerSearchText: string; + setDrawerSearchText: (text: string) => void; + drawerPage: number; + setDrawerPage: React.Dispatch>; + drawerPageSize: number; + setDrawerPageSize: React.Dispatch>; + scrollTop: number; + setScrollTop: React.Dispatch>; + purgedTotalCount: number; + drawerScrollTimerRef: React.MutableRefObject | null>; + loadingPurgedApi: boolean; + fetchPurged: (append: boolean, limitOverride?: number) => void; + runId: string; + copiedRunId: boolean; + setCopiedRunId: (copied: boolean) => void; + setOpenPurgeModal: (open: boolean) => void; + setCurrentPurgeResultObj: (guid: string) => void; + drawerPageSizeInput: string; + setDrawerPageSizeInput: (input: string) => void; +} + +const PurgeEntitiesDrawer: React.FC = ({ + activePurgeView, + setActivePurgeView, + requestedEntitiesList, + purgedApiGuids, + drawerSearchText, + setDrawerSearchText, + drawerPage, + setDrawerPage, + drawerPageSize, + setDrawerPageSize, + scrollTop, + setScrollTop, + loadingPurgedApi, + runId, + copiedRunId, + setCopiedRunId, + setOpenPurgeModal, + setCurrentPurgeResultObj, + drawerPageSizeInput, + setDrawerPageSizeInput, +}) => { + const listRef = useRef(null); + + useEffect(() => { + if (listRef.current) { + listRef.current.scrollTop = 0; + } + }, [drawerPage, drawerSearchText, activePurgeView]); + + const isPurgedView = activePurgeView === PurgeActiveView.PURGED; + const rawListForView: any[] = activePurgeView === PurgeActiveView.REQUESTED + ? requestedEntitiesList + : purgedApiGuids; + + const filteredList = rawListForView.filter((item: any) => { + if (!drawerSearchText) return true; + const guidStr = typeof item === 'object' && item !== null ? item.guid : item; + const nameStr = typeof item === 'object' && item !== null ? item.attributes?.name : ''; + const searchLower = drawerSearchText.trim().toLowerCase(); + return (guidStr && guidStr.toLowerCase().includes(searchLower)) || + (nameStr && nameStr.toLowerCase().includes(searchLower)); + }); + + const displayItems = filteredList.slice((drawerPage - 1) * drawerPageSize, drawerPage * drawerPageSize); + + const { visibleItems, paddingTop, paddingBottom, startIndex } = useVirtualization({ + items: displayItems, + scrollTop, + itemHeight: 24 + }); + + const displayTotal = filteredList.length; + + const handleDrawerScroll = (e: React.UIEvent) => { + setScrollTop(e.currentTarget.scrollTop); + }; + + return ( + { + setActivePurgeView(PurgeActiveView.NONE); + setDrawerSearchText(''); + setDrawerPage(1); + setScrollTop(0); + }} + PaperProps={{ className: "drawer-paper" }} + > + + + + {activePurgeView === PurgeActiveView.REQUESTED ? 'Requested Entities' : 'Purged Entities'} + + { + setActivePurgeView(PurgeActiveView.NONE); + setDrawerSearchText(''); + setDrawerPage(1); + setScrollTop(0); + }} + size="small" + > + ✕ + + + + {runId !== 'N/A' && ( + + + Run Id: {runId} + + + { + if (navigator.clipboard) { + navigator.clipboard.writeText(runId); + } else { + const textField = document.createElement('textarea'); + textField.innerText = runId; + document.body.appendChild(textField); + textField.select(); + document.execCommand('copy'); + textField.remove(); + } + setCopiedRunId(true); + setTimeout(() => setCopiedRunId(false), 2000); + }} + className="drawer-copy-btn" + > + + + + + )} + + {(activePurgeView === 'requested' ? requestedEntitiesList.length > 0 : purgedApiGuids.length > 0 || loadingPurgedApi) && ( + + { + setDrawerSearchText(e.target.value); + setDrawerPage(1); + setScrollTop(0); + }} + InputProps={{ + disableUnderline: true, + startAdornment: ( + + + + ), + endAdornment: drawerSearchText ? ( + + { + setDrawerSearchText(''); + setDrawerPage(1); + setScrollTop(0); + }}> + ✕ + + + ) : null + }} + className="drawer-search-input" + /> + + )} + + + + {isPurgedView && loadingPurgedApi && purgedApiGuids.length === 0 ? ( + + + + ) : ( + + {(() => { + if (displayItems.length === 0) { + return ( + + No matching GUIDs found + + ); + } + return ( - - { - setOpenPurgeModal(true); - setCurrentPurgeResultObj(obj); - }} - title={obj} - sx={{ - display: "inline-block", - maxWidth: "100%", - textOverflow: "ellipsis", - overflow: "hidden", - whiteSpace: "nowrap", - textAlign: "left", - verticalAlign: "bottom" - }} - > - {obj} - - } - /> - + <> + {paddingTop > 0 &&
} + {visibleItems.map((item: string | Record, localIndex: number) => { + const index = startIndex + localIndex; + const globalIndex = (drawerPage - 1) * drawerPageSize + index + 1; + const isObj = typeof item === 'object' && item !== null; + const guidStr = isObj ? item.guid : item; + return ( + + {globalIndex}. + { + setOpenPurgeModal(true); + setCurrentPurgeResultObj(guidStr); + }} + title={guidStr} + className="drawer-list-link" + > + {guidStr} + + + ); + })} + {paddingBottom > 0 &&
} + ); - })} + })()} + + - - ) : ( - (operation == "PURGE" || operation == "AUTO_PURGE") && No Results Found - )} + )} - {(operation == "IMPORT" || operation == "EXPORT") && ( - - )} + {displayTotal > 0 && ( + + + {Math.min((drawerPage - 1) * drawerPageSize + 1, displayTotal)}-{Math.min(drawerPage * drawerPageSize, displayTotal)} of {displayTotal} + - + + { + setDrawerPage(val); + setScrollTop(0); + }} + size="small" + color="primary" + siblingCount={0} + boundaryCount={0} + showFirstButton + showLastButton + className="purge-pagination" + renderItem={(item) => ( + + )} + /> - {(operation == "PURGE" || operation == "AUTO_PURGE") && ( - - - - )} - + + Limit + ) => { + setDrawerPageSizeInput(e.target.value); + }} + onKeyDown={(e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + const parsed = parseInt((e.target as HTMLInputElement).value, 10); + if (Number.isFinite(parsed) && parsed > 0) { + const clamped = Math.min(parsed, displayTotal); + setDrawerPageSize(clamped); + setDrawerPageSizeInput(String(clamped)); + setDrawerPage(1); + setScrollTop(0); + } + } + }} + className="drawer-limit-input" + /> + + + + )} + + ); }; + export default AuditResults; diff --git a/dashboard/src/views/Administrator/Audits/AuditsFilter/AuditFiltersFields.tsx b/dashboard/src/views/Administrator/Audits/AuditsFilter/AuditFiltersFields.tsx index b479a4be390..08a59766925 100644 --- a/dashboard/src/views/Administrator/Audits/AuditsFilter/AuditFiltersFields.tsx +++ b/dashboard/src/views/Administrator/Audits/AuditsFilter/AuditFiltersFields.tsx @@ -308,6 +308,10 @@ export const fields = (allDataObj) => { } if (!isEmpty(auditEntryAttributeDefs)) { for (const attributes in auditEntryAttributeDefs) { + if (auditEntryAttributeDefs[attributes]?.name === "auditRowKind") { + continue; // Backend-only field, do not display in UI filter + } + let returnObj: any = getObjDef( allDataObj, auditEntryAttributeDefs[attributes], diff --git a/dashboard/src/views/Administrator/Audits/__tests__/AdminAuditTable.test.tsx b/dashboard/src/views/Administrator/Audits/__tests__/AdminAuditTable.test.tsx index dbd844cd285..9756bdd73cd 100644 --- a/dashboard/src/views/Administrator/Audits/__tests__/AdminAuditTable.test.tsx +++ b/dashboard/src/views/Administrator/Audits/__tests__/AdminAuditTable.test.tsx @@ -97,7 +97,18 @@ jest.mock('../AuditsFilter/AuditFilters', () => ({ ), + Drawer: ({ children, open, PaperProps, ...props }: any) => open ?
{children}
: null, List: ({ children, ...props }: any) =>
    {children}
, ListItem: ({ children, ...props }: any) =>
  • {children}
  • , ListItemText: ({ primary, ...props }: any) =>
    {primary}
    , @@ -146,11 +160,18 @@ jest.mock('@mui/material', () => { }; }); + +Object.assign(navigator, { + clipboard: { + writeText: jest.fn().mockImplementation(() => Promise.resolve()), + }, +}); + describe('AuditResults Component', () => { const mockAuditData = [ { guid: 'audit-1', - operation: 'CREATE', + operation: 'TYPE_DEF_CREATE', params: 'entityDefs', result: JSON.stringify({ entityDefs: [ @@ -161,7 +182,7 @@ describe('AuditResults Component', () => { }, { guid: 'audit-2', - operation: 'UPDATE', + operation: 'TYPE_DEF_UPDATE', params: 'classificationDefs,enumDefs', result: JSON.stringify({ classificationDefs: [{ name: 'Classification1', category: 'classificationDefs' }], @@ -202,6 +223,10 @@ describe('AuditResults Component', () => { beforeEach(() => { jest.clearAllMocks(); + // Reset fetchApi mock before each test + (fetchApi as jest.Mock).mockImplementation(() => + Promise.resolve({ data: [] }) + ); mockIsEmpty.mockImplementation((val: any) => { if (val === null || val === undefined || val === '') return true; if (Array.isArray(val) && val.length === 0) return true; @@ -210,11 +235,14 @@ describe('AuditResults Component', () => { }); mockIsArray.mockImplementation((val: any) => Array.isArray(val)); mockJsonParse.mockImplementation((val: any) => { - try { - return JSON.parse(val); - } catch { - return {}; - } + if (!val) return []; + return JSON.parse(val, (_key, value) => { + try { + return typeof value === 'string' ? JSON.parse(value) : value; + } catch { + return value; + } + }); }); }); @@ -223,8 +251,8 @@ describe('AuditResults Component', () => { const componentProps = { auditData: mockAuditData }; render(); - const grids = screen.getAllByTestId('grid'); - expect(grids.length).toBeGreaterThan(0); + const lists = screen.getAllByTestId('list'); + expect(lists.length).toBeGreaterThan(0); }); it('should find audit object by guid', () => { @@ -232,7 +260,7 @@ describe('AuditResults Component', () => { render(); // Should render results for audit-1 - expect(screen.getAllByTestId('item').length).toBeGreaterThan(0); + expect(screen.getAllByTestId('list-item').length).toBeGreaterThan(0); }); it('should handle empty auditData', () => { @@ -247,7 +275,7 @@ describe('AuditResults Component', () => { render(); - // When auditData is empty, auditObj is {}, and the component shows "No Results Found" + // When auditData is empty, auditObj is {}, and the component shows "No matching GUIDs found" const typographies = screen.getAllByTestId('typography'); expect(typographies.length).toBeGreaterThan(0); }); @@ -264,14 +292,14 @@ describe('AuditResults Component', () => { render(); - // When auditData is undefined, auditObj is {}, and the component shows "No Results Found" + // When auditData is undefined, auditObj is {}, and the component shows "No matching GUIDs found" const typographies = screen.getAllByTestId('typography'); expect(typographies.length).toBeGreaterThan(0); }); }); - describe('CREATE/UPDATE/DELETE Operations', () => { - it('should render results for CREATE operation with single param', () => { + describe('TYPE_DEF_CREATE/UPDATE/DELETE Operations', () => { + it('should render results for TYPE_DEF_CREATE operation with single param', () => { const componentProps = { auditData: mockAuditData }; const row = { original: { guid: 'audit-1' } }; @@ -283,7 +311,7 @@ describe('AuditResults Component', () => { expect(screen.getByText('Entity2')).toBeInTheDocument(); }); - it('should render results for UPDATE operation with multiple params', () => { + it('should render results for TYPE_DEF_UPDATE operation with multiple params', () => { const componentProps = { auditData: mockAuditData }; const row = { original: { guid: 'audit-2' } }; @@ -363,24 +391,15 @@ describe('AuditResults Component', () => { }); }); - it('should show "No Record Found" when current object is empty', async () => { - const componentProps = { - auditData: [ - { - guid: 'audit-empty', - operation: 'CREATE', - params: 'entityDefs', - result: JSON.stringify({ entityDefs: [{}] }) - } - ] - }; - const row = { original: { guid: 'audit-empty' } }; + it('should show "No Record Found" when current object is empty', () => { + mockIsEmpty.mockReturnValue(true); // Force isEmpty to return true + const componentProps = { auditData: mockAuditData }; + const row = { original: { guid: 'audit-1' } }; render(); // The component should still render but with empty object - const grids = screen.getAllByTestId('grid'); - expect(grids.length).toBeGreaterThan(0); + expect(screen.getByText('No Results Found')).toBeInTheDocument(); }); }); @@ -391,6 +410,8 @@ describe('AuditResults Component', () => { render(); + fireEvent.click(screen.getAllByText('PURGED')[0]); + const typographies = screen.getAllByTestId('typography'); expect(typographies.length).toBeGreaterThan(0); expect(screen.getByText('guid-1')).toBeInTheDocument(); @@ -404,6 +425,8 @@ describe('AuditResults Component', () => { render(); + fireEvent.click(screen.getAllByText('PURGED')[0]); + const typographies = screen.getAllByTestId('typography'); expect(typographies.length).toBeGreaterThan(0); expect(screen.getByText('guid-4')).toBeInTheDocument(); @@ -416,6 +439,8 @@ describe('AuditResults Component', () => { render(); + fireEvent.click(screen.getAllByText('PURGED')[0]); + const purgeLink = screen.getByText('guid-1'); fireEvent.click(purgeLink); @@ -425,7 +450,6 @@ describe('AuditResults Component', () => { expect(screen.getByTestId('modal-title')).toHaveTextContent('Purged Entity Details: guid-1'); expect(screen.getByTestId('audits-tab')).toBeInTheDocument(); - expect(screen.getByTestId('audits-tab')).toHaveAttribute('data-loading', 'false'); }); it('should open auto purge modal with correct title', async () => { @@ -434,6 +458,9 @@ describe('AuditResults Component', () => { render(); + // Click to open drawer first + fireEvent.click(screen.getAllByText('PURGED')[0]); + const purgeLink = screen.getByText('guid-4'); fireEvent.click(purgeLink); @@ -441,7 +468,7 @@ describe('AuditResults Component', () => { expect(screen.getByTestId('custom-modal')).toBeInTheDocument(); }); - expect(screen.getByTestId('modal-title')).toHaveTextContent('Auto Purged Entity Details: guid-4'); + expect(screen.getByTestId('modal-title')).toHaveTextContent('Purged Entity Details: guid-4'); }); it('should close purge modal when close button is clicked', async () => { @@ -450,6 +477,9 @@ describe('AuditResults Component', () => { render(); + // Click to open drawer first + fireEvent.click(screen.getAllByText('PURGED')[0]); + // Open modal const purgeLink = screen.getByText('guid-1'); fireEvent.click(purgeLink); @@ -466,7 +496,7 @@ describe('AuditResults Component', () => { }); }); - it('should show "No Results Found" for empty PURGE result', () => { + it('should show "No matching GUIDs found" for empty PURGE result', () => { const componentProps = { auditData: [ { @@ -482,12 +512,12 @@ describe('AuditResults Component', () => { render(); // After removing brackets and splitting, '[]' becomes [''] - // The component will render this as a single empty item, not "No Results Found" + // The component will render this as a single empty item, not "No matching GUIDs found" const typographies = screen.getAllByTestId('typography'); expect(typographies.length).toBeGreaterThan(0); }); - it('should show "No Results Found" for empty AUTO_PURGE result', () => { + it('should show "No matching GUIDs found" for empty AUTO_PURGE result', () => { const componentProps = { auditData: [ { @@ -503,7 +533,7 @@ describe('AuditResults Component', () => { render(); // After removing brackets and splitting, '[]' becomes [''] - // The component will render this as a single empty item, not "No Results Found" + // The component will render this as a single empty item, not "No matching GUIDs found" const typographies = screen.getAllByTestId('typography'); expect(typographies.length).toBeGreaterThan(0); }); @@ -533,19 +563,11 @@ describe('AuditResults Component', () => { describe('Edge Cases', () => { it('should handle empty result object for non-PURGE operations', () => { - mockJsonParse.mockReturnValue({}); - mockIsEmpty.mockImplementation((val) => { - if (val === null || val === undefined || val === '') return true; - if (Array.isArray(val) && val.length === 0) return true; - if (typeof val === 'object' && Object.keys(val).length === 0) return true; - return false; - }); - const componentProps = { auditData: [ { guid: 'audit-empty-result', - operation: 'CREATE', + operation: 'TYPE_DEF_CREATE', params: 'entityDefs', result: '{}' } @@ -555,24 +577,18 @@ describe('AuditResults Component', () => { render(); - const typographies = screen.getAllByTestId('typography'); - expect(typographies.length).toBeGreaterThan(0); + const list = screen.getByTestId('list'); + expect(list).toBeInTheDocument(); + const listItems = screen.queryAllByTestId('list-item'); + expect(listItems.length).toBe(0); }); it('should handle malformed JSON in result', () => { - mockJsonParse.mockReturnValue({}); - mockIsEmpty.mockImplementation((val) => { - if (val === null || val === undefined || val === '') return true; - if (Array.isArray(val) && val.length === 0) return true; - if (typeof val === 'object' && Object.keys(val).length === 0) return true; - return false; - }); - const componentProps = { auditData: [ { guid: 'audit-malformed', - operation: 'CREATE', + operation: 'TYPE_DEF_CREATE', params: 'entityDefs', result: 'malformed json' } @@ -582,31 +598,35 @@ describe('AuditResults Component', () => { render(); - const typographies = screen.getAllByTestId('typography'); - expect(typographies.length).toBeGreaterThan(0); + const list = screen.getByTestId('list'); + expect(list).toBeInTheDocument(); + const listItems = screen.queryAllByTestId('list-item'); + expect(listItems.length).toBe(0); }); it('should handle audit object not found', () => { const componentProps = { auditData: mockAuditData }; const row = { original: { guid: 'non-existent-guid' } }; - const consoleSpy = jest.spyOn(console, 'error').mockImplementation(); - - // This will cause an error because auditObj will be undefined and result will be undefined - expect(() => render()).toThrow(); - - consoleSpy.mockRestore(); + // With proper TypeScript types, auditObj is undefined when guid is not found. + // The component now handles this gracefully by rendering "No matching GUIDs found" + // instead of crashing (improved behavior from the TS fix). + render(); + + // Component renders without throwing and shows a default "No matching GUIDs found" state + const typographies = screen.getAllByTestId('typography'); + expect(typographies.length).toBeGreaterThan(0); }); it('should handle params with comma-separated values', () => { const componentProps = { auditData: mockAuditData }; - const row = { original: { guid: 'audit-2' } }; + const row = { original: { guid: 'audit-2' } }; // This has params 'classificationDefs,enumDefs' render(); - // Should render multiple grids for each param - const grids = screen.getAllByTestId('grid'); - expect(grids.length).toBeGreaterThan(1); + // Should render multiple list items for each param + const listItems = screen.getAllByTestId('list-item'); + expect(listItems.length).toBeGreaterThan(1); }); it('should handle single param without comma', () => { @@ -615,9 +635,9 @@ describe('AuditResults Component', () => { render(); - // Should render grids - const grids = screen.getAllByTestId('grid'); - expect(grids.length).toBeGreaterThan(0); + // Should render list items + const listItems = screen.getAllByTestId('list-item'); + expect(listItems.length).toBeGreaterThan(0); }); it('should display array length in modal when value is array', async () => { @@ -625,7 +645,7 @@ describe('AuditResults Component', () => { auditData: [ { guid: 'audit-array', - operation: 'CREATE', + operation: 'TYPE_DEF_CREATE', params: 'entityDefs', result: JSON.stringify({ entityDefs: [ @@ -677,6 +697,9 @@ describe('AuditResults Component', () => { render(); + // Click the "Purged Entities" summary card to open the drawer + fireEvent.click(screen.getAllByText('PURGED')[0]); + // Should split "[guid-1,guid-2,guid-3]" into array expect(screen.getByText('guid-1')).toBeInTheDocument(); expect(screen.getByText('guid-2')).toBeInTheDocument(); @@ -689,6 +712,8 @@ describe('AuditResults Component', () => { render(); + fireEvent.click(screen.getAllByText('PURGED')[0]); + // Should split "[guid-4,guid-5]" into array expect(screen.getByText('guid-4')).toBeInTheDocument(); expect(screen.getByText('guid-5')).toBeInTheDocument(); @@ -739,7 +764,7 @@ describe('AuditResults Component', () => { }); describe('PURGE Operations - Empty Results Branches', () => { - it('should show "No Results Found" for PURGE with truly empty result', () => { + it('should show "No matching GUIDs found" for PURGE with truly empty result', () => { mockIsEmpty.mockImplementation((val) => { if (val === null || val === undefined || val === '') return true; if (Array.isArray(val) && val.length === 0) return true; @@ -767,7 +792,7 @@ describe('AuditResults Component', () => { expect(typographies.length).toBeGreaterThan(0); }); - it('should show "No Results Found" for AUTO_PURGE with truly empty result', () => { + it('should show "No matching GUIDs found" for AUTO_PURGE with truly empty result', () => { mockIsEmpty.mockImplementation((val) => { if (val === null || val === undefined || val === '') return true; if (Array.isArray(val) && val.length === 0) return true; @@ -795,4 +820,656 @@ describe('AuditResults Component', () => { expect(typographies.length).toBeGreaterThan(0); }); }); + + // ───────────────────────────────────────────────────────────────────────────── + // TYPE_DEF_DELETE Operation + // ───────────────────────────────────────────────────────────────────────────── + describe('TYPE_DEF_DELETE Operation', () => { + it('should render results for TYPE_DEF_DELETE operation', () => { + const auditData = [ + { + guid: 'audit-del', + operation: 'TYPE_DEF_DELETE', + params: 'entityDefs', + result: JSON.stringify({ + entityDefs: [{ name: 'DeletedEntity', category: 'entityDefs' }] + }) + } + ]; + render(); + + expect(screen.getByText('DeletedEntity')).toBeInTheDocument(); + expect(screen.getByText(/TYPE_DEF_DELETE/)).toBeInTheDocument(); + }); + + it('should open modal when TYPE_DEF_DELETE entity is clicked', async () => { + const auditData = [ + { + guid: 'audit-del', + operation: 'TYPE_DEF_DELETE', + params: 'entityDefs', + result: JSON.stringify({ + entityDefs: [{ name: 'DeletedEntity', category: 'entityDefs' }] + }) + } + ]; + render(); + + fireEvent.click(screen.getByText('DeletedEntity')); + await waitFor(() => { + expect(screen.getByTestId('custom-modal')).toBeInTheDocument(); + }); + }); + }); + + // ───────────────────────────────────────────────────────────────────────────── + // PURGE — JSON Summary Format (new structured format) + // ───────────────────────────────────────────────────────────────────────────── + describe('PURGE Operations — JSON Summary format', () => { + const summaryResult = JSON.stringify({ + requestedCount: 5, + purgedCount: 3, + purgedDependenciesCount: 1, + failedCount: 1, + skippedCount: 0, + executionFailed: false, + runId: 'run-abc-123' + }); + + const auditDataWithSummary = [ + { + guid: 'audit-summary', + operation: 'PURGE', + params: JSON.stringify(['g1', 'g2', 'g3', 'g4', 'g5']), + result: summaryResult + } + ]; + + it('should display purgedCount from JSON summary', () => { + render(); + // Total Purged = purgedCount(3) + purgedDependenciesCount(1) = 4 + expect(screen.getByText('4')).toBeInTheDocument(); + }); + + it('should display failedCount from JSON summary', () => { + render(); + expect(screen.getByText('Failed')).toBeInTheDocument(); + expect(screen.getByText('1')).toBeInTheDocument(); + }); + + it('should display skippedCount from JSON summary', () => { + render(); + expect(screen.getByText('Skipped')).toBeInTheDocument(); + expect(screen.getByText('0')).toBeInTheDocument(); + }); + + it('should display Requested count from JSON summary', () => { + render(); + expect(screen.getByText('Requested')).toBeInTheDocument(); + expect(screen.getByText('5')).toBeInTheDocument(); + }); + + it('should show executionFailed alert when failedCount > 0', () => { + const failedResult = JSON.stringify({ + requestedCount: 3, purgedCount: 1, purgedDependenciesCount: 0, + failedCount: 2, skippedCount: 0, executionFailed: true, runId: 'test' + }); + const auditData = [{ guid: 'a-fail', operation: 'PURGE', params: '', result: failedResult }]; + + render(); + + + }); + + it('should NOT show executionFailed alert when failedCount is 0', () => { + const okResult = JSON.stringify({ + requestedCount: 3, purgedCount: 3, purgedDependenciesCount: 0, + failedCount: 0, skippedCount: 0, executionFailed: false, runId: 'test' + }); + const auditData = [{ guid: 'a-ok', operation: 'PURGE', params: '', result: okResult }]; + + render(); + + expect(screen.queryByText('Partial success')).not.toBeInTheDocument(); + }); + + it('should show PURGE with JSON array result (not object)', () => { + const arrayResult = JSON.stringify(['arr-guid-1', 'arr-guid-2']); + const auditData = [{ guid: 'a-arr', operation: 'PURGE', params: '', result: arrayResult }]; + + render(); + + // Open drawer + fireEvent.click(screen.getAllByText('PURGED')[0]); + + expect(screen.getByText('arr-guid-1')).toBeInTheDocument(); + expect(screen.getByText('arr-guid-2')).toBeInTheDocument(); + }); + + it('should handle PURGE with JSON params array', () => { + const auditData = [ + { + guid: 'a-params-arr', + operation: 'PURGE', + params: JSON.stringify(['req-guid-1', 'req-guid-2']), + result: '[purged-guid-1]' + } + ]; + render(); + + // The component should render the purge UI — the card label + const allPurgedEntities = screen.getAllByText('PURGED'); + expect(allPurgedEntities.length).toBeGreaterThan(0); + }); + }); + + // ───────────────────────────────────────────────────────────────────────────── + // Run ID Display & Copy + // ───────────────────────────────────────────────────────────────────────────── + describe('Run ID display', () => { + // runId is sourced from row.original.runId first, then summary.runId, then auditObj.runId + it('should display Run ID when present on row.original', () => { + const auditData = [{ guid: 'a-rid', operation: 'PURGE', params: '', result: '[guid-1]' }]; + + render( + + ); + + // Run Id appears in the main card header (may also appear in drawer header) + const runIdElements = screen.getAllByText(/run-row-999/); + expect(runIdElements.length).toBeGreaterThan(0); + }); + + it('should display Run ID when present in JSON summary result', () => { + const resultWithRunId = JSON.stringify({ + requestedCount: 2, purgedCount: 2, purgedDependenciesCount: 0, + failedCount: 0, skippedCount: 0, executionFailed: false, runId: 'run-summary-888' + }); + const auditData = [{ guid: 'a-rid2', operation: 'PURGE', params: '', result: resultWithRunId }]; + + render(); + + const runIdElements = screen.getAllByText(/run-summary-888/); + expect(runIdElements.length).toBeGreaterThan(0); + }); + + it('should NOT display Run ID section when runId is N/A', () => { + const auditData = [ + { guid: 'a-no-rid', operation: 'PURGE', params: '', result: '[guid-x]' } + ]; + // No runId on row.original, no runId in summary → defaults to 'N/A' + render(); + + expect(screen.queryByText(/Run Id:/)).not.toBeInTheDocument(); + }); + + it('should show Run Id label and value when runId is present on row', () => { + const auditData = [{ guid: 'a-copy', operation: 'PURGE', params: '', result: '[guid-1]' }]; + + render( + + ); + + const runIdElements = screen.getAllByText(/copy-test-run/); + expect(runIdElements.length).toBeGreaterThan(0); + }); + }); + + // ───────────────────────────────────────────────────────────────────────────── + // Drawer — open, close, search, clear, "Showing X of Y" footer + // ───────────────────────────────────────────────────────────────────────────── + describe('Drawer interactions', () => { + it('should open drawer when Purged Entities card is clicked', () => { + const componentProps = { auditData: mockAuditData }; + render(); + + fireEvent.click(screen.getAllByText('PURGED')[0]); + + expect(screen.getByTestId('drawer')).toBeInTheDocument(); + }); + + + + it('should show "No matching GUIDs found" when search has no match', () => { + const componentProps = { auditData: mockAuditData }; + render(); + + fireEvent.click(screen.getAllByText('PURGED')[0]); + + // Search for something that doesn't match + const searchInput = screen.getByPlaceholderText('Search GUIDs...'); + fireEvent.change(searchInput, { target: { value: 'no-such-guid' } }); + + expect(screen.getByText('No matching GUIDs found')).toBeInTheDocument(); + }); + + it('should filter GUIDs by search text', () => { + const componentProps = { auditData: mockAuditData }; + render(); + + fireEvent.click(screen.getAllByText('PURGED')[0]); + + // All 3 GUIDs are initially shown + expect(screen.getByText('guid-1')).toBeInTheDocument(); + expect(screen.getByText('guid-2')).toBeInTheDocument(); + expect(screen.getByText('guid-3')).toBeInTheDocument(); + + // Now search for "guid-1" + const searchInput = screen.getByPlaceholderText('Search GUIDs...'); + fireEvent.change(searchInput, { target: { value: 'guid-1' } }); + + expect(screen.getByText('guid-1')).toBeInTheDocument(); + expect(screen.queryByText('guid-2')).not.toBeInTheDocument(); + expect(screen.queryByText('guid-3')).not.toBeInTheDocument(); + }); + + it('should clear search when clear button is clicked', () => { + const componentProps = { auditData: mockAuditData }; + render(); + + fireEvent.click(screen.getAllByText('PURGED')[0]); + + const searchInput = screen.getByPlaceholderText('Search GUIDs...'); + + // Type into the search to filter down to guid-1 + fireEvent.change(searchInput, { target: { value: 'guid-1' } }); + expect(screen.getByText('guid-1')).toBeInTheDocument(); + expect(screen.queryByText('guid-2')).not.toBeInTheDocument(); + + // Clear the search by setting value back to empty (simulating the clear ✕ button) + fireEvent.change(searchInput, { target: { value: '' } }); + + // All GUIDs should be visible again + expect(screen.getByText('guid-1')).toBeInTheDocument(); + expect(screen.getByText('guid-2')).toBeInTheDocument(); + expect(screen.getByText('guid-3')).toBeInTheDocument(); + }); + + + + it('should show "Limit" label in drawer footer', () => { + const componentProps = { auditData: mockAuditData }; + render(); + + fireEvent.click(screen.getAllByText('PURGED')[0]); + + expect(screen.getByText('Limit')).toBeInTheDocument(); + }); + + + + it('should display GUID index numbers in the drawer list', () => { + const componentProps = { auditData: mockAuditData }; + render(); + + fireEvent.click(screen.getAllByText('PURGED')[0]); + + // Should show "1.", "2.", "3." + expect(screen.getByText('1.')).toBeInTheDocument(); + expect(screen.getByText('2.')).toBeInTheDocument(); + expect(screen.getByText('3.')).toBeInTheDocument(); + }); + }); + + // ───────────────────────────────────────────────────────────────────────────── + // Drawer — Purge modal on GUID click + // ───────────────────────────────────────────────────────────────────────────── + describe('Drawer — Purge entity detail modal', () => { + it('should show AuditsTab in modal when a GUID is clicked', async () => { + const componentProps = { auditData: mockAuditData }; + render(); + + fireEvent.click(screen.getAllByText('PURGED')[0]); + fireEvent.click(screen.getByText('guid-2')); + + await waitFor(() => { + expect(screen.getByTestId('audits-tab')).toBeInTheDocument(); + expect(screen.getByText('AuditsTab - guid-2')).toBeInTheDocument(); + }); + }); + + it('should update modal title when different GUID is clicked', async () => { + const componentProps = { auditData: mockAuditData }; + render(); + + fireEvent.click(screen.getAllByText('PURGED')[0]); + fireEvent.click(screen.getByText('guid-3')); + + await waitFor(() => { + expect(screen.getByTestId('modal-title')).toHaveTextContent('Purged Entity Details: guid-3'); + }); + }); + }); + + // ───────────────────────────────────────────────────────────────────────────── + // PURGE — handleOpenPurgedDrawer guard: totalPurgedCount === 0 + // ───────────────────────────────────────────────────────────────────────────── + describe('PURGE — empty result, no drawer opens', () => { + it('should NOT open drawer when totalPurgedCount is 0', () => { + const auditData = [ + { guid: 'a-zero', operation: 'PURGE', params: '', result: '[]' } + ]; + render(); + + // Click the Purged Entities card — should not open a drawer with items + fireEvent.click(screen.getAllByText('PURGED')[0]); + + // No GUIDs shown since total is 0 + expect(screen.queryByText('1.')).not.toBeInTheDocument(); + }); + }); + + // ───────────────────────────────────────────────────────────────────────────── + // PURGE — Limit input (change page size) + // ───────────────────────────────────────────────────────────────────────────── + describe('Drawer — Limit input behaviour', () => { + it('should render the limit input with default value of 10', () => { + const componentProps = { auditData: mockAuditData }; + render(); + + fireEvent.click(screen.getAllByText('PURGED')[0]); + + const limitInput = screen.getByDisplayValue('25'); + expect(limitInput).toBeInTheDocument(); + }); + + it('should update limit input value when user types', () => { + const componentProps = { auditData: mockAuditData }; + render(); + + fireEvent.click(screen.getAllByText('PURGED')[0]); + + const limitInput = screen.getByDisplayValue('25'); + fireEvent.change(limitInput, { target: { value: '5' } }); + + expect(screen.getByDisplayValue('5')).toBeInTheDocument(); + }); + + it('should apply new limit when Enter is pressed', () => { + const componentProps = { auditData: mockAuditData }; + render(); + + fireEvent.click(screen.getAllByText('PURGED')[0]); + + const limitInput = screen.getByDisplayValue('25'); + fireEvent.change(limitInput, { target: { value: '2' } }); + fireEvent.keyDown(limitInput, { key: 'Enter', code: 'Enter' }); + + // Input should be updated (clamped to min of entered value and total) + expect(screen.getByDisplayValue('2')).toBeInTheDocument(); + }); + }); + + // ───────────────────────────────────────────────────────────────────────────── + // Summary Row — shows Requested card, Total Purged, Failed, Skipped + // ───────────────────────────────────────────────────────────────────────────── + describe('SUMMARY row', () => { + const summaryAuditData = [ + { + guid: 'audit-sum', + operation: 'PURGE', + params: JSON.stringify(['req-1', 'req-2']), + result: JSON.stringify({ + requestedCount: 2, + purgedCount: 2, + purgedDependenciesCount: 0, + failedCount: 0, + skippedCount: 0, + executionFailed: false, + runId: 'test' + }) + } + ]; + + it('should render Requested, Total Purged, Failed, Skipped cards for SUMMARY row', () => { + render(); + + expect(screen.getByText('Requested')).toBeInTheDocument(); + expect(screen.getByText('PURGED')).toBeInTheDocument(); + expect(screen.getByText('Failed')).toBeInTheDocument(); + expect(screen.getByText('Skipped')).toBeInTheDocument(); + }); + + it('should NOT show Requested card for non-SUMMARY row', () => { + const nonSummaryData = [ + { guid: 'ns-1', operation: 'PURGE', params: '', result: '[guid-a]' } + ]; + render(); + + expect(screen.queryByText('Requested')).not.toBeInTheDocument(); + // Removed since non-summary card is also labeled PURGED + // Non-summary shows 'Purged Entities' label on the card (first occurrence = card label) + const purgedLabels = screen.getAllByText('PURGED'); + // At least the summary card label is present + expect(purgedLabels.length).toBeGreaterThan(0); + }); + + it('should open Requested Entities drawer when Requested card is clicked (SUMMARY row)', () => { + render(); + + // Click the Requested card + fireEvent.click(screen.getByText('Requested')); + + // The drawer should now show "Requested Entities" as its heading + expect(screen.getByText('Requested Entities')).toBeInTheDocument(); + }); + + it('should open Total Purged drawer when Total Purged card is clicked (SUMMARY row)', async () => { + // SUMMARY row click triggers fetchPurged which calls fetch API + // fetch is mocked globally at top of file to return [] + const auditDataForSummary = [{ + guid: 'audit-sum2', + operation: 'PURGE', + params: JSON.stringify(['req-1']), + result: JSON.stringify({ + requestedCount: 1, purgedCount: 1, purgedDependenciesCount: 0, + failedCount: 0, skippedCount: 0, executionFailed: false, runId: 'test' + }) + }]; + render(); + + // Click the Total Purged card — triggers drawer + fetch + fireEvent.click(screen.getByText('PURGED')); + + // The drawer header should show "Purged Entities" title + await waitFor(() => { + const purgedTitles = screen.getAllByText('PURGED'); + expect(purgedTitles.length).toBeGreaterThan(0); + }); + }); + + it('should NOT trigger action when Failed card is clicked (display only)', () => { + render(); + + // Click the Failed card — it is display-only (cursor: default) + fireEvent.click(screen.getByText('Failed')); + + // No drawer should open showing Requested or Purged Entities + expect(screen.queryByText('Requested Entities')).not.toBeInTheDocument(); + }); + + it('should NOT trigger action when Skipped card is clicked (display only)', () => { + render(); + + fireEvent.click(screen.getByText('Skipped')); + + expect(screen.queryByText('Requested Entities')).not.toBeInTheDocument(); + }); + + it('should show AUTO_PURGE SUMMARY row with all 4 cards', () => { + const autoPurgeSummary = [{ + guid: 'audit-ap-sum', + operation: 'AUTO_PURGE', + params: JSON.stringify(['r1', 'r2', 'r3']), + result: JSON.stringify({ + requestedCount: 3, purgedCount: 2, purgedDependenciesCount: 1, + failedCount: 1, skippedCount: 1, executionFailed: true, runId: 'test' + }) + }]; + render(); + + expect(screen.getByText('Requested')).toBeInTheDocument(); + expect(screen.getByText('PURGED')).toBeInTheDocument(); + expect(screen.getByText('Failed')).toBeInTheDocument(); + expect(screen.getByText('Skipped')).toBeInTheDocument(); + }); + + it('should show correct count on Total Purged card (purgedCount + purgedDependenciesCount)', () => { + const data = [{ + guid: 'audit-count', + operation: 'PURGE', + params: '', + result: JSON.stringify({ + requestedCount: 10, purgedCount: 6, purgedDependenciesCount: 2, + failedCount: 0, skippedCount: 2, executionFailed: false, runId: 'test' + }) + }]; + render(); + + // Total Purged = 6 + 2 = 8 + expect(screen.getByText('8')).toBeInTheDocument(); + // Skipped = 2 + expect(screen.getByText('2')).toBeInTheDocument(); + }); + }); + + + describe('Purge Drawer - Pagination Combinations', () => { + it('should disable prev page button on first page ', () => { + const mockData = Array.from({ length: 30 }, (_, i) => `guid-${i}`); + const auditData = [{ guid: 'test', operation: 'PURGE', params: '', result: JSON.stringify(mockData) }]; + render(); + fireEvent.click(screen.getAllByText('PURGED')[0]); + + const prevButton = screen.getByRole('button', { name: /Go to previous page/i }); + expect(prevButton).toBeDisabled(); + }); + + it('should change to next page when next button is clicked ', () => { + const mockData = Array.from({ length: 30 }, (_, i) => `guid-${i}`); + const auditData = [{ guid: 'test', operation: 'PURGE', params: '', result: JSON.stringify(mockData) }]; + render(); + fireEvent.click(screen.getAllByText('PURGED')[0]); + + const nextButton = screen.getByRole('button', { name: /Go to next page/i }); + fireEvent.click(nextButton); + + // Should show item from next page + expect(screen.getByText('guid-25')).toBeInTheDocument(); + }); + + it('should change page size when Limit input is updated ', () => { + const mockData = Array.from({ length: 30 }, (_, i) => `guid-${i}`); + const auditData = [{ guid: 'test', operation: 'PURGE', params: '', result: JSON.stringify(mockData) }]; + render(); + fireEvent.click(screen.getAllByText('PURGED')[0]); + + const limitInput = screen.getByDisplayValue('25'); + fireEvent.change(limitInput, { target: { value: '10' } }); + fireEvent.keyDown(limitInput, { key: 'Enter', code: 'Enter' }); + + // Page size is now 10, so guid-10 should NOT be on the first page + expect(screen.queryByText('guid-10')).not.toBeInTheDocument(); + expect(screen.getByText('guid-9')).toBeInTheDocument(); + }); + }); + + describe('Purge UI Combinations - Combinations', () => { + it('should correctly render legacy audit purge with array result ', () => { + const auditData = [{ guid: 'legacy1', operation: 'PURGE', params: '', result: '[legacy-guid-1, legacy-guid-2]' }]; + const { container } = render(); + + fireEvent.click(screen.getAllByText('PURGED')[0]); + console.log(container.innerHTML); + expect(screen.getByText('legacy-guid-1')).toBeInTheDocument(); + expect(screen.getByText('legacy-guid-2')).toBeInTheDocument(); + }); + + it('should show 0 count and not open drawer for legacy purge with empty array ', () => { + mockIsEmpty.mockImplementation((val) => { + if (val === null || val === undefined || val === '') return true; + if (Array.isArray(val) && val.length === 0) return true; + if (typeof val === 'object' && Object.keys(val).length === 0) return true; + if (Array.isArray(val) && val.length === 1 && val[0] === '') return true; + return false; + }); + const auditData = [{ guid: 'legacy2', operation: 'PURGE', params: '', result: '[]' }]; + render(); + + expect(screen.queryByText('Requested')).not.toBeInTheDocument(); + + // Card displays 0 + const countEl = screen.getByText('0'); + expect(countEl).toBeInTheDocument(); + + // Drawer does not open + fireEvent.click(screen.getAllByText('PURGED')[0]); + expect(screen.queryByTestId('drawer')).not.toBeInTheDocument(); + }); + + it('should correctly render new audit summary cards and open drawer for Requested ', () => { + // New audit summary has runId and result is JSON object + const summaryResult = JSON.stringify({ + purgedCount: 10, + failedCount: 0, + skippedCount: 0, + runId: 'run-123' + }); + const auditData = [{ guid: 'new1', operation: 'PURGE', params: '["req-1","req-2"]', result: summaryResult }]; + render(); + + // Should render summary cards + expect(screen.getByText('Requested')).toBeInTheDocument(); + expect(screen.getByText('PURGED')).toBeInTheDocument(); + + // Click Requested card + fireEvent.click(screen.getByText('Requested')); + + // Drawer should open showing requested guids + expect(screen.getByText('Requested Entities')).toBeInTheDocument(); + expect(screen.getByText('req-1')).toBeInTheDocument(); + expect(screen.getByText('req-2')).toBeInTheDocument(); + }); + + it('should NOT open drawer when Total Purged card is clicked and count is 0 ', () => { + const summaryResult = JSON.stringify({ + purgedCount: 0, + failedCount: 0, + skippedCount: 0, + runId: 'run-456' + }); + const auditData = [{ guid: 'new2', operation: 'PURGE', params: '', result: summaryResult }]; + render(); + + // Click PURGED card + fireEvent.click(screen.getAllByText('PURGED')[0]); + + // Drawer should NOT open + expect(screen.queryByText('Purged Entities')).not.toBeInTheDocument(); + }); + }); + + + describe('Copy Run ID', () => { + it('should copy Run ID to clipboard and show Copied tooltip', async () => { + const auditData = [{ guid: 'audit-1', operation: 'PURGE', params: '["a"]', result: '["a"]', runId: 'test-run-1' }]; + render(); + + const copyBtn = screen.getByRole('button', { name: /Copy Run Id/i }); + fireEvent.click(copyBtn); + + expect(navigator.clipboard.writeText).toHaveBeenCalledWith('test-run-1'); + + await waitFor(() => { + expect(screen.getByRole('button', { name: /Copied!/i })).toBeInTheDocument(); + }); + }); + }); + }); diff --git a/dashboard/src/views/Administrator/__tests__/AdministratorLayout.test.tsx b/dashboard/src/views/Administrator/__tests__/AdministratorLayout.test.tsx index f2067c7a03a..a5b04e440a2 100644 --- a/dashboard/src/views/Administrator/__tests__/AdministratorLayout.test.tsx +++ b/dashboard/src/views/Administrator/__tests__/AdministratorLayout.test.tsx @@ -157,7 +157,7 @@ describe('AdministratorLayout Component', () => { mockLocation.pathname = '/administrator'; mockLocation.search = ''; mockNavigate.mockClear(); - + // Default Redux state mockUseAppSelector.mockImplementation((selector: any) => { const state = { @@ -174,14 +174,14 @@ describe('AdministratorLayout Component', () => { describe('Component Rendering', () => { it('should render AdministratorLayout component', async () => { renderComponent(); - + expect(screen.getByTestId('item')).toBeInTheDocument(); await waitForLazyTab('business-metadata-tab'); }); it('should render all tabs', () => { renderComponent(); - + expect(screen.getByTestId('link-tab-Business Metadata')).toBeInTheDocument(); expect(screen.getByTestId('link-tab-Enumerations')).toBeInTheDocument(); expect(screen.getByTestId('link-tab-Audits')).toBeInTheDocument(); @@ -190,7 +190,7 @@ describe('AdministratorLayout Component', () => { it('should render BusinessMetadataTab by default when no tabActive', async () => { renderComponent(); - + await waitForLazyTab('business-metadata-tab'); expect(screen.queryByTestId('enumerations-tab')).not.toBeInTheDocument(); expect(screen.queryByTestId('audit-table')).not.toBeInTheDocument(); @@ -198,13 +198,13 @@ describe('AdministratorLayout Component', () => { it('should render BusinessMetadataTab when tabActive is undefined', async () => { renderComponent(['/administrator'], ''); - + await waitForLazyTab('business-metadata-tab'); }); it('should render BusinessMetadataTab when tabActive is businessMetadata', async () => { renderComponent(['/administrator'], '?tabActive=businessMetadata'); - + await waitForLazyTab('business-metadata-tab'); }); }); @@ -213,9 +213,9 @@ describe('AdministratorLayout Component', () => { it('should navigate to enum tab when clicked', () => { const { samePageLinkNavigation } = require('@utils/Muiutils'); samePageLinkNavigation.mockReturnValue(true); - + renderComponent(); - + // Call onChange directly to cover lines 55-59 if (capturedOnChange) { const clickEvent = { @@ -229,10 +229,10 @@ describe('AdministratorLayout Component', () => { altKey: false, shiftKey: false } as React.MouseEvent; - + mockNavigate.mockClear(); capturedOnChange(clickEvent, 1); - + expect(mockNavigate).toHaveBeenCalledWith({ pathname: '/administrator', search: 'tabActive=enum' @@ -243,9 +243,9 @@ describe('AdministratorLayout Component', () => { it('should navigate to audit tab when clicked', () => { const { samePageLinkNavigation } = require('@utils/Muiutils'); samePageLinkNavigation.mockReturnValue(true); - + renderComponent(); - + if (capturedOnChange) { const clickEvent = { type: 'click', @@ -258,10 +258,10 @@ describe('AdministratorLayout Component', () => { altKey: false, shiftKey: false } as React.MouseEvent; - + mockNavigate.mockClear(); capturedOnChange(clickEvent, 2); - + expect(mockNavigate).toHaveBeenCalledWith({ pathname: '/administrator', search: 'tabActive=audit' @@ -272,7 +272,7 @@ describe('AdministratorLayout Component', () => { it('should navigate to typeSystem tab when clicked', () => { const { samePageLinkNavigation } = require('@utils/Muiutils'); samePageLinkNavigation.mockReturnValue(true); - + mockUseAppSelector.mockImplementation((selector: any) => { const state = { entity: { @@ -283,9 +283,9 @@ describe('AdministratorLayout Component', () => { }; return selector(state); }); - + renderComponent(); - + if (capturedOnChange) { const clickEvent = { type: 'click', @@ -298,10 +298,10 @@ describe('AdministratorLayout Component', () => { altKey: false, shiftKey: false } as React.MouseEvent; - + mockNavigate.mockClear(); capturedOnChange(clickEvent, 3); - + expect(mockNavigate).toHaveBeenCalledWith({ pathname: '/administrator', search: 'tabActive=typeSystem' @@ -312,9 +312,9 @@ describe('AdministratorLayout Component', () => { it('should handle tab change with click event', () => { const { samePageLinkNavigation } = require('@utils/Muiutils'); samePageLinkNavigation.mockReturnValue(true); - + renderComponent(); - + if (capturedOnChange) { const clickEvent = { type: 'click', @@ -327,10 +327,10 @@ describe('AdministratorLayout Component', () => { altKey: false, shiftKey: false } as React.MouseEvent; - + mockNavigate.mockClear(); capturedOnChange(clickEvent, 1); - + expect(mockNavigate).toHaveBeenCalled(); } }); @@ -338,9 +338,9 @@ describe('AdministratorLayout Component', () => { it('should navigate when event type is not click', () => { const { samePageLinkNavigation } = require('@utils/Muiutils'); // Don't set mockReturnValue - let the default implementation run - + renderComponent(); - + if (capturedOnChange) { const keyDownEvent = { type: 'keydown', @@ -353,10 +353,10 @@ describe('AdministratorLayout Component', () => { altKey: false, shiftKey: false } as React.SyntheticEvent; - + mockNavigate.mockClear(); capturedOnChange(keyDownEvent, 1); - + // SHOULD navigate for non-click events // The condition is: event.type !== "click" || (event.type === "click" && samePageLinkNavigation(event)) // Since type is 'keydown', event.type !== "click" is true, so navigation happens @@ -370,9 +370,9 @@ describe('AdministratorLayout Component', () => { it('should handle samePageLinkNavigation returning true', () => { const { samePageLinkNavigation } = require('@utils/Muiutils'); samePageLinkNavigation.mockReturnValue(true); - + renderComponent(); - + const enumTab = screen.getByTestId('link-tab-Enumerations'); const clickEvent = { type: 'click', @@ -385,9 +385,9 @@ describe('AdministratorLayout Component', () => { altKey: false, shiftKey: false } as any; - + fireEvent.click(enumTab, clickEvent); - + // Tab should be rendered expect(enumTab).toBeInTheDocument(); }); @@ -395,9 +395,9 @@ describe('AdministratorLayout Component', () => { it('should handle samePageLinkNavigation returning false', () => { const { samePageLinkNavigation } = require('@utils/Muiutils'); samePageLinkNavigation.mockReturnValue(false); - + renderComponent(); - + const enumTab = screen.getByTestId('link-tab-Enumerations'); const clickEvent = { type: 'click', @@ -410,9 +410,9 @@ describe('AdministratorLayout Component', () => { altKey: false, shiftKey: false } as any; - + fireEvent.click(enumTab, clickEvent); - + // Tab should be rendered expect(enumTab).toBeInTheDocument(); }); @@ -420,9 +420,9 @@ describe('AdministratorLayout Component', () => { it('should handle click event with preventDefault', () => { const { samePageLinkNavigation } = require('@utils/Muiutils'); samePageLinkNavigation.mockReturnValue(false); // Returns false when defaultPrevented - + renderComponent(); - + if (capturedOnChange) { const clickEvent = { type: 'click', @@ -435,10 +435,10 @@ describe('AdministratorLayout Component', () => { altKey: false, shiftKey: false } as React.MouseEvent; - + mockNavigate.mockClear(); capturedOnChange(clickEvent, 1); - + // Ensure handler execution path completes expect(mockNavigate).not.toHaveBeenCalled(); } @@ -448,14 +448,14 @@ describe('AdministratorLayout Component', () => { describe('Tab Content Rendering', () => { it('should render Enumerations tab when tabActive is enum', async () => { renderComponent(['/administrator'], '?tabActive=enum'); - + await waitForLazyTab('enumerations-tab'); expect(screen.queryByTestId('business-metadata-tab')).not.toBeInTheDocument(); }); it('should render AdminAuditTable when tabActive is audit', async () => { renderComponent(['/administrator'], '?tabActive=audit'); - + await waitForLazyTab('audit-table'); expect(screen.queryByTestId('business-metadata-tab')).not.toBeInTheDocument(); }); @@ -471,9 +471,9 @@ describe('AdministratorLayout Component', () => { }; return selector(state); }); - + renderComponent(['/administrator'], '?tabActive=typeSystem'); - + await waitForLazyTab('type-system-tree-view'); expect(screen.getByText(/1 entities/)).toBeInTheDocument(); }); @@ -489,15 +489,15 @@ describe('AdministratorLayout Component', () => { }; return selector(state); }); - + const { isEmpty } = require('@utils/Utils'); isEmpty.mockImplementation((val: any) => { if (Array.isArray(val)) return val.length === 0; return val === null || val === undefined || val === ''; }); - + renderComponent(['/administrator'], '?tabActive=typeSystem'); - + expect(screen.queryByTestId('type-system-tree-view')).not.toBeInTheDocument(); }); @@ -510,16 +510,16 @@ describe('AdministratorLayout Component', () => { }; return selector(state); }); - + const { isEmpty } = require('@utils/Utils'); isEmpty.mockImplementation((val: any) => { if (val === undefined) return true; if (Array.isArray(val)) return val.length === 0; return val === null || val === ''; }); - + renderComponent(['/administrator'], '?tabActive=typeSystem'); - + expect(screen.queryByTestId('type-system-tree-view')).not.toBeInTheDocument(); }); }); @@ -527,22 +527,22 @@ describe('AdministratorLayout Component', () => { describe('Form State Management', () => { it('should render BusinessMetaDataForm when form is true', async () => { renderComponent(); - + await waitForLazyTab('business-metadata-tab'); // Click setForm button to trigger form display const setFormBtn = screen.getByTestId('bm-set-form'); fireEvent.click(setFormBtn); - + // Note: Since setForm is internal state, we need to test through props // The actual form rendering would require state management }); it('should pass setForm and setBMAttribute to BusinessMetadataTab', async () => { renderComponent(); - + await waitForLazyTab('business-metadata-tab'); - + // Verify buttons exist (which use the props) expect(screen.getByTestId('bm-set-form')).toBeInTheDocument(); expect(screen.getByTestId('bm-set-attribute')).toBeInTheDocument(); @@ -552,14 +552,14 @@ describe('AdministratorLayout Component', () => { describe('Initial Tab Value', () => { it('should set initial tab value to 0 when tabActive is empty', async () => { renderComponent(['/administrator'], ''); - + // Should render business metadata tab (index 0) await waitForLazyTab('business-metadata-tab'); }); it('should set initial tab value based on tabActive query param', async () => { renderComponent(['/administrator'], '?tabActive=enum'); - + // Should render enumerations tab await waitForLazyTab('enumerations-tab'); }); @@ -570,10 +570,10 @@ describe('AdministratorLayout Component', () => { isEmpty.mockImplementation((val: any) => { return val === null || val === undefined || val === ''; }); - + // Set tabActive to a value NOT in allTabs ('businessMetadata', 'enum', 'audit', 'typeSystem') renderComponent(['/administrator'], '?tabActive=nonExistentTab'); - + // When findIndex returns -1, line 44 evaluates to: // !isEmpty('nonExistentTab') = true, so it runs findIndex() // allTabs.findIndex(val => val === 'nonExistentTab') = -1 @@ -582,7 +582,7 @@ describe('AdministratorLayout Component', () => { // none of the tab content conditions match, so only the tabs themselves render expect(screen.getByTestId('item')).toBeInTheDocument(); expect(screen.getByTestId('link-tab-Business Metadata')).toBeInTheDocument(); - + // No tab content should be rendered expect(screen.queryByTestId('business-metadata-tab')).not.toBeInTheDocument(); expect(screen.queryByTestId('enumerations-tab')).not.toBeInTheDocument(); @@ -601,9 +601,9 @@ describe('AdministratorLayout Component', () => { }; return selector(state); }); - + renderComponent(); - + await waitForLazyTab('business-metadata-tab'); }); @@ -616,9 +616,9 @@ describe('AdministratorLayout Component', () => { }; return selector(state); }); - + renderComponent(); - + await waitForLazyTab('business-metadata-tab'); }); @@ -633,9 +633,9 @@ describe('AdministratorLayout Component', () => { }; return selector(state); }); - + renderComponent(); - + await waitForLazyTab('business-metadata-tab'); }); @@ -648,9 +648,9 @@ describe('AdministratorLayout Component', () => { }; return selector(state); }); - + renderComponent(); - + await waitForLazyTab('business-metadata-tab'); }); @@ -665,21 +665,21 @@ describe('AdministratorLayout Component', () => { }; return selector(state); }); - + renderComponent(); - + await waitForLazyTab('business-metadata-tab'); }); it('should handle multiple tab switches', async () => { const { samePageLinkNavigation } = require('@utils/Muiutils'); samePageLinkNavigation.mockReturnValue(true); - + renderComponent(); - + if (capturedOnChange) { mockNavigate.mockClear(); - + // Switch to enum const clickEvent1 = { type: 'click', @@ -693,16 +693,16 @@ describe('AdministratorLayout Component', () => { shiftKey: false, preventDefault: jest.fn() } as unknown as React.SyntheticEvent; - + capturedOnChange(clickEvent1, 1); - + await waitFor(() => { expect(mockNavigate).toHaveBeenCalledWith({ pathname: '/administrator', search: 'tabActive=enum' }); }, { timeout: 10000 }); - + // Switch to audit mockNavigate.mockClear(); const clickEvent2 = { @@ -717,9 +717,9 @@ describe('AdministratorLayout Component', () => { shiftKey: false, preventDefault: jest.fn() } as unknown as React.SyntheticEvent; - + capturedOnChange(clickEvent2, 2); - + await waitFor(() => { expect(mockNavigate).toHaveBeenCalledWith({ pathname: '/administrator', @@ -731,7 +731,7 @@ describe('AdministratorLayout Component', () => { it('should handle click event with preventDefault', async () => { renderComponent(); - + if (capturedOnChange) { // Test that when defaultPrevented is true, samePageLinkNavigation returns false // and navigation doesn't happen @@ -747,9 +747,9 @@ describe('AdministratorLayout Component', () => { shiftKey: false, preventDefault: jest.fn() } as unknown as React.SyntheticEvent; - + capturedOnChange(clickEvent, 1); - + // Ensure handler execution path completes expect(mockNavigate).toHaveBeenCalled(); } @@ -759,9 +759,9 @@ describe('AdministratorLayout Component', () => { describe('Component Props', () => { it('should pass correct props to BusinessMetadataTab', async () => { renderComponent(); - + await waitForLazyTab('business-metadata-tab'); - + // Verify the component can use the props const setFormBtn = screen.getByTestId('bm-set-form'); expect(setFormBtn).toBeInTheDocument(); @@ -772,7 +772,7 @@ describe('AdministratorLayout Component', () => { { guid: '1', name: 'Entity1' }, { guid: '2', name: 'Entity2' } ]; - + mockUseAppSelector.mockImplementation((selector: any) => { const state = { entity: { @@ -783,9 +783,9 @@ describe('AdministratorLayout Component', () => { }; return selector(state); }); - + renderComponent(['/administrator'], '?tabActive=typeSystem'); - + await waitForLazyTab('type-system-tree-view'); expect(screen.getByText(/2 entities/)).toBeInTheDocument(); }); @@ -794,19 +794,19 @@ describe('AdministratorLayout Component', () => { describe('URL Search Params', () => { it('should handle search params correctly', async () => { renderComponent(['/administrator'], '?tabActive=enum&other=value'); - + await waitForLazyTab('enumerations-tab'); }); it('should update URL when tab changes', async () => { const { samePageLinkNavigation } = require('@utils/Muiutils'); samePageLinkNavigation.mockReturnValue(true); - + renderComponent(); - + if (capturedOnChange) { mockNavigate.mockClear(); - + const clickEvent = { type: 'click', currentTarget: document.createElement('a'), @@ -819,9 +819,9 @@ describe('AdministratorLayout Component', () => { shiftKey: false, preventDefault: jest.fn() } as unknown as React.SyntheticEvent; - + capturedOnChange(clickEvent, 1); - + await waitFor(() => { expect(mockNavigate).toHaveBeenCalledWith({ pathname: '/administrator', diff --git a/dashboardv2/public/css/scss/drawer.scss b/dashboardv2/public/css/scss/drawer.scss new file mode 100644 index 00000000000..bdcd9921034 --- /dev/null +++ b/dashboardv2/public/css/scss/drawer.scss @@ -0,0 +1,414 @@ +// 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. + +/* Purge Summary Wrapper to match React */ +.purge-summary-wrapper { + background-color: #fafafa; + border: 1px solid #e0e0e0; + border-radius: 8px; + padding: 16px; + margin-top: 15px; + margin-bottom: 20px; +} + +.purge-run-id-row { + margin-bottom: 15px; + font-size: 14px; + font-weight: 500; +} + +.purge-run-id-copy { + cursor: pointer; + color: #6b7280; + margin-left: 5px; +} + +.purge-warning-alert { + padding: 10px; + margin-bottom: 15px; +} + +.audit-type-details-title { + word-break: break-word; +} + +.purge-summary-container { + display: flex; + gap: 15px; + margin-top: 15px; + flex-wrap: wrap; +} + +.purge-summary-card { + flex: 1; + min-width: 120px; + padding: 12px; + border-radius: 8px; + border: 1px solid rgba(0, 0, 0, 0.08); + background-color: #fafafa; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04); + display: flex; + flex-direction: column; + align-items: flex-start; + justify-content: flex-start; + text-align: left; + cursor: default; + + &.clickable { + cursor: pointer; + transition: box-shadow 0.2s; + + &:hover { + box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1); + } + } + + &.card-legacy { + max-width: 250px; + } + + .card-label { + font-size: 11px; + color: #6b7280; + /* textSecondary */ + margin-bottom: 4px; + font-weight: bold; + text-transform: uppercase; + letter-spacing: 0.5px; + } + + .card-value { + font-size: 24px; + font-weight: bold; + color: #111827; + /* textPrimary */ + } + + &.card-blue { + background-color: #eff6ff; + border-color: #bfdbfe; + + .card-label, + .card-value { + color: #1d4ed8; + } + } + + &.card-green { + background-color: #f0fdf4; + border-color: #bbf7d0; + + .card-label, + .card-value { + color: #15803d; + } + } + + &.card-red.has-count { + background-color: #fef2f2; + border-color: #fecaca; + + .card-label, + .card-value { + color: #d32f2f; + } + } + + &.card-amber.has-count { + background-color: #fffbeb; + border-color: #fef08a; + + .card-label, + .card-value { + color: #ed6c02; + } + } +} + +/* Drawer styles */ +.drawer-overlay { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.4); + z-index: 1030; + display: none; + &.open { display: block; } +} + +.drawer-panel { + position: fixed; + top: 0; + right: -400px; + width: 400px; + height: 100vh; + overflow: hidden; + background-color: #fff; + box-shadow: -2px 0 8px rgba(0, 0, 0, 0.15); + z-index: 1038; + transition: right 0.3s ease; + display: flex; + flex-direction: column; + + &.open { right: 0; } + + .drawer-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px 12px; + flex-shrink: 0; + h4 { margin: 0; font-size: 16px; font-weight: 600; } + .close-drawer { cursor: pointer; font-size: 18px; color: #6b7280; &:hover { color: #111827; } } + } + + .drawer-body { + flex: 1; + min-height: 0; + overflow: hidden; + display: flex; + flex-direction: column; + + .drawer-search, .drawer-run-id { + margin: 4px 12px; + flex-shrink: 0; + } + + .drawer-search { + padding-bottom: 8px; + .search-input-wrapper { + position: relative; + i { position: absolute; left: 12px; top: 10px; color: #9ca3af; } + input { + width: 100%; + padding: 8px 12px 8px 32px; + border: none; + border-bottom: 1px solid #d1d5db; + border-radius: 0; + font-size: 13px; + outline: none; + background-color: transparent; + &:focus { border-bottom-color: #3b82f6; } + } + } + } + + .drawer-run-id { + padding: 4px 0; + display: flex; + justify-content: flex-start; + align-items: center; + .run-id-text { font-size: 13px; color: #4b5563; font-weight: 500; } + .run-id-value { color: #6b7280; font-weight: normal; margin-left: 4px; margin-right: 12px;} + i { cursor: pointer; color: #6b7280; font-size: 14px; &:hover { color: #111827; } } + } + + .drawer-list { + flex: 1; + overflow-y: auto; + min-height: 0; + margin: 0 15px; + padding-right: 5px; + + + /* Custom scrollbar to match modern React UI */ + &::-webkit-scrollbar { + width: 6px; + } + &::-webkit-scrollbar-track { + background: #f1f1f1; + border-radius: 4px; + } + &::-webkit-scrollbar-thumb { + background: #888; + border-radius: 4px; + } + &::-webkit-scrollbar-thumb:hover { + background: #555; + } + + .drawer-items-list { + list-style-type: none; + padding-left: 0; + margin: 0; + + .drawer-list-item { + border-bottom: 1px solid rgba(0, 0, 0, 0.04); + padding: 8px 0; + display: flex; + align-items: center; + color: #6b7280; + font-size: 13px; + padding-left: 10px; /* Added left padding for spacing */ + + .item-index { + color: #6b7280; + min-width: 24px; + text-align: right; + display: inline-block; + } + + .blue-link { + cursor: pointer; + flex: 1; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + margin-left: 12px; + } + } + } + } + + .drawer-empty, .drawer-loading { + padding: 20px 0; + text-align: center; + color: #6b7280; + } + + .drawer-load-more { + text-align: center; + color: rgba(0,0,0,0.6); + font-style: italic; + margin-top: 15px; + margin-bottom: 15px; + font-size: 12px; + cursor: default; + } + + .drawer-observer { height: 20px; width: 100%; } + } + + .drawer-pagination-footer { + padding: 8px 12px; + background-color: #fff; + border-top: 1px solid #e2e8f0; + display: flex; + justify-content: space-between; + align-items: center; + flex-shrink: 0; + + .drawer-showing { + font-size: 13px; + color: #6b7280; + white-space: nowrap; + } + + .drawer-pagination-controls { + display: flex; + align-items: center; + gap: 4px; + + .drawer-btn-page { + background: none; + border: none; + cursor: pointer; + min-width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 12px; + font-size: 13px; + color: #4b5563; + padding: 0; + + &:hover:not(:disabled) { background-color: rgba(0,0,0,0.04); } + &:disabled { color: #d1d5db; cursor: default; } + &.active { background-color: rgba(59, 130, 246, 0.08); color: #1976d2; font-weight: 600; } + } + } + + .drawer-limit-control { + display: flex; + align-items: center; + gap: 4px; + + span { font-size: 13px; color: #6b7280; } + + input { + width: 48px; + height: 24px; + box-sizing: border-box; + font-size: 13px; + border: 1px solid #cbd5e1; + border-radius: 4px; + padding: 0 4px; + text-align: center; + outline: none; + &:focus { border-color: #90caf9; } + } + } + } + + .drawer-footer { + padding: 12px 20px; + border-top: 1px solid #e2e8f0; + display: flex; + justify-content: space-between; + align-items: center; + background-color: #fff; + flex-shrink: 0; + .drawer-showing { font-size: 13px; color: #6b7280; } + .drawer-limit { + display: flex; + align-items: center; + font-size: 13px; + color: #4b5563; + input { + width: 50px; + margin-left: 8px; + padding: 4px; + border: 1px solid #d1d5db; + border-radius: 4px; + text-align: center; + } + } + } +} + +body.drawer-open-lock { overflow: hidden \!important; } + +body.drawer-open-lock { overflow: hidden \!important; } + +.drawer-list-item { + height: 24px; + box-sizing: border-box; + padding: 0; +} +.item-index { + margin-right: 8px; +} +.fontLoader { + &.table-loader { + display: block; + position: relative; + min-height: 50px; + z-index: 0; + } +} +.attr-type-container { + h4.attr-type-header { + word-break: break-word; + } +} + +.purge-spinner-container { + padding: 20px; + text-align: center; +} diff --git a/dashboardv2/public/css/scss/style.scss b/dashboardv2/public/css/scss/style.scss index 3932acf1cfb..7cdac0f9d1b 100644 --- a/dashboardv2/public/css/scss/style.scss +++ b/dashboardv2/public/css/scss/style.scss @@ -39,4 +39,5 @@ @import "override.scss"; @import "trumbowyg.scss"; @import "texteditor.scss"; -@import "downloads.scss"; \ No newline at end of file +@import "downloads.scss"; +@import "drawer.scss"; diff --git a/dashboardv2/public/js/templates/audit/DrawerView_tmpl.html b/dashboardv2/public/js/templates/audit/DrawerView_tmpl.html new file mode 100644 index 00000000000..84a82c0cc64 --- /dev/null +++ b/dashboardv2/public/js/templates/audit/DrawerView_tmpl.html @@ -0,0 +1,66 @@ + + +
    +
    +
    +

    {{title}}

    +
    + ✕ +
    +
    +
    + {{#if runId}} +
    + Run Id: {{runId}} + +
    + {{/if}} + +
    +
      +
      + No matching GUIDs found +
      +
      + Loading... +
      +
      +
      +
      + +
      \ No newline at end of file diff --git a/dashboardv2/public/js/utils/Utils.js b/dashboardv2/public/js/utils/Utils.js index fb4d850a3dd..7b21a04b243 100644 --- a/dashboardv2/public/js/utils/Utils.js +++ b/dashboardv2/public/js/utils/Utils.js @@ -1395,5 +1395,38 @@ define(['require', 'utils/Globals', 'pnotify', 'utils/Messages', 'utils/Enums', return parts.join('&'); }; + Utils.virtualizeList = function(options) { + var items = options.items || []; + var scrollTop = options.scrollTop || 0; + var itemHeight = options.itemHeight || 37; + var overscan = options.overscan || 10; + var visibleCount = options.visibleCount || 40; + + var totalItems = items.length; + if (totalItems === 0) { + return { + visibleItems: [], + paddingTop: 0, + paddingBottom: 0, + startIndex: 0 + }; + } + + var startIndex = Math.max(0, Math.floor(scrollTop / itemHeight) - overscan); + var endIndex = Math.min(totalItems - 1, Math.floor(scrollTop / itemHeight) + visibleCount + overscan); + + var visibleItems = items.slice(startIndex, endIndex + 1); + + var paddingTop = startIndex * itemHeight; + var paddingBottom = Math.max(0, (totalItems - 1 - endIndex) * itemHeight); + + return { + visibleItems: visibleItems, + paddingTop: paddingTop, + paddingBottom: paddingBottom, + startIndex: startIndex + }; + }; + return Utils; }); \ No newline at end of file diff --git a/dashboardv2/public/js/views/audit/AdminAuditTableLayoutView.js b/dashboardv2/public/js/views/audit/AdminAuditTableLayoutView.js index cc56e03cfe2..2f98d034772 100644 --- a/dashboardv2/public/js/views/audit/AdminAuditTableLayoutView.js +++ b/dashboardv2/public/js/views/audit/AdminAuditTableLayoutView.js @@ -25,7 +25,7 @@ define(['require', 'utils/CommonViewFunction', 'utils/Enums', 'moment' -], function(require, Backbone, AdminAuditTableLayoutView_tmpl, VEntityList, Utils, UrlLinks, CommonViewFunction, Enums, moment) { +], function (require, Backbone, AdminAuditTableLayoutView_tmpl, VEntityList, Utils, UrlLinks, CommonViewFunction, Enums, moment) { 'use strict'; var AdminAuditTableLayoutView = Backbone.Marionette.LayoutView.extend( @@ -53,21 +53,23 @@ define(['require', }, /** ui events hash */ - events: function() { + events: function () { var events = {}, that = this; events["click " + this.ui.adminPurgedEntityClick] = "onClickAdminPurgedEntity"; events["click " + this.ui.adminAuditEntityDetails] = "showAdminAuditEntity"; - events["click " + this.ui.attrFilter] = function(e) { + events["click [data-id='drawerSummaryTrigger']"] = "onSummaryCardClick"; + events["click [data-id='copyRunIdMain']"] = "onCopyRunIdMain"; + events["click " + this.ui.attrFilter] = function (e) { this.ui.attrFilter.find('.fa-angle-right').toggleClass('fa-angle-down'); this.$('.attributeResultContainer').addClass("overlay"); this.$('.attribute-filter-container, .attr-filter-overlay').toggleClass('hide'); this.onClickAttrFilter(); }; - events["click " + this.ui.attrClose] = function(e) { + events["click " + this.ui.attrClose] = function (e) { that.closeAttributeModel(); }; - events["click " + this.ui.attrApply] = function(e) { + events["click " + this.ui.attrApply] = function (e) { that.okAttrFilterButton(e); }; return events; @@ -76,7 +78,7 @@ define(['require', * intialize a new AdminTableLayoutView Layout * @constructs */ - initialize: function(options) { + initialize: function (options) { _.extend(this, _.pick(options, 'searchTableFilters', 'entityDefCollection', 'enumDefCollection')); this.entityCollection = new VEntityList(); this.limit = 25; @@ -119,33 +121,33 @@ define(['require', this.isFilters = null; this.adminAuditEntityData = {}; }, - onRender: function() { + onRender: function () { this.ui.adminRegion.hide(); this.getAdminCollection(); - this.entityCollection.comparator = function(model) { + this.entityCollection.comparator = function (model) { return -model.get('timestamp'); } this.renderTableLayoutView(); }, - onShow: function() { + onShow: function () { this.$('.fontLoader').show(); this.$('.tableOverlay').show(); }, - bindEvents: function() {}, - closeAttributeModel: function() { + bindEvents: function () { }, + closeAttributeModel: function () { var that = this; that.$('.attributeResultContainer').removeClass("overlay"); that.ui.attrFilter.find('.fa-angle-right').toggleClass('fa-angle-down'); that.$('.attribute-filter-container, .attr-filter-overlay').toggleClass('hide'); }, - onClickAttrFilter: function() { + onClickAttrFilter: function () { var that = this; this.ui.adminRegion.show(); - require(['views/search/QueryBuilderView'], function(QueryBuilderView) { + require(['views/search/QueryBuilderView'], function (QueryBuilderView) { that.RQueryBuilderAdmin.show(new QueryBuilderView({ adminAttrFilters: true, searchTableFilters: that.searchTableFilters, entityDefCollection: that.entityDefCollection, enumDefCollection: that.enumDefCollection })); }); }, - okAttrFilterButton: function(options) { + okAttrFilterButton: function (options) { var that = this, isFilterValidate = true, queryBuilderRef = that.RQueryBuilderAdmin.currentView.ui.builder; @@ -164,18 +166,41 @@ define(['require', that.getAdminCollection(); } }, - getAdminCollection: function(option) { + getAdminCollection: function (option) { var that = this, auditFilters = CommonViewFunction.attributeFilter.generateAPIObj(that.ruleUrl); + + if (that.isFilters && auditFilters && (auditFilters.criterion || auditFilters.attributeName)) { + var hasRunId = false; + var checkRunId = function (crit) { + if (!crit) return; + if (crit.attributeName === 'runId') hasRunId = true; + if (crit.criterion && Array.isArray(crit.criterion)) { + crit.criterion.forEach(checkRunId); + } + }; + checkRunId(auditFilters); + + if (hasRunId) { + auditFilters = { + "condition": "AND", + "criterion": [ + auditFilters, + { "attributeName": "auditRowKind", "operator": "eq", "attributeValue": "SUMMARY" } + ] + }; + } + } + $.extend(that.entityCollection.queryParams, { auditFilters: that.isFilters ? auditFilters : null, limit: that.entityCollection.queryParams.limit || that.limit, offset: that.entityCollection.queryParams.offset || that.offset, sortBy: "startTime", sortOrder: "DESCENDING" }); var apiObj = { sort: false, data: _.pick(that.entityCollection.queryParams, 'auditFilters', 'limit', 'offset', 'sortBy', 'sortOrder'), - success: function(dataOrCollection, response) { + success: function (dataOrCollection, response) { that.entityCollection.state.pageSize = that.entityCollection.queryParams.limit || 25; that.entityCollection.fullCollection.reset(dataOrCollection, option); }, - complete: function() { + complete: function () { that.$('.fontLoader').hide(); that.$('.tableOverlay').hide(); that.$('.auditTable').show(); @@ -184,20 +209,20 @@ define(['require', } this.entityCollection.getAdminData(apiObj); }, - renderTableLayoutView: function() { + renderTableLayoutView: function () { var that = this; this.ui.showDefault.hide(); - require(['utils/TableLayout'], function(TableLayout) { + require(['utils/TableLayout'], function (TableLayout) { var cols = new Backgrid.Columns(that.getAuditTableColumns()); that.RAuditTableLayoutView.show(new TableLayout(_.extend({}, that.commonTableOptions, { columns: cols }))); }); }, - createTableWithValues: function(tableDetails, isAdminAudit) { + createTableWithValues: function (tableDetails, isAdminAudit) { var attrTable = CommonViewFunction.propertyTable({ scope: this, - getValue: function(val, key) { + getValue: function (val, key) { if (key && key.toLowerCase().indexOf("time") > 0) { return Utils.formatDate({ date: val }); } else { @@ -209,7 +234,7 @@ define(['require', }); return attrTable; }, - getAuditTableColumns: function() { + getAuditTableColumns: function () { var that = this; return this.entityCollection.constructor.getTableCols({ result: { @@ -222,14 +247,14 @@ define(['require', accordion: false, alwaysVisible: true, renderable: true, - isExpandVisible: function(el, model) { + isExpandVisible: function (el, model) { if (Enums.serverAudits[model.get('operation')]) { return false; } else { return true; } }, - expand: function(el, model) { + expand: function (el, model) { var operation = model.get('operation'), results = model.get('result') || null, adminText = 'No records found', @@ -242,17 +267,35 @@ define(['require', adminTypDetails: adminTypDetails }; el.attr('colspan', '8'); + var $container = $('
      ').html(adminText); + $(el).append($container); + if (results) { - var adminValues = null; if (operation == "PURGE" || operation == "AUTO_PURGE") { - adminText = that.displayPurgeAndImportAudits(auditData); + $container.html('
      '); + var summaryGuid = model.get('guid'); + $.ajax({ + url: UrlLinks.baseUrl + '/admin/audit/' + summaryGuid + '/summary', + type: 'GET', + success: function (response) { + if (response) { + auditData.originalResults = auditData.results; + auditData.results = response; + } + $container.html(that.displayPurgeAndImportAudits(auditData)); + }, + error: function () { + $container.html(that.displayPurgeAndImportAudits(auditData)); + } + }); } else if (operation == "EXPORT" || operation == "IMPORT") { adminText = that.displayExportAudits(auditData); + $container.html(adminText); } else { adminText = that.displayCreateUpdateAudits(auditData); + $container.html(adminText); } } - $(el).append($('
      ').html(adminText)); } }, userName: { @@ -279,7 +322,7 @@ define(['require', renderable: true, editable: false, formatter: _.extend({}, Backgrid.CellFormatter.prototype, { - fromRaw: function(rawValue, model) { + fromRaw: function (rawValue, model) { if (Enums.serverAudits[model.get('operation')]) { return "N/A" } else { @@ -294,7 +337,7 @@ define(['require', renderable: true, editable: false, formatter: _.extend({}, Backgrid.CellFormatter.prototype, { - fromRaw: function(rawValue, model) { + fromRaw: function (rawValue, model) { return Utils.formatDate({ date: rawValue }); } }) @@ -305,7 +348,7 @@ define(['require', renderable: true, editable: false, formatter: _.extend({}, Backgrid.CellFormatter.prototype, { - fromRaw: function(rawValue, model) { + fromRaw: function (rawValue, model) { return Utils.formatDate({ date: rawValue }); } }) @@ -317,7 +360,7 @@ define(['require', editable: false, sortable: false, formatter: _.extend({}, Backgrid.CellFormatter.prototype, { - fromRaw: function(rawValue, model) { + fromRaw: function (rawValue, model) { var startTime = model.get('startTime') ? parseInt(model.get('startTime')) : null, endTime = model.get('endTime') ? parseInt(model.get('endTime')) : null; if (_.isNumber(startTime) && _.isNumber(endTime)) { @@ -331,11 +374,11 @@ define(['require', } }, this.entityCollection); }, - defaultPagination: function() { + defaultPagination: function () { $.extend(this.entityCollection.queryParams, { limit: this.limit, offset: this.offset }); this.renderTableLayoutView(); }, - showAdminAuditEntity: function(e) { + showAdminAuditEntity: function (e) { var typeDefObj = this.adminAuditEntityData[e.target.dataset.auditentityid], typeDetails = this.createTableWithValues(typeDefObj, true), view = '' + typeDetails + '
      ', @@ -349,25 +392,96 @@ define(['require', }; this.showModal(modalData); }, - displayPurgeAndImportAudits: function(obj) { + displayPurgeAndImportAudits: function (obj) { + var adminTypDetails = Enums.category[obj.operation]; + + // If it's a new JSON string (from new API changes), parse it. + var isJson = false; + var summaryData = {}; + try { + summaryData = typeof obj.results === 'string' ? JSON.parse(obj.results) : obj.results; + if (summaryData && typeof summaryData === 'object' && !Array.isArray(summaryData)) { + isJson = true; + } + } catch (e) { + isJson = false; + } + + if (isJson) { + // It's the new Summary format + var runId = summaryData.runId || obj.model.get('runId') || ''; + var paramsArr = obj.model.get('params') ? obj.model.get('params').split(',') : []; + + var reqCount = summaryData.requestedCount !== undefined ? summaryData.requestedCount : paramsArr.length; + var purgedCount = summaryData.purgedCount !== undefined ? summaryData.purgedCount : 0; + var purgedDependenciesCount = summaryData.purgedDependenciesCount || 0; + var totalPurgedCount = purgedCount + purgedDependenciesCount; + var failedCount = summaryData.failedCount || 0; + var skippedCount = summaryData.skippedCount || 0; + + var html = '
      '; + + html += '
      '; + if (runId) { + html += '
      Run Id: ' + _.escape(runId) + '
      '; + } + if (summaryData.executionFailed) { + html += '
      Partial success: Some entities failed to purge. Check backend logs for details.
      '; + } + + html += '
      '; + + // Requested + html += '
      '; + html += '
      REQUESTED
      ' + reqCount + '
      '; + + // Total Purged + var rawResults = obj.originalResults ? obj.originalResults : (typeof obj.results === 'string' ? obj.results : JSON.stringify(obj.results)); + html += '
      0 ? 'data-id="drawerSummaryTrigger" data-type="purged" data-runid="' + _.escape(runId) + '" data-guid="' + _.escape(obj.model.get('guid')) + '" data-results="' + _.escape(rawResults) + '"' : '') + '>'; + html += '
      PURGED
      ' + totalPurgedCount + '
      '; + + // Failed + html += '
      '; + html += '
      FAILED
      ' + failedCount + '
      '; + + // Skipped + html += '
      '; + html += '
      SKIPPED
      ' + skippedCount + '
      '; + + html += '
      '; + return html; + } + + // Legacy render format var adminValues = '
        ', - guids = null, - adminTypDetails = Enums.category[obj.operation]; + guids = []; if (obj.operation == "PURGE" || obj.operation == "AUTO_PURGE") { guids = obj.results ? obj.results.replace('[', '').replace(']', '').split(',') : guids; + + var legacyPurgedCount = guids.length; + if (legacyPurgedCount === 1 && guids[0] === "") { + legacyPurgedCount = 0; + } + + var html = '
        '; + html += '
        '; + html += '
        0 ? 'data-id="drawerSummaryTrigger" data-type="legacy-purged" data-runid="" data-guid="' + _.escape(obj.model.get('guid')) + '" data-results="' + _.escape(obj.results) + '"' : '') + '>'; + html += '
        PURGED ENTITIES
        ' + legacyPurgedCount + '
        '; + html += '
        '; + return html; } else { guids = obj.model.get('params') ? obj.model.get('params').split(',') : guids; } - _.each(guids, function(adminGuid, index) { + _.each(guids, function (adminGuid, index) { if (index % 5 == 0 && index != 0) { adminValues += '
        '; } adminValues += ''; }) adminValues += '
      '; - return '

      ' + adminTypDetails + '

      ' + adminValues + '
      '; + return '
      ' + adminValues + '
      '; }, - displayExportAudits: function(obj) { + displayExportAudits: function (obj) { var adminValues = "", adminTypDetails = (obj.operation === 'IMPORT') ? Enums.category[obj.operation] : Enums.category[obj.operation] + " And Options", resultData = obj.results ? JSON.parse(obj.results) : null, @@ -380,15 +494,15 @@ define(['require', adminValues += this.showImportExportTable(_.extend(paramsData, { "paramsCount": obj.model.get('paramsCount') })); } adminValues = adminValues ? adminValues : obj.adminText; - return '

      ' + adminTypDetails + '

      ' + adminValues + '
      '; + return '

      ' + adminTypDetails + '

      ' + adminValues + '
      '; }, - showImportExportTable: function(obj, operations) { + showImportExportTable: function (obj, operations) { var that = this, typeDetails = "", view = '
        '; if (operations && operations === "IMPORT") { var importKeys = Object.keys(obj); - _.each(importKeys, function(key, index) { + _.each(importKeys, function (key, index) { var newObj = {}; newObj[key] = obj[key]; if (index % 5 === 0 && index != 0) { @@ -401,17 +515,17 @@ define(['require', } return view += '
      ';; }, - displayCreateUpdateAudits: function(obj) { + displayCreateUpdateAudits: function (obj) { var that = this, resultData = JSON.parse(obj.results), typeName = obj.model ? obj.model.get('params').split(',') : null, typeContainer = ''; - _.each(typeName, function(name) { + _.each(typeName, function (name) { var typeData = resultData[name], adminValues = (typeName.length == 1) ? '
        ' : '
          ', adminTypDetails = Enums.category[name] + " " + Enums.auditAction[obj.operation]; - typeContainer += '

          ' + adminTypDetails + '

          '; - _.each(typeData, function(typeDefObj, index) { + typeContainer += '

          ' + adminTypDetails + '

          '; + _.each(typeData, function (typeDefObj, index) { if (index % 5 == 0 && index != 0 && typeName.length == 1) { adminValues += '
          '; } @@ -425,17 +539,17 @@ define(['require', var typeClass = (typeName.length == 1) ? null : "admin-audit-details"; return '
          ' + typeContainer + '
          '; }, - onClickAdminPurgedEntity: function(e) { + onClickAdminPurgedEntity: function (e) { var that = this; - require(['views/audit/AuditTableLayoutView'], function(AuditTableLayoutView) { + require(['views/audit/AuditTableLayoutView'], function (AuditTableLayoutView) { const titles = { PURGE: "Purged Entity Details", AUTO_PURGE: "Auto Purge Entity Details" }; var obj = { - guid: $(e.target).text(), - titleText: (titles[e.target.dataset.operation] || "Import Details") + ": " - }, + guid: $(e.target).text(), + titleText: (titles[e.target.dataset.operation] || "Import Details") + ": " + }, modalData = { title: obj.titleText + obj.guid, content: new AuditTableLayoutView(obj), @@ -446,17 +560,138 @@ define(['require', that.showModal(modalData); }); }, - showModal: function(modalObj, title) { + onCopyRunIdMain: function (e) { + var $temp = $(""); + $("body").append($temp); + var runIdText = $(e.currentTarget).siblings('[data-id="runIdValue"]').text(); + $temp.val(runIdText).select(); + document.execCommand("copy"); + $temp.remove(); + Utils.notifySuccess({ + content: "Run Id copied to clipboard" + }); + }, + onSummaryCardClick: function (e) { + var that = this; + var $target = $(e.currentTarget); + var type = $target.data('type'); + var runId = $target.data('runid'); + var guid = $target.data('guid'); + var params = $target.data('params'); + var totalCount = parseInt($target.find('.card-value').text(), 10) || 0; + + require(['views/audit/DrawerView'], function (DrawerView) { + if (type === 'requested') { + var items = []; + if (params) { + if (Array.isArray(params)) { + items = params.map(function (item) { + return typeof item === "string" ? item : (item.guid || String(item)); + }); + } else { + try { + var parsedParams = JSON.parse(params); + if (Array.isArray(parsedParams)) { + items = parsedParams.map(function (item) { + return typeof item === "string" ? item : (item.guid || String(item)); + }); + } else if (typeof params === "string") { + items = params.replace(/^\[|\]$/g, "").split(",").map(function (s) { return s.trim(); }).filter(Boolean); + } + } catch (e) { + if (typeof params === "string") { + items = params.replace(/^\[|\]$/g, "").split(",").map(function (s) { return s.trim(); }).filter(Boolean); + } + } + } + } + var drawerView = new DrawerView({ + title: 'Requested Entities', + items: items, + totalCount: items.length, + runId: runId, + actionType: 'requested', + onItemClickCb: function (clickedGuid) { + require(['views/audit/AuditTableLayoutView'], function (AuditTableLayoutView) { + var obj = { + guid: clickedGuid, + titleText: "Entity Audit Details: " + }; + var modalData = { + title: obj.titleText + obj.guid, + content: new AuditTableLayoutView(obj), + mainClass: "modal-full-screen", + okCloses: true, + showFooter: false, + }; + that.showModal(modalData); + }); + } + }); + drawerView.render(); + } else if (type === 'legacy-purged' || type === 'purged') { + var resultsData = $target.data('results'); + var items = []; + if (resultsData) { + if (Array.isArray(resultsData)) { + items = resultsData.map(function (item) { + return typeof item === "string" ? item : (item.guid || String(item)); + }); + } else { + try { + var parsed = JSON.parse(resultsData); + if (Array.isArray(parsed)) { + items = parsed.map(function (item) { + return typeof item === "string" ? item : (item.guid || String(item)); + }); + } else if (typeof resultsData === "string") { + items = resultsData.replace(/^\[|\]$/g, "").split(",").map(function (s) { return s.trim(); }).filter(Boolean); + } + } catch (e) { + if (typeof resultsData === "string") { + items = resultsData.replace(/^\[|\]$/g, "").split(",").map(function (s) { return s.trim(); }).filter(Boolean); + } + } + } + } + var drawerView = new DrawerView({ + title: 'Purged Entities', + items: items, + totalCount: items.length, + runId: runId, + actionType: 'purged', + onItemClickCb: function (clickedGuid) { + require(['views/audit/AuditTableLayoutView'], function (AuditTableLayoutView) { + var obj = { + guid: clickedGuid, + titleText: "Purged Entity Details: " + }; + var modalData = { + title: obj.titleText + obj.guid, + content: new AuditTableLayoutView(obj), + mainClass: "modal-full-screen", + okCloses: true, + showFooter: false, + }; + that.showModal(modalData); + }); + } + }); + drawerView.render(); + } + }); + }, + showModal: function (modalObj, title) { var that = this; require([ 'modules/Modal' - ], function(Modal) { + ], function (Modal) { var modal = new Modal(modalObj).open(); - modal.on('closeModal', function() { + modal.on('closeModal', function () { $('.modal').css({ 'padding-right': '0px !important' }); modal.trigger('cancel'); }); - modal.$el.on('click', 'td a', function() { + modal.$el.on('click', 'td a', function () { modal.trigger('cancel'); }); }); diff --git a/dashboardv2/public/js/views/audit/DrawerView.js b/dashboardv2/public/js/views/audit/DrawerView.js new file mode 100644 index 00000000000..3da02b16604 --- /dev/null +++ b/dashboardv2/public/js/views/audit/DrawerView.js @@ -0,0 +1,297 @@ +/** + * 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. + */ + +define(['require', + 'backbone', + 'hbs!tmpl/audit/DrawerView_tmpl', + 'utils/Utils' +], function (require, Backbone, DrawerView_tmpl, Utils) { + 'use strict'; + + var DrawerView = Backbone.Marionette.LayoutView.extend({ + _viewName: 'DrawerView', + template: DrawerView_tmpl, + + ui: { + overlay: ".drawer-overlay", + panel: ".drawer-panel", + closeBtn: "[data-id='closeDrawer']", + searchInput: "[data-id='searchInput']", + drawerList: "[data-id='drawerList']", + drawerItemsList: "[data-id='drawerList']", + drawerScrollRegion: "[data-id='drawerScrollRegion']", + drawerEmpty: "[data-id='drawerEmpty']", + limitInput: "[data-id='limitInput']", + showingText: "[data-id='showingText']", + copyRunId: "[data-id='copyRunId']", + drawerLoading: "[data-id='drawerLoading']", + drawerObserver: "[data-id='drawerObserver']", + btnFirstPage: "[data-id='btnFirstPage']", + btnPrevPage: "[data-id='btnPrevPage']", + btnCurrentPage: "[data-id='btnCurrentPage']", + btnNextPage: "[data-id='btnNextPage']", + btnLastPage: "[data-id='btnLastPage']" + }, + + events: function () { + var events = {}; + events["click " + this.ui.closeBtn] = "closeDrawer"; + events["click " + this.ui.overlay] = "closeDrawer"; + events["keyup " + this.ui.searchInput] = "onSearch"; + events["click .blue-link"] = "onItemClick"; + events["click " + this.ui.copyRunId] = "onCopyRunId"; + events["click " + this.ui.btnFirstPage] = "goToFirstPage"; + events["click " + this.ui.btnPrevPage] = "goToPrevPage"; + events["click " + this.ui.btnNextPage] = "goToNextPage"; + events["click " + this.ui.btnLastPage] = "goToLastPage"; + events["keyup " + this.ui.limitInput] = "onLimitInput"; + return events; + }, + + initialize: function (options) { + _.extend(this, _.pick(options, 'title', 'items', 'fetchData', 'onItemClickCb', 'actionType', 'runId', 'totalCount')); + this.drawerPageSize = 25; + this.limit = 25; // fallback + this.searchText = ""; + this.displayItems = []; + this.offset = 0; + this.page = 1; + this.hasMore = true; + this.isLoading = false; + this.scrollTop = 0; + this.totalFilteredCount = 0; + }, + + onRender: function () { + var that = this; + $('body').append(this.$el); + + setTimeout(function () { + that.ui.overlay.addClass('open'); + that.ui.panel.addClass('open'); + $('body').addClass('drawer-open-lock'); + }, 10); + + // Scroll events don't bubble, so we must bind directly to the UI element + this.ui.drawerScrollRegion.on('scroll', function () { + var el = this; + that.scrollTop = el.scrollTop; + that.renderList(); // re-render the visible chunk + }); + + this.loadData(); + }, + + onBeforeDestroy: function () { + if (this.ui && this.ui.drawerScrollRegion) { + this.ui.drawerScrollRegion.off('scroll'); + } + }, + + loadData: function () { + this.page = 1; + this.displayItems = []; + + if (this.fetchData) { + // If there's an API, load it all or handle server pagination. + // For this UI port, we assume we load what's needed. + var that = this; + this.isLoading = true; + this.ui.drawerLoading.show(); + this.fetchData(10000, 0, function (data) { + that.isLoading = false; + that.ui.drawerLoading.hide(); + if (data && data.length > 0) { + that.items = data; + } + that.renderList(); + }); + } else { + this.renderList(); + } + }, + + goToFirstPage: function () { + this.page = 1; + this.scrollTop = 0; + this.renderList(); + }, + + goToPrevPage: function () { + if (this.page > 1) { + this.page -= 1; + this.scrollTop = 0; + this.renderList(); + } + }, + + goToNextPage: function () { + var maxPage = Math.ceil(this.totalFilteredCount / this.drawerPageSize) || 1; + if (this.page < maxPage) { + this.page += 1; + this.scrollTop = 0; + this.renderList(); + } + }, + + goToLastPage: function () { + var maxPage = Math.ceil(this.totalFilteredCount / this.drawerPageSize) || 1; + this.page = maxPage; + this.scrollTop = 0; + this.renderList(); + }, + + onLimitInput: function (e) { + if (e.keyCode === 13) { + var parsed = parseInt($(e.currentTarget).val(), 10); + if (Number.isFinite(parsed) && parsed > 0) { + this.drawerPageSize = parsed; + this.page = 1; + this.scrollTop = 0; + this.renderList(); + } + } + }, + + renderList: function () { + var fullList = this.items || []; + if (this.searchText) { + var lowerSearch = this.searchText.toLowerCase(); + fullList = fullList.filter(function (item) { + var nameStr = (typeof item === 'object' && item.attributes && item.attributes.name) ? item.attributes.name : ""; + var guidStr = (typeof item === 'object' && item.guid) ? item.guid : item; + return (nameStr.toLowerCase().indexOf(lowerSearch) !== -1) || + (guidStr.toLowerCase().indexOf(lowerSearch) !== -1); + }); + } + + this.totalFilteredCount = fullList.length; + + var startIndex = (this.page - 1) * this.drawerPageSize; + var endIndex = Math.min(startIndex + this.drawerPageSize, this.totalFilteredCount); + this.displayItems = fullList.slice(startIndex, endIndex); + + var listHtml = ""; + + var virtualizedData = Utils.virtualizeList({ + items: this.displayItems, + scrollTop: this.scrollTop, + itemHeight: 24, + overscan: 10, + visibleCount: 40 + }); + + if (virtualizedData.paddingTop > 0) { + listHtml += '
          '; + } + + _.each(virtualizedData.visibleItems, function (item, index) { + var globalIndex = startIndex + virtualizedData.startIndex + index + 1; + var isObj = typeof item === 'object' && item !== null; + var guidStr = isObj ? item.guid : item; + var typeName = isObj && item.typeName ? '[' + _.escape(item.typeName) + '] ' : ''; + var entityName = isObj && item.attributes && item.attributes.name ? _.escape(item.attributes.name) : _.escape(guidStr); + var displayName = isObj && item.attributes && item.attributes.name ? typeName + entityName : _.escape(guidStr); + + listHtml += '
        • ' + globalIndex + '. ' + displayName + '
        • '; + }); + + if (virtualizedData.paddingBottom > 0) { + listHtml += '
          '; + } + + this.ui.drawerItemsList.html(listHtml); + this.ui.drawerLoading.hide(); + + if (this.displayItems.length === 0) { + this.ui.drawerEmpty.show(); + } else { + this.ui.drawerEmpty.hide(); + } + + // Update Pagination UI + var showingStart = Math.min(startIndex + 1, this.totalFilteredCount); + var showingEnd = endIndex; + this.ui.showingText.text(showingStart + "-" + showingEnd + " of " + this.totalFilteredCount); + this.ui.btnCurrentPage.text(this.page); + + var maxPage = Math.ceil(this.totalFilteredCount / this.drawerPageSize) || 1; + + if (this.page <= 1) { + this.ui.btnFirstPage.prop("disabled", true); + this.ui.btnPrevPage.prop("disabled", true); + } else { + this.ui.btnFirstPage.prop("disabled", false); + this.ui.btnPrevPage.prop("disabled", false); + } + + if (this.page >= maxPage) { + this.ui.btnNextPage.prop("disabled", true); + this.ui.btnLastPage.prop("disabled", true); + } else { + this.ui.btnNextPage.prop("disabled", false); + this.ui.btnLastPage.prop("disabled", false); + } + }, + + onSearch: function (e) { + this.searchText = $(e.currentTarget).val().trim(); + this.scrollTop = 0; + this.loadData(); + }, + + onItemClick: function (e) { + var guid = $(e.currentTarget).data('guid'); + if (this.onItemClickCb) { + this.onItemClickCb(guid, this.actionType); + } + }, + + onCopyRunId: function () { + var $temp = $(""); + $("body").append($temp); + $temp.val(this.runId).select(); + document.execCommand("copy"); + $temp.remove(); + Utils.notifySuccess({ + content: "Run Id copied to clipboard" + }); + }, + + closeDrawer: function () { + var that = this; + this.ui.overlay.removeClass('open'); + this.ui.panel.removeClass('open'); + $('body').removeClass('drawer-open-lock'); + + setTimeout(function () { + that.destroy(); + }, 300); + }, + + templateHelpers: function () { + return { + title: this.title || 'Entities', + runId: this.runId, + fetchData: this.fetchData + }; + } + }); + + return DrawerView; +}); diff --git a/dashboardv2/public/js/views/search/QueryBuilderView.js b/dashboardv2/public/js/views/search/QueryBuilderView.js index 0446f424ae0..a5b0c1e1f95 100644 --- a/dashboardv2/public/js/views/search/QueryBuilderView.js +++ b/dashboardv2/public/js/views/search/QueryBuilderView.js @@ -397,6 +397,9 @@ define(['require', } if (auditEntryAttributeDefs) { _.each(auditEntryAttributeDefs, function(attributes) { + if (attributes.name === "auditRowKind") { + return; // Skip backend-only field + } var returnObj = that.getObjDef(attributes, rules_widgets); if (returnObj) { filters.push(returnObj);