Brijesh619 commented on code in PR #708: URL: https://github.com/apache/atlas/pull/708#discussion_r3749622416
########## 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: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + } + + .card-value { + font-size: 24px; + font-weight: 500; + 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 { Review Comment: Fixed! Removed the duplicate body.drawer-open-lock rule from drawer.scss to keep the stylesheet clean. ########## dashboard/src/hooks/__tests__/useVirtualization.test.ts: ########## @@ -0,0 +1,89 @@ +/* Review Comment: Fixed! I have updated the main PR description to accurately reflect the final scope of the work, specifically highlighting the runId/SUMMARY filtering, auditRowKind hiding, client-side pagination, and TYPE_DEF rendering changes. ########## dashboard/src/views/Administrator/Audits/AuditResults.tsx: ########## @@ -15,227 +15,716 @@ * 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, 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<boolean>(false); const [openPurgeModal, setOpenPurgeModal] = useState<boolean>(false); - const [currentResultObj, setCurrentObj] = useState<any>({}); - const [currentPurgeResultObj, setCurrentPurgeResultObj] = useState<any>(""); + const [currentResultObj, setCurrentObj] = useState<Record<string, unknown> | undefined>(); + // Stores the guid of the clicked purged entity + const [currentPurgeResultObj, setCurrentPurgeResultObj] = useState<string | undefined>(); + const [activePurgeView, setActivePurgeView] = useState<PurgeActiveView>(PurgeActiveView.NONE); + const [drawerSearchText, setDrawerSearchText] = useState<string>(''); + const [drawerPage, setDrawerPage] = useState<number>(1); + const [drawerPageSize, setDrawerPageSize] = useState<number>(25); + const [drawerPageSizeInput, setDrawerPageSizeInput] = useState<string>('25'); + const [scrollTop, setScrollTop] = useState<number>(0); + const [copiedRunId, setCopiedRunId] = useState<boolean>(false); + const [purgedApiGuids, setPurgedApiGuids] = useState<string[]>([]); + const [summaryData, setSummaryData] = useState<Record<string, unknown> | null>(null); + const [loadingSummary, setLoadingSummary] = useState<boolean>(false); + + 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(() => { + const controller = new AbortController(); + + if (isPurgeOperation && summaryGuid) { + setLoadingSummary(true); + fetchApi(`/api/atlas/admin/audit/${summaryGuid}/summary`, { + method: "GET", + headers: { 'Accept': 'application/json', 'Content-Type': 'application/json' }, + signal: controller.signal + }) + .then(res => { + if (!controller.signal.aborted) { + if (res.data && !Array.isArray(res.data) && typeof res.data === 'object') { + setSummaryData(res.data); + } + } + }) + .catch(err => { + if (!controller.signal.aborted && err.name !== 'AbortError' && err.name !== 'CanceledError') { + console.error("Failed to fetch purge summary", err); + } + }) + .finally(() => { + if (!controller.signal.aborted) { + setLoadingSummary(false); + } + }); + } + + return () => { + controller.abort(); + }; + }, [isPurgeOperation, summaryGuid]); + + let summary: Record<string, unknown> = 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<string, unknown>).summary + ? (parsed as Record<string, unknown>).summary as Record<string, unknown> + : parsed as Record<string, unknown>; + } 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); + } + } - const resultObj = - (operation == "PURGE" || operation == "AUTO_PURGE") - ? result.replace("[", "").replace("]", "").split(",") - : jsonParse(result); + 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<string, unknown>; + } catch (_e) { + summary = {}; + } + } + + const runId = (row.original.runId as string | undefined) + ?? (summary?.runId as string | undefined) + ?? (auditObj?.runId as string | undefined) + ?? 'N/A'; + + 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; + + const handleOpenPurgedDrawer = () => { + if (totalPurgedCount === 0) return; + setActivePurgeView(PurgeActiveView.PURGED); + setDrawerPage(1); + setScrollTop(0); + // As requested, Total Purged simply uses the raw `result` object string (legacyPurgedList) + setPurgedApiGuids(legacyPurgedList); + }; return ( <> - {operation != "PURGE" && - operation != "AUTO_PURGE" && - operation != "IMPORT" && - operation != "EXPORT" && - !isEmpty(resultObj) ? ( - <Grid container spacing={2}> - {params.split(",").length > 1 ? ( - <> - {params.split(",")?.map((param: { param: string }) => { - return ( - <Grid item md={4}> - <Item - sx={{ - height: "100%", - maxHeight: "300px", - overflow: "auto", - }} - > - <Typography - sx={{ padding: "1rem 0 0 1rem", textAlign: "left" }} - >{`${category[param as any]} ${auditAction[operation] - }`}</Typography> - - <List className="audit-results-list"> - {resultObj[param as any].map( - (obj: { name: string }) => { - const { name } = obj; - return ( - <> - <ListItem className="audit-results-list-item"> - <Link - className="audit-results-entityid" - component="button" - variant="body2" - onClick={() => { - setOpenModal(true); - setCurrentObj(obj); - }} - title={name} - sx={{ - display: "inline-block", - maxWidth: "100%", - textOverflow: "ellipsis", - overflow: "hidden", - whiteSpace: "nowrap", - textAlign: "left", - verticalAlign: "bottom" - }} - > - {name} - </Link> - </ListItem> - </> - ); - } - )} - </List> - </Item> - </Grid> - ); - })} - </> - ) : ( - <> - <Grid item md={4}> - <Item - sx={{ - height: "100%", - maxHeight: "300px", - overflow: "auto", - }} - > - <Typography - sx={{ padding: "1rem 0 0 1rem", textAlign: "left" }} - >{`${category[params as any]} ${auditAction[operation] - }`}</Typography> - <List className="audit-results-list"> - {resultObj[params].map((obj: { name: string }) => { - const { name } = obj; - return ( - <> - <ListItem className="audit-results-list-item"> + <TypeDefAuditDetailModal + open={openModal} + onClose={handleCloseModal} + detailObject={currentResultObj ?? null} + maxWidth="md" + /> + + <CustomModal + open={openPurgeModal} + onClose={handleClosePurgeModal} + title={`Purged Entity Details: ${currentPurgeResultObj}`} + button1Handler={undefined} + button2Handler={undefined} + maxWidth="md" + footer={false} + > + <AuditsTab auditResultGuid={currentPurgeResultObj} /> + </CustomModal> + + {operation === "TYPE_DEF_CREATE" || + operation === "TYPE_DEF_UPDATE" || + operation === "TYPE_DEF_DELETE" ? ( + <List className="audit-results-list"> + {summary && + Object.keys(summary).map((key: string) => { + const rawItems = summary[key]; + const items: Array<Record<string, unknown> | string> = Array.isArray(rawItems) + ? (rawItems as Array<Record<string, unknown> | string>) + : []; + return ( + <div key={key}> + <Typography className="audit-list-header"> + {`${category[key as keyof typeof category] || key} ${auditAction[operation as keyof typeof auditAction] || operation}`} + </Typography> + {items.map((obj: Record<string, unknown> | string, idx: number) => { + const name = typeof obj === 'object' && obj !== null + ? (obj.name as string) || String(obj) + : String(obj); + return ( + <ListItem key={name + idx} className="audit-results-list-item"> + <ListItemText + primary={ <Link - className="audit-results-entityid" + className="audit-results-entityid audit-list-link" component="button" variant="body2" onClick={() => { 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} </Link> - </ListItem> - </> - ); - })} - </List> - </Item> + } + /> + </ListItem> + ); + })} + </div> + ); + })} + </List> + ) : operation === "IMPORT" || operation === "EXPORT" ? ( + <ImportExportAudits auditObj={auditObj} /> + ) : !isPurgeOperation ? ( + <Typography>No Results Found</Typography> + ) : null} + + {/* Purge Audit View */} + {isPurgeOperation ? ( + <Box className="purge-audit-view"> + {loadingSummary && Object.keys(summary).length === 0 && legacyPurgedList.length === 0 && !result ? ( + <Box sx={{ p: 2 }}> + <Skeleton variant="text" width="40%" height={30} sx={{ mb: 2 }} /> + <Grid container spacing={2}> + <Grid item xs={6} sm={3}><Skeleton variant="rectangular" height={70} sx={{ borderRadius: 1 }} /></Grid> + <Grid item xs={6} sm={3}><Skeleton variant="rectangular" height={70} sx={{ borderRadius: 1 }} /></Grid> + <Grid item xs={6} sm={3}><Skeleton variant="rectangular" height={70} sx={{ borderRadius: 1 }} /></Grid> + <Grid item xs={6} sm={3}><Skeleton variant="rectangular" height={70} sx={{ borderRadius: 1 }} /></Grid> </Grid> - </> + </Box> + ) : ( + <Box className="purge-summary-container"> + + {/* Run Id Header with Copy Action */} + {runId !== 'N/A' && ( + <Box className="purge-runid-header"> + <Typography variant="body2" color="textSecondary" className="runid-text"> + <strong>Run Id:</strong> {runId} + </Typography> + <Tooltip title={copiedRunId ? "Copied!" : "Copy Run Id"}> + <IconButton + size="small" + onClick={() => { + 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" + > + <ContentCopyIcon className={`copy-icon ${copiedRunId ? "copied" : ""}`} /> + </IconButton> + </Tooltip> + </Box> + )} + + {/* 4 Cards Grid: Requested, Total Purged, Failed (Display Only), Skipped (Display Only) */} + <Grid container spacing={2}> + {/* 1. Clickable Requested Card */} + {isSummaryRow && ( + <Grid item xs={6} sm={3}> + <Box + onClick={() => { + setActivePurgeView(PurgeActiveView.REQUESTED); + setDrawerPage(1); + setScrollTop(0); + }} + className="purge-card purge-card-requested" + > + <Typography variant="caption" color="primary.main" display="block" className="card-title"> + Requested + </Typography> + <Typography variant="h5" color="primary.main" className="card-count"> + {requestedCount} + </Typography> + </Box> + </Grid> + )} + + {/* 2. Clickable Total Purged Card */} + <Grid item xs={isSummaryRow ? 6 : 12} sm={isSummaryRow ? 3 : 4}> + <Box + onClick={handleOpenPurgedDrawer} + className={`purge-card purge-card-purged ${totalPurgedCount > 0 ? "clickable" : ""}`} + > + <Typography variant="caption" color="success.main" display="block" className="card-title"> + PURGED + </Typography> + <Typography variant="h5" color="success.main" className="card-count"> + {totalPurgedCount} + </Typography> + </Box> + </Grid> + + {/* 3 & 4. Display-Only Failed and Skipped Cards */} + {isSummaryRow && ( + <> + <Grid item xs={6} sm={3}> + <LightTooltip + title={ + totalFailedCount > 0 || executionFailed + ? "Some entities failed to purge. Please check purgefailure.log for details." + : "No failed entities during this purge operation." + } + arrow + placement="top" + > + <Box + className={`purge-card ${totalFailedCount > 0 ? "purge-card-failed" : "purge-card-failed-empty"}`} + > + <Typography variant="caption" color={totalFailedCount > 0 ? "error.main" : "textSecondary"} display="block" className="card-title"> + Failed + </Typography> + <Typography variant="h5" color={totalFailedCount > 0 ? "error.main" : "textPrimary"} className="card-count"> + {totalFailedCount} + </Typography> + </Box> + </LightTooltip> + </Grid> + + {/* 4. Display-Only Skipped Card */} + <Grid item xs={6} sm={3}> + <LightTooltip + title={ + skippedCount > 0 || executionFailed + ? "Some entities were skipped during purge. Please check purgefailure.log for details." + : "No skipped entities during this purge operation." + } + arrow + placement="top" + > + <Box + className={`purge-card ${skippedCount > 0 ? "purge-card-skipped" : "purge-card-skipped-empty"}`} + > + <Typography variant="caption" color={skippedCount > 0 ? "warning.main" : "textSecondary"} display="block" className="card-title"> + Skipped + </Typography> + <Typography variant="h5" color={skippedCount > 0 ? "warning.main" : "textPrimary"} className="card-count"> + {skippedCount} + </Typography> + </Box> + </LightTooltip> + </Grid> + </> + )} + </Grid> + </Box> )} - </Grid> - ) : ( - operation != "PURGE" && - operation != "AUTO_PURGE" && - operation != "IMPORT" && - operation != "EXPORT" && <Typography>No Results Found</Typography> - )} - - {(operation == "PURGE" || operation == "AUTO_PURGE") && !isEmpty(resultObj) ? ( - <> - <Typography>{`${category[operation]}`}</Typography> - <List className="audit-results-list"> - {resultObj.map((obj: string) => { + + {/* Right Side Drawer — client-side pagination for both Purged and Requested */} + <PurgeEntitiesDrawer + activePurgeView={activePurgeView} + setActivePurgeView={setActivePurgeView} + requestedEntitiesList={requestedEntitiesList} + purgedApiGuids={purgedApiGuids} + drawerSearchText={drawerSearchText} + setDrawerSearchText={setDrawerSearchText} + drawerPage={drawerPage} + setDrawerPage={setDrawerPage} + drawerPageSize={drawerPageSize} + setDrawerPageSize={setDrawerPageSize} + scrollTop={scrollTop} + setScrollTop={setScrollTop} + runId={runId} + copiedRunId={copiedRunId} + setCopiedRunId={setCopiedRunId} + setOpenPurgeModal={setOpenPurgeModal} + setCurrentPurgeResultObj={setCurrentPurgeResultObj} + drawerPageSizeInput={drawerPageSizeInput} + setDrawerPageSizeInput={setDrawerPageSizeInput} + /> + </Box> + ) : null} + </> + ); +}; + + +interface PurgeEntitiesDrawerProps { + activePurgeView: PurgeActiveView; + setActivePurgeView: (view: PurgeActiveView) => void; + requestedEntitiesList: string[]; + purgedApiGuids: string[]; + drawerSearchText: string; + setDrawerSearchText: (text: string) => void; + drawerPage: number; + setDrawerPage: React.Dispatch<React.SetStateAction<number>>; + drawerPageSize: number; + setDrawerPageSize: React.Dispatch<React.SetStateAction<number>>; + scrollTop: number; + setScrollTop: React.Dispatch<React.SetStateAction<number>>; + 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<PurgeEntitiesDrawerProps> = ({ + activePurgeView, + setActivePurgeView, + requestedEntitiesList, + purgedApiGuids, + drawerSearchText, + setDrawerSearchText, + drawerPage, + setDrawerPage, + drawerPageSize, + setDrawerPageSize, + scrollTop, + setScrollTop, + runId, + copiedRunId, + setCopiedRunId, + setOpenPurgeModal, + setCurrentPurgeResultObj, + drawerPageSizeInput, + setDrawerPageSizeInput, +}) => { + const listRef = useRef<HTMLUListElement | null>(null); + + useEffect(() => { + if (listRef.current) { + listRef.current.scrollTop = 0; + } + }, [drawerPage, drawerSearchText, activePurgeView]); + + const rawListForView: (string | Record<string, any>)[] = + activePurgeView === PurgeActiveView.REQUESTED ? requestedEntitiesList : purgedApiGuids; + + const filteredList = rawListForView.filter((item: string | Record<string, 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<HTMLUListElement>) => { + setScrollTop(e.currentTarget.scrollTop); + }; + + return ( + <Drawer + anchor="right" + open={activePurgeView !== PurgeActiveView.NONE} + onClose={() => { + setActivePurgeView(PurgeActiveView.NONE); + setDrawerSearchText(''); + setDrawerPage(1); + setScrollTop(0); + }} + PaperProps={{ className: "drawer-paper" }} + > + <Box className="drawer-content-wrapper"> + <Stack direction="row" justifyContent="space-between" alignItems="center" className="drawer-search-container"> + <Typography className="drawer-title"> + {activePurgeView === PurgeActiveView.REQUESTED ? 'Requested Entities' : 'Purged Entities'} + </Typography> + <IconButton + aria-label="Close drawer" + onClick={() => { + setActivePurgeView(PurgeActiveView.NONE); + setDrawerSearchText(''); + setDrawerPage(1); + setScrollTop(0); + }} + size="small" + > + ✕ + </IconButton> + </Stack> + + {runId !== 'N/A' && ( + <Box className="drawer-runid-container"> + <Typography variant="caption" color="textSecondary" className="drawer-runid-text"> + <strong>Run Id:</strong> {runId} + </Typography> + <Tooltip title={copiedRunId ? "Copied!" : "Copy Run Id"}> + <IconButton + size="small" + onClick={() => { + 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" + > + <ContentCopyIcon className={`drawer-copy-icon ${copiedRunId ? "copied" : ""}`} /> + </IconButton> + </Tooltip> + </Box> + )} + + {(activePurgeView === PurgeActiveView.REQUESTED ? requestedEntitiesList.length > 0 : purgedApiGuids.length > 0) && ( + <Box className="drawer-search-container"> + <TextField + fullWidth + size="small" + variant="standard" + placeholder="Search GUIDs..." + value={drawerSearchText} + onChange={(e) => { + setDrawerSearchText(e.target.value); + setDrawerPage(1); + setScrollTop(0); + }} + InputProps={{ + disableUnderline: true, + startAdornment: ( + <InputAdornment position="start"> + <SearchIcon className="drawer-search-icon" /> + </InputAdornment> + ), + endAdornment: drawerSearchText ? ( + <InputAdornment position="end"> + <IconButton size="small" onClick={() => { + setDrawerSearchText(''); + setDrawerPage(1); + setScrollTop(0); + }}> + ✕ + </IconButton> + </InputAdornment> + ) : null + }} + className="drawer-search-input" + /> + </Box> + )} + + + + <List dense className="drawer-list-container" onScroll={handleDrawerScroll} onWheel={handleDrawerScroll} ref={listRef}> + {(() => { + if (displayItems.length === 0) { return ( - <ListItem className="audit-results-list-item"> - <ListItemText - primary={ + <Typography variant="body2" color="textSecondary" className="drawer-list-empty"> + {activePurgeView === PurgeActiveView.PURGED && purgedApiGuids.length === 0 && !drawerSearchText + ? "Entity list not available for summary audits — see purgefailure.log" + : "No matching GUIDs found"} + </Typography> + ); + } + + return ( + <> + {paddingTop > 0 && <div style={{ height: paddingTop }} />} + {visibleItems.map((item: string | Record<string, any>, 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 ( + <ListItem key={guidStr + index} className="drawer-list-item"> + <Typography variant="body2" className="drawer-list-index">{globalIndex}.</Typography> <Link - className="audit-results-entityid" component="button" variant="body2" + underline="hover" onClick={() => { setOpenPurgeModal(true); - setCurrentPurgeResultObj(obj); - }} - title={obj} - sx={{ - display: "inline-block", - maxWidth: "100%", - textOverflow: "ellipsis", - overflow: "hidden", - whiteSpace: "nowrap", - textAlign: "left", - verticalAlign: "bottom" + setCurrentPurgeResultObj(guidStr); }} + title={guidStr} + className="drawer-list-link" > - {obj} + {guidStr} </Link> - } - /> - </ListItem> - ); - })} - </List> - </> - ) : ( - (operation == "PURGE" || operation == "AUTO_PURGE") && <Typography>No Results Found</Typography> - )} + </ListItem> + ); + })} + {paddingBottom > 0 && <div style={{ height: paddingBottom }} />} + </> + ); + })()} - {(operation == "IMPORT" || operation == "EXPORT") && ( - <ImportExportAudits auditObj={auditObj} /> - )} - <TypeDefAuditDetailModal - open={openModal} - onClose={handleCloseModal} - detailObject={currentResultObj} - maxWidth="sm" - /> + </List> Review Comment: Fixed! I've added a new dedicated test suite to AuditResults.test.tsx covering the useEffect fetch logic. It now includes explicit tests for: 1) successful fetch populating cards, 2) failing fetch gracefully falling back to the parsed result string, 3) loading skeleton appearing while pending, and 4) AbortController correctly cancelling the fetch on unmount. -- This is an automated message from the Apache Git Service. To respond to the message, please log on to GitHub and use the URL above to go to the specific comment. To unsubscribe, e-mail: [email protected] For queries about this service, please contact Infrastructure at: [email protected]
