You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@atlas.apache.org by pp...@apache.org on 2026/08/21 15:57:24 UTC
(atlas) 01/02: ATLAS-5350: Atlas UI: Enhance Purge Audit Results UI (#708)
This is an automated email from the ASF dual-hosted git repository.
pawarprasad123 pushed a commit to branch atlas-2.6
in repository https://gitbox.apache.org/repos/asf/atlas.git
commit 9a2cd9fa49076572d0f3466209839e4f8ab5eeab
Author: Brijesh Bhalala <32...@users.noreply.github.com>
AuthorDate: Tue Aug 11 15:16:27 2026 +0530
ATLAS-5350: Atlas UI: Enhance Purge Audit Results UI (#708)
( cherry-picked form the commit fba3527e34452ac1e103ca9ce75be43064ddd211)
* ATLAS-5350: Atlas UI: Enhance Purge Audit Results UI
* ATLAS-5350: Atlas UI: Enhance Purge Audit Results UI
* ATLAS-5350: Atlas UI: Enhance Purge Audit Results UI
* ATLAS-5350: Atlas UI: Enhance Purge Audit Results UI
* ATLAS-5350: Atlas UI: Enhance Purge Audit Results UI
* ATLAS-5350: Atlas UI: Enhance Purge Audit Results UI
* ATLAS-5350: Atlas UI: Enhance Purge Audit Results UI
* ATLAS-5350: Atlas UI: Enhance Purge Audit Results UI
* ATLAS-5350: Atlas UI: Enhance Purge Audit Results UI
---
.../src/hooks/__tests__/useVirtualization.test.ts | 89 ++
dashboard/src/hooks/useVirtualization.ts | 33 +
dashboard/src/utils/Enum.ts | 14 +
dashboard/src/utils/Utils.ts | 43 +-
.../views/Administrator/Audits/AdminAuditTable.tsx | 35 +-
.../views/Administrator/Audits/AuditResults.scss | 309 +++++++
.../views/Administrator/Audits/AuditResults.tsx | 857 ++++++++++++++----
.../Audits/AuditsFilter/AuditFiltersFields.tsx | 4 +
.../Audits/__tests__/AdminAuditTable.test.tsx | 122 ++-
.../Audits/__tests__/AuditResults.test.tsx | 975 +++++++++++++++++++--
.../__tests__/AdministratorLayout.test.tsx | 180 ++--
dashboardv2/public/css/scss/drawer.scss | 417 +++++++++
dashboardv2/public/css/scss/style.scss | 3 +-
.../public/js/templates/audit/DrawerView_tmpl.html | 66 ++
dashboardv2/public/js/utils/Utils.js | 33 +
.../js/views/audit/AdminAuditTableLayoutView.js | 356 ++++++--
dashboardv2/public/js/views/audit/DrawerView.js | 298 +++++++
.../public/js/views/search/QueryBuilderView.js | 3 +
18 files changed, 3418 insertions(+), 419 deletions(-)
diff --git a/dashboard/src/hooks/__tests__/useVirtualization.test.ts b/dashboard/src/hooks/__tests__/useVirtualization.test.ts
new file mode 100644
index 0000000000..62ce583568
--- /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 0000000000..c518fd81cb
--- /dev/null
+++ b/dashboard/src/hooks/useVirtualization.ts
@@ -0,0 +1,33 @@
+/*
+ * 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';
+import { virtualizeList } from '../utils/Utils';
+
+interface UseVirtualizationProps<T> {
+ items: T[];
+ scrollTop: number;
+ itemHeight?: number;
+ overscan?: number;
+ visibleCount?: number;
+}
+
+export const useVirtualization = <T>(options: UseVirtualizationProps<T>) => {
+ return useMemo(() => {
+ return virtualizeList(options);
+ }, [options.items, options.scrollTop, options.itemHeight, options.overscan, options.visibleCount]);
+};
diff --git a/dashboard/src/utils/Enum.ts b/dashboard/src/utils/Enum.ts
index 50644400b8..a67463a1f4 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/utils/Utils.ts b/dashboard/src/utils/Utils.ts
index 38556b9b54..3d0b0d7d69 100644
--- a/dashboard/src/utils/Utils.ts
+++ b/dashboard/src/utils/Utils.ts
@@ -801,6 +801,45 @@ const getNavigate = () => {
return backNavigate;
};
+const virtualizeList = (options: {
+ items: any[];
+ scrollTop: number;
+ itemHeight?: number;
+ overscan?: number;
+ visibleCount?: number;
+}) => {
+ const items = options.items || [];
+ const scrollTop = options.scrollTop || 0;
+ const itemHeight = options.itemHeight || 37;
+ const overscan = options.overscan || 10;
+ const visibleCount = options.visibleCount || 40;
+
+ 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,
+ };
+};
+
const globalSearchFilterInitialQuery: any = {
query: {},
setQuery: (newQuery: any) => {
@@ -857,5 +896,7 @@ export {
setNavigate,
getNavigate,
globalSearchParams,
- globalSearchFilterInitialQuery
+ globalSearchFilterInitialQuery,
+ virtualizeList
};
+
diff --git a/dashboard/src/views/Administrator/Audits/AdminAuditTable.tsx b/dashboard/src/views/Administrator/Audits/AdminAuditTable.tsx
index 8673283228..ed3a2cb95c 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<string, any>) => {
+ if (node && node.criterion) {
+ node.criterion = node.criterion.filter((c: Record<string, any>) => 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<string, unknown> = {
+ 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 0000000000..65e14ad35a
--- /dev/null
+++ b/dashboard/src/views/Administrator/Audits/AuditResults.scss
@@ -0,0 +1,309 @@
+/*
+ * 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-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 acde90ff39..42677ad408 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, 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;
+}
-const AuditResults = ({ componentProps, row }: any) => {
+interface PurgeSummary {
+ runId?: string;
+ requestedCount?: number;
+ purgedCount?: number;
+ purgedDependenciesCount?: number;
+ failedCount?: number;
+ failedDependenciesCount?: number;
+ skippedCount?: number;
+ executionFailed?: boolean;
+}
+
+interface AuditResultsProps {
+ componentProps?: {
+ auditData?: AuditEntry[];
+ };
+ row: {
+ original: {
+ guid: string;
+ runId?: string;
+ };
+ };
+}
+
+const AuditResults: React.FC<AuditResultsProps> = ({ componentProps, row }) => {
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 handleCopyRunId = (idToCopy: string, setCopied: (val: boolean) => void) => {
+ if (navigator.clipboard) {
+ navigator.clipboard.writeText(idToCopy);
+ } else {
+ const textField = document.createElement('textarea');
+ textField.innerText = idToCopy;
+ document.body.appendChild(textField);
+ textField.select();
+ document.execCommand('copy');
+ textField.remove();
+ }
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
+ };
+
+ 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);
+ }
+ });
+ }
- const resultObj =
- (operation == "PURGE" || operation == "AUTO_PURGE")
- ? result.replace("[", "").replace("]", "").split(",")
- : jsonParse(result);
+ 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);
+ }
+ }
+
+ 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 purgeSummary = summary as PurgeSummary;
+ const runId = row.original.runId
+ ?? purgeSummary?.runId
+ ?? auditObj?.runId
+ ?? 'N/A';
+
+ const isSummaryRow = (runId !== 'N/A') && isPurgeOperation;
+
+ const requestedCount = purgeSummary?.requestedCount ?? requestedEntitiesList.length;
+ const purgedCount = purgeSummary?.purgedCount ?? legacyPurgedList.length;
+ const purgedDependenciesCount = purgeSummary?.purgedDependenciesCount ?? 0;
+ const totalPurgedCount = purgedCount + purgedDependenciesCount;
+ const failedCount = purgeSummary?.failedCount ?? 0;
+ const failedDependenciesCount = purgeSummary?.failedDependenciesCount ?? 0;
+ const totalFailedCount = failedCount + failedDependenciesCount;
+ const skippedCount = purgeSummary?.skippedCount ?? 0;
+ const executionFailed = !!purgeSummary?.executionFailed || 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={operation === "AUTO_PURGE" ? `Auto Purge Entity Details: ${currentPurgeResultObj}` : `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={() => handleCopyRunId(runId, setCopiedRunId)}
+ 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
+ role="button"
+ tabIndex={0}
+ aria-label="View Requested Entities"
+ onClick={() => {
+ setActivePurgeView(PurgeActiveView.REQUESTED);
+ setDrawerPage(1);
+ setScrollTop(0);
+ }}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter' || e.key === ' ') {
+ e.preventDefault();
+ 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
+ role={totalPurgedCount > 0 ? "button" : undefined}
+ tabIndex={totalPurgedCount > 0 ? 0 : undefined}
+ aria-label="View Purged Entities"
+ onClick={handleOpenPurgedDrawer}
+ onKeyDown={(e) => {
+ if (totalPurgedCount > 0 && (e.key === 'Enter' || e.key === ' ')) {
+ e.preventDefault();
+ 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}
+ handleCopyRunId={handleCopyRunId}
+ 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;
+ handleCopyRunId: (id: string, setCopied: (val: boolean) => void) => 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,
+ handleCopyRunId,
+ setOpenPurgeModal,
+ setCurrentPurgeResultObj,
+ drawerPageSizeInput,
+ setDrawerPageSizeInput,
+}) => {
+ const [drawerCopiedRunId, setDrawerCopiedRunId] = useState<boolean>(false);
+ const listRef = useRef<HTMLUListElement | null>(null);
+
+ useEffect(() => {
+ if (listRef.current) {
+ listRef.current.scrollTop = 0;
+ }
+ }, [drawerPage, drawerSearchText, activePurgeView]);
+
+ const rawListForView: (string | { guid: string; attributes?: { name?: string } })[] =
+ activePurgeView === PurgeActiveView.REQUESTED ? requestedEntitiesList : purgedApiGuids;
+
+ const filteredList = rawListForView.filter((item: string | { guid: string; attributes?: { name?: string } }) => {
+ 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={drawerCopiedRunId ? "Copied!" : "Copy Run Id"}>
+ <IconButton
+ size="small"
+ onClick={() => handleCopyRunId(runId, setDrawerCopiedRunId)}
+ className="drawer-runid-copy"
+ >
+ <ContentCopyIcon className={`copy-icon ${drawerCopiedRunId ? "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">
+ {"No matching GUIDs found"}
+ </Typography>
+ );
+ }
+
+ return (
+ <>
+ {paddingTop > 0 && <div style={{ height: paddingTop }} />}
+ {visibleItems.map((item: string | { guid: string; attributes?: { name?: string } }, 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>
- {(operation == "PURGE" || operation == "AUTO_PURGE") && (
- <CustomModal
- open={openPurgeModal}
- onClose={handleClosePurgeModal}
- title={operation == "PURGE" ? `Purged Entity Details: ${currentPurgeResultObj}` : `Auto Purged Entity Details: ${currentPurgeResultObj}`}
- footer={false}
- button1Handler={undefined}
- button2Handler={undefined}
- maxWidth="lg"
- >
- <AuditsTab auditResultGuid={currentPurgeResultObj} loading={false} />
- </CustomModal>
- )}
- </>
+ {displayTotal > 0 && (
+ <Box className="drawer-footer">
+ <Typography variant="caption" color="textSecondary" className="drawer-footer-count">
+ {Math.min((drawerPage - 1) * drawerPageSize + 1, displayTotal)}-{Math.min(drawerPage * drawerPageSize, displayTotal)} of {displayTotal}
+ </Typography>
+
+ <Box className="drawer-pagination-container">
+ <Pagination
+ count={Math.ceil(displayTotal / drawerPageSize) || 1}
+ page={drawerPage}
+ onChange={(_e, val) => {
+ setDrawerPage(val);
+ setScrollTop(0);
+ }}
+ size="small"
+ color="primary"
+ siblingCount={0}
+ boundaryCount={0}
+ showFirstButton
+ showLastButton
+ className="purge-pagination"
+ renderItem={(item) => (
+ <PaginationItem
+ slots={{ first: KeyboardDoubleArrowLeftIcon, last: KeyboardDoubleArrowRightIcon }}
+ {...item}
+ />
+ )}
+ />
+
+ <Box className="drawer-limit-container">
+ <Typography variant="caption" color="textSecondary" className="drawer-limit-label">Limit</Typography>
+ <Box
+ component="input"
+ type="number"
+ min={1}
+ value={drawerPageSizeInput}
+ onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
+ setDrawerPageSizeInput(e.target.value);
+ }}
+ onKeyDown={(e: React.KeyboardEvent<HTMLInputElement>) => {
+ 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"
+ />
+ </Box>
+ </Box>
+ </Box>
+ )}
+ </Box>
+ </Drawer>
);
};
+
export default AuditResults;
diff --git a/dashboard/src/views/Administrator/Audits/AuditsFilter/AuditFiltersFields.tsx b/dashboard/src/views/Administrator/Audits/AuditsFilter/AuditFiltersFields.tsx
index b479a4be39..08a5976692 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 dbd844cd28..9756bdd73c 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', () => ({
</button>
<button
onClick={() => {
- setQueryApiObj({ userName: 'testUser' });
+ // If we add a window.mockRunId it will set runId
+ if ((window as any).mockRunId) {
+ setQueryApiObj({
+ condition: "AND",
+ criterion: [
+ { attributeName: "runId", operator: "eq", attributeValue: "test-run-id" },
+ { attributeName: "auditRowKind", operator: "eq", attributeValue: "DETAIL" }
+ ]
+ });
+ } else {
+ setQueryApiObj({ userName: 'testUser' });
+ }
setupdateTable(moment.now());
}}
data-testid="apply-filters"
@@ -905,4 +916,113 @@ describe('AdminAuditTable Component', () => {
}, { timeout: 5000 });
});
});
+
+ describe('RunId Filtering Combinations', () => {
+ afterEach(() => {
+ delete (window as any).mockRunId;
+ });
+
+ it('should inject auditRowKind=SUMMARY when runId filter is present ', async () => {
+ mockGetAuditData.mockResolvedValue({ data: [] });
+ mockIsEmpty.mockImplementation((val: any) => {
+ if (Array.isArray(val)) return val.length === 0;
+ if (typeof val === 'object' && val !== null) return false;
+ return true;
+ });
+
+ render(<AdminAuditTable />);
+
+ await waitFor(() => {
+ expect(capturedFetchData).toBeDefined();
+ });
+
+ // Enable the mock runId logic
+ (window as any).mockRunId = true;
+
+ fireEvent.click(screen.getByTestId('custom-button')); // open filters
+
+ await waitFor(() => {
+ expect(screen.getByTestId('audit-filters')).toBeInTheDocument();
+ });
+
+ fireEvent.click(screen.getByTestId('apply-filters')); // apply filters with runId
+
+ // Component re-renders and should trigger fetchData
+ // capturedFetchData is called automatically when queryApiObj changes because of the mock TableLayout
+ // wait, our mock TableLayout only calls fetchData on mount!
+ // But AdminAuditTable passes `tableFilters` as queryApiObj.
+ // AdminAuditTable has a `fetchAuditResult` function that is passed to TableLayout as `fetchData`.
+ // We can directly invoke `capturedFetchData` again!
+
+ await capturedFetchData({ pagination: { pageSize: 25, pageIndex: 0 } });
+
+ await waitFor(() => {
+ expect(mockGetAuditData).toHaveBeenCalled();
+ const callArgs = mockGetAuditData.mock.calls[mockGetAuditData.mock.calls.length - 1][0];
+
+ // Assert that the filter contains the injected SUMMARY condition
+ const filtersStr = JSON.stringify(callArgs.auditFilters);
+ expect(filtersStr).toContain('"attributeValue":"SUMMARY"');
+
+ // And assert it removed the old auditRowKind (DETAIL)
+ expect(filtersStr).not.toContain('"attributeValue":"DETAIL"');
+ });
+ });
+
+ it('should NOT inject auditRowKind=SUMMARY when runId filter is NOT present ', async () => {
+ mockGetAuditData.mockResolvedValue({ data: [] });
+ mockIsEmpty.mockImplementation((val: any) => {
+ if (Array.isArray(val)) return val.length === 0;
+ if (typeof val === 'object' && val !== null) return false;
+ return true;
+ });
+
+ render(<AdminAuditTable />);
+
+ await waitFor(() => {
+ expect(capturedFetchData).toBeDefined();
+ });
+
+ // NO mockRunId here
+
+ fireEvent.click(screen.getByTestId('custom-button'));
+
+ await waitFor(() => {
+ expect(screen.getByTestId('audit-filters')).toBeInTheDocument();
+ });
+
+ fireEvent.click(screen.getByTestId('apply-filters')); // apply normal filter (userName)
+
+ await capturedFetchData({ pagination: { pageSize: 25, pageIndex: 0 } });
+
+ await waitFor(() => {
+ expect(mockGetAuditData).toHaveBeenCalled();
+ const callArgs = mockGetAuditData.mock.calls[mockGetAuditData.mock.calls.length - 1][0];
+
+ // Assert that the filter does NOT contain the injected SUMMARY condition
+ const filtersStr = JSON.stringify(callArgs.auditFilters);
+ expect(filtersStr).not.toContain('"attributeValue":"SUMMARY"');
+ expect(filtersStr).toContain('userName');
+ });
+ });
+
+ it('should handle API failure gracefully', async () => {
+ mockGetAuditData.mockRejectedValue({ response: { data: { errorMessage: 'Internal Server Error' } } });
+
+ render(<AdminAuditTable />);
+
+ await waitFor(() => {
+ expect(capturedFetchData).toBeDefined();
+ });
+
+ // Trigger fetch
+ await capturedFetchData({ pagination: { pageSize: 25, pageIndex: 0 } });
+
+ // We are just making sure it doesn't crash and console.error is called
+ await waitFor(() => {
+ expect(mockGetAuditData).toHaveBeenCalled();
+ });
+ });
+ });
+
});
diff --git a/dashboard/src/views/Administrator/Audits/__tests__/AuditResults.test.tsx b/dashboard/src/views/Administrator/Audits/__tests__/AuditResults.test.tsx
index 1896838f7d..ed4415bfe8 100644
--- a/dashboard/src/views/Administrator/Audits/__tests__/AuditResults.test.tsx
+++ b/dashboard/src/views/Administrator/Audits/__tests__/AuditResults.test.tsx
@@ -28,6 +28,8 @@
import React from 'react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import AuditResults from '../AuditResults';
+import { toast } from 'react-toastify';
+jest.mock('react-toastify', () => ({ toast: { error: jest.fn() } }));
// Mock dependencies
const mockIsEmpty = jest.fn((val: any) => {
@@ -39,21 +41,33 @@ const mockIsEmpty = jest.fn((val: any) => {
const mockIsArray = jest.fn((val: any) => Array.isArray(val));
const mockJsonParse = jest.fn((val: any) => {
- try {
- return JSON.parse(val);
- } catch {
- return {};
- }
+ if (!val) return [];
+ // Real jsonParse throws if the outer JSON is invalid
+ return JSON.parse(val, (_key, value) => {
+ try {
+ return typeof value === 'string' ? JSON.parse(value) : value;
+ } catch {
+ return value;
+ }
+ });
});
jest.mock('@utils/Utils', () => ({
+ ...jest.requireActual('@utils/Utils'),
isArray: (...args: any[]) => mockIsArray(...args),
isEmpty: (...args: any[]) => mockIsEmpty(...args),
jsonParse: (...args: any[]) => mockJsonParse(...args)
}));
+// Mock fetchApi so tests that trigger API calls don't crash
+jest.mock('@api/apiMethods/fetchApi', () => ({
+ fetchApi: jest.fn(() => Promise.resolve({ data: [] }))
+}));
+import { fetchApi } from '@api/apiMethods/fetchApi';
+
// Mock Enum
jest.mock('@utils/Enum', () => ({
+ ...jest.requireActual('@utils/Enum'),
auditAction: {
CREATE: 'Created',
UPDATE: 'Updated',
@@ -139,6 +153,7 @@ jest.mock('@mui/material', () => {
{children}
</button>
),
+ Drawer: ({ children, open, PaperProps, ...props }: any) => open ? <div data-testid="drawer" {...props}>{children}</div> : null,
List: ({ children, ...props }: any) => <ul data-testid="list" {...props}>{children}</ul>,
ListItem: ({ children, ...props }: any) => <li data-testid="list-item" {...props}>{children}</li>,
ListItemText: ({ primary, ...props }: any) => <div data-testid="list-item-text" {...props}>{primary}</div>,
@@ -146,11 +161,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 +183,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 +224,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 +236,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 +252,8 @@ describe('AuditResults Component', () => {
const componentProps = { auditData: mockAuditData };
render(<AuditResults componentProps={componentProps} row={mockRow} />);
- 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 +261,7 @@ describe('AuditResults Component', () => {
render(<AuditResults componentProps={componentProps} row={mockRow} />);
// 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 +276,7 @@ describe('AuditResults Component', () => {
render(<AuditResults componentProps={componentProps} row={mockRow} />);
- // 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 +293,14 @@ describe('AuditResults Component', () => {
render(<AuditResults componentProps={componentProps} row={mockRow} />);
- // 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 +312,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 +392,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(<AuditResults componentProps={componentProps} row={row} />);
// 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 +411,8 @@ describe('AuditResults Component', () => {
render(<AuditResults componentProps={componentProps} row={row} />);
+ fireEvent.click(screen.getAllByText('PURGED')[0]);
+
const typographies = screen.getAllByTestId('typography');
expect(typographies.length).toBeGreaterThan(0);
expect(screen.getByText('guid-1')).toBeInTheDocument();
@@ -404,6 +426,8 @@ describe('AuditResults Component', () => {
render(<AuditResults componentProps={componentProps} row={row} />);
+ fireEvent.click(screen.getAllByText('PURGED')[0]);
+
const typographies = screen.getAllByTestId('typography');
expect(typographies.length).toBeGreaterThan(0);
expect(screen.getByText('guid-4')).toBeInTheDocument();
@@ -416,6 +440,8 @@ describe('AuditResults Component', () => {
render(<AuditResults componentProps={componentProps} row={row} />);
+ fireEvent.click(screen.getAllByText('PURGED')[0]);
+
const purgeLink = screen.getByText('guid-1');
fireEvent.click(purgeLink);
@@ -425,7 +451,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 +459,9 @@ describe('AuditResults Component', () => {
render(<AuditResults componentProps={componentProps} row={row} />);
+ // Click to open drawer first
+ fireEvent.click(screen.getAllByText('PURGED')[0]);
+
const purgeLink = screen.getByText('guid-4');
fireEvent.click(purgeLink);
@@ -441,7 +469,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('Auto Purge Entity Details: guid-4');
});
it('should close purge modal when close button is clicked', async () => {
@@ -450,6 +478,9 @@ describe('AuditResults Component', () => {
render(<AuditResults componentProps={componentProps} row={row} />);
+ // Click to open drawer first
+ fireEvent.click(screen.getAllByText('PURGED')[0]);
+
// Open modal
const purgeLink = screen.getByText('guid-1');
fireEvent.click(purgeLink);
@@ -466,7 +497,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: [
{
@@ -481,13 +512,11 @@ describe('AuditResults Component', () => {
render(<AuditResults componentProps={componentProps} row={row} />);
- // After removing brackets and splitting, '[]' becomes ['']
- // The component will render this as a single empty item, not "No Results Found"
- const typographies = screen.getAllByTestId('typography');
- expect(typographies.length).toBeGreaterThan(0);
+ const countElement = screen.getByText('0', { selector: '.card-count' });
+ expect(countElement).toBeInTheDocument();
});
- 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: [
{
@@ -502,10 +531,8 @@ describe('AuditResults Component', () => {
render(<AuditResults componentProps={componentProps} row={row} />);
- // After removing brackets and splitting, '[]' becomes ['']
- // The component will render this as a single empty item, not "No Results Found"
- const typographies = screen.getAllByTestId('typography');
- expect(typographies.length).toBeGreaterThan(0);
+ const countElement = screen.getByText('0', { selector: '.card-count' });
+ expect(countElement).toBeInTheDocument();
});
});
@@ -533,19 +560,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 +574,18 @@ describe('AuditResults Component', () => {
render(<AuditResults componentProps={componentProps} row={row} />);
- 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 +595,35 @@ describe('AuditResults Component', () => {
render(<AuditResults componentProps={componentProps} row={row} />);
- 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(<AuditResults componentProps={componentProps} row={row} />)).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(<AuditResults componentProps={componentProps} row={row} />);
+
+ // 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(<AuditResults componentProps={componentProps} row={row} />);
- // 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 +632,9 @@ describe('AuditResults Component', () => {
render(<AuditResults componentProps={componentProps} row={row} />);
- // 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 +642,7 @@ describe('AuditResults Component', () => {
auditData: [
{
guid: 'audit-array',
- operation: 'CREATE',
+ operation: 'TYPE_DEF_CREATE',
params: 'entityDefs',
result: JSON.stringify({
entityDefs: [
@@ -677,6 +694,9 @@ describe('AuditResults Component', () => {
render(<AuditResults componentProps={componentProps} row={row} />);
+ // 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 +709,8 @@ describe('AuditResults Component', () => {
render(<AuditResults componentProps={componentProps} row={row} />);
+ 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 +761,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;
@@ -762,12 +784,11 @@ describe('AuditResults Component', () => {
const row = { original: { guid: 'audit-purge-truly-empty' } };
render(<AuditResults componentProps={componentProps} row={row} />);
-
- const typographies = screen.getAllByTestId('typography');
- expect(typographies.length).toBeGreaterThan(0);
+ const countElement = screen.getByText('0', { selector: '.card-count' });
+ expect(countElement).toBeInTheDocument();
});
- 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;
@@ -791,8 +812,784 @@ describe('AuditResults Component', () => {
render(<AuditResults componentProps={componentProps} row={row} />);
- const typographies = screen.getAllByTestId('typography');
- expect(typographies.length).toBeGreaterThan(0);
+ const countElement = screen.getByText('0', { selector: '.card-count' });
+ expect(countElement).toBeInTheDocument();
+ });
+ });
+
+ // ─────────────────────────────────────────────────────────────────────────────
+ // 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(<AuditResults componentProps={{ auditData }} row={{ original: { guid: 'audit-del' } }} />);
+
+ 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(<AuditResults componentProps={{ auditData }} row={{ original: { guid: 'audit-del' } }} />);
+
+ 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(<AuditResults componentProps={{ auditData: auditDataWithSummary }} row={{ original: { guid: 'audit-summary' } }} />);
+ // Total Purged = purgedCount(3) + purgedDependenciesCount(1) = 4
+ expect(screen.getByText('4')).toBeInTheDocument();
+ });
+
+ it('should display failedCount from JSON summary', () => {
+ render(<AuditResults componentProps={{ auditData: auditDataWithSummary }} row={{ original: { guid: 'audit-summary' } }} />);
+ expect(screen.getByText('Failed')).toBeInTheDocument();
+ expect(screen.getByText('1')).toBeInTheDocument();
+ });
+
+ it('should display skippedCount from JSON summary', () => {
+ render(<AuditResults componentProps={{ auditData: auditDataWithSummary }} row={{ original: { guid: 'audit-summary' } }} />);
+ expect(screen.getByText('Skipped')).toBeInTheDocument();
+ expect(screen.getByText('0')).toBeInTheDocument();
+ });
+
+ it('should display Requested count from JSON summary', () => {
+ render(<AuditResults componentProps={{ auditData: auditDataWithSummary }} row={{ original: { guid: 'audit-summary' } }} />);
+ expect(screen.getByText('Requested')).toBeInTheDocument();
+ expect(screen.getByText('5')).toBeInTheDocument();
+ });
+
+ describe('Purge Failure States', () => {
+ it('should apply failure styling when failedCount > 0 or executionFailed is true', () => {
+ 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 }];
+ const { container } = render(<AuditResults componentProps={{ auditData }} row={{ original: { guid: 'a-fail' } }} />);
+
+ expect(container.querySelector('.purge-card-failed')).toBeInTheDocument();
+ });
+
+ it('should NOT apply failure styling when failedCount is 0 and executionFailed is false', () => {
+ 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 }];
+ const { container } = render(<AuditResults componentProps={{ auditData }} row={{ original: { guid: 'a-ok' } }} />);
+
+ expect(container.querySelector('.purge-card-failed')).not.toBeInTheDocument();
+ expect(container.querySelector('.purge-card-failed-empty')).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(<AuditResults componentProps={{ auditData }} row={{ original: { guid: 'a-arr' } }} />);
+
+ // 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(<AuditResults componentProps={{ auditData }} row={{ original: { guid: 'a-params-arr' } }} />);
+
+ // 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(
+ <AuditResults
+ componentProps={{ auditData }}
+ row={{ original: { guid: 'a-rid', runId: 'run-row-999' } }}
+ />
+ );
+
+ // 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(<AuditResults componentProps={{ auditData }} row={{ original: { guid: 'a-rid2', runId: 'run-summary-888' } }} />);
+
+ 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(<AuditResults componentProps={{ auditData }} row={{ original: { guid: 'a-no-rid' } }} />);
+
+ 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(
+ <AuditResults
+ componentProps={{ auditData }}
+ row={{ original: { guid: 'a-copy', runId: 'copy-test-run' } }}
+ />
+ );
+
+ 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(<AuditResults componentProps={componentProps} row={{ original: { guid: 'audit-3' } }} />);
+
+ 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(<AuditResults componentProps={componentProps} row={{ original: { guid: 'audit-3' } }} />);
+
+ 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(<AuditResults componentProps={componentProps} row={{ original: { guid: 'audit-3' } }} />);
+
+ 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(<AuditResults componentProps={componentProps} row={{ original: { guid: 'audit-3' } }} />);
+
+ 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(<AuditResults componentProps={componentProps} row={{ original: { guid: 'audit-3' } }} />);
+
+ 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(<AuditResults componentProps={componentProps} row={{ original: { guid: 'audit-3' } }} />);
+
+ 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();
+ });
+
+ it('should handle pagination correctly in the drawer', () => {
+ const largeList = Array.from({ length: 30 }, (_, i) => `guid-page-${i + 1}`);
+ const largeAuditData = [
+ { guid: 'audit-large', operation: 'PURGE', params: '', result: JSON.stringify(largeList) }
+ ];
+ render(<AuditResults componentProps={{ auditData: largeAuditData }} row={{ original: { guid: 'audit-large' } }} />);
+
+ fireEvent.click(screen.getAllByText('PURGED')[0]);
+
+ expect(screen.getByText('guid-page-1')).toBeInTheDocument();
+ expect(screen.queryByText('guid-page-30')).not.toBeInTheDocument();
+
+ const nextPageBtn = screen.getByRole('button', { name: /Go to next page/i });
+ fireEvent.click(nextPageBtn);
+
+ expect(screen.queryByText('guid-page-1')).not.toBeInTheDocument();
+ expect(screen.getByText('guid-page-30')).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(<AuditResults componentProps={componentProps} row={{ original: { guid: 'audit-3' } }} />);
+
+ 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(<AuditResults componentProps={componentProps} row={{ original: { guid: 'audit-3' } }} />);
+
+ 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(<AuditResults componentProps={{ auditData }} row={{ original: { guid: 'a-zero' } }} />);
+
+ // 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 25', () => {
+ const componentProps = { auditData: mockAuditData };
+ render(<AuditResults componentProps={componentProps} row={{ original: { guid: 'audit-3' } }} />);
+
+ 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(<AuditResults componentProps={componentProps} row={{ original: { guid: 'audit-3' } }} />);
+
+ 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(<AuditResults componentProps={componentProps} row={{ original: { guid: 'audit-3' } }} />);
+
+ 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(<AuditResults componentProps={{ auditData: summaryAuditData }} row={{ original: { guid: 'audit-sum' } }} />);
+
+ 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(<AuditResults componentProps={{ auditData: nonSummaryData }} row={{ original: { guid: 'ns-1' } }} />);
+
+ 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(<AuditResults componentProps={{ auditData: summaryAuditData }} row={{ original: { guid: 'audit-sum' } }} />);
+
+ // 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(<AuditResults componentProps={{ auditData: auditDataForSummary }} row={{ original: { guid: 'audit-sum2' } }} />);
+
+ // 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 show "No matching GUIDs found" when PURGED count > 0 but GUID list is empty', async () => {
+ const auditDataForSummary = [{
+ guid: 'audit-empty-sum',
+ operation: 'PURGE',
+ params: JSON.stringify(['req-1']),
+ result: JSON.stringify({
+ requestedCount: 1, purgedCount: 5, purgedDependenciesCount: 0,
+ failedCount: 0, skippedCount: 0, executionFailed: false, runId: 'test-empty'
+ })
+ }];
+ render(<AuditResults componentProps={{ auditData: auditDataForSummary }} row={{ original: { guid: 'audit-empty-sum' } }} />);
+
+ fireEvent.click(screen.getByText('PURGED'));
+
+ await waitFor(() => {
+ expect(screen.getByText('No matching GUIDs found')).toBeInTheDocument();
+ });
+ });
+
+ it('should NOT trigger action when Failed card is clicked (display only)', () => {
+ render(<AuditResults componentProps={{ auditData: summaryAuditData }} row={{ original: { guid: 'audit-sum' } }} />);
+
+ // 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(<AuditResults componentProps={{ auditData: summaryAuditData }} row={{ original: { guid: 'audit-sum' } }} />);
+
+ 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(<AuditResults componentProps={{ auditData: autoPurgeSummary }} row={{ original: { guid: 'audit-ap-sum' } }} />);
+
+ 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(<AuditResults componentProps={{ auditData: data }} row={{ original: { guid: 'audit-count' } }} />);
+
+ // Total Purged = 6 + 2 = 8
+ expect(screen.getByText('8')).toBeInTheDocument();
+ // Skipped = 2
+ expect(screen.getByText('2')).toBeInTheDocument();
+ });
+ });
+
+
+ describe('Purge Drawer - Pagination Combinations', () => {
+ it('should gracefully handle invalid limit inputs', () => {
+ const auditData = [{ guid: 'test-limit', operation: 'PURGE', params: '', result: '["a", "b"]' }];
+ render(<AuditResults componentProps={{ auditData }} row={{ original: { guid: 'test-limit', runId: 'N/A' } }} />);
+
+ fireEvent.click(screen.getAllByText('PURGED')[0]);
+
+ const limitInput = screen.getByDisplayValue('25');
+
+ // Invalid input (empty)
+ fireEvent.change(limitInput, { target: { value: '' } });
+ fireEvent.keyDown(limitInput, { key: 'Enter', code: 'Enter' });
+ // Usually it defaults to something or ignores, let's just make sure it doesn't crash
+ expect(screen.getByText('a')).toBeInTheDocument();
+
+ // Negative input
+ fireEvent.change(limitInput, { target: { value: '-10' } });
+ fireEvent.keyDown(limitInput, { key: 'Enter', code: 'Enter' });
+ expect(screen.getByText('a')).toBeInTheDocument();
+ });
+
+ 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(<AuditResults componentProps={{ auditData }} row={{ original: { guid: 'test' } }} />);
+ 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(<AuditResults componentProps={{ auditData }} row={{ original: { guid: 'test' } }} />);
+ 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(<AuditResults componentProps={{ auditData }} row={{ original: { guid: 'test' } }} />);
+ 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(<AuditResults componentProps={{ auditData }} row={{ original: { guid: 'legacy1', runId: 'N/A' } }} />);
+
+ fireEvent.click(screen.getAllByText('PURGED')[0]);
+ 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(<AuditResults componentProps={{ auditData }} row={{ original: { guid: 'legacy2', runId: 'N/A' } }} />);
+
+ 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(<AuditResults componentProps={{ auditData }} row={{ original: { guid: 'new1', runId: 'run-123' } }} />);
+
+ // 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(<AuditResults componentProps={{ auditData }} row={{ original: { guid: 'new2', runId: 'run-456' } }} />);
+
+ // Click PURGED card
+ fireEvent.click(screen.getAllByText('PURGED')[0]);
+
+ // Drawer should NOT open
+ expect(screen.queryByText('Purged Entities')).not.toBeInTheDocument();
+ });
+ });
+
+
+ describe('Summary fetch logic (useEffect)', () => {
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('(1) successful summary fetch populates cards', async () => {
+ (fetchApi as jest.Mock).mockResolvedValueOnce({
+ data: {
+ requestedCount: 10, purgedCount: 8, purgedDependenciesCount: 2, failedCount: 0, skippedCount: 0, executionFailed: false, runId: 'run-fetch-1'
+ }
+ });
+
+ const auditData = [{ guid: 'fetch-test-1', operation: 'PURGE', params: '[]' }];
+ render(<AuditResults componentProps={{ auditData }} row={{ original: { guid: 'fetch-test-1' } }} />);
+
+ await waitFor(() => {
+ expect(screen.getAllByText('10').length).toBeGreaterThan(0);
+ });
+ });
+
+ it('(2) fetch failure falls back to parsed result', async () => {
+ (fetchApi as jest.Mock).mockRejectedValueOnce(new Error('Fetch failed'));
+
+ const fallbackResult = JSON.stringify({
+ requestedCount: 5, purgedCount: 5, purgedDependenciesCount: 0,
+ failedCount: 0, skippedCount: 0, executionFailed: false, runId: 'run-fallback'
+ });
+ const auditData = [{ guid: 'fetch-test-2', operation: 'PURGE', params: '[]', result: fallbackResult }];
+ render(<AuditResults componentProps={{ auditData }} row={{ original: { guid: 'fetch-test-2' } }} />);
+
+ await waitFor(() => {
+ expect(screen.getAllByText('5').length).toBeGreaterThan(0);
+ });
+ });
+
+ it('(3) loading skeleton shows while fetching', async () => {
+ let resolvePromise: any;
+ const promise = new Promise((resolve) => {
+ resolvePromise = resolve;
+ });
+ (fetchApi as jest.Mock).mockReturnValueOnce(promise);
+
+ const auditData = [{ guid: 'fetch-test-3', operation: 'PURGE', params: '[]' }];
+ render(<AuditResults componentProps={{ auditData }} row={{ original: { guid: 'fetch-test-3' } }} />);
+
+ expect(document.querySelector('.MuiSkeleton-root')).not.toBeNull();
+
+ resolvePromise({ data: {} });
+ await waitFor(() => {
+ expect(document.querySelector('.MuiSkeleton-root')).toBeNull();
+ });
+ });
+
+ it('(4) AbortController cancels on unmount', () => {
+ const abortSpy = jest.spyOn(AbortController.prototype, 'abort');
+
+ const auditData = [{ guid: 'fetch-test-4', operation: 'PURGE', params: '[]', result: 'run-fetch-4' }];
+ const { unmount } = render(<AuditResults componentProps={{ auditData }} row={{ original: { guid: 'fetch-test-4' } }} />);
+
+ unmount();
+
+ expect(abortSpy).toHaveBeenCalled();
+ abortSpy.mockRestore();
+ });
+ });
+
+ 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(<AuditResults componentProps={{ auditData }} row={{ original: { guid: 'audit-1' } }} />);
+
+ 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 f2067c7a03..a5b04e440a 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<HTMLAnchorElement, 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<HTMLAnchorElement, 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<HTMLAnchorElement, 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<HTMLAnchorElement, 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<HTMLAnchorElement, 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 0000000000..622cd502b9
--- /dev/null
+++ b/dashboardv2/public/css/scss/drawer.scss
@@ -0,0 +1,417 @@
+// 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 {
+ margin-top: 16px;
+ margin-bottom: 16px;
+ 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-run-id-row {
+ margin-bottom: 15px;
+ font-size: 14px;
+ font-weight: 500;
+}
+
+.runid-text {
+ font-family: monospace;
+}
+
+.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: 14px;
+ 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 {
+ 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; }
+
+.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 3932acf1cf..7cdac0f9d1 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 0000000000..84a82c0cc6
--- /dev/null
+++ b/dashboardv2/public/js/templates/audit/DrawerView_tmpl.html
@@ -0,0 +1,66 @@
+<!--
+ * 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.
+-->
+
+<div class="drawer-overlay"></div>
+<div class="drawer-panel">
+ <div class="drawer-header">
+ <h4 data-id="drawerTitle">{{title}}</h4>
+ <div class="close-drawer" data-id="closeDrawer" aria-label="Close drawer" role="button" tabindex="0">
+ ✕
+ </div>
+ </div>
+ <div class="drawer-body">
+ {{#if runId}}
+ <div class="drawer-run-id">
+ <span class="run-id-text">Run Id: <span class="run-id-value">{{runId}}</span></span>
+ <i class="fa fa-copy" data-id="copyRunId" title="Copy to clipboard" aria-label="Copy Run Id to clipboard" role="button" tabindex="0"></i>
+ </div>
+ {{/if}}
+ <div class="drawer-search">
+ <div class="search-input-wrapper">
+ <i class="fa fa-search" aria-hidden="true"></i>
+ <input type="text" data-id="searchInput" placeholder="Search GUIDs..." aria-label="Search GUIDs" />
+ </div>
+ </div>
+ <div class="drawer-list" data-id="drawerScrollRegion">
+ <ol class="drawer-items-list" data-id="drawerList"></ol>
+ <div class="drawer-empty" data-id="drawerEmpty">
+ No matching GUIDs found
+ </div>
+ <div class="drawer-loading" data-id="drawerLoading">
+ Loading...
+ </div>
+ <div class="drawer-observer" data-id="drawerObserver"></div>
+ </div>
+ </div>
+ <div class="drawer-pagination-footer">
+ <div class="drawer-showing" data-id="showingText">
+ 0-0 of 0
+ </div>
+ <div class="drawer-pagination-controls">
+ <button data-id="btnFirstPage" class="drawer-btn-page"><i class="fa fa-angle-double-left"></i></button>
+ <button data-id="btnPrevPage" class="drawer-btn-page"><i class="fa fa-angle-left"></i></button>
+ <button data-id="btnCurrentPage" class="drawer-btn-page active" disabled>1</button>
+ <button data-id="btnNextPage" class="drawer-btn-page"><i class="fa fa-angle-right"></i></button>
+ <button data-id="btnLastPage" class="drawer-btn-page"><i class="fa fa-angle-double-right"></i></button>
+ </div>
+ <div class="drawer-limit-control">
+ <span>Limit</span>
+ <input type="number" data-id="limitInput" min="1" value="25" />
+ </div>
+ </div>
+</div>
\ No newline at end of file
diff --git a/dashboardv2/public/js/utils/Utils.js b/dashboardv2/public/js/utils/Utils.js
index fb4d850a3d..7b21a04b24 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 cc56e03cfe..cc6438d14a 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,51 @@ 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) {
+ // Remove any existing auditRowKind to prevent duplicates/conflicts
+ var removeAuditRowKind = function (node) {
+ if (node.criterion && Array.isArray(node.criterion)) {
+ node.criterion = node.criterion.filter(function (c) {
+ return c.attributeName !== 'auditRowKind';
+ });
+ node.criterion.forEach(removeAuditRowKind);
+ }
+ };
+ removeAuditRowKind(auditFilters);
+ 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 +219,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 +244,7 @@ define(['require',
});
return attrTable;
},
- getAuditTableColumns: function() {
+ getAuditTableColumns: function () {
var that = this;
return this.entityCollection.constructor.getTableCols({
result: {
@@ -222,14 +257,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 +277,35 @@ define(['require',
adminTypDetails: adminTypDetails
};
el.attr('colspan', '8');
+ var $container = $('<div>').html(adminText);
+ $(el).append($container);
+
if (results) {
- var adminValues = null;
if (operation == "PURGE" || operation == "AUTO_PURGE") {
- adminText = that.displayPurgeAndImportAudits(auditData);
+ $container.html('<div class="purge-spinner-container"><i class="fa fa-spinner fa-spin fa-2x"></i></div>');
+ 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($('<div>').html(adminText));
}
},
userName: {
@@ -279,7 +332,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 +347,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 +358,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 +370,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 +384,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 = '<table class="table admin-audit-details bold-key" ><tbody >' + typeDetails + '</tbody></table>',
@@ -349,25 +402,97 @@ 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 failedDependenciesCount = summaryData.failedDependenciesCount || 0;
+ var totalFailedCount = failedCount + failedDependenciesCount;
+ var skippedCount = summaryData.skippedCount || 0;
+
+ var html = '<div class="row"><div class="attr-details">';
+
+ html += '<div class="purge-summary-wrapper">';
+ if (runId) {
+ html += '<div class="purge-run-id-row"><strong>Run Id:</strong> <span class="runid-text" data-id="runIdValue">' + _.escape(runId) + '</span> <i class="fa fa-copy purge-run-id-copy" data-id="copyRunIdMain" title="Copy to clipboard"></i></div>';
+ }
+
+ html += '<div class="purge-summary-container">';
+
+ // Requested
+ html += '<div class="purge-summary-card card-blue clickable" data-id="drawerSummaryTrigger" data-type="requested" data-runid="' + _.escape(runId) + '" data-guid="' + _.escape(obj.model.get('guid')) + '" data-params="' + _.escape(obj.model.get('params')) + '">';
+ html += '<div class="card-label">REQUESTED</div><div class="card-value">' + reqCount + '</div></div>';
+
+ // Total Purged
+ var rawResults = obj.originalResults ? obj.originalResults : (typeof obj.results === 'string' ? obj.results : JSON.stringify(obj.results));
+ html += '<div class="purge-summary-card card-green ' + (totalPurgedCount > 0 ? 'clickable' : '') + '" ' + (totalPurgedCount > 0 ? 'data-id="drawerSummaryTrigger" data-type="purged" data-runid="' + _.escape(runId) + '" data-guid="' + _.escape(obj.model.get('guid')) + '" data-results="' + _.escape(rawResults) + '"' : '') + '>';
+ html += '<div class="card-label">PURGED</div><div class="card-value">' + totalPurgedCount + '</div></div>';
+
+ // Failed
+ var failedTitle = (totalFailedCount > 0 || summaryData.executionFailed) ? 'Some entities failed to purge. Please check purgefailure.log for details.' : 'No failed entities during this purge operation.';
+ html += '<div class="purge-summary-card card-red ' + (totalFailedCount > 0 ? 'has-count' : '') + '" title="' + failedTitle + '">';
+ html += '<div class="card-label">FAILED</div><div class="card-value">' + totalFailedCount + '</div></div>';
+
+ // Skipped
+ var skippedTitle = (skippedCount > 0 || summaryData.executionFailed) ? 'Some entities were skipped during purge. Please check purgefailure.log for details.' : 'No skipped entities during this purge operation.';
+ html += '<div class="purge-summary-card card-amber ' + (skippedCount > 0 ? 'has-count' : '') + '" title="' + skippedTitle + '">';
+ html += '<div class="card-label">SKIPPED</div><div class="card-value">' + skippedCount + '</div></div>';
+
+ html += '</div></div></div></div>';
+ return html;
+ }
+
+ // Legacy render format
var adminValues = '<ul class="col-sm-6">',
- 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 = '<div class="row"><div class="attr-details">';
+ html += '<div class="purge-summary-container">';
+ html += '<div class="purge-summary-card card-legacy card-green ' + (legacyPurgedCount > 0 ? 'clickable' : '') + '" ' + (legacyPurgedCount > 0 ? 'data-id="drawerSummaryTrigger" data-type="legacy-purged" data-runid="" data-guid="' + _.escape(obj.model.get('guid')) + '" data-results="' + _.escape(obj.results) + '"' : '') + '>';
+ html += '<div class="card-label">PURGED</div><div class="card-value">' + legacyPurgedCount + '</div></div>';
+ html += '</div></div></div>';
+ 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 += '</ul><ul class="col-sm-6">';
}
adminValues += '<li class="blue-link" data-id="adminPurgedEntity" data-operation=' + obj.operation + '>' + adminGuid.trim() + '</li>';
})
adminValues += '</ul>';
- return '<div class="row"><div class="attr-details"><h4 style="word-break: break-word;">' + adminTypDetails + '</h4>' + adminValues + '</div></div>';
+ return '<div class="row"><div class="attr-details">' + adminValues + '</div></div>';
},
- 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 +505,15 @@ define(['require',
adminValues += this.showImportExportTable(_.extend(paramsData, { "paramsCount": obj.model.get('paramsCount') }));
}
adminValues = adminValues ? adminValues : obj.adminText;
- return '<div class="row"><div class="attr-details"><h4 style="word-break: break-word;">' + adminTypDetails + '</h4>' + adminValues + '</div></div>';
+ return '<div class="row"><div class="attr-details"><h4 class="audit-type-details-title">' + adminTypDetails + '</h4>' + adminValues + '</div></div>';
},
- showImportExportTable: function(obj, operations) {
+ showImportExportTable: function (obj, operations) {
var that = this,
typeDetails = "",
view = '<ul class="col-sm-5 import-export"><table class="table admin-audit-details bold-key" ><tbody >';
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 +526,17 @@ define(['require',
}
return view += '</tbody></table></ul>';;
},
- 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) ? '<ul class="col-sm-4">' : '<ul>',
adminTypDetails = Enums.category[name] + " " + Enums.auditAction[obj.operation];
- typeContainer += '<div class="attr-type-container"><h4 style="word-break: break-word;">' + adminTypDetails + '</h4>';
- _.each(typeData, function(typeDefObj, index) {
+ typeContainer += '<div class="attr-type-container"><h4 class="attr-type-header">' + adminTypDetails + '</h4>';
+ _.each(typeData, function (typeDefObj, index) {
if (index % 5 == 0 && index != 0 && typeName.length == 1) {
adminValues += '</ul><ul class="col-sm-4">';
}
@@ -425,17 +550,17 @@ define(['require',
var typeClass = (typeName.length == 1) ? null : "admin-audit-details";
return '<div class="row"><div class="attr-details ' + typeClass + '">' + typeContainer + '</div></div>';
},
- 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 +571,138 @@ define(['require',
that.showModal(modalData);
});
},
- showModal: function(modalObj, title) {
+ onCopyRunIdMain: function (e) {
+ var $temp = $("<input>");
+ $("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 0000000000..f0a2cc985d
--- /dev/null
+++ b/dashboardv2/public/js/views/audit/DrawerView.js
@@ -0,0 +1,298 @@
+/**
+ * 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 += '<div style="height: ' + virtualizedData.paddingTop + 'px"></div>';
+ }
+
+ _.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 += '<li class="drawer-list-item" ><span class="item-index" >' + globalIndex + '.</span> <a class="blue-link" title="' + _.escape(guidStr) + '" data-guid="' + _.escape(guidStr) + '">' + displayName + '</a></li>';
+ });
+
+ if (virtualizedData.paddingBottom > 0) {
+ listHtml += '<div style="height: ' + virtualizedData.paddingBottom + 'px"></div>';
+ }
+
+ this.ui.drawerItemsList.html(listHtml);
+ this.ui.drawerLoading.hide();
+
+ if (this.displayItems.length === 0) {
+ this.ui.drawerEmpty.html("No matching GUIDs found");
+ 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 = $("<input>");
+ $("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 0446f424ae..a5b0c1e1f9 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);