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/04/16 09:57:20 UTC
(atlas) branch ATLAS-5269 created (now 043ea6a43)
This is an automated email from the ASF dual-hosted git repository.
ppawar pushed a change to branch ATLAS-5269
in repository https://gitbox.apache.org/repos/asf/atlas.git
at 043ea6a43 ATLAS-5269: Atlas entity - schema tab not showing any data.
This branch includes the following new commits:
new 043ea6a43 ATLAS-5269: Atlas entity - schema tab not showing any data.
The 1 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails. The revisions
listed as "add" were already present in the repository and have only
been added to this reference.
(atlas) 01/01: ATLAS-5269: Atlas entity - schema tab not showing any data.
Posted by pp...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
ppawar pushed a commit to branch ATLAS-5269
in repository https://gitbox.apache.org/repos/asf/atlas.git
commit 043ea6a433f851cadce76fd913a578a576e7bde2
Author: Prasad Pawar <pr...@freestoneinfotech.com>
AuthorDate: Thu Apr 16 15:26:42 2026 +0530
ATLAS-5269: Atlas entity - schema tab not showing any data.
---
.../src/api/apiMethods/adminTasksApiMethod.ts | 46 +
dashboard/src/api/apiMethods/searchApiMethod.ts | 11 +-
dashboard/src/components/DialogShowMoreLess.tsx | 102 +-
dashboard/src/components/Table/TableLayout.tsx | 7 +-
dashboard/src/components/Table/TablePagination.tsx | 63 +-
.../TaskTab.tsx => models/schemaTabTypes.ts} | 20 +-
dashboard/src/models/tableLayoutType.ts | 4 +
dashboard/src/redux/slice/detailPageSlice.ts | 48 +-
dashboard/src/utils/relationshipSearchQuery.ts | 126 +++
.../src/utils/schemaElementsAttributeUtils.ts | 46 +
dashboard/src/utils/schemaHistoricalRows.ts | 80 ++
dashboard/src/views/Classification/AddTag.tsx | 33 +-
.../src/views/DetailPage/EntityDetailPage.tsx | 29 +-
.../EntityDetailTabs/RelationshipsTab.tsx | 36 +-
.../DetailPage/EntityDetailTabs/SchemaTab.tsx | 948 ++++++++++++----
.../views/DetailPage/EntityDetailTabs/TaskTab.tsx | 199 +++-
dashboardv2/public/css/scss/loader.scss | 21 +
dashboardv2/public/css/scss/table.scss | 13 +
.../schema/SchemaTableLayoutView_tmpl.html | 5 +-
dashboardv2/public/js/utils/TableLayout.js | 220 +++-
dashboardv2/public/js/utils/Utils.js | 54 +
.../js/views/detail_page/DetailPageLayoutView.js | 18 +-
.../detail_page/RelationshipCardsLayoutView.js | 48 +-
.../public/js/views/schema/SchemaLayoutView.js | 1137 +++++++++++++++++++-
24 files changed, 2958 insertions(+), 356 deletions(-)
diff --git a/dashboard/src/api/apiMethods/adminTasksApiMethod.ts b/dashboard/src/api/apiMethods/adminTasksApiMethod.ts
new file mode 100644
index 000000000..57e836fbf
--- /dev/null
+++ b/dashboard/src/api/apiMethods/adminTasksApiMethod.ts
@@ -0,0 +1,46 @@
+/*
+ * 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 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 { getBaseApiUrl } from "../apiUrlLinks/commonApiUrl";
+import { fetchApi } from "./fetchApi";
+
+const pendingTasksApiUrl = () => `${getBaseApiUrl("url")}/admin/tasks`;
+
+export interface PendingTasksQuery {
+ limit?: number;
+ offset?: number;
+}
+
+const serializeQuery = (q: PendingTasksQuery): string => {
+ const parts: string[] = [];
+ if (q.limit !== undefined) {
+ parts.push(`limit=${encodeURIComponent(String(q.limit))}`);
+ }
+ if (q.offset !== undefined) {
+ parts.push(`offset=${encodeURIComponent(String(q.offset))}`);
+ }
+ return parts.join("&");
+};
+
+/**
+ * GET /api/atlas/admin/tasks — same endpoint as classic PendingTaskTableLayoutView.
+ */
+export const getPendingTasks = (query: PendingTasksQuery) => {
+ const qs = serializeQuery(query);
+ const url = qs ? `${pendingTasksApiUrl()}?${qs}` : pendingTasksApiUrl();
+ return fetchApi(url, { method: "GET" });
+};
diff --git a/dashboard/src/api/apiMethods/searchApiMethod.ts b/dashboard/src/api/apiMethods/searchApiMethod.ts
index bf32a5832..11a061e4f 100644
--- a/dashboard/src/api/apiMethods/searchApiMethod.ts
+++ b/dashboard/src/api/apiMethods/searchApiMethod.ts
@@ -17,6 +17,7 @@
import { searchApiUrl } from "../apiUrlLinks/searchApiUrl";
import { fetchApi } from "./fetchApi";
+import { serializeRelationshipSearchParams } from "@utils/relationshipSearchQuery";
const getBasicSearchResult = (params: any, searchType: string | null) => {
const config: any = {
@@ -49,12 +50,10 @@ const getRelationShip = (params: any) => {
return fetchApi(searchApiUrl("relationship"), config);
};
-const getRelationShipV2 = (params: any) => {
- const config: any = {
- method: "GET",
- ...params
- };
- return fetchApi(searchApiUrl("relationship"), config);
+const getRelationShipV2 = (params: { params: Record<string, unknown> }) => {
+ const qs = serializeRelationshipSearchParams(params.params);
+ const url = `${searchApiUrl("relationship")}?${qs}`;
+ return fetchApi(url, { method: "GET" });
};
export {
diff --git a/dashboard/src/components/DialogShowMoreLess.tsx b/dashboard/src/components/DialogShowMoreLess.tsx
index 2088daf65..d782222e8 100644
--- a/dashboard/src/components/DialogShowMoreLess.tsx
+++ b/dashboard/src/components/DialogShowMoreLess.tsx
@@ -40,20 +40,7 @@ import { fetchGlossaryData } from "@redux/slice/glossarySlice";
const CHIP_MAX_WIDTH = "200px";
const ITEM_HEIGHT = 48;
-const DialogShowMoreLess = ({
- value,
- readOnly,
- setUpdateTable,
- columnVal,
- colName,
- displayText,
- optionalDisplayText,
- removeApiMethod,
- isShowMoreLess,
- detailPage,
- entity,
- relatedTerm
-}: {
+export interface DialogShowMoreLessProps {
value: any;
readOnly?: boolean;
setUpdateTable?: any;
@@ -66,7 +53,27 @@ const DialogShowMoreLess = ({
detailPage?: boolean;
entity?: any;
relatedTerm?: boolean;
-}) => {
+ /** Schema tab: GET affected column entity/ies and merge (after parent detail refresh). */
+ onSchemaChildEntityRefresh?: (
+ childGuids: string[]
+ ) => void | Promise<void>;
+}
+
+const DialogShowMoreLess = ({
+ value,
+ readOnly,
+ setUpdateTable,
+ columnVal,
+ colName,
+ displayText,
+ optionalDisplayText,
+ removeApiMethod,
+ isShowMoreLess,
+ detailPage,
+ entity,
+ relatedTerm,
+ onSchemaChildEntityRefresh
+}: DialogShowMoreLessProps) => {
const typedef: any = useAppSelector((state: any) => state.classification);
const { classificationData }: any = typedef;
const [openMenu, setOpenMenu] = useState<null | HTMLElement>(null);
@@ -106,6 +113,7 @@ const DialogShowMoreLess = ({
setAttributeModal(false);
};
const handleCloseModal = () => {
+ setRemoveLoader(false);
setOpenModal(false);
};
@@ -116,7 +124,25 @@ const DialogShowMoreLess = ({
const handleClose = () => {
setOpenMenu(null);
};
+ /**
+ * Same rules as search / classic `tagForTable`: show remove (X) only when the
+ * classification is on this entity; propagated tags use another entityGuid.
+ */
+ const canShowDeleteOnClassificationChip = (tag: any) => {
+ if (colName !== "Classification") {
+ return true;
+ }
+ if (!tag) {
+ return false;
+ }
+ return (
+ value.guid === tag.entityGuid ||
+ (value.guid !== tag.entityGuid && tag.entityStatus === "DELETED")
+ );
+ };
+
const handleDelete = (currentVal: string) => {
+ setRemoveLoader(false);
let { name } = extractKeyValueFromEntity(detailPage ? entity : value);
setCurrentValue({
selectedValue: currentVal,
@@ -126,7 +152,7 @@ const DialogShowMoreLess = ({
setOpenModal(true);
};
- const handleRemove = async () => {
+ const handleRemove = async (): Promise<void> => {
try {
setRemoveLoader(true);
if (colName == "Classification") {
@@ -194,16 +220,36 @@ const DialogShowMoreLess = ({
colName == "Term" ? "association" : currentValue.selectedValue
} was removed successfully`
);
+ const isSchemaClassificationFlow =
+ colName === "Classification" &&
+ typeof onSchemaChildEntityRefresh === "function";
if (!isEmpty(guid)) {
- let params: any = { gtype: gType, guid: guid };
- dispatchApi(fetchGlossaryData());
- dispatchApi(fetchGlossaryDetails(params));
- dispatchApi(fetchDetailPageData(guid as string));
+ if (!isEmpty(gType)) {
+ const params: { gtype: string | null; guid: string } = {
+ gtype: gType,
+ guid: guid as string
+ };
+ dispatchApi(fetchGlossaryData());
+ dispatchApi(fetchGlossaryDetails(params));
+ }
+ if (!isSchemaClassificationFlow) {
+ await dispatchApi(fetchDetailPageData(guid as string)).unwrap();
+ }
+ }
+ if (isSchemaClassificationFlow && onSchemaChildEntityRefresh) {
+ const childGuid = detailPage ? entity?.guid : value?.guid;
+ if (childGuid) {
+ await Promise.resolve(
+ onSchemaChildEntityRefresh([childGuid as string])
+ );
+ }
}
!isEmpty(setUpdateTable) && setUpdateTable(moment.now());
} catch (error) {
setOpenModal(false);
serverError(error, toastId);
+ } finally {
+ setRemoveLoader(false);
}
};
@@ -330,10 +376,7 @@ const DialogShowMoreLess = ({
}
onDelete={
!isEmpty(removeApiMethod) &&
- (colName !== "Classification" ||
- value.guid === value[columnVal][0].entityGuid ||
- (value.guid !== value[columnVal][0].entityGuid &&
- value[columnVal][0].entityStatus === "DELETED"))
+ canShowDeleteOnClassificationChip(value[columnVal][0])
? () => {
handleDelete(value[columnVal][0][displayText]);
}
@@ -377,10 +420,7 @@ const DialogShowMoreLess = ({
}
onDelete={
!isEmpty(removeApiMethod) &&
- (colName !== "Classification" ||
- value.guid === obj.entityGuid ||
- (value.guid !== obj.entityGuid &&
- obj.entityStatus === "DELETED"))
+ canShowDeleteOnClassificationChip(obj)
? () => {
handleDelete(obj[displayText] || obj);
}
@@ -476,10 +516,7 @@ const DialogShowMoreLess = ({
className="chip-items"
onDelete={
!isEmpty(removeApiMethod) &&
- (colName !== "Classification" ||
- value.guid === obj.entityGuid ||
- (value.guid !== obj.entityGuid &&
- obj.entityStatus === "DELETED"))
+ canShowDeleteOnClassificationChip(obj)
? () => {
handleDelete(obj[displayText] || obj);
}
@@ -562,6 +599,7 @@ const DialogShowMoreLess = ({
onClose={handleCloseTagModal}
setUpdateTable={setUpdateTable}
setRowSelection={undefined}
+ onSchemaChildEntityRefresh={onSchemaChildEntityRefresh}
/>
)}
{termModal && colName == "Term" && !relatedTerm && (
diff --git a/dashboard/src/components/Table/TableLayout.tsx b/dashboard/src/components/Table/TableLayout.tsx
index 0eed92e27..ff53e0b6a 100644
--- a/dashboard/src/components/Table/TableLayout.tsx
+++ b/dashboard/src/components/Table/TableLayout.tsx
@@ -353,7 +353,9 @@ const TableLayout: FC<TableProps> = ({
isEmptyData,
setIsEmptyData,
showGoToPage,
- customLeftButton
+ customLeftButton,
+ defaultPageSize,
+ onClientPageSizeChange
}) => {
let defaultHideColumns = { ...defaultColumnVisibility };
const location = useLocation();
@@ -362,7 +364,7 @@ const TableLayout: FC<TableProps> = ({
const [searchParams] = useSearchParams();
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
- pageSize: 25
+ pageSize: defaultPageSize ?? 25
});
const [goToPageVal, setGoToPageVal] = useState<any>("");
@@ -730,6 +732,7 @@ const TableLayout: FC<TableProps> = ({
setIsEmptyData={setIsEmptyData}
showGoToPage={showGoToPage}
totalCount={totalCount}
+ onClientPageSizeChange={onClientPageSizeChange}
/>
)}
</Paper>
diff --git a/dashboard/src/components/Table/TablePagination.tsx b/dashboard/src/components/Table/TablePagination.tsx
index 2820b47f5..7577f0007 100644
--- a/dashboard/src/components/Table/TablePagination.tsx
+++ b/dashboard/src/components/Table/TablePagination.tsx
@@ -67,16 +67,18 @@ interface PaginationProps {
setIsEmptyData?: any;
showGoToPage?: boolean;
totalCount?: number;
+ /** Client mode: notify parent when page size changes (user action). */
+ onClientPageSizeChange?: (pageSize: number) => void;
}
const TablePagination: React.FC<PaginationProps> = ({
isServerSide = false,
- getPageCount,
+ getPageCount: _getPageCount,
previousPage,
nextPage,
setPageIndex,
setPageSize,
- getRowModel,
+ getRowModel: _getRowModel,
pagination,
setRowSelection,
memoizedData,
@@ -87,7 +89,8 @@ const TablePagination: React.FC<PaginationProps> = ({
isEmptyData,
setIsEmptyData,
showGoToPage = false,
- totalCount
+ totalCount,
+ onClientPageSizeChange
}) => {
const theme: any = useTheme();
const location = useLocation();
@@ -95,6 +98,14 @@ const TablePagination: React.FC<PaginationProps> = ({
const searchParams = new URLSearchParams(location.search);
const { pageIndex, pageSize } = pagination;
+ /** Client mode: optional approximate total so Next/footer work before all rows load. */
+ const clientEffectiveTotal =
+ !isServerSide &&
+ typeof totalCount === "number" &&
+ totalCount >= 0
+ ? totalCount
+ : memoizedData.length;
+
const [value, setValue] = useState<any>({
label:
searchParams.get("pageLimit") ??
@@ -119,7 +130,7 @@ const TablePagination: React.FC<PaginationProps> = ({
? Number(searchParams.get("pageOffset")) + Number(limit)
: isServerSide
? Number(limit)
- : Math.min((pageIndex + 1) * pageSize, getRowModel?.().rows.length || 0)
+ : Math.min((pageIndex + 1) * pageSize, clientEffectiveTotal)
);
const [offset, setOffset] = useState<number>(
isServerSide && searchParams.get("pageOffset")
@@ -185,7 +196,10 @@ const TablePagination: React.FC<PaginationProps> = ({
setPageIndex?.(0);
setLimit(newPageSize);
setPageFrom(1);
- setPageTo(Math.min(newPageSize, getRowModel?.().rows.length || 0));
+ setPageTo(Math.min(newPageSize, clientEffectiveTotal));
+ if (typeof onClientPageSizeChange === "function") {
+ onClientPageSizeChange(newPageSize);
+ }
// Clear Go-to state on page size change
setGoToPageVal?.("");
setPendingGoToPageVal("");
@@ -236,7 +250,11 @@ const TablePagination: React.FC<PaginationProps> = ({
setGoToPageVal?.("");
setPendingGoToPageVal("");
} else {
- if (goToPage > (getPageCount?.() || Infinity)) {
+ const maxPage = Math.max(
+ 1,
+ Math.ceil(clientEffectiveTotal / pageSize) || 1
+ );
+ if (goToPage > maxPage) {
toast.dismiss(toastId.current);
toastId.current = toast.info(
<>
@@ -258,9 +276,7 @@ const TablePagination: React.FC<PaginationProps> = ({
}
setPageIndex?.(goToPage - 1);
setPageFrom((goToPage - 1) * pageSize + 1);
- setPageTo(
- Math.min(goToPage * pageSize, getRowModel?.().rows.length || 0)
- );
+ setPageTo(Math.min(goToPage * pageSize, clientEffectiveTotal));
setGoToPageVal?.("");
setPendingGoToPageVal("");
setGoToPageTrigger("");
@@ -294,9 +310,10 @@ const TablePagination: React.FC<PaginationProps> = ({
navigate({ search: searchParams.toString() });
} else {
previousPage?.();
- setPageFrom(pageIndex * pageSize + 1);
+ const newPageIndex = Math.max(0, pageIndex - 1);
+ setPageFrom(newPageIndex * pageSize + 1);
setPageTo(
- Math.min(pageIndex * pageSize, getRowModel?.().rows.length || 0)
+ Math.min((newPageIndex + 1) * pageSize, clientEffectiveTotal)
);
}
setGoToPageVal?.("");
@@ -319,7 +336,7 @@ const TablePagination: React.FC<PaginationProps> = ({
nextPage?.();
setPageFrom((pageIndex + 1) * pageSize + 1);
setPageTo(
- Math.min((pageIndex + 2) * pageSize, getRowModel?.().rows.length || 0)
+ Math.min((pageIndex + 2) * pageSize, clientEffectiveTotal)
);
}
setGoToPageVal?.("");
@@ -332,17 +349,25 @@ const TablePagination: React.FC<PaginationProps> = ({
? (typeof totalCount === "number" && totalCount >= 0
? offset + limit >= totalCount
: memoizedData.length < limit)
- : pageIndex + 1 >= (getPageCount?.() || Infinity);
+ : clientEffectiveTotal === 0
+ ? true
+ : pageIndex + 1 >= Math.ceil(clientEffectiveTotal / pageSize);
+
+ /** Client-side: optional totalCount (approximate) or loaded row count. */
+ const totalDatasetRows = isServerSide
+ ? typeof totalCount === "number" && totalCount >= 0
+ ? totalCount
+ : memoizedData.length
+ : clientEffectiveTotal;
- const totalRows = getRowModel?.().rows.length || 0;
const displayFrom = isServerSide
? pageFrom
- : totalRows === 0
+ : totalDatasetRows === 0
? 0
- : pageIndex * pageSize + 1;
+ : Math.min(pageIndex * pageSize + 1, totalDatasetRows);
const displayTo = isServerSide
? pageTo
- : Math.min((pageIndex + 1) * pageSize, totalRows);
+ : Math.min((pageIndex + 1) * pageSize, totalDatasetRows);
return (
<Stack
@@ -356,8 +381,8 @@ const TablePagination: React.FC<PaginationProps> = ({
>
<div>
<span className="text-grey">
- Showing <u>{totalRows.toLocaleString()} records</u> From {displayFrom}{" "}
- - {displayTo}
+ Showing <u>{totalDatasetRows.toLocaleString()} records</u> From{" "}
+ {displayFrom} - {displayTo}
</span>
</div>
diff --git a/dashboard/src/views/DetailPage/EntityDetailTabs/TaskTab.tsx b/dashboard/src/models/schemaTabTypes.ts
similarity index 52%
copy from dashboard/src/views/DetailPage/EntityDetailTabs/TaskTab.tsx
copy to dashboard/src/models/schemaTabTypes.ts
index ddf3df9a5..359ed73ec 100644
--- a/dashboard/src/views/DetailPage/EntityDetailTabs/TaskTab.tsx
+++ b/dashboard/src/models/schemaTabTypes.ts
@@ -2,9 +2,9 @@
* 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
+ * The ASF licenses this file 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
*
@@ -15,8 +15,12 @@
* limitations under the License.
*/
-const TaskTab = () => {
- return <h1>Task</h1>;
-};
-
-export default TaskTab;
+/** Cached schema tab state for the current entity (lifted to EntityDetailPage). */
+export interface SchemaTabCacheState {
+ rowsByRelation: Record<string, any[]>;
+ totals: Record<string, number>;
+ referredEntities: Record<string, any>;
+ initialLoadDone: boolean;
+ hasError: boolean;
+ pageLimitByRelation: Record<string, number>;
+}
diff --git a/dashboard/src/models/tableLayoutType.ts b/dashboard/src/models/tableLayoutType.ts
index 0e8258cb2..4fa240f6f 100644
--- a/dashboard/src/models/tableLayoutType.ts
+++ b/dashboard/src/models/tableLayoutType.ts
@@ -57,4 +57,8 @@ export interface TableProps {
setIsEmptyData?: React.Dispatch<React.SetStateAction<boolean>>;
showGoToPage?: boolean;
customLeftButton?: React.ReactNode;
+ /** Client pagination: default rows per page (e.g. schema tab uses 100 to match relationship API). */
+ defaultPageSize?: number;
+ /** Client pagination: invoked when the user changes page size (e.g. sync schema relationship chunk limit). */
+ onClientPageSizeChange?: (pageSize: number) => void;
}
diff --git a/dashboard/src/redux/slice/detailPageSlice.ts b/dashboard/src/redux/slice/detailPageSlice.ts
index a48ac02c0..27eab45b3 100644
--- a/dashboard/src/redux/slice/detailPageSlice.ts
+++ b/dashboard/src/redux/slice/detailPageSlice.ts
@@ -2,7 +2,7 @@
* 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 ASF licenses this file 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
*
@@ -15,7 +15,11 @@
* limitations under the License.
*/
-import { createSlice, createAsyncThunk } from "@reduxjs/toolkit";
+import {
+ createSlice,
+ createAsyncThunk,
+ PayloadAction,
+} from "@reduxjs/toolkit";
import {
getDetailPageData,
getEntityHeader
@@ -26,6 +30,13 @@ import {
shouldMergeMeaningsFromEntityHeader
} from "@utils/entityDetailMeaningsUtils";
+/** detailPageData payload shape (`referredEntities` updated via merge after child GET). */
+export interface DetailPageDataShape {
+ entity?: unknown;
+ referredEntities?: Record<string, unknown>;
+ [key: string]: unknown;
+}
+
export const fetchDetailPageData = createAsyncThunk(
"detailPage/fetchDetailPageData",
async (guid: string, { rejectWithValue }) => {
@@ -55,16 +66,41 @@ export const fetchDetailPageData = createAsyncThunk(
}
);
-const initialState = {
+const initialState: {
+ loading: boolean;
+ detailPageData: DetailPageDataShape | null;
+ error: unknown;
+} = {
loading: false,
detailPageData: null,
- error: null as unknown
+ error: null as unknown,
};
const detailPageSlice = createSlice({
name: "detailPage",
initialState,
- reducers: {},
+ reducers: {
+ mergeReferredEntities: (
+ state,
+ action: PayloadAction<Record<string, unknown>>,
+ ) => {
+ if (!state.detailPageData) {
+ return;
+ }
+ const patch = action.payload;
+ if (!patch || typeof patch !== "object") {
+ return;
+ }
+ const keys = Object.keys(patch);
+ if (keys.length === 0) {
+ return;
+ }
+ state.detailPageData.referredEntities = {
+ ...(state.detailPageData.referredEntities || {}),
+ ...patch,
+ };
+ },
+ },
extraReducers: (builder) => {
builder
.addCase(fetchDetailPageData.pending, (state) => {
@@ -82,4 +118,6 @@ const detailPageSlice = createSlice({
}
});
+export const { mergeReferredEntities } = detailPageSlice.actions;
+
export const detailPageReducer = detailPageSlice.reducer;
diff --git a/dashboard/src/utils/relationshipSearchQuery.ts b/dashboard/src/utils/relationshipSearchQuery.ts
new file mode 100644
index 000000000..a4a9d0078
--- /dev/null
+++ b/dashboard/src/utils/relationshipSearchQuery.ts
@@ -0,0 +1,126 @@
+/*
+ * 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 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.
+ */
+
+/** Extra attributes requested for schema tab rows (type, position ordering). */
+export const SCHEMA_RELATIONSHIP_EXTRA_ATTRIBUTES = ["type", "position"];
+
+/**
+ * Schema tab always requests active + deleted (`excludeDeletedEntities: false`).
+ * "Show historical entities" filters cached rows only — it must not change params.
+ */
+export const SCHEMA_RELATIONSHIP_INCLUDE_DELETED = true;
+
+export const DEFAULT_RELATIONSHIP_PAGE_LIMIT = 100;
+
+/**
+ * Atlas may return approximateCount -1 when getApproximateCount is false or unknown.
+ * Only non-negative finite values are usable for pagination totals.
+ */
+export const normalizeRelationshipApproximateCount = (
+ raw: unknown
+): number | undefined => {
+ if (raw === undefined || raw === null) {
+ return undefined;
+ }
+ const n = Number(raw);
+ if (!Number.isFinite(n) || n < 0) {
+ return undefined;
+ }
+ return n;
+};
+
+export const getMinPageLimitForTotal = (totalCount?: number): number => {
+ if (typeof totalCount !== "number" || !Number.isFinite(totalCount)) {
+ return 1;
+ }
+ if (totalCount <= 0) {
+ return 1;
+ }
+ return totalCount <= 1 ? 1 : 2;
+};
+
+export interface RelationshipSearchParamsInput {
+ guid: string;
+ relation: string;
+ limit: number;
+ offset: number;
+ isSorted: boolean;
+ showDeleted: boolean;
+ getApproximateCount: boolean;
+ /** When set, adds repeated attributes= query params (e.g. type, position). */
+ extraAttributes?: string[];
+}
+
+/**
+ * Builds the flat param object for GET /v2/search/relationship (same semantics
+ * as relationship cards on the entity detail page).
+ */
+export const buildRelationshipSearchParams = (
+ opts: RelationshipSearchParamsInput
+): Record<string, unknown> => {
+ const base: Record<string, unknown> = {
+ limit: opts.limit,
+ offset: opts.offset,
+ guid: opts.guid,
+ sortBy: opts.isSorted ? "name" : undefined,
+ sortOrder: opts.isSorted ? "ASCENDING" : undefined,
+ disableDefaultSorting: !opts.isSorted,
+ excludeDeletedEntities: !opts.showDeleted,
+ includeSubClassifications: true,
+ includeSubTypes: true,
+ includeClassificationAttributes: true,
+ relation: opts.relation,
+ getApproximateCount: opts.getApproximateCount
+ };
+ if (opts.extraAttributes && opts.extraAttributes.length > 0) {
+ base.attributes = opts.extraAttributes;
+ }
+ return base;
+};
+
+/**
+ * Serializes relationship search params for GET query string.
+ * Repeats `attributes` as attributes=a&attributes=b (JAX-RS style).
+ */
+export const serializeRelationshipSearchParams = (
+ params: Record<string, unknown>
+): string => {
+ const parts: string[] = [];
+ for (const [key, val] of Object.entries(params)) {
+ if (val === undefined || val === null) {
+ continue;
+ }
+ if (key === "attributes" && Array.isArray(val)) {
+ val.forEach((a) => {
+ parts.push(
+ `${encodeURIComponent(key)}=${encodeURIComponent(String(a))}`
+ );
+ });
+ continue;
+ }
+ if (typeof val === "boolean") {
+ parts.push(
+ `${encodeURIComponent(key)}=${encodeURIComponent(val ? "true" : "false")}`
+ );
+ continue;
+ }
+ parts.push(
+ `${encodeURIComponent(key)}=${encodeURIComponent(String(val))}`
+ );
+ }
+ return parts.join("&");
+};
diff --git a/dashboard/src/utils/schemaElementsAttributeUtils.ts b/dashboard/src/utils/schemaElementsAttributeUtils.ts
new file mode 100644
index 000000000..f75846141
--- /dev/null
+++ b/dashboard/src/utils/schemaElementsAttributeUtils.ts
@@ -0,0 +1,46 @@
+/*
+ * 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 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 { isEmpty } from "@utils/Utils";
+
+/**
+ * Normalizes typedef option schemaElementsAttribute (string | string[] or
+ * comma-separated) into a list of relationship attribute names for API calls.
+ */
+export const normalizeSchemaElementsAttribute = (
+ raw: string | string[] | undefined | null
+): string[] => {
+ if (raw === undefined || raw === null) {
+ return [];
+ }
+ if (Array.isArray(raw)) {
+ return raw
+ .map((s) => String(s).trim())
+ .filter((s) => s.length > 0);
+ }
+ const s = String(raw).trim();
+ if (isEmpty(s)) {
+ return [];
+ }
+ if (s.includes(",")) {
+ return s
+ .split(",")
+ .map((p) => p.trim())
+ .filter((p) => p.length > 0);
+ }
+ return [s];
+};
diff --git a/dashboard/src/utils/schemaHistoricalRows.ts b/dashboard/src/utils/schemaHistoricalRows.ts
new file mode 100644
index 000000000..f02d53649
--- /dev/null
+++ b/dashboard/src/utils/schemaHistoricalRows.ts
@@ -0,0 +1,80 @@
+/*
+ * 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 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 { entityStateReadOnly } from "@utils/Enum";
+
+/** Same ordering as Classic `sortRowsInRelation` / Schema tab. */
+export const sortRowsInRelation = (rows: any[]): any[] => {
+ return [...rows].sort((a, b) => {
+ const pa = a?.attributes?.position;
+ const pb = b?.attributes?.position;
+ if (pa != null && pb != null && Number(pa) !== Number(pb)) {
+ return Number(pa) - Number(pb);
+ }
+ const na = String(a?.attributes?.name ?? "");
+ const nb = String(b?.attributes?.name ?? "");
+ return na.localeCompare(nb);
+ });
+};
+
+export const isSchemaRowHistorical = (row: any): boolean => {
+ return Boolean(
+ row?.status && entityStateReadOnly[row.status as string]
+ );
+};
+
+/**
+ * Client-side visibility for the schema table (Classic: active only vs
+ * active + historical; if only historical exist, show historical only).
+ * When historical is requested but the cache has none, return [] so the
+ * table empty state shows "No records found" (not active rows + banner).
+ */
+export const getVisibleSchemaRowsFromFullMerged = (
+ fullMergedRows: any[],
+ checked: boolean
+): any[] => {
+ const active = fullMergedRows.filter((r) => !isSchemaRowHistorical(r));
+ const historical = fullMergedRows.filter((r) => isSchemaRowHistorical(r));
+ if (!checked) {
+ return active;
+ }
+ if (historical.length === 0) {
+ return [];
+ }
+ if (active.length === 0 && historical.length > 0) {
+ return historical;
+ }
+ return active.concat(historical);
+};
+
+export const countHistoricalInFullMerged = (fullMergedRows: any[]): number => {
+ return fullMergedRows.filter((r) => isSchemaRowHistorical(r)).length;
+};
+
+export const buildFullMergedResolvedRows = (
+ rowsByRelation: Record<string, any[]>,
+ relationNames: string[],
+ resolve: (r: any) => any
+): any[] => {
+ const blocks: any[] = [];
+ for (const name of relationNames) {
+ const raw = rowsByRelation[name] || [];
+ const resolved = raw.map((r) => resolve(r));
+ blocks.push(...sortRowsInRelation(resolved));
+ }
+ return blocks;
+};
diff --git a/dashboard/src/views/Classification/AddTag.tsx b/dashboard/src/views/Classification/AddTag.tsx
index 0863dfe6b..f06fbc6bb 100644
--- a/dashboard/src/views/Classification/AddTag.tsx
+++ b/dashboard/src/views/Classification/AddTag.tsx
@@ -60,9 +60,18 @@ const AddTag = (props: {
entityData: any;
setUpdateTable: any;
setRowSelection: any;
+ /** Schema tab: GET child entity/ies by guid and merge into detail + tab cache (no relationship replay). */
+ onSchemaChildEntityRefresh?: (childGuids: string[]) => void | Promise<void>;
}) => {
- const { open, onClose, entityData, setUpdateTable, isAdd, setRowSelection } =
- props;
+ const {
+ open,
+ onClose,
+ entityData,
+ setUpdateTable,
+ isAdd,
+ setRowSelection,
+ onSchemaChildEntityRefresh
+ } = props;
const { guid }: any = useParams();
const location = useLocation();
const dispatchApi = useAppDispatch();
@@ -302,7 +311,15 @@ const AddTag = (props: {
dispatchApi(fetchGlossaryData());
dispatchApi(fetchGlossaryDetails(params));
}
- dispatchApi(fetchDetailPageData(guid as string));
+ if (typeof onSchemaChildEntityRefresh !== "function") {
+ await dispatchApi(fetchDetailPageData(guid as string)).unwrap();
+ }
+ }
+ const childGuids = isArray(entityData)
+ ? entityData.map((obj: { guid: string }) => obj.guid)
+ : [entityGuid];
+ if (typeof onSchemaChildEntityRefresh === "function") {
+ await Promise.resolve(onSchemaChildEntityRefresh(childGuids));
}
if (!isEmpty(setRowSelection)) {
@@ -330,7 +347,15 @@ const AddTag = (props: {
dispatchApi(fetchGlossaryData());
dispatchApi(fetchGlossaryDetails(params));
}
- dispatchApi(fetchDetailPageData(guid as string));
+ if (typeof onSchemaChildEntityRefresh !== "function") {
+ await dispatchApi(fetchDetailPageData(guid as string)).unwrap();
+ }
+ }
+ const editChildGuids = isArray(entityData)
+ ? entityData.map((obj: { guid: string }) => obj.guid)
+ : [entityGuid];
+ if (typeof onSchemaChildEntityRefresh === "function") {
+ await Promise.resolve(onSchemaChildEntityRefresh(editChildGuids));
}
if (!isEmpty(setRowSelection)) {
diff --git a/dashboard/src/views/DetailPage/EntityDetailPage.tsx b/dashboard/src/views/DetailPage/EntityDetailPage.tsx
index 994d3adef..19491194f 100644
--- a/dashboard/src/views/DetailPage/EntityDetailPage.tsx
+++ b/dashboard/src/views/DetailPage/EntityDetailPage.tsx
@@ -15,7 +15,7 @@
* limitations under the License.
*/
-import { useEffect, useState } from "react";
+import { useEffect, useRef, useState } from "react";
import { Divider, IconButton, Stack, Tabs, Typography } from "@mui/material";
import { useLocation, useNavigate, useParams } from "react-router-dom";
import DisplayImage from "@components/EntityDisplayImage";
@@ -44,6 +44,8 @@ import TaskTab from "./EntityDetailTabs/TaskTab";
import { Item, samePageLinkNavigation, StyledPaper } from "@utils/Muiutils";
import { cloneDeep } from "@utils/Helper";
import { fetchDetailPageData } from "@redux/slice/detailPageSlice";
+import { normalizeSchemaElementsAttribute } from "@utils/schemaElementsAttributeUtils";
+import { SchemaTabCacheState } from "@models/schemaTabTypes";
import React from "react";
import AddTag from "@views/Classification/AddTag";
import AssignTerm from "@views/Glossary/AssignTerm";
@@ -69,6 +71,10 @@ const EntityDetailPage: React.FC = () => {
const [openAddTagModal, setOpenAddTagModal] = useState<boolean>(false);
const [openAddTermModal, setOpenAddTermModal] = useState<boolean>(false);
+ const [schemaTabCache, setSchemaTabCache] =
+ useState<SchemaTabCacheState | null>(null);
+ /** Only reset schema cache when navigating to a different entity — not on first mount / full refresh (avoids racing SchemaTab init). */
+ const prevGuidForSchemaCacheRef = useRef<string | undefined>(undefined);
const handleCloseAddTagModal = () => {
setOpenAddTagModal(false);
@@ -119,6 +125,8 @@ const EntityDetailPage: React.FC = () => {
let schemaOptions = entityObj?.options;
let schemaElementsAttribute = schemaOptions?.schemaElementsAttribute;
+ const schemaRelationNames =
+ normalizeSchemaElementsAttribute(schemaElementsAttribute);
let allTabs = [
"properties",
@@ -148,7 +156,7 @@ const EntityDetailPage: React.FC = () => {
removeTab("lineage");
}
- if (!isEmpty(schemaElementsAttribute)) {
+ if (!isEmpty(schemaRelationNames)) {
addTab("schema");
} else {
removeTab("schema");
@@ -208,6 +216,16 @@ const EntityDetailPage: React.FC = () => {
}
}, [guid]);
+ useEffect(() => {
+ if (
+ prevGuidForSchemaCacheRef.current !== undefined &&
+ prevGuidForSchemaCacheRef.current !== guid
+ ) {
+ setSchemaTabCache(null);
+ }
+ prevGuidForSchemaCacheRef.current = guid;
+ }, [guid]);
+
useEffect(() => {
const tabIndex = tabsName.findIndex((tab) => tab === activeTab);
setValue(tabIndex !== -1 ? tabIndex : 0);
@@ -245,10 +263,13 @@ const EntityDetailPage: React.FC = () => {
),
schema: (
<SchemaTab
+ key={guid}
entity={entity}
referredEntities={referredEntities}
loading={loading}
- schemaElementsAttribute={schemaElementsAttribute}
+ schemaRelationNames={schemaRelationNames}
+ schemaCache={schemaTabCache}
+ setSchemaCache={setSchemaTabCache}
/>
),
raudits: (
@@ -526,7 +547,7 @@ const EntityDetailPage: React.FC = () => {
<LinkTab label="Relationships" />
<LinkTab label="Classifications" />
<LinkTab label="Audits" />
- {!isEmpty(schemaElementsAttribute) && <LinkTab label="Schema" />}
+ {!isEmpty(schemaRelationNames) && <LinkTab label="Schema" />}
{!isEmpty(entity) && entity.typeName == "AtlasServer" && (
<LinkTab label="Export/Import Audits" />
)}
diff --git a/dashboard/src/views/DetailPage/EntityDetailTabs/RelationshipsTab.tsx b/dashboard/src/views/DetailPage/EntityDetailTabs/RelationshipsTab.tsx
index 41dfeab24..21433279c 100644
--- a/dashboard/src/views/DetailPage/EntityDetailTabs/RelationshipsTab.tsx
+++ b/dashboard/src/views/DetailPage/EntityDetailTabs/RelationshipsTab.tsx
@@ -33,6 +33,11 @@ import RelationshipCardSkeleton from "./RelationshipCardSkeleton";
import { getRelationShipV2 } from "@api/apiMethods/searchApiMethod";
import { useParams } from "react-router-dom";
import { serverError } from "@utils/Utils";
+import {
+ buildRelationshipSearchParams,
+ DEFAULT_RELATIONSHIP_PAGE_LIMIT,
+ getMinPageLimitForTotal
+} from "@utils/relationshipSearchQuery";
import { useSelector } from "react-redux";
import { EntityState } from "@models/relationshipSearchType";
@@ -155,20 +160,6 @@ const RelationshipsTab: React.FC<EntityDetailTabProps> = ({
setChecked(event.target.checked);
};
- // Fetch initial relationship attributes data
- /** Page limit may be 1 only when the card total is 0 or 1; otherwise minimum is 2. */
- const getMinPageLimitForTotal = (totalCount?: number): number => {
- if (typeof totalCount !== "number" || !Number.isFinite(totalCount)) {
- return 1;
- }
- if (totalCount <= 0) {
- return 1;
- }
- return totalCount <= 1 ? 1 : 2;
- };
-
- const DEFAULT_RELATIONSHIP_PAGE_LIMIT = 100;
-
const getPageLimit = (relationName: string, totalCount?: number) => {
const minL = getMinPageLimitForTotal(totalCount);
const currentLimit = pageLimitByAttr[relationName];
@@ -214,20 +205,15 @@ const RelationshipsTab: React.FC<EntityDetailTabProps> = ({
DEFAULT_RELATIONSHIP_PAGE_LIMIT
)
: getPageLimit(relationName, totalCount);
- return {
+ return buildRelationshipSearchParams({
+ guid: guid as string,
+ relation: relationName,
limit,
offset,
- guid,
- sortBy: isSorted ? "name" : undefined,
- sortOrder: isSorted ? "ASCENDING" : undefined,
- disableDefaultSorting: !isSorted,
- excludeDeletedEntities: !showDeleted,
- includeSubClassifications: true,
- includeSubTypes: true,
- includeClassificationAttributes: true,
- relation: relationName,
+ isSorted,
+ showDeleted,
getApproximateCount: offset === 0
- };
+ });
};
const fetchRelationshipData = async (
diff --git a/dashboard/src/views/DetailPage/EntityDetailTabs/SchemaTab.tsx b/dashboard/src/views/DetailPage/EntityDetailTabs/SchemaTab.tsx
index e4f321468..fc0c2312d 100644
--- a/dashboard/src/views/DetailPage/EntityDetailTabs/SchemaTab.tsx
+++ b/dashboard/src/views/DetailPage/EntityDetailTabs/SchemaTab.tsx
@@ -2,9 +2,9 @@
* 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
+ * The ASF licenses this file 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
*
@@ -25,219 +25,779 @@ import {
} from "@mui/material";
import { TableLayout } from "@components/Table/TableLayout";
import { EntityState } from "@models/relationshipSearchType";
+import { SchemaTabCacheState } from "@models/schemaTabTypes";
import { useSelector } from "react-redux";
+import { useAppDispatch } from "@hooks/reducerHook";
+import { getEntity } from "@api/apiMethods/entityFormApiMethod";
+import { mergeReferredEntities } from "@redux/slice/detailPageSlice";
import { isEmpty, pick } from "@utils/Utils";
import { LightTooltip } from "@components/muiComponents";
import { Link } from "react-router-dom";
import { entityStateReadOnly } from "@utils/Enum";
import DeleteOutlineOutlinedIcon from "@mui/icons-material/DeleteOutlineOutlined";
import DialogShowMoreLess from "@components/DialogShowMoreLess";
-import { useMemo, useState } from "react";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { AntSwitch } from "@utils/Muiutils";
+import { getRelationShipV2 } from "@api/apiMethods/searchApiMethod";
+import { useParams } from "react-router-dom";
+import { serverError } from "@utils/Utils";
+import {
+ buildRelationshipSearchParams,
+ DEFAULT_RELATIONSHIP_PAGE_LIMIT,
+ getMinPageLimitForTotal,
+ normalizeRelationshipApproximateCount,
+ SCHEMA_RELATIONSHIP_EXTRA_ATTRIBUTES,
+ SCHEMA_RELATIONSHIP_INCLUDE_DELETED
+} from "@utils/relationshipSearchQuery";
+import {
+ buildFullMergedResolvedRows,
+ countHistoricalInFullMerged,
+ getVisibleSchemaRowsFromFullMerged,
+ isSchemaRowHistorical,
+ sortRowsInRelation
+} from "@utils/schemaHistoricalRows";
+import { removeClassification } from "@api/apiMethods/classificationApiMethod";
+import { ColumnDef, PaginationState, SortingState } from "@tanstack/react-table";
+
+interface SchemaTabProps {
+ entity: any;
+ referredEntities: Record<string, any> | undefined;
+ loading: boolean;
+ schemaRelationNames: string[];
+ schemaCache: SchemaTabCacheState | null;
+ setSchemaCache: React.Dispatch<
+ React.SetStateAction<SchemaTabCacheState | null>
+ >;
+}
+
+const getRelationshipLimitForSnapshot = (
+ relationName: string,
+ snap: SchemaTabCacheState,
+ totalCount?: number
+): number => {
+ const minL = getMinPageLimitForTotal(totalCount);
+ const currentLimit = snap.pageLimitByRelation[relationName];
+ if (typeof currentLimit !== "number") {
+ return Math.max(minL, DEFAULT_RELATIONSHIP_PAGE_LIMIT);
+ }
+ if (
+ typeof totalCount === "number" &&
+ Number.isFinite(totalCount) &&
+ currentLimit > totalCount
+ ) {
+ return totalCount;
+ }
+ return Math.max(minL, currentLimit);
+};
+
+const mergeReferredFromRelationshipResponse = (
+ prev: Record<string, any>,
+ resp: any
+): Record<string, any> => {
+ const next = { ...prev };
+ if (resp?.referredEntities && typeof resp.referredEntities === "object") {
+ Object.assign(next, resp.referredEntities);
+ }
+ return next;
+};
const SchemaTab = ({
entity,
- referredEntities,
- loading,
- schemaElementsAttribute
-}: any) => {
- const { entityData, loading: _loader } = useSelector(
- (state: EntityState) => state.entity
- );
+ referredEntities: detailReferredEntities,
+ loading: detailLoading,
+ schemaRelationNames,
+ schemaCache,
+ setSchemaCache
+}: SchemaTabProps) => {
+ const dispatch = useAppDispatch();
+ const { guid } = useParams<{ guid: string }>();
+ const toastId = useRef<any>(null);
+ const schemaCacheRef = useRef<SchemaTabCacheState | null>(null);
+ const mergedRowCountRef = useRef<number>(0);
+ const hasMoreRef = useRef<boolean>(false);
+ const loadLockRef = useRef<boolean>(false);
+ const initialFetchRef = useRef<boolean>(false);
+ /** Current client pagination (synced with TableLayout) for load-more chunking. */
+ const paginationRef = useRef<PaginationState>({
+ pageIndex: 0,
+ pageSize: DEFAULT_RELATIONSHIP_PAGE_LIMIT
+ });
+
+ const { entityData } = useSelector((state: EntityState) => state.entity);
const [checked, setChecked] = useState<boolean>(
!isEmpty(entity) && entityStateReadOnly[entity.status]
);
+ /** Keeps pagination `fetchData` stable when toggling historical — no relationship API replay. */
+ const checkedRef = useRef<boolean>(checked);
+ useEffect(() => {
+ checkedRef.current = checked;
+ }, [checked]);
- let schemaTableAttribute = null;
- let attribute: any = null;
- let firstColumn: { typeName: string } | any = null;
- let defObj = null;
- let activeObj: any = [];
- let deleteObj: any = [];
-
- const getRelationshipEntityObj = (entityObj: { guid: string | number }) => {
- return referredEntities?.[entityObj.guid] != undefined
- ? referredEntities?.[entityObj.guid]
- : entityObj;
- };
-
- if (!isEmpty(entity) && !isEmpty(entityData?.entityDefs)) {
- attribute =
- entity.relationshipAttributes[schemaElementsAttribute] ||
- entity.attributes[schemaElementsAttribute];
- firstColumn = !isEmpty(attribute)
- ? getRelationshipEntityObj(attribute[0])
- : null;
- defObj =
- !isEmpty(entityData?.entityDefs) && !isEmpty(firstColumn)
- ? entityData?.entityDefs?.find((obj: { name: any }) => {
- return obj.name == firstColumn.typeName;
- })
- : {};
- }
+ const [loadingMore, setLoadingMore] = useState<boolean>(false);
+ const [initialLoading, setInitialLoading] = useState<boolean>(false);
+
+ useEffect(() => {
+ schemaCacheRef.current = schemaCache;
+ }, [schemaCache]);
- if (defObj && defObj?.options?.schemaAttributes) {
- if (firstColumn) {
- try {
- let mapObj = JSON.parse(defObj?.options?.schemaAttributes);
- schemaTableAttribute = pick(firstColumn.attributes, mapObj);
- } catch {
- /* JSON parse error - ignore */
+ useEffect(() => {
+ paginationRef.current = {
+ pageIndex: 0,
+ pageSize: DEFAULT_RELATIONSHIP_PAGE_LIMIT
+ };
+ }, [guid]);
+
+ useEffect(() => {
+ if (!schemaCache) {
+ setSchemaCache({
+ rowsByRelation: {},
+ totals: {},
+ referredEntities: {},
+ initialLoadDone: false,
+ hasError: false,
+ pageLimitByRelation: {}
+ });
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- only when parent clears cache
+ }, [schemaCache, setSchemaCache]);
+
+ const fetchRelationPage = useCallback(
+ async (
+ relationName: string,
+ offset: number,
+ cacheSnapshot: SchemaTabCacheState,
+ options?: { requestLimit?: number }
+ ) => {
+ if (!guid) {
+ return { entities: [], approximateCount: undefined, referredEntities: {} };
}
+ const totalCount = cacheSnapshot.totals[relationName];
+ const defaultLimit = getRelationshipLimitForSnapshot(
+ relationName,
+ cacheSnapshot,
+ totalCount
+ );
+ const limit =
+ typeof options?.requestLimit === "number" &&
+ Number.isFinite(options.requestLimit) &&
+ options.requestLimit > 0
+ ? Math.max(1, Math.floor(options.requestLimit))
+ : defaultLimit;
+ const params = buildRelationshipSearchParams({
+ guid,
+ relation: relationName,
+ limit,
+ offset,
+ isSorted: false,
+ showDeleted: SCHEMA_RELATIONSHIP_INCLUDE_DELETED,
+ getApproximateCount: offset === 0,
+ extraAttributes: SCHEMA_RELATIONSHIP_EXTRA_ATTRIBUTES
+ });
+ const response = await getRelationShipV2({ params });
+ const data = response?.data;
+ const rawCount = data?.approximateCount ?? data?.totalCount;
+ /** Chunk requests use getApproximateCount=false; API may return -1 — never use that for totals. */
+ const approximateCount =
+ offset === 0
+ ? normalizeRelationshipApproximateCount(rawCount)
+ : undefined;
+ return {
+ entities: data?.entities || [],
+ approximateCount,
+ referredEntities: data?.referredEntities
+ };
+ },
+ [guid]
+ );
+
+ const runInitialFetch = useCallback(async () => {
+ if (
+ !guid ||
+ !schemaCache ||
+ schemaCache.initialLoadDone ||
+ isEmpty(schemaRelationNames) ||
+ initialFetchRef.current
+ ) {
+ return;
}
- }
+ initialFetchRef.current = true;
+ setInitialLoading(true);
+ let mergedRef = {
+ ...(schemaCache.referredEntities || {}),
+ ...(detailReferredEntities || {})
+ };
+ const nextRows: Record<string, any[]> = { ...schemaCache.rowsByRelation };
+ const nextTotals: Record<string, number> = { ...schemaCache.totals };
- let defaultColumns: any = !isEmpty(schemaTableAttribute)
- ? Object.keys(schemaTableAttribute).map((key) => {
- if (key != undefined && key !== "position") {
- if (key == "name") {
- return {
- accessorFn: (row: any) => row.attributes.name,
- accessorKey: "name",
- cell: (info: any) => {
- let entityDef: any = info.row.original;
-
- const getName = (entity: { guid: string }) => {
- const href = `/detailPage/${entity.guid}`;
-
- return (
- <LightTooltip title={entityDef.attributes.name}>
- {entity.guid != "-1" ? (
- <Link
- className={`entity-name text-decoration-none ${
- entityDef.status &&
- entityStateReadOnly[entityDef.status]
- ? "text-red"
- : "text-blue"
- }`}
- style={{
- width: "unset !important",
- whiteSpace: "nowrap"
- }}
- to={{
- pathname: href
- }}
- >
- {entityDef.attributes.name}
- </Link>
- ) : (
- <span>{entityDef.attributes.name} </span>
- )}
- </LightTooltip>
- );
- };
-
- return (
- <div className="searchTableName">
- {getName(entityDef)}
- {entityDef.status &&
- entityStateReadOnly[entityDef.status] && (
- <LightTooltip title="Deleted">
- <IconButton
- aria-label="back"
- sx={{
- display: "inline-flex",
- position: "relative",
- padding: "4px",
- marginLeft: "4px",
- color: (theme) => theme.palette.grey[500]
- }}
- >
- <DeleteOutlineOutlinedIcon
- sx={{ fontSize: "1.25rem" }}
- />
- </IconButton>
- </LightTooltip>
- )}
- </div>
- );
- },
- header: "Name",
- enableSorting: true,
- id: "name"
- };
- }
- return {
- accessorFn: (row: any) => row.attributes[key],
- accessorKey: key,
- cell: (info: any) => {
- let values = info.row.original.attributes;
- return (
- <Typography>
- {!isEmpty(values[key]) ? values[key] : "N/A"}
- </Typography>
- );
- },
- header: key,
- enableSorting: true,
- id: key
- };
+ try {
+ for (const relationName of schemaRelationNames) {
+ const res = await fetchRelationPage(relationName, 0, {
+ ...schemaCache,
+ referredEntities: mergedRef
+ });
+ const list = Array.isArray(res.entities) ? res.entities : [];
+ mergedRef = mergeReferredFromRelationshipResponse(mergedRef, {
+ referredEntities: res.referredEntities
+ });
+ nextRows[relationName] = sortRowsInRelation(list);
+ if (res.approximateCount !== undefined) {
+ nextTotals[relationName] = res.approximateCount;
}
- })
- : [];
- defaultColumns.push({
- accessorFn: (row: any) => row.classificationNames[0],
- accessorKey: "classificationNames",
- cell: (info: any) => {
- let data: { status: string; guid: string; classifications: any } =
- info.row.original;
- if (data.guid == "-1") {
- return;
}
+ const initialPageLimits = schemaRelationNames.reduce(
+ (acc: Record<string, number>, r: string) => {
+ acc[r] = DEFAULT_RELATIONSHIP_PAGE_LIMIT;
+ return acc;
+ },
+ {}
+ );
+ setSchemaCache({
+ rowsByRelation: nextRows,
+ totals: nextTotals,
+ referredEntities: mergedRef,
+ initialLoadDone: true,
+ hasError: false,
+ pageLimitByRelation: {
+ ...initialPageLimits,
+ ...schemaCache.pageLimitByRelation
+ }
+ });
+ } catch (e: any) {
+ console.error("Schema tab fetch failed", e);
+ serverError(e, toastId);
+ setSchemaCache((prev) =>
+ prev
+ ? {
+ ...prev,
+ initialLoadDone: true,
+ hasError: true
+ }
+ : prev
+ );
+ } finally {
+ setInitialLoading(false);
+ initialFetchRef.current = false;
+ }
+ }, [
+ guid,
+ schemaCache,
+ schemaRelationNames,
+ fetchRelationPage,
+ detailReferredEntities,
+ setSchemaCache
+ ]);
- return (
- <DialogShowMoreLess
- value={data}
- readOnly={
- data.status && entityStateReadOnly[data.status] ? true : false
- }
- // setUpdateTable={setUpdateTable}
- columnVal="classifications"
- colName="Classification"
- displayText="typeName"
- // removeApiMethod={removeClassification}
- isShowMoreLess={true}
- />
+ useEffect(() => {
+ if (
+ schemaCache &&
+ !schemaCache.initialLoadDone &&
+ !isEmpty(schemaRelationNames)
+ ) {
+ void runInitialFetch();
+ }
+ }, [schemaCache, schemaRelationNames, runInitialFetch]);
+
+ /** Redux detail `referredEntities` (e.g. after GET by child guid) must override schema cache. */
+ const mergedReferred = useMemo(() => {
+ return {
+ ...(schemaCache?.referredEntities || {}),
+ ...(detailReferredEntities || {})
+ };
+ }, [detailReferredEntities, schemaCache?.referredEntities]);
+
+ const resolveEntity = useCallback(
+ (row: any) => {
+ const g = row?.guid;
+ if (g && mergedReferred[g]) {
+ const r = mergedReferred[g];
+ /* Fresh GET may omit classifications when empty; ?? row would keep stale chips. */
+ const fullish =
+ r &&
+ typeof r === "object" &&
+ (r.typeName != null || r.status != null);
+ let classifications = row.classifications;
+ if (Object.prototype.hasOwnProperty.call(r, "classifications")) {
+ classifications = r.classifications ?? [];
+ } else if (fullish && r.guid === g) {
+ classifications = [];
+ }
+ let classificationNames = row.classificationNames;
+ if (Object.prototype.hasOwnProperty.call(r, "classificationNames")) {
+ classificationNames = r.classificationNames;
+ }
+ return {
+ ...row,
+ ...r,
+ attributes: r.attributes || row.attributes,
+ classifications,
+ classificationNames
+ };
+ }
+ return row;
+ },
+ [mergedReferred]
+ );
+
+ const firstRowForDef = useMemo(() => {
+ if (!schemaCache?.initialLoadDone) {
+ return null;
+ }
+ for (const name of schemaRelationNames) {
+ const rows = schemaCache.rowsByRelation[name];
+ if (rows && rows.length > 0) {
+ return resolveEntity(rows[0]);
+ }
+ }
+ const rel0 = schemaRelationNames[0];
+ const embedded =
+ entity?.relationshipAttributes?.[rel0]?.[0] ||
+ entity?.attributes?.[rel0]?.[0];
+ return embedded ? resolveEntity(embedded) : null;
+ }, [schemaCache, schemaRelationNames, entity, resolveEntity]);
+
+ const defObj = useMemo(() => {
+ if (!firstRowForDef?.typeName || !entityData?.entityDefs) {
+ return null;
+ }
+ return entityData.entityDefs.find(
+ (obj: { name: string }) => obj.name === firstRowForDef.typeName
+ );
+ }, [entityData, firstRowForDef]);
+
+ const schemaTableAttribute = useMemo(() => {
+ if (!defObj?.options?.schemaAttributes || !firstRowForDef) {
+ return null;
+ }
+ try {
+ const mapObj = JSON.parse(defObj.options.schemaAttributes);
+ return pick(firstRowForDef.attributes || {}, mapObj);
+ } catch {
+ return null;
+ }
+ }, [defObj, firstRowForDef]);
+
+ const fullMergedRows = useMemo(() => {
+ if (!schemaCache?.initialLoadDone) {
+ return [];
+ }
+ return buildFullMergedResolvedRows(
+ schemaCache.rowsByRelation,
+ schemaRelationNames,
+ resolveEntity
+ );
+ }, [schemaCache, schemaRelationNames, resolveEntity]);
+
+ const tableData = useMemo(() => {
+ return getVisibleSchemaRowsFromFullMerged(fullMergedRows, checked);
+ }, [fullMergedRows, checked]);
+
+ const historicalRowCount = useMemo(
+ () => countHistoricalInFullMerged(fullMergedRows),
+ [fullMergedRows]
+ );
+
+ /**
+ * Sum of per-relation approximate totals from relationship search (offset=0).
+ * Drives client pagination footer + Next until chunks load; must not use only
+ * loaded row count (e.g. 100) when approximateCount is ~230k.
+ */
+ const mergedApproximateTotal = useMemo(() => {
+ if (!schemaCache?.initialLoadDone) {
+ return 0;
+ }
+ return schemaRelationNames.reduce((sum, name) => {
+ const loaded = schemaCache.rowsByRelation[name]?.length ?? 0;
+ const approx = schemaCache.totals[name];
+ const t =
+ typeof approx === "number" && Number.isFinite(approx) && approx >= 0
+ ? Math.max(approx, loaded)
+ : loaded;
+ return sum + t;
+ }, 0);
+ }, [schemaCache, schemaRelationNames]);
+
+ /** When "historical" is on but cache has none, footer should match empty table (0), not API total. */
+ const schemaPaginationTotalCount = useMemo(() => {
+ if (!schemaCache?.initialLoadDone) {
+ return 0;
+ }
+ if (
+ checked &&
+ historicalRowCount === 0 &&
+ fullMergedRows.length > 0
+ ) {
+ return 0;
+ }
+ return mergedApproximateTotal;
+ }, [
+ schemaCache?.initialLoadDone,
+ checked,
+ historicalRowCount,
+ fullMergedRows.length,
+ mergedApproximateTotal
+ ]);
+
+ const schemaEmptyText = useMemo(() => {
+ if (schemaCache?.hasError) {
+ return "Failed to load schema";
+ }
+ return "No Records found!";
+ }, [schemaCache?.hasError]);
+
+ useEffect(() => {
+ mergedRowCountRef.current = tableData.length;
+ }, [tableData]);
+
+ /** Match Classic: if only historical columns exist, force the switch on. */
+ useEffect(() => {
+ if (!schemaCache?.initialLoadDone) {
+ return;
+ }
+ const activeCount = fullMergedRows.filter((r) => !isSchemaRowHistorical(r))
+ .length;
+ if (!checked && activeCount === 0 && historicalRowCount > 0) {
+ setChecked(true);
+ }
+ }, [
+ schemaCache?.initialLoadDone,
+ fullMergedRows,
+ checked,
+ historicalRowCount
+ ]);
+
+ const countVisibleSchemaRows = useCallback(
+ (cache: SchemaTabCacheState | null) => {
+ if (!cache?.initialLoadDone) {
+ return 0;
+ }
+ const full = buildFullMergedResolvedRows(
+ cache.rowsByRelation,
+ schemaRelationNames,
+ resolveEntity
);
+ return getVisibleSchemaRowsFromFullMerged(full, checkedRef.current).length;
},
- header: "Classifications",
- show: true,
- enableSorting: false
- });
+ [schemaRelationNames, resolveEntity]
+ );
- for (let keys in attribute) {
- if (!entityStateReadOnly[attribute[keys].entityStatus]) {
- activeObj.push(attribute[keys]);
- } else if (entityStateReadOnly[attribute[keys].entityStatus]) {
- deleteObj.push(attribute[keys]);
+ const handleSwitchChange = useCallback(
+ (event: React.ChangeEvent<HTMLInputElement>) => {
+ event.stopPropagation();
+ setChecked(event.target.checked);
+ },
+ []
+ );
+
+ const hasMoreAnywhere = useCallback(() => {
+ if (!schemaCache?.initialLoadDone) {
+ return false;
}
- }
+ for (const name of schemaRelationNames) {
+ const rawTotal = schemaCache.totals[name];
+ const total =
+ typeof rawTotal === "number" &&
+ Number.isFinite(rawTotal) &&
+ rawTotal >= 0
+ ? rawTotal
+ : 0;
+ const rows = schemaCache.rowsByRelation[name] || [];
+ if (rows.length < total) {
+ return true;
+ }
+ }
+ return false;
+ }, [schemaCache, schemaRelationNames]);
- const getAttributeObj = () => {
- if ((isEmpty(deleteObj) && !checked) || (isEmpty(deleteObj) && checked)) {
- return attribute;
- } else if (!isEmpty(deleteObj) && checked) {
- return deleteObj;
+ useEffect(() => {
+ hasMoreRef.current = hasMoreAnywhere();
+ }, [hasMoreAnywhere]);
+
+ const loadNextChunk = useCallback(async (): Promise<number> => {
+ const snap = schemaCacheRef.current;
+ if (!snap?.initialLoadDone || !guid || loadLockRef.current) {
+ return countVisibleSchemaRows(snap);
+ }
+ const target = schemaRelationNames.find((name) => {
+ const rawTotal = snap.totals[name];
+ const total =
+ typeof rawTotal === "number" &&
+ Number.isFinite(rawTotal) &&
+ rawTotal >= 0
+ ? rawTotal
+ : 0;
+ const rows = snap.rowsByRelation[name] || [];
+ return rows.length < total;
+ });
+ if (!target) {
+ return countVisibleSchemaRows(snap);
}
- };
-
- let activeDeletedAttributes = getAttributeObj();
-
- let tableData = useMemo(
- () =>
- !isEmpty(activeDeletedAttributes)
- ? activeDeletedAttributes.map((obj: { guid: string | number }) => {
- return getRelationshipEntityObj(obj);
- })
- : [],
- [checked]
+ loadLockRef.current = true;
+ setLoadingMore(true);
+ try {
+ const existing = snap.rowsByRelation[target] || [];
+ const res = await fetchRelationPage(target, existing.length, snap);
+ const list = Array.isArray(res.entities) ? res.entities : [];
+ const mergedRef = mergeReferredFromRelationshipResponse(snap.referredEntities, {
+ referredEntities: res.referredEntities
+ });
+ const nextRows = sortRowsInRelation([...existing, ...list]);
+ const nextCache: SchemaTabCacheState = {
+ ...snap,
+ rowsByRelation: {
+ ...snap.rowsByRelation,
+ [target]: nextRows
+ },
+ referredEntities: mergedRef,
+ totals:
+ res.approximateCount !== undefined
+ ? {
+ ...snap.totals,
+ [target]: res.approximateCount
+ }
+ : snap.totals
+ };
+ schemaCacheRef.current = nextCache;
+ setSchemaCache(nextCache);
+ return countVisibleSchemaRows(nextCache);
+ } catch (e: any) {
+ console.error("Schema load more failed", e);
+ serverError(e, toastId);
+ return countVisibleSchemaRows(schemaCacheRef.current);
+ } finally {
+ setLoadingMore(false);
+ loadLockRef.current = false;
+ }
+ }, [
+ guid,
+ schemaRelationNames,
+ fetchRelationPage,
+ setSchemaCache,
+ countVisibleSchemaRows
+ ]);
+
+ const handleClientPageSizeChange = useCallback(
+ (size: number) => {
+ if (!Number.isFinite(size) || size < 1) {
+ return;
+ }
+ paginationRef.current = {
+ pageIndex: 0,
+ pageSize: size
+ };
+ setSchemaCache((prev) => {
+ if (!prev) {
+ return prev;
+ }
+ const nextLimits: Record<string, number> = {};
+ schemaRelationNames.forEach((n) => {
+ nextLimits[n] = size;
+ });
+ return {
+ ...prev,
+ pageLimitByRelation: {
+ ...prev.pageLimitByRelation,
+ ...nextLimits
+ }
+ };
+ });
+ },
+ [schemaRelationNames, setSchemaCache]
);
- const handleSwitchChange = (event: React.ChangeEvent<HTMLInputElement>) => {
- event.stopPropagation();
- setChecked(event.target.checked);
- };
+ /**
+ * After classification add/remove, GET each affected column (child) entity with
+ * ignoreRelationships=true and merge into Redux + schema tab cache (no relationship replay).
+ */
+ const refreshSchemaChildEntities = useCallback(
+ async (childGuids: string[]) => {
+ const unique = [
+ ...new Set(childGuids.filter((g) => typeof g === "string" && g.length > 0))
+ ];
+ if (unique.length === 0) {
+ return;
+ }
+ const mergedFromGet: Record<string, any> = {};
+ for (const g of unique) {
+ try {
+ const res = await getEntity(g, "GET", {});
+ const ent = (res as any)?.data?.entity;
+ if (ent) {
+ mergedFromGet[g] = ent;
+ }
+ } catch (err) {
+ console.error("Schema child entity refresh failed", g, err);
+ }
+ }
+ if (Object.keys(mergedFromGet).length === 0) {
+ return;
+ }
+ dispatch(mergeReferredEntities(mergedFromGet));
+ setSchemaCache((prev) =>
+ prev
+ ? {
+ ...prev,
+ referredEntities: {
+ ...prev.referredEntities,
+ ...mergedFromGet
+ }
+ }
+ : prev
+ );
+ },
+ [dispatch, setSchemaCache]
+ );
+
+ const handleFetchForPagination = useCallback(
+ async ({
+ pagination
+ }: {
+ pagination: PaginationState;
+ sorting: SortingState;
+ }) => {
+ paginationRef.current = pagination;
+ const needRows = (pagination.pageIndex + 1) * pagination.pageSize;
+ let len = countVisibleSchemaRows(schemaCacheRef.current);
+ let guard = 0;
+ while (guard++ < 120) {
+ if (len >= needRows) {
+ mergedRowCountRef.current = len;
+ return;
+ }
+ if (!hasMoreRef.current) {
+ return;
+ }
+ const before = len;
+ const after = await loadNextChunk();
+ mergedRowCountRef.current = after;
+ if (after <= before) {
+ return;
+ }
+ len = after;
+ }
+ },
+ [loadNextChunk, countVisibleSchemaRows]
+ );
+
+ const defaultColumns: ColumnDef<any>[] = useMemo(() => {
+ const cols: ColumnDef<any>[] = [];
+ if (!isEmpty(schemaTableAttribute)) {
+ Object.keys(schemaTableAttribute).forEach((key) => {
+ if (key === undefined || key === "position" || key === "description") {
+ return;
+ }
+ if (key === "name") {
+ cols.push({
+ accessorFn: (row: any) => row.attributes?.name,
+ accessorKey: "name",
+ cell: (info: any) => {
+ const entityDef: any = info.row.original;
+ const href = `/detailPage/${entityDef.guid}`;
+ return (
+ <div className="searchTableName">
+ <LightTooltip title={entityDef.attributes?.name}>
+ {entityDef.guid != "-1" ? (
+ <Link
+ className={`entity-name text-decoration-none ${
+ entityDef.status &&
+ entityStateReadOnly[entityDef.status]
+ ? "text-red"
+ : "text-blue"
+ }`}
+ style={{
+ width: "unset !important",
+ whiteSpace: "nowrap"
+ }}
+ to={{ pathname: href }}
+ >
+ {entityDef.attributes?.name}
+ </Link>
+ ) : (
+ <span>{entityDef.attributes?.name} </span>
+ )}
+ </LightTooltip>
+ {entityDef.status &&
+ entityStateReadOnly[entityDef.status] && (
+ <LightTooltip title="Deleted">
+ <IconButton
+ aria-label="deleted-entity"
+ sx={{
+ display: "inline-flex",
+ position: "relative",
+ padding: "4px",
+ marginLeft: "4px",
+ color: (theme) => theme.palette.grey[500]
+ }}
+ >
+ <DeleteOutlineOutlinedIcon
+ sx={{ fontSize: "1.25rem" }}
+ />
+ </IconButton>
+ </LightTooltip>
+ )}
+ </div>
+ );
+ },
+ header: "Name",
+ enableSorting: true,
+ id: "name"
+ });
+ return;
+ }
+ cols.push({
+ accessorFn: (row: any) => row.attributes?.[key],
+ accessorKey: key,
+ cell: (info: any) => {
+ const values = info.row.original.attributes;
+ return (
+ <Typography>
+ {!isEmpty(values?.[key]) ? values[key] : "N/A"}
+ </Typography>
+ );
+ },
+ header: key,
+ enableSorting: true,
+ id: key
+ });
+ });
+ }
+ cols.push({
+ accessorFn: (row: any) => row.classificationNames?.[0],
+ accessorKey: "classificationNames",
+ cell: (info: any) => {
+ const data = info.row.original;
+ if (data.guid === "-1") {
+ return;
+ }
+ return (
+ <DialogShowMoreLess
+ value={data}
+ readOnly={
+ data.status && entityStateReadOnly[data.status] ? true : false
+ }
+ columnVal="classifications"
+ colName="Classification"
+ displayText="typeName"
+ removeApiMethod={removeClassification}
+ isShowMoreLess={true}
+ onSchemaChildEntityRefresh={refreshSchemaChildEntities}
+ />
+ );
+ },
+ header: "Classifications",
+ enableSorting: false,
+ id: "classifications"
+ });
+ return cols;
+ }, [schemaTableAttribute, refreshSchemaChildEntities]);
+
+ /** Avoid indefinite skeleton when schemaRelationNames is not ready yet (only !schemaCache mattered). */
+ const needsSchemaFetch = !isEmpty(schemaRelationNames);
+ const isFetching =
+ detailLoading ||
+ initialLoading ||
+ loadingMore ||
+ (needsSchemaFetch && !schemaCache);
return (
<>
@@ -249,6 +809,8 @@ const SchemaTab = ({
justifyContent="flex-end"
alignItems="center"
marginBottom="0.75rem"
+ flexWrap="wrap"
+ gap={1}
data-id="checkDeletedEntity"
>
<FormGroup>
@@ -264,7 +826,7 @@ const SchemaTab = ({
e.stopPropagation();
}}
sx={{ marginRight: "4px" }}
- inputProps={{ "aria-label": "controlled" }}
+ inputProps={{ "aria-label": "show-historical-entities" }}
/>
}
label="Show historical entities"
@@ -273,17 +835,27 @@ const SchemaTab = ({
</Stack>
<TableLayout
+ key={`${guid ?? ""}-schema`}
data={tableData}
columns={defaultColumns.filter(Boolean)}
- emptyText="No Records found!"
- isFetching={loading}
+ emptyText={schemaEmptyText}
+ isFetching={isFetching}
showRowSelection={true}
columnVisibility={false}
clientSideSorting={true}
columnSort={true}
showPagination={true}
+ isClientSidePagination={true}
+ totalCount={
+ schemaCache?.initialLoadDone
+ ? schemaPaginationTotalCount
+ : undefined
+ }
+ fetchData={handleFetchForPagination}
tableFilters={false}
assignFilters={{ classifications: true, term: false }}
+ defaultPageSize={DEFAULT_RELATIONSHIP_PAGE_LIMIT}
+ onClientPageSizeChange={handleClientPageSizeChange}
/>
</Stack>
</Grid>
diff --git a/dashboard/src/views/DetailPage/EntityDetailTabs/TaskTab.tsx b/dashboard/src/views/DetailPage/EntityDetailTabs/TaskTab.tsx
index ddf3df9a5..21721935f 100644
--- a/dashboard/src/views/DetailPage/EntityDetailTabs/TaskTab.tsx
+++ b/dashboard/src/views/DetailPage/EntityDetailTabs/TaskTab.tsx
@@ -2,9 +2,9 @@
* 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
+ * The ASF licenses this file 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
*
@@ -15,8 +15,199 @@
* limitations under the License.
*/
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { ColumnDef } from "@tanstack/react-table";
+import { Grid, IconButton, Stack, Typography, Collapse } from "@mui/material";
+import RefreshIcon from "@mui/icons-material/Refresh";
+import { TableLayout } from "@components/Table/TableLayout";
+import { getPendingTasks } from "@api/apiMethods/adminTasksApiMethod";
+import { serverError, dateFormat } from "@utils/Utils";
+import moment from "moment-timezone";
+import { auditAction } from "@utils/Enum";
+import { isEmpty } from "@utils/Utils";
+
+const formatTaskDate = (v: unknown): string => {
+ if (v == null || v === "") {
+ return "N/A";
+ }
+ const t =
+ typeof v === "number"
+ ? v
+ : moment(v as string | Date).valueOf();
+ if (!Number.isFinite(t) || t <= 0) {
+ return "N/A";
+ }
+ return dateFormat(t);
+};
+
+export interface AtlasTaskRow {
+ guid?: string;
+ type?: string;
+ status?: string;
+ createdTime?: string | number | Date;
+ updatedTime?: string | number | Date;
+ parameters?: Record<string, unknown>;
+ attemptCount?: number;
+ createdBy?: string;
+}
+
const TaskTab = () => {
- return <h1>Task</h1>;
+ const toastId = useRef<any>(null);
+ const [tasks, setTasks] = useState<AtlasTaskRow[]>([]);
+ const [loading, setLoading] = useState<boolean>(true);
+ const [expandedByGuid, setExpandedByGuid] = useState<Record<string, boolean>>(
+ {}
+ );
+
+ const loadTasks = useCallback(async () => {
+ setLoading(true);
+ try {
+ const resp = await getPendingTasks({});
+ const data = resp?.data;
+ const list = Array.isArray(data) ? data : [];
+ setTasks(list);
+ } catch (e: any) {
+ console.error(e);
+ serverError(e, toastId);
+ setTasks([]);
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ useEffect(() => {
+ void loadTasks();
+ }, [loadTasks]);
+
+ const columns = useMemo<ColumnDef<AtlasTaskRow>[]>(
+ () => [
+ {
+ id: "expand",
+ header: "",
+ cell: ({ row }) => {
+ const guid = String(row.original.guid ?? row.id);
+ const open = !!expandedByGuid[guid];
+ const params = row.original.parameters || {};
+ const displayParams: Record<string, unknown> = {
+ ...params,
+ attemptCount: row.original.attemptCount,
+ createdBy: row.original.createdBy
+ };
+ delete displayParams.entityGuid;
+ return (
+ <>
+ <IconButton
+ size="small"
+ aria-expanded={open}
+ aria-label="toggle task parameters"
+ onClick={() => {
+ setExpandedByGuid((prev) => ({
+ ...prev,
+ [guid]: !prev[guid]
+ }));
+ }}
+ >
+ {open ? "−" : "+"}
+ </IconButton>
+ <Collapse in={open} timeout="auto" unmountOnExit>
+ <Typography
+ variant="caption"
+ component="div"
+ sx={{ p: 1, bgcolor: "action.hover" }}
+ >
+ <strong>Parameters</strong>
+ <pre
+ style={{
+ margin: 0,
+ whiteSpace: "pre-wrap",
+ fontSize: 12
+ }}
+ >
+ {JSON.stringify(displayParams, null, 2)}
+ </pre>
+ </Typography>
+ </Collapse>
+ </>
+ );
+ },
+ enableSorting: false
+ },
+ {
+ accessorKey: "type",
+ header: "Type",
+ cell: (info) => {
+ const v = info.getValue() as string;
+ return (
+ <Typography>{!isEmpty(v) ? auditAction[v] || v : "N/A"}</Typography>
+ );
+ },
+ enableSorting: false
+ },
+ {
+ accessorKey: "guid",
+ header: "Guid",
+ cell: (info) => (
+ <Typography sx={{ wordBreak: "break-all" }}>
+ {!isEmpty(info.getValue()) ? String(info.getValue()) : "N/A"}
+ </Typography>
+ ),
+ enableSorting: false
+ },
+ {
+ accessorKey: "status",
+ header: "Status",
+ enableSorting: false
+ },
+ {
+ accessorKey: "createdTime",
+ header: "Created Time",
+ cell: (info) => {
+ const v = info.getValue();
+ return <Typography>{formatTaskDate(v)}</Typography>;
+ },
+ enableSorting: false
+ },
+ {
+ accessorKey: "updatedTime",
+ header: "Updated Time",
+ cell: (info) => {
+ const v = info.getValue();
+ return <Typography>{formatTaskDate(v)}</Typography>;
+ },
+ enableSorting: false
+ }
+ ],
+ [expandedByGuid]
+ );
+
+ return (
+ <Grid container marginTop={0} className="properties-container">
+ <Grid item md={12} p={2}>
+ <Stack direction="row" justifyContent="flex-end" sx={{ mb: 1 }}>
+ <IconButton
+ aria-label="refresh tasks"
+ onClick={() => void loadTasks()}
+ size="small"
+ >
+ <RefreshIcon />
+ </IconButton>
+ </Stack>
+ <TableLayout
+ data={tasks}
+ columns={columns}
+ emptyText="No records found!"
+ isFetching={loading}
+ columnVisibility={false}
+ clientSideSorting={true}
+ columnSort={false}
+ showPagination={true}
+ isClientSidePagination={true}
+ showRowSelection={false}
+ tableFilters={false}
+ />
+ </Grid>
+ </Grid>
+ );
};
export default TaskTab;
diff --git a/dashboardv2/public/css/scss/loader.scss b/dashboardv2/public/css/scss/loader.scss
index a7db0ad59..a574ed44e 100644
--- a/dashboardv2/public/css/scss/loader.scss
+++ b/dashboardv2/public/css/scss/loader.scss
@@ -61,6 +61,27 @@
left: 50%;
position: absolute;
display: none;
+ z-index: 100;
+}
+
+/* Match SearchResultLayoutView: `.show` toggles visibility (Bootstrap or explicit). */
+.fontLoader.show {
+ display: block !important;
+}
+
+.tableOverlay.show {
+ display: block !important;
+}
+
+/* TableLayout grid spinner (schema uses manual collection push — not collection.request). */
+[data-id="r_tableSpinner"].show {
+ display: block !important;
+ z-index: 101;
+}
+
+/* Schema tab: give the overlay a box so it covers the grid while loading. */
+.schema-table-loader-wrap {
+ min-height: 280px;
}
.fontLoader-relative {
diff --git a/dashboardv2/public/css/scss/table.scss b/dashboardv2/public/css/scss/table.scss
index 11de40d7a..c82daa8be 100644
--- a/dashboardv2/public/css/scss/table.scss
+++ b/dashboardv2/public/css/scss/table.scss
@@ -183,6 +183,7 @@ tr.empty {
>.disabled>a,
>li>button[disabled] {
color: $color_mountain_mist_approx;
+ cursor: not-allowed;
&:hover {
cursor: not-allowed;
@@ -190,6 +191,18 @@ tr.empty {
background: $color_jungle_green_light !important;
}
}
+
+ /*
+ * Atlas client pagination (schema): fetch in progress — disabled buttons do not
+ * receive hover, so cursor + ul.atlas-pagination-busy show loading (see TableLayout).
+ */
+ &.atlas-pagination-busy {
+ cursor: wait;
+
+ > li > button {
+ cursor: wait !important;
+ }
+ }
}
.toggleList.semi-collapsed div:nth-child(n+2) {
diff --git a/dashboardv2/public/js/templates/schema/SchemaTableLayoutView_tmpl.html b/dashboardv2/public/js/templates/schema/SchemaTableLayoutView_tmpl.html
index d77544f50..74d89bf55 100644
--- a/dashboardv2/public/js/templates/schema/SchemaTableLayoutView_tmpl.html
+++ b/dashboardv2/public/js/templates/schema/SchemaTableLayoutView_tmpl.html
@@ -14,8 +14,11 @@
* See the License for the specific language governing permissions and
* limitations under the License.
-->
-<div class="position-relative">
+<div class="position-relative schema-table-loader-wrap">
<div class="tableOverlay"></div>
+ <div class="fontLoader" style="z-index:999" aria-hidden="true">
+ <i class="fa fa-refresh fa-spin-custom"></i>
+ </div>
<div class="inline-content-fr">
<div class="clearfix inline">
<a href="javascript:void(0)" title="Assign Classification" class="inputAssignTag multiSelectTag assignTag btn btn-action btn-sm" style="display:none" data-id="addAssignTag"><i class="fa fa-plus"></i> Classification</a>
diff --git a/dashboardv2/public/js/utils/TableLayout.js b/dashboardv2/public/js/utils/TableLayout.js
index 273acf312..26bdd559e 100644
--- a/dashboardv2/public/js/utils/TableLayout.js
+++ b/dashboardv2/public/js/utils/TableLayout.js
@@ -201,6 +201,9 @@ define(['require',
_.extend(this, options.atlasPaginationOpts);
_.extend(this.gridOpts, options.gridOpts, { collection: this.collection, columns: this.columns });
_.extend(this.sortOpts, options.sortOpts);
+ /* Atlas schema / client pagination: block double-clicks while fetch runs. */
+ this._atlasPaginationBusy = false;
+ this._lastRenderAtlasPaginationOptions = {};
if (this.isApiSorting) {
//after audit sorting pagination values
if (this.offset === 0) {
@@ -420,15 +423,149 @@ define(['require',
}
},
+ /**
+ * Optional merged approximate total (e.g. schema tab across relations).
+ * When set (number or function), footer and next/prev bounds use it like React
+ * TablePagination client mode.
+ */
+ resolveAtlasApproximateTotal: function() {
+ var v = this.atlasApproximateDatasetTotal;
+ if (_.isFunction(v)) {
+ var n = v.call(this);
+ return (_.isFinite(n) && n >= 0) ? n : null;
+ }
+ if (typeof v === 'number' && _.isFinite(v) && v >= 0) {
+ return v;
+ }
+ return null;
+ },
+
+ getAtlasPaginationCeiling: function() {
+ var approx = this.resolveAtlasApproximateTotal();
+ if (approx !== null) {
+ return approx;
+ }
+ return this.collection.fullCollection
+ ? this.collection.fullCollection.length
+ : this.collection.length;
+ },
+
+ /**
+ * While relationship/chunk fetch or grid rebuild is in progress, disable
+ * Next/Prev and Page Limit (schema tab double-click / stale nav).
+ * @param {boolean} busy
+ * @param {'next'|'prev'|'both'} [navHint] Which control triggered load — drives spinner
+ * placement (Classic schema: user sees feedback like basic search).
+ */
+ setAtlasPaginationBusy: function(busy, navHint) {
+ this._atlasPaginationBusy = !!busy;
+ if (busy) {
+ this._atlasPaginationBusyHint = navHint === 'next' || navHint === 'prev'
+ ? navHint
+ : 'both';
+ } else {
+ this._atlasPaginationBusyHint = null;
+ }
+ if (!this.includeAtlasPagination || !this.ui || !this.ui.nextData || !this.ui.nextData.length) {
+ return;
+ }
+ this.renderAtlasPagination(this._lastRenderAtlasPaginationOptions || {});
+ },
+
+ /**
+ * After renderAtlasPagination sets disabled props, show spinner + busy chrome so
+ * the user sees work in progress (disabled buttons do not get :hover cursor).
+ */
+ _syncAtlasPaginationBusyVisuals: function() {
+ if (!this.includeAtlasPagination || !this.ui || !this.ui.nextData || !this.ui.nextData.length) {
+ return;
+ }
+ var nextBtn = this.ui.nextData;
+ var prevBtn = this.ui.previousData;
+ var ul = nextBtn.closest('ul');
+ var spinHtml = '<i class="fa fa-spinner fa-spin" aria-hidden="true"></i>';
+ var prevHtml = '<i class="fa fa-angle-left" aria-hidden="true"></i>';
+ var nextHtml = '<i class="fa fa-angle-right" aria-hidden="true"></i>';
+ var loadingTitle = 'Loading...';
+ if (this._atlasPaginationBusy) {
+ ul.addClass('atlas-pagination-busy');
+ ul.attr('aria-busy', 'true');
+ var hint = this._atlasPaginationBusyHint || 'both';
+ if (hint === 'next') {
+ nextBtn.html(spinHtml);
+ nextBtn.attr('title', loadingTitle);
+ if (prevBtn && prevBtn.length) {
+ prevBtn.html(prevHtml);
+ prevBtn.attr('title', 'Previous');
+ }
+ } else if (hint === 'prev') {
+ if (prevBtn && prevBtn.length) {
+ prevBtn.html(spinHtml);
+ prevBtn.attr('title', loadingTitle);
+ }
+ nextBtn.html(nextHtml);
+ nextBtn.attr('title', 'Next');
+ } else {
+ nextBtn.html(spinHtml);
+ nextBtn.attr('title', loadingTitle);
+ if (prevBtn && prevBtn.length) {
+ prevBtn.html(spinHtml);
+ prevBtn.attr('title', loadingTitle);
+ }
+ }
+ } else {
+ ul.removeClass('atlas-pagination-busy');
+ ul.attr('aria-busy', 'false');
+ nextBtn.html(nextHtml);
+ nextBtn.attr('title', 'Next');
+ if (prevBtn && prevBtn.length) {
+ prevBtn.html(prevHtml);
+ prevBtn.attr('title', 'Previous');
+ }
+ }
+ },
+
renderAtlasPagination: function(options) {
+ this._lastRenderAtlasPaginationOptions = options || {};
+ /*
+ * Schema tab: Next updates collection.queryParams.offset before fetchCollection;
+ * Pageable state can briefly leave this.offset stale so footer shows 1–100.
+ */
+ if (this.clientAtlasPagination && this.collection && this.collection.queryParams) {
+ var qo = parseInt(this.collection.queryParams.offset, 10);
+ if (_.isFinite(qo) && qo >= 0) {
+ this.offset = qo;
+ }
+ var ql = parseInt(this.collection.queryParams.limit, 10);
+ if (_.isFinite(ql) && ql > 0) {
+ this.limit = ql;
+ }
+ /*
+ * Page Limit dropdown is authoritative. Stale queryParams.limit (e.g. after
+ * relationship chunk size vs UI limit) made prev subtract the wrong step and
+ * renderAtlasPagination's old "previous" branch used pageTo - limit with a
+ * mismatched limit — wrong page number (e.g. 10 -> 7) and range (31-35).
+ */
+ if (this.ui && this.ui.showPage && this.ui.showPage.length) {
+ var lp = parseInt(this.ui.showPage.val(), 10);
+ if (_.isFinite(lp) && lp > 0) {
+ this.limit = lp;
+ this.collection.queryParams.limit = lp;
+ }
+ }
+ }
var isFirstPage = this.offset === 0,
dataLength = this.collection.length,
- goToPage = this.ui.gotoPage.val();
+ goToPage = (this.ui.gotoPage && this.ui.gotoPage.length) ? this.ui.gotoPage.val() : '',
+ ceiling = this.getAtlasPaginationCeiling(),
+ approxTotal = this.resolveAtlasApproximateTotal();
/*Next button check.
It's outside of Previous button else condition
because when user comes from 2 page to 1 page than we need to check next button.*/
- if (dataLength < this.limit) {
+ if (this.clientAtlasPagination && approxTotal !== null) {
+ this.ui.nextData.attr('disabled', (this.offset + this.limit) >= ceiling);
+ } else if (dataLength < this.limit) {
this.ui.nextData.attr('disabled', true);
} else {
this.ui.nextData.attr('disabled', false);
@@ -443,27 +580,32 @@ define(['require',
// Previous button check.s
if (isFirstPage) {
this.ui.previousData.attr('disabled', true);
- this.pageFrom = 1;
- this.pageTo = this.limit;
+ this.pageFrom = ceiling === 0 ? 0 : 1;
+ this.pageTo = Math.min(this.limit, ceiling);
} else {
this.ui.previousData.attr('disabled', false);
}
- if (options && options.next) {
- //on next click, adding "1" for showing the another records.
- this.pageTo = this.offset + this.limit;
+ if (!isFirstPage) {
+ /* Next/Prev both: offset + limit are authoritative (set before fetchCollection). */
this.pageFrom = this.offset + 1;
- } else if (!isFirstPage && options && options.previous) {
- this.pageTo = this.pageTo - this.limit;
- this.pageFrom = (this.pageTo - this.limit) + 1;
+ this.pageTo = Math.min(this.offset + this.limit, ceiling);
}
if (this.isApiSorting && !this.pageTo && !this.pageFrom) {
this.limit = this.count;
this.pageTo = (this.offset + this.limit);
this.pageFrom = this.offset + 1;
}
- this.ui.pageRecordText.html("Showing <u>" + this.collection.length + " records</u> From " + this.pageFrom + " - " + this.pageTo);
- this.activePage = Math.round(this.pageTo / this.limit);
+ var totalRecords = ceiling;
+ var displayTo = Math.min(this.pageTo, totalRecords);
+ var displayFrom = totalRecords === 0 ? 0 : Math.min(this.pageFrom, displayTo);
+ var totalLabel = (typeof totalRecords === 'number' && totalRecords.toLocaleString)
+ ? totalRecords.toLocaleString('en-US')
+ : totalRecords;
+ this.ui.pageRecordText.html("Showing <u>" + totalLabel + " records</u> From " + displayFrom + " - " + displayTo);
+ this.activePage = (this.limit > 0)
+ ? (Math.floor(this.offset / this.limit) + 1)
+ : 1;
this.ui.activePage.attr('title', "Page " + this.activePage);
this.ui.activePage.text(this.activePage);
this.ui.showPage.val(this.limit).trigger('change', { "skipViewChange": true });
@@ -489,8 +631,26 @@ define(['require',
html: true,
content: Messages.search.noRecordForPage + '<b>' + Utils.getNumberSuffix({ number: pageNumber, sup: true }) + '</b> page'
});
+ if (this._atlasPaginationBusy) {
+ this.ui.nextData.prop('disabled', true);
+ this.ui.previousData.prop('disabled', true);
+ if (this.ui.showPage && this.ui.showPage.length) {
+ this.ui.showPage.prop('disabled', true);
+ }
+ }
+ this._syncAtlasPaginationBusyVisuals();
return;
}
+ if (this._atlasPaginationBusy) {
+ this.ui.nextData.prop('disabled', true);
+ this.ui.previousData.prop('disabled', true);
+ if (this.ui.showPage && this.ui.showPage.length) {
+ this.ui.showPage.prop('disabled', true);
+ }
+ } else if (this.ui.showPage && this.ui.showPage.length) {
+ this.ui.showPage.prop('disabled', false);
+ }
+ this._syncAtlasPaginationBusyVisuals();
},
/**
@@ -657,9 +817,22 @@ define(['require',
atlasNextBtn
**/
onClicknextData: function() {
+ if (this._atlasPaginationBusy) {
+ return;
+ }
+ if (this.clientAtlasPagination && typeof this.atlasShowLoaderBeforeFetch === 'function') {
+ this.atlasShowLoaderBeforeFetch();
+ }
+ if (this.clientAtlasPagination && this.ui && this.ui.showPage && this.ui.showPage.length) {
+ var ln = parseInt(this.ui.showPage.val(), 10);
+ if (_.isFinite(ln) && ln > 0) {
+ this.limit = ln;
+ }
+ }
this.offset = this.offset + this.limit;
_.extend(this.collection.queryParams, {
- offset: this.offset
+ offset: this.offset,
+ limit: this.limit
});
if (this.value) {
this.value.pageOffset = this.offset;
@@ -677,12 +850,25 @@ define(['require',
atlasPrevBtn
**/
onClickpreviousData: function() {
+ if (this._atlasPaginationBusy) {
+ return;
+ }
+ if (this.clientAtlasPagination && typeof this.atlasShowLoaderBeforeFetch === 'function') {
+ this.atlasShowLoaderBeforeFetch();
+ }
+ if (this.clientAtlasPagination && this.ui && this.ui.showPage && this.ui.showPage.length) {
+ var lp = parseInt(this.ui.showPage.val(), 10);
+ if (_.isFinite(lp) && lp > 0) {
+ this.limit = lp;
+ }
+ }
this.offset = this.offset - this.limit;
if (this.offset <= -1) {
this.offset = 0;
}
_.extend(this.collection.queryParams, {
- offset: this.offset
+ offset: this.offset,
+ limit: this.limit
});
if (this.value) {
this.value.pageOffset = this.offset;
@@ -701,6 +887,9 @@ define(['require',
**/
changePageLimit: function(e, obj) {
if (!obj || (obj && !obj.skipViewChange)) {
+ if (this._atlasPaginationBusy) {
+ return;
+ }
var limit = parseInt(this.ui.showPage.val());
if (limit == 0) {
this.ui.showPage.data('select2').$container.addClass('has-error');
@@ -718,7 +907,8 @@ define(['require',
}
}
_.extend(this.collection.queryParams, { limit: this.limit, offset: this.offset });
- this.fetchCollection();
+ /* Schema tab: Classic UI re-sorts cached rows by position when limit changes. */
+ this.fetchCollection({ fromPageLimitChange: true });
}
},
/**
diff --git a/dashboardv2/public/js/utils/Utils.js b/dashboardv2/public/js/utils/Utils.js
index c7e47055a..fb4d850a3 100644
--- a/dashboardv2/public/js/utils/Utils.js
+++ b/dashboardv2/public/js/utils/Utils.js
@@ -1341,5 +1341,59 @@ define(['require', 'utils/Globals', 'pnotify', 'utils/Messages', 'utils/Enums',
el.attr('disabled', false);
}, 1000);
}
+
+ /**
+ * Normalizes typedef option schemaElementsAttribute (string | string[] or
+ * comma-separated) into relationship names for schema API calls.
+ */
+ Utils.normalizeSchemaElementsAttribute = function(raw) {
+ if (raw === undefined || raw === null) {
+ return [];
+ }
+ if (_.isArray(raw)) {
+ return _.chain(raw).map(function(s) {
+ return String(s).trim();
+ }).filter(function(s) {
+ return s.length > 0;
+ }).value();
+ }
+ var s = String(raw).trim();
+ if (!s.length) {
+ return [];
+ }
+ if (s.indexOf(',') !== -1) {
+ return _.chain(s.split(',')).map(function(p) {
+ return p.trim();
+ }).filter(function(p) {
+ return p.length > 0;
+ }).value();
+ }
+ return [s];
+ };
+
+ /**
+ * Serializes GET /v2/search/relationship query params; repeats attributes=a&attributes=b.
+ */
+ Utils.serializeRelationshipQueryParams = function(params) {
+ var parts = [];
+ _.each(params, function(val, key) {
+ if (val === undefined || val === null) {
+ return;
+ }
+ if (key === 'attributes' && _.isArray(val)) {
+ _.each(val, function(a) {
+ parts.push(encodeURIComponent(key) + '=' + encodeURIComponent(String(a)));
+ });
+ return;
+ }
+ if (typeof val === 'boolean') {
+ parts.push(encodeURIComponent(key) + '=' + encodeURIComponent(val ? 'true' : 'false'));
+ return;
+ }
+ parts.push(encodeURIComponent(key) + '=' + encodeURIComponent(String(val)));
+ });
+ return parts.join('&');
+ };
+
return Utils;
});
\ No newline at end of file
diff --git a/dashboardv2/public/js/views/detail_page/DetailPageLayoutView.js b/dashboardv2/public/js/views/detail_page/DetailPageLayoutView.js
index b69732aab..35074105e 100644
--- a/dashboardv2/public/js/views/detail_page/DetailPageLayoutView.js
+++ b/dashboardv2/public/js/views/detail_page/DetailPageLayoutView.js
@@ -289,10 +289,12 @@ define(['require',
} else {
this.generateTag([]);
}
+ /* Terms come from entity or header only. Do not call ensureRelationshipView here:
+ * that prefetches every relationship search (columns, db, …) on Properties tab load.
+ * Relationship cards load when the Relationships tab is opened (tab click / tabActive). */
if (collectionJSON && !_.startsWith(collectionJSON.typeName, "AtlasGlossary")) {
if (collectionJSON.relationshipAttributes && collectionJSON.relationshipAttributes.meanings && collectionJSON.relationshipAttributes.meanings.length) {
this.generateTerm(collectionJSON.relationshipAttributes.meanings);
- this.ensureRelationshipView();
} else {
$.ajax({
url: UrlLinks.entityHeaderApiUrl(that.id),
@@ -316,7 +318,6 @@ define(['require',
}
}
that.generateTerm(normalized);
- that.ensureRelationshipView();
}
});
}
@@ -413,13 +414,16 @@ define(['require',
// To render Schema check attribute "schemaElementsAttribute"
var schemaOptions = this.activeEntityDef.get('options');
var schemaElementsAttribute = schemaOptions && schemaOptions.schemaElementsAttribute;
- if (!_.isEmpty(schemaElementsAttribute)) {
+ var schemaRelationNames = Utils.normalizeSchemaElementsAttribute(schemaElementsAttribute);
+ if (!_.isEmpty(schemaRelationNames)) {
this.$('.schemaTable').show();
var relA = collectionJSON.relationshipAttributes || {};
var attrA = collectionJSON.attributes || {};
+ var firstRel = schemaRelationNames[0];
+ var embeddedAttr = relA[firstRel] || attrA[firstRel] || [];
this.renderSchemaLayoutView(_.extend({}, obj, {
- attribute: relA[schemaElementsAttribute] ||
- attrA[schemaElementsAttribute]
+ schemaRelationNames: schemaRelationNames,
+ attribute: embeddedAttr
}));
} else {
this.$('.schemaTable').hide();
@@ -825,6 +829,10 @@ define(['require',
var that = this;
require(['views/schema/SchemaLayoutView'], function(SchemaLayoutView) {
that.RSchemaTableLayoutView.show(new SchemaLayoutView(obj));
+ /* After async require + show, sync URL tab (e.g. refresh on tabActive=schema) so schema pane is active. */
+ if (that.value && that.value.tabActive === 'schema' && _.isFunction(that.updateTab)) {
+ that.updateTab();
+ }
});
},
renderAuditTableLayoutView: function(obj) {
diff --git a/dashboardv2/public/js/views/detail_page/RelationshipCardsLayoutView.js b/dashboardv2/public/js/views/detail_page/RelationshipCardsLayoutView.js
index 81bf2aa2b..5ded221b4 100644
--- a/dashboardv2/public/js/views/detail_page/RelationshipCardsLayoutView.js
+++ b/dashboardv2/public/js/views/detail_page/RelationshipCardsLayoutView.js
@@ -25,6 +25,20 @@ define([
], function(require, Backbone, RelationshipCardsLayoutViewTmpl, UrlLinks, Utils) {
"use strict";
+ /**
+ * Match dashboard/src/utils/relationshipSearchQuery.ts — Atlas may return -1 for approximateCount.
+ */
+ function normalizeRelationshipApproximateCount(raw) {
+ if (raw === undefined || raw === null) {
+ return undefined;
+ }
+ var n = Number(raw);
+ if (!isFinite(n) || n < 0) {
+ return undefined;
+ }
+ return n;
+ }
+
var RelationshipCardsLayoutView = Backbone.Marionette.LayoutView.extend({
template: RelationshipCardsLayoutViewTmpl,
ui: {
@@ -235,12 +249,19 @@ define([
that.referredEntities = _.extend({}, that.referredEntities, response.referredEntities);
}
that.cardData[name] = _.isArray(entities) ? entities : (entities ? [entities] : []);
- if (!_.isUndefined(response.approximateCount)) {
- that.cardCounts[name] = response.approximateCount;
- } else if (!_.isUndefined(response.totalCount)) {
- that.cardCounts[name] = response.totalCount;
+ var approxNorm = normalizeRelationshipApproximateCount(response.approximateCount);
+ var totalNorm = normalizeRelationshipApproximateCount(response.totalCount);
+ var listLen = that.cardData[name].length;
+ var reqLimit = that.getRelationshipParams(name, 0, undefined, { showDeleted: true }).limit;
+ if (approxNorm !== undefined) {
+ that.cardCounts[name] = approxNorm;
+ } else if (totalNorm !== undefined) {
+ that.cardCounts[name] = totalNorm;
+ } else if (listLen >= reqLimit) {
+ /* Unknown total but full page — allow scroll load-more (match React schema semantics). */
+ that.cardCounts[name] = listLen + 1;
} else {
- that.cardCounts[name] = that.cardData[name].length;
+ that.cardCounts[name] = listLen;
}
if (_.isUndefined(that.sortByName[name])) {
that.sortByName[name] = false;
@@ -354,6 +375,13 @@ define([
}
var newItems = _.isArray(entities) ? entities : (entities ? [entities] : []);
that.cardData[relationName] = existing.concat(newItems);
+ var approxMore = normalizeRelationshipApproximateCount(response && response.approximateCount);
+ var totalMore = normalizeRelationshipApproximateCount(response && response.totalCount);
+ if (approxMore !== undefined) {
+ that.cardCounts[relationName] = approxMore;
+ } else if (totalMore !== undefined) {
+ that.cardCounts[relationName] = totalMore;
+ }
var updatedCount = that.getTotalCountForName(relationName, that.cardData[relationName].length);
if (newItems.length === 0 || updatedCount <= that.cardData[relationName].length) {
that.exhaustedByName[relationName] = true;
@@ -400,10 +428,12 @@ define([
}
var newItems = _.isArray(entities) ? entities : (entities ? [entities] : []);
that.cardData[relationName] = newItems;
- if (!_.isUndefined(response.approximateCount)) {
- that.cardCounts[relationName] = response.approximateCount;
- } else if (!_.isUndefined(response.totalCount)) {
- that.cardCounts[relationName] = response.totalCount;
+ var approxR = normalizeRelationshipApproximateCount(response.approximateCount);
+ var totalR = normalizeRelationshipApproximateCount(response.totalCount);
+ if (approxR !== undefined) {
+ that.cardCounts[relationName] = approxR;
+ } else if (totalR !== undefined) {
+ that.cardCounts[relationName] = totalR;
}
var prevSnapR = that._lastCardCountSnapshot[relationName];
that.adjustPageLimitWhenTotalGrew(relationName, prevSnapR, that.cardCounts[relationName]);
diff --git a/dashboardv2/public/js/views/schema/SchemaLayoutView.js b/dashboardv2/public/js/views/schema/SchemaLayoutView.js
index b8357aef1..2202c955b 100644
--- a/dashboardv2/public/js/views/schema/SchemaLayoutView.js
+++ b/dashboardv2/public/js/views/schema/SchemaLayoutView.js
@@ -29,6 +29,9 @@ define(['require',
], function(require, Backbone, SchemaTableLayoutViewTmpl, VSchemaList, Utils, CommonViewFunction, Messages, Globals, Enums, UrlLinks) {
'use strict';
+ /** Per-entity cache so switching detail tabs does not refetch schema (same guid). */
+ var schemaTabCacheByGuid = {};
+
var SchemaTableLayoutView = Backbone.Marionette.LayoutView.extend(
/** @lends SchemaTableLayoutView */
{
@@ -73,17 +76,33 @@ define(['require',
* @constructs
*/
initialize: function(options) {
- _.extend(this, _.pick(options, 'guid', 'classificationDefCollection', 'entityDefCollection', 'attribute', 'fetchCollection', 'enumDefCollection'));
+ _.extend(this, _.pick(options, 'guid', 'classificationDefCollection', 'entityDefCollection', 'attribute', 'fetchCollection', 'enumDefCollection', 'schemaRelationNames', 'referredEntities'));
+ this.schemaRelationNames = options.schemaRelationNames || [];
this.schemaCollection = new VSchemaList([], {});
+ /* Match relationship search chunk size to table page size (default 100). */
+ this.schemaCollection.state.pageSize = 100;
+ /*
+ * TableLayout onClicknextData / changePageLimit mirror offset here; sync must
+ * read this before overwriting tl.offset from Pageable state (stale currentPage
+ * leaves footer at 1–100 while Next already requested offset 100).
+ */
+ if (!this.schemaCollection.queryParams) {
+ this.schemaCollection.queryParams = { limit: 100, offset: 0 };
+ }
this.commonTableOptions = {
collection: this.schemaCollection,
includeFilter: false,
- includePagination: true,
- includePageSize: true,
- includeGotoPage: true,
- includeFooterRecords: true,
+ includePagination: false,
+ includeAtlasPagination: true,
+ includeAtlasPageSize: true,
+ includePageSize: false,
+ includeGotoPage: false,
+ includeFooterRecords: false,
includeOrderAbleColumns: false,
- includeAtlasTableSorting: true,
+ /* Relationship order is by position (see sortSchemaEntityBlocks on load).
+ * Client header-sort reorders fullCollection and breaks pagination slices
+ * after classification refresh; keep schema order only. */
+ includeAtlasTableSorting: false,
gridOpts: {
className: "table table-hover backgrid table-quickMenu",
emptyText: 'No records found!'
@@ -93,6 +112,21 @@ define(['require',
};
this.bindEvents();
this.bradCrumbList = [];
+ this.schemaRowsByRelation = {};
+ this.schemaTotals = {};
+ this._schemaPaginationGuardAttached = false;
+ this._schemaTableLayout = null;
+ /** Last successful UI page (limit + offset); survives if TableLayout is briefly missing. */
+ this._lastKnownSchemaPagination = null;
+ /**
+ * Canonical client pagination for the merged schema list (cache is indexed by
+ * offset into `attribute` / mergedAttribute). Survives fullCollection.reset and
+ * TableLayout rebuild so page N × limit always maps to rows (N-1)*limit+1 … N*limit
+ * of cached data, independent of PageableCollection currentPage drift.
+ */
+ this._schemaCanonicalPagination = null;
+ /** True while sync chunk load runs inside getPage; defer clearing atlas busy until grid renders. */
+ this._schemaChunkFetchInProgress = false;
},
bindEvents: function() {
var that = this;
@@ -120,11 +154,946 @@ define(['require',
});
},
onRender: function() {
- this.generateTableData();
+ var that = this;
+ if (!this.guid || _.isEmpty(this.schemaRelationNames)) {
+ this.generateTableData();
+ return;
+ }
+ var cacheKey = this.guid;
+ var cached = schemaTabCacheByGuid[cacheKey];
+ var namesKey = this.schemaRelationNames.slice().sort().join('|');
+ if (cached && cached.namesKey === namesKey && !cached.rowsByRelation) {
+ delete schemaTabCacheByGuid[cacheKey];
+ cached = null;
+ }
+ if (cached && cached.namesKey === namesKey && cached.rowsByRelation) {
+ this.schemaRowsByRelation = cached.rowsByRelation;
+ this.schemaTotals = cached.totals || {};
+ this.attribute = cached.mergedAttribute;
+ this.referredEntities = _.extend({}, this.referredEntities || {}, cached.referredEntities);
+ if (cached.paginationCanonical &&
+ _.isFinite(cached.paginationCanonical.pageSize) &&
+ cached.paginationCanonical.pageSize > 0) {
+ this._schemaCanonicalPagination = {
+ pageSize: cached.paginationCanonical.pageSize,
+ offset: _.isFinite(cached.paginationCanonical.offset)
+ ? Math.max(0, cached.paginationCanonical.offset)
+ : 0
+ };
+ this._lastKnownSchemaPagination = _.extend({}, this._schemaCanonicalPagination);
+ }
+ this.generateTableData();
+ return;
+ }
+ this.showLoader();
+ this.fetchSchemaFromRelationshipApi(function(err, merged, refMap) {
+ if (err) {
+ that.hideLoader();
+ that.generateTableData();
+ return;
+ }
+ that.attribute = merged;
+ that.referredEntities = _.extend({}, that.referredEntities || {}, refMap);
+ schemaTabCacheByGuid[cacheKey] = {
+ namesKey: namesKey,
+ mergedAttribute: merged,
+ referredEntities: refMap,
+ rowsByRelation: that.schemaRowsByRelation,
+ totals: that.schemaTotals,
+ paginationCanonical: null
+ };
+ that.hideLoader();
+ that.generateTableData();
+ });
+ },
+ /**
+ * Match React SchemaTab: order by relationship `attributes.position` (numeric)
+ * when present; rows with position sort before rows without; then name; then guid.
+ */
+ sortRowsInRelation: function(rows) {
+ if (!rows || !rows.length) {
+ return rows || [];
+ }
+ var positionKey = function(row) {
+ var p = row && row.attributes && row.attributes.position;
+ if (p === undefined || p === null || p === '') {
+ return null;
+ }
+ var n = Number(p);
+ return _.isFinite(n) ? n : null;
+ };
+ return rows.slice().sort(function(a, b) {
+ var pa = positionKey(a);
+ var pb = positionKey(b);
+ if (pa !== null && pb !== null && pa !== pb) {
+ return pa - pb;
+ }
+ if (pa !== null && pb === null) {
+ return -1;
+ }
+ if (pa === null && pb !== null) {
+ return 1;
+ }
+ var na = (a.attributes && a.attributes.name) ? String(a.attributes.name) : '';
+ var nb = (b.attributes && b.attributes.name) ? String(b.attributes.name) : '';
+ var cmp = na.localeCompare(nb);
+ if (cmp !== 0) {
+ return cmp;
+ }
+ var ga = a.guid ? String(a.guid) : '';
+ var gb = b.guid ? String(b.guid) : '';
+ return ga.localeCompare(gb);
+ });
+ },
+ sortSchemaEntityBlocks: function(blocks) {
+ var that = this;
+ return _.flatten(_.map(blocks, function(block) {
+ return that.sortRowsInRelation(block || []);
+ }));
+ },
+ /**
+ * Re-sort every cached relation block and the flattened merged list by
+ * attributes.position (relationship order). Call when Page Limit changes
+ * so pagination slices match position order; keeps add/remove classification
+ * display consistent with sorted cache.
+ */
+ resortCachedSchemaMergedAttributeByPosition: function() {
+ var that = this;
+ if (!this.schemaRelationNames || _.isEmpty(this.schemaRowsByRelation)) {
+ return;
+ }
+ _.each(this.schemaRelationNames, function(relName) {
+ var rows = that.schemaRowsByRelation[relName];
+ if (rows && rows.length) {
+ that.schemaRowsByRelation[relName] = that.sortRowsInRelation(rows);
+ }
+ });
+ var blocks = _.map(this.schemaRelationNames, function(n) {
+ return that.schemaRowsByRelation[n] || [];
+ });
+ this.attribute = _.flatten(blocks);
+ var cacheKey = this.guid;
+ if (cacheKey) {
+ var cached = schemaTabCacheByGuid[cacheKey];
+ if (cached) {
+ cached.rowsByRelation = this.schemaRowsByRelation;
+ cached.mergedAttribute = this.attribute;
+ }
+ }
+ },
+ /**
+ * After child GET merge, refresh merged row objects in `attribute` without
+ * re-sorting. Full sortSchemaEntityBlocks() would reorder if compare keys shift
+ * (e.g. col_289 moves off page 12 after adding a classification).
+ */
+ patchAttributeRowsFromSchemaRowsByRelation: function() {
+ var that = this;
+ var prev = this.attribute;
+ if (!prev || !prev.length) {
+ var blocks = _.map(this.schemaRelationNames, function(n) {
+ return that.schemaRowsByRelation[n] || [];
+ });
+ this.attribute = this.sortSchemaEntityBlocks(blocks);
+ return;
+ }
+ var mergedByGuid = {};
+ _.each(this.schemaRelationNames, function(relName) {
+ _.each(that.schemaRowsByRelation[relName] || [], function(row) {
+ if (row && row.guid) {
+ mergedByGuid[row.guid] = row;
+ }
+ });
+ });
+ this.attribute = _.map(prev, function(row) {
+ if (row && row.guid && mergedByGuid[row.guid]) {
+ return mergedByGuid[row.guid];
+ }
+ return row;
+ });
+ },
+ /** Write canonical pagination into `schemaTabCacheByGuid` for this entity. */
+ persistSchemaCanonicalToCache: function() {
+ var snap = this._schemaCanonicalPagination;
+ var key = this.guid;
+ if (!key || !snap || !_.isFinite(snap.pageSize) || snap.pageSize <= 0) {
+ return;
+ }
+ var cached = schemaTabCacheByGuid[key];
+ if (!cached) {
+ return;
+ }
+ cached.paginationCanonical = {
+ pageSize: snap.pageSize,
+ offset: _.isFinite(snap.offset) ? Math.max(0, snap.offset) : 0
+ };
+ },
+ /** Derive canonical pagination from TableLayout (footer offset/limit) and sync cache. */
+ persistSchemaCanonicalFromTableLayout: function() {
+ var tl = this._schemaTableLayout;
+ if (!tl || !_.isFinite(tl.limit) || tl.limit <= 0) {
+ return;
+ }
+ var off = _.isFinite(tl.offset) ? Math.max(0, tl.offset) : 0;
+ this._schemaCanonicalPagination = {
+ pageSize: tl.limit,
+ offset: off
+ };
+ this._lastKnownSchemaPagination = {
+ pageSize: tl.limit,
+ offset: off
+ };
+ this.persistSchemaCanonicalToCache();
+ this.syncSchemaCollectionQueryParamsToPagination(tl.limit, off);
+ },
+ /** Keep collection.queryParams aligned with footer offset/limit (TableLayout reads this in sync). */
+ syncSchemaCollectionQueryParamsToPagination: function(pageSize, offset) {
+ var col = this.schemaCollection;
+ if (!col || !_.isFinite(pageSize) || pageSize <= 0) {
+ return;
+ }
+ if (!col.queryParams) {
+ col.queryParams = {};
+ }
+ col.queryParams.limit = pageSize;
+ col.queryParams.offset = _.isFinite(offset) ? Math.max(0, offset) : 0;
+ },
+ /**
+ * Match React relationshipSearchQuery.normalizeRelationshipApproximateCount:
+ * Atlas may expose approximateCount and/or totalCount; ignore negative / -1.
+ */
+ normalizeRelationshipApproximateCount: function(resp) {
+ if (!resp) {
+ return undefined;
+ }
+ var raw = resp.approximateCount !== undefined && resp.approximateCount !== null
+ ? resp.approximateCount
+ : resp.totalCount;
+ if (raw === undefined || raw === null) {
+ return undefined;
+ }
+ var n = Number(raw);
+ if (!_.isFinite(n) || n < 0) {
+ return undefined;
+ }
+ return n;
+ },
+ /**
+ * Rows to request per relationship search call (per-relation offset cursor).
+ * Kept at 100 for fewer round-trips on large schemas; grid Page Limit is separate
+ * (TableLayout / canonical pagination slice the merged list). Network may show
+ * limit=100 while footer shows limit=10 — that is expected.
+ */
+ getRelationshipApiLimit: function() {
+ return 100;
+ },
+ /**
+ * Merge relationship-search row with fresh GET entity. Atlas often omits
+ * `classifications` when empty; shallow _.extend keeps stale chips otherwise.
+ * Mirrors React resolveEntity (classifications + attributes precedence).
+ */
+ mergeSchemaRowWithFreshEntity: function(row, entity, opts) {
+ opts = opts || {};
+ if (!entity) {
+ return row;
+ }
+ var merged = _.extend({}, row, entity);
+ /*
+ * GET with minExtInfo often returns partial attributes (no position/name).
+ * Replacing attributes wholesale made sortSchemaEntityBlocks treat position as
+ * missing (1e9), moving the row to the end — wrong slice on paginated pages.
+ */
+ merged.attributes = _.extend(
+ {},
+ (row && row.attributes) || {},
+ (entity && entity.attributes) || {}
+ );
+ /*
+ * Relationship search row is authoritative for schema order (position, name).
+ * minExtInfo GET can still overwrite or omit these; keep row values.
+ */
+ var ra = row && row.attributes;
+ if (ra) {
+ if (ra.position !== undefined && ra.position !== null) {
+ merged.attributes.position = ra.position;
+ }
+ if (ra.name !== undefined && ra.name !== null && ra.name !== '') {
+ merged.attributes.name = ra.name;
+ }
+ }
+ if (Object.prototype.hasOwnProperty.call(entity, 'classifications')) {
+ merged.classifications = entity.classifications == null ? [] : entity.classifications;
+ } else if (opts.fromClassificationRefresh) {
+ /*
+ * After remove-tag (or last tag removed), Atlas may omit the
+ * classifications key entirely when empty (same as [] in Preview).
+ * Keeping row.classifications here left stale chips on the row.
+ */
+ merged.classifications = [];
+ } else {
+ /*
+ * minExtInfo GET may omit classifications entirely; do not replace
+ * with [] or add-tag refresh appears to lose the new assignment.
+ */
+ merged.classifications = (row && row.classifications) ? row.classifications : [];
+ }
+ if (Object.prototype.hasOwnProperty.call(entity, 'classificationNames')) {
+ merged.classificationNames = entity.classificationNames;
+ } else if (opts.fromClassificationRefresh &&
+ (!merged.classifications || merged.classifications.length === 0)) {
+ merged.classificationNames = [];
+ }
+ return merged;
+ },
+ /**
+ * After classification add/remove on schema row(s), GET each child entity guid
+ * (ignoreRelationships) and merge into cache — no relationship search replay.
+ */
+ refreshChildEntitiesAfterClassification: function(childGuids, done) {
+ var that = this;
+ childGuids = _.chain(childGuids || []).compact().uniq().value();
+ if (childGuids.length === 0) {
+ if (done) {
+ done();
+ }
+ return;
+ }
+ var index = 0;
+ var step = function() {
+ if (index >= childGuids.length) {
+ /*
+ * Do not re-sort the merged list after classification — only patch row
+ * objects from schemaRowsByRelation so global index (page N) is stable.
+ */
+ that.patchAttributeRowsFromSchemaRowsByRelation();
+ var cacheKey = that.guid;
+ var cached = schemaTabCacheByGuid[cacheKey];
+ if (cached) {
+ cached.rowsByRelation = that.schemaRowsByRelation;
+ cached.mergedAttribute = that.attribute;
+ cached.referredEntities = _.extend({}, cached.referredEntities || {}, that.referredEntities || {});
+ if (that._schemaCanonicalPagination) {
+ cached.paginationCanonical = _.extend({}, that._schemaCanonicalPagination);
+ }
+ }
+ that.generateTableData();
+ if (done) {
+ done();
+ }
+ return;
+ }
+ var g = childGuids[index++];
+ var url = UrlLinks.entitiesApiUrl({ guid: g, minExtInfo: true });
+ $.ajax({
+ url: url,
+ type: 'GET',
+ dataType: 'json'
+ }).fail(function() {
+ step();
+ }).done(function(resp) {
+ var entity = resp && resp.entity ? resp.entity : null;
+ if (entity) {
+ that.referredEntities = _.extend({}, that.referredEntities || {});
+ that.referredEntities[g] = entity;
+ _.each(that.schemaRelationNames, function(relName) {
+ var rows = that.schemaRowsByRelation[relName] || [];
+ for (var i = 0; i < rows.length; i++) {
+ var row = rows[i];
+ if (row && row.guid === g) {
+ rows[i] = that.mergeSchemaRowWithFreshEntity(row, entity, {
+ fromClassificationRefresh: true
+ });
+ break;
+ }
+ }
+ });
+ }
+ step();
+ });
+ };
+ step();
+ },
+ fetchSchemaFromRelationshipApi: function(done) {
+ var that = this;
+ var apiUrl = UrlLinks.relationshipSearchV2ApiUrl();
+ /*
+ * jQuery $.when with multiple $.ajax passes each jqXHR result as three
+ * callback args (data, textStatus, jqXHR). Unwrap to one JSON body per
+ * relation so arguments.length === schemaRelationNames.length.
+ */
+ var reqs = _.map(this.schemaRelationNames, function(relationName) {
+ var params = {
+ limit: that.getRelationshipApiLimit(),
+ offset: 0,
+ guid: that.guid,
+ disableDefaultSorting: true,
+ excludeDeletedEntities: false,
+ includeSubClassifications: true,
+ includeSubTypes: true,
+ includeClassificationAttributes: true,
+ relation: relationName,
+ getApproximateCount: true,
+ attributes: ['type', 'position']
+ };
+ var qs = Utils.serializeRelationshipQueryParams(params);
+ return $.ajax({
+ url: apiUrl + '?' + qs,
+ type: 'GET',
+ dataType: 'json'
+ }).then(function(data) {
+ return data;
+ });
+ });
+ if (reqs.length === 0) {
+ return done(null, [], {});
+ }
+ $.when.apply($, reqs).fail(function() {
+ done(new Error('schema relationship fetch'));
+ }).done(function() {
+ var blocks = [];
+ var refMap = {};
+ var rowsByRelation = {};
+ var totals = {};
+ for (var i = 0; i < arguments.length; i++) {
+ var resp = arguments[i];
+ var relationName = that.schemaRelationNames[i];
+ var entities = resp && resp.entities ? resp.entities : [];
+ blocks.push(entities);
+ rowsByRelation[relationName] = entities;
+ var approx = that.normalizeRelationshipApproximateCount(resp);
+ if (approx !== undefined) {
+ totals[relationName] = approx;
+ }
+ if (resp && resp.referredEntities) {
+ _.extend(refMap, resp.referredEntities);
+ }
+ }
+ that.schemaRowsByRelation = rowsByRelation;
+ that.schemaTotals = totals;
+ var merged = that.sortSchemaEntityBlocks(blocks);
+ done(null, merged, refMap);
+ });
+ },
+ schemaHasMore: function() {
+ var that = this;
+ if (!this.schemaRelationNames || _.isEmpty(this.schemaRowsByRelation)) {
+ return false;
+ }
+ return _.some(this.schemaRelationNames, function(name) {
+ var rows = that.schemaRowsByRelation[name] || [];
+ var total = that.schemaTotals[name] || 0;
+ return rows.length < total;
+ });
+ },
+ /** Total merged rows across relations (same idea as React totalMergedRows). Prefer over fullCollection.length in prefetch guard. */
+ getMergedSchemaRowCount: function() {
+ var that = this;
+ if (!this.schemaRelationNames || _.isEmpty(this.schemaRowsByRelation)) {
+ return this.schemaCollection && this.schemaCollection.fullCollection
+ ? this.schemaCollection.fullCollection.length
+ : 0;
+ }
+ return _.reduce(this.schemaRelationNames, function(sum, name) {
+ return sum + ((that.schemaRowsByRelation[name] || []).length);
+ }, 0);
},
- generateTableData: function(checkedDelete) {
+ /**
+ * Sum of per-relation max(approximateCount, loaded) — matches React SchemaTab
+ * mergedApproximateTotal / TablePagination totalCount.
+ */
+ getMergedApproximateTotal: function() {
+ var that = this;
+ if (!this.schemaRelationNames || _.isEmpty(this.schemaRowsByRelation)) {
+ return this.getMergedSchemaRowCount();
+ }
+ return _.reduce(this.schemaRelationNames, function(sum, name) {
+ var loaded = (that.schemaRowsByRelation[name] || []).length;
+ var approx = that.schemaTotals[name];
+ var t = (_.isFinite(approx) && approx >= 0) ? Math.max(approx, loaded) : loaded;
+ return sum + t;
+ }, 0);
+ },
+ handleSchemaAtlasFetch: function(options) {
+ var col = this.schemaCollection;
+ var that = this;
+ if (!col) {
+ return;
+ }
+ var tlNav = this._schemaTableLayout;
+ /*
+ * Page Limit change: re-sort full cached schema by relationship position,
+ * then rebuild the grid so slices and classification updates stay aligned.
+ */
+ if (options && options.fromPageLimitChange && this.guid &&
+ !_.isEmpty(this.schemaRelationNames) &&
+ !_.isEmpty(this.schemaRowsByRelation) &&
+ this.getMergedSchemaRowCount() > 0) {
+ /*
+ * captureSchemaPaginationSnapshot prefers _schemaCanonicalPagination
+ * before live TableLayout. After the user changes Page Limit, canonical
+ * still held the previous pageSize/offset until generateTableData runs —
+ * applySchemaPaginationSnapshot sliced the wrong window and classification
+ * refresh used a stale offset. Align canonical + collection with the
+ * dropdown (offset 0) before resort + rebuild.
+ */
+ var tlPl = this._schemaTableLayout;
+ if (tlPl && _.isFinite(tlPl.limit) && tlPl.limit > 0) {
+ this._schemaCanonicalPagination = {
+ pageSize: tlPl.limit,
+ offset: 0
+ };
+ }
+ if (col.state) {
+ if (tlPl && _.isFinite(tlPl.limit) && tlPl.limit > 0) {
+ col.state.pageSize = tlPl.limit;
+ }
+ col.queryParams = col.queryParams || {};
+ if (tlPl && _.isFinite(tlPl.limit) && tlPl.limit > 0) {
+ col.queryParams.limit = tlPl.limit;
+ }
+ col.queryParams.offset = 0;
+ }
+ this.showLoader();
+ if (this._schemaTableLayout && this._schemaTableLayout.setAtlasPaginationBusy) {
+ this._schemaTableLayout.setAtlasPaginationBusy(true, 'both');
+ }
+ this.resortCachedSchemaMergedAttributeByPosition();
+ this.generateTableData(undefined, function() {
+ that.hideLoader();
+ if (that._schemaTableLayout && that._schemaTableLayout.setAtlasPaginationBusy) {
+ that._schemaTableLayout.setAtlasPaginationBusy(false);
+ }
+ });
+ return;
+ }
+ /*
+ * onClicknextData / onClickpreviousData already updated tl.offset and
+ * queryParams before fetchCollection. getNextPage can leave
+ * state.currentPage stale (0), and syncSchemaAtlasTableOffsets would then
+ * set tl.offset = 0 — footer stays "1–100" while the grid shows page 2.
+ * Preserve the navigated offset for Next/Prev only (not first page).
+ */
+ var preservedNavOffset = (tlNav && _.isFinite(tlNav.offset))
+ ? Math.max(0, tlNav.offset)
+ : null;
+ this.showLoader();
+ if (this._schemaTableLayout && this._schemaTableLayout.setAtlasPaginationBusy) {
+ var busyNavHint = (options && options.next)
+ ? 'next'
+ : (options && options.previous)
+ ? 'prev'
+ : 'both';
+ this._schemaTableLayout.setAtlasPaginationBusy(true, busyNavHint);
+ }
+ /*
+ * TableLayout Page Limit must drive PageableCollection.pageSize on every
+ * nav (next/prev/first). Otherwise state.pageSize can stay at the initial
+ * chunk size (e.g. 100) while the UI shows limit 5 — getNextPage advances
+ * by the wrong window and classification refresh restores the wrong slice.
+ */
+ if (this._schemaTableLayout &&
+ _.isFinite(this._schemaTableLayout.limit) &&
+ this._schemaTableLayout.limit > 0) {
+ col.state.pageSize = this._schemaTableLayout.limit;
+ }
+ /*
+ * getNextPage may run fetchNextSchemaChunkSync (jQuery async:false). That
+ * blocks the main thread until the relationship API returns, so the
+ * browser cannot paint showLoader() until the request finishes — the
+ * loader looked “late”. Yield one task after showLoader/setBusy so the
+ * overlay can paint, then run navigation + any sync chunk fetch.
+ */
+ setTimeout(function() {
+ if (!that.schemaCollection || that.isDestroyed) {
+ return;
+ }
+ if (options && options.next) {
+ col.getNextPage({ reset: true });
+ } else if (options && options.previous) {
+ col.getPreviousPage({ reset: true });
+ } else {
+ col.getFirstPage({ reset: true });
+ }
+ that.syncSchemaAtlasTableOffsets();
+ if (tlNav && preservedNavOffset !== null &&
+ options && (options.next || options.previous)) {
+ var limNav = _.isFinite(col.state.pageSize) && col.state.pageSize > 0
+ ? col.state.pageSize
+ : tlNav.limit;
+ if (_.isFinite(limNav) && limNav > 0) {
+ tlNav.offset = preservedNavOffset;
+ tlNav.limit = limNav;
+ var stNav = col.state;
+ var idxNav = Math.floor(preservedNavOffset / limNav);
+ if (stNav.firstPage === 0) {
+ stNav.currentPage = idxNav;
+ } else {
+ stNav.currentPage = idxNav + 1;
+ }
+ that.syncSchemaCollectionQueryParamsToPagination(limNav, preservedNavOffset);
+ }
+ }
+ that.persistSchemaCanonicalFromTableLayout();
+ if (that._schemaTableLayout && that._schemaTableLayout.renderAtlasPagination) {
+ that._schemaTableLayout.renderAtlasPagination(options || {});
+ }
+ /*
+ * Chunk prefetch inside getNextPage runs generateTableData(async). Defer
+ * clearing busy/loader until that callback runs.
+ */
+ if (!that._schemaChunkFetchInProgress) {
+ setTimeout(function() {
+ that.hideLoader();
+ if (that._schemaTableLayout && that._schemaTableLayout.setAtlasPaginationBusy) {
+ that._schemaTableLayout.setAtlasPaginationBusy(false);
+ }
+ }, 0);
+ }
+ }, 0);
+ },
+ syncSchemaAtlasTableOffsets: function() {
+ var col = this.schemaCollection;
+ var tl = this._schemaTableLayout;
+ if (!col || !tl) {
+ return;
+ }
+ var st = col.state;
+ /*
+ * Page Limit dropdown is source of truth. state.pageSize can stay at the
+ * initial relationship chunk size (100) after load until the user
+ * navigates — sync used to set tl.offset = pageIndex * 100 while the
+ * footer showed limit 4, so the first classification refresh after
+ * changing page limit captured the wrong offset (wrong rows on screen).
+ */
+ var lim = tl.limit;
+ if (tl.ui && tl.ui.showPage && tl.ui.showPage.length) {
+ var fromSelect = parseInt(tl.ui.showPage.val(), 10);
+ if (_.isFinite(fromSelect) && fromSelect > 0) {
+ lim = fromSelect;
+ }
+ }
+ if (_.isFinite(lim) && lim > 0) {
+ st.pageSize = lim;
+ tl.limit = lim;
+ }
+ /*
+ * fetchNextSchemaChunkSync runs inside PageableCollection.getPage while
+ * loading more relationship rows (while mergedLen < needRows). Until
+ * origGetPage returns, state.currentPage still reflects the *previous*
+ * page. Writing tl.offset from that stale currentPage zeros the offset and
+ * rebuilds the grid on the wrong slice — footer can show "From 46–60"
+ * while rows are from another window; classification merge then targets
+ * the wrong models. Skip offset sync until the chunk round finishes.
+ */
+ if (this._schemaChunkFetchInProgress) {
+ return;
+ }
+ /*
+ * Next/Prev update collection.queryParams.offset before fetchCollection; that is
+ * authoritative for client Atlas pagination. PageableCollection.currentPage can
+ * lag or reset after fullCollection rebuild — do not derive tl.offset from it
+ * when queryParams already match the user's navigation.
+ */
+ var qp = col.queryParams;
+ if (qp && qp.offset !== undefined && qp.offset !== null) {
+ var qo = parseInt(qp.offset, 10);
+ if (_.isFinite(qo) && qo >= 0) {
+ tl.offset = qo;
+ var pageIdx = lim > 0 ? Math.floor(qo / lim) : 0;
+ if (st.firstPage === 0) {
+ st.currentPage = pageIdx;
+ } else {
+ st.currentPage = pageIdx + 1;
+ }
+ return;
+ }
+ }
+ var pageIndex = (st.firstPage === 0) ? st.currentPage : st.currentPage - 1;
+ if (pageIndex < 0) {
+ pageIndex = 0;
+ }
+ tl.offset = pageIndex * st.pageSize;
+ if (!col.queryParams) {
+ col.queryParams = {};
+ }
+ col.queryParams.offset = tl.offset;
+ col.queryParams.limit = st.pageSize;
+ },
+ /**
+ * Read page limit + item offset before fullCollection.reset.
+ * Prefer live TableLayout limit/offset (matches footer "From A–B"); then
+ * PageableCollection.state; then _lastKnownSchemaPagination;
+ * then _schemaPaginationSnapshot. Classification refresh must not restore
+ * the wrong slice when page limit and Next/Prev desynced state.pageSize.
+ */
+ captureSchemaPaginationSnapshot: function() {
+ var pageSize = 100;
+ var offset = 0;
+ var col = this.schemaCollection;
+ var st = col && col.state;
+ /*
+ * Canonical index into merged `attribute` (cache): offset = pageIndex * limit.
+ * Prefer this over PageableCollection / tl when TableLayout is stale mid-rebuild.
+ */
+ if (this._schemaCanonicalPagination &&
+ _.isFinite(this._schemaCanonicalPagination.pageSize) &&
+ this._schemaCanonicalPagination.pageSize > 0) {
+ pageSize = this._schemaCanonicalPagination.pageSize;
+ offset = _.isFinite(this._schemaCanonicalPagination.offset)
+ ? Math.max(0, this._schemaCanonicalPagination.offset)
+ : 0;
+ var canonical = { pageSize: pageSize, offset: offset };
+ this._lastKnownSchemaPagination = canonical;
+ return canonical;
+ }
+ /*
+ * Prefer live TableLayout offset/limit first — that is what the user sees
+ * (footer "From A–B"). PageableCollection.state can lag after page-limit
+ * changes or chunk prefetch; using it first caused wrong offsets on refresh.
+ */
+ if (this._schemaTableLayout &&
+ _.isFinite(this._schemaTableLayout.limit) &&
+ this._schemaTableLayout.limit > 0) {
+ pageSize = this._schemaTableLayout.limit;
+ offset = _.isFinite(this._schemaTableLayout.offset)
+ ? Math.max(0, this._schemaTableLayout.offset)
+ : 0;
+ var live = { pageSize: pageSize, offset: offset };
+ this._lastKnownSchemaPagination = live;
+ return live;
+ }
+ if (st && _.isFinite(st.pageSize) && st.pageSize > 0) {
+ var pageIdx = 0;
+ if (_.isFinite(st.currentPage)) {
+ pageIdx = (st.firstPage === 0) ? st.currentPage : st.currentPage - 1;
+ }
+ if (pageIdx < 0) {
+ pageIdx = 0;
+ }
+ pageSize = st.pageSize;
+ offset = pageIdx * pageSize;
+ var fromState = { pageSize: pageSize, offset: offset };
+ this._lastKnownSchemaPagination = fromState;
+ return fromState;
+ }
+ if (this._lastKnownSchemaPagination &&
+ _.isFinite(this._lastKnownSchemaPagination.pageSize) &&
+ this._lastKnownSchemaPagination.pageSize > 0) {
+ return {
+ pageSize: this._lastKnownSchemaPagination.pageSize,
+ offset: _.isFinite(this._lastKnownSchemaPagination.offset)
+ ? Math.max(0, this._lastKnownSchemaPagination.offset)
+ : 0
+ };
+ }
+ if (this._schemaPaginationSnapshot &&
+ _.isFinite(this._schemaPaginationSnapshot.pageSize) &&
+ this._schemaPaginationSnapshot.pageSize > 0) {
+ var prev = {
+ pageSize: this._schemaPaginationSnapshot.pageSize,
+ offset: _.isFinite(this._schemaPaginationSnapshot.offset)
+ ? Math.max(0, this._schemaPaginationSnapshot.offset)
+ : 0
+ };
+ this._lastKnownSchemaPagination = prev;
+ return prev;
+ }
+ return { pageSize: pageSize, offset: offset };
+ },
+ /**
+ * After models are pushed into fullCollection, restore page size and visible page
+ * using getPageByOffset (same idea as React keeping pageIndex + pageSize).
+ */
+ applySchemaPaginationSnapshot: function(snap) {
+ var col = this.schemaCollection;
+ if (!col || !snap || !_.isFinite(snap.pageSize) || snap.pageSize <= 0) {
+ return {
+ pageSize: (snap && snap.pageSize) || 100,
+ offset: 0
+ };
+ }
+ var ps = snap.pageSize;
+ var wantOff = _.isFinite(snap.offset) ? Math.max(0, snap.offset) : 0;
+ col.state.pageSize = ps;
+ var total = col.fullCollection.length;
+ if (total === 0) {
+ return { pageSize: ps, offset: 0 };
+ }
+ var lastPageOffset = Math.floor((total - 1) / ps) * ps;
+ var safeOff = Math.min(wantOff, lastPageOffset);
+ if (_.isFunction(col.getPageByOffset)) {
+ col.getPageByOffset(safeOff, { reset: true });
+ } else {
+ var pageNum = Math.floor(safeOff / ps);
+ col.getPage(pageNum, { reset: true });
+ }
+ return { pageSize: ps, offset: safeOff };
+ },
+ fetchNextSchemaChunkSync: function() {
+ var that = this;
+ this._schemaChunkFetchInProgress = true;
+ if (this._schemaTableLayout &&
+ _.isFinite(this._schemaTableLayout.limit) &&
+ this._schemaTableLayout.limit > 0) {
+ this.schemaCollection.state.pageSize = this._schemaTableLayout.limit;
+ } else if (this._schemaCanonicalPagination &&
+ _.isFinite(this._schemaCanonicalPagination.pageSize) &&
+ this._schemaCanonicalPagination.pageSize > 0) {
+ this.schemaCollection.state.pageSize = this._schemaCanonicalPagination.pageSize;
+ }
+ var target = _.find(this.schemaRelationNames, function(name) {
+ var rows = that.schemaRowsByRelation[name] || [];
+ var total = that.schemaTotals[name] || 0;
+ return rows.length < total;
+ });
+ if (!target) {
+ this._schemaChunkFetchInProgress = false;
+ this.hideLoader();
+ if (this._schemaTableLayout && this._schemaTableLayout.setAtlasPaginationBusy) {
+ this._schemaTableLayout.setAtlasPaginationBusy(false);
+ }
+ return;
+ }
+ var existing = this.schemaRowsByRelation[target] || [];
+ var offset = existing.length;
+ var apiUrl = UrlLinks.relationshipSearchV2ApiUrl();
+ var params = {
+ limit: this.getRelationshipApiLimit(),
+ offset: offset,
+ guid: this.guid,
+ disableDefaultSorting: true,
+ excludeDeletedEntities: false,
+ includeSubClassifications: true,
+ includeSubTypes: true,
+ includeClassificationAttributes: true,
+ relation: target,
+ getApproximateCount: false,
+ attributes: ['type', 'position']
+ };
+ var qs = Utils.serializeRelationshipQueryParams(params);
+ var resp = null;
+ $.ajax({
+ url: apiUrl + '?' + qs,
+ type: 'GET',
+ dataType: 'json',
+ async: false,
+ success: function(data) {
+ resp = data;
+ }
+ }).fail(function() {
+ resp = null;
+ });
+ if (!resp) {
+ this._schemaChunkFetchInProgress = false;
+ this.hideLoader();
+ if (this._schemaTableLayout && this._schemaTableLayout.setAtlasPaginationBusy) {
+ this._schemaTableLayout.setAtlasPaginationBusy(false);
+ }
+ return;
+ }
+ var list = resp.entities ? resp.entities : [];
+ this.schemaRowsByRelation[target] = existing.concat(list);
+ /* Offset > 0: getApproximateCount=false may return -1; keep totals from first page only. */
+ if (resp.referredEntities) {
+ this.referredEntities = _.extend({}, this.referredEntities || {}, resp.referredEntities);
+ }
+ var blocks = _.map(this.schemaRelationNames, function(n) {
+ return that.schemaRowsByRelation[n] || [];
+ });
+ this.attribute = this.sortSchemaEntityBlocks(blocks);
+ var cacheKey = this.guid;
+ var cached = schemaTabCacheByGuid[cacheKey];
+ if (cached) {
+ cached.rowsByRelation = this.schemaRowsByRelation;
+ cached.totals = this.schemaTotals;
+ cached.mergedAttribute = this.attribute;
+ cached.referredEntities = _.extend({}, cached.referredEntities || {}, this.referredEntities || {});
+ if (this._schemaCanonicalPagination) {
+ cached.paginationCanonical = _.extend({}, this._schemaCanonicalPagination);
+ }
+ }
+ this.generateTableData(undefined, function() {
+ that._schemaChunkFetchInProgress = false;
+ that.hideLoader();
+ if (that._schemaTableLayout && that._schemaTableLayout.setAtlasPaginationBusy) {
+ that._schemaTableLayout.setAtlasPaginationBusy(false);
+ }
+ });
+ },
+ attachSchemaClientPaginationGuard: function() {
+ if (this._schemaPaginationGuardAttached || !this.schemaCollection) {
+ return;
+ }
+ this._schemaPaginationGuardAttached = true;
+ var schemaView = this;
+ var col = this.schemaCollection;
+ var origGetPage = col.getPage.bind(col);
+ col.getPage = function(index, options) {
+ if (this.mode !== 'client') {
+ return origGetPage.call(this, index, options);
+ }
+ var state = this.state;
+ var firstPage = state.firstPage;
+ var currentPage = state.currentPage;
+ var lastPage = state.lastPage;
+ var pageSize = state.pageSize;
+ var pageNum = index;
+ switch (index) {
+ case 'first':
+ pageNum = firstPage;
+ break;
+ case 'prev':
+ pageNum = currentPage - 1;
+ break;
+ case 'next':
+ pageNum = currentPage + 1;
+ break;
+ case 'last':
+ pageNum = lastPage;
+ break;
+ default:
+ pageNum = typeof index === 'number' ? index : parseInt(index, 10);
+ }
+ if (!_.isFinite(pageNum)) {
+ return origGetPage.call(this, index, options);
+ }
+ var pageStart = (firstPage === 0 ? pageNum : pageNum - 1) * pageSize;
+ var needRows = pageStart + pageSize;
+ var guard = 0;
+ var mergedLen = schemaView.getMergedSchemaRowCount();
+ while (mergedLen < needRows && schemaView.schemaHasMore() && guard++ < 80) {
+ var beforeLen = mergedLen;
+ schemaView.fetchNextSchemaChunkSync();
+ mergedLen = schemaView.getMergedSchemaRowCount();
+ if (mergedLen <= beforeLen) {
+ break;
+ }
+ }
+ /* Numeric index: chunk loads reset collection state; avoid 'next'/'prev' using stale currentPage. */
+ return origGetPage.call(this, pageNum, options);
+ };
+ },
+ generateTableData: function(checkedDelete, onTableRenderDone) {
var that = this,
newModel;
+ /*
+ * Preserve Page Limit + current page across full rebuild (e.g. classification refresh).
+ * Capture before sync — sync overwrites tl.offset from Pageable state and can
+ * desync from the cached merged list window.
+ */
+ var paginationSnap = this.captureSchemaPaginationSnapshot();
+ if (this._schemaTableLayout) {
+ this.syncSchemaAtlasTableOffsets();
+ }
+ this.schemaCollection.fullCollection.reset([]);
+ /* Preserve insertion order from this.attribute (position order from API). */
+ this.schemaCollection.comparator = null;
+ if (this.schemaCollection.fullCollection) {
+ this.schemaCollection.fullCollection.comparator = null;
+ }
+ if (this.schemaCollection.state) {
+ this.schemaCollection.state.sortKey = null;
+ this.schemaCollection.state.order = null;
+ }
this.activeObj = [];
this.deleteObj = [];
this.schemaTableAttribute = null;
@@ -143,36 +1112,101 @@ define(['require',
_.each(this.attribute, function(obj) {
if (!Enums.entityStateReadOnly[obj.status]) {
that.activeObj.push(obj);
- that.schemaCollection.push(obj);
} else if (Enums.entityStateReadOnly[obj.status]) {
that.deleteObj.push(obj);
}
});
- if (this.schemaCollection.length === 0 && this.deleteObj.length) {
+ var histChecked = this.ui.checkDeletedEntity.find("input").prop('checked');
+ if (histChecked && this.deleteObj.length === 0) {
+ this.schemaCollection.fullCollection.reset([]);
+ } else if (this.activeObj.length === 0 && this.deleteObj.length) {
this.ui.checkDeletedEntity.find("input").prop('checked', true);
this.schemaCollection.fullCollection.reset(this.deleteObj);
+ } else if (histChecked && this.deleteObj.length > 0) {
+ this.schemaCollection.fullCollection.reset(this.activeObj.concat(this.deleteObj));
+ } else {
+ this.schemaCollection.fullCollection.reset(this.activeObj);
}
if (this.activeObj.length === 0 && this.deleteObj.length === 0) {
this.ui.checkDeletedEntity.hide();
}
- this.renderTableLayoutView();
+ this.attachSchemaClientPaginationGuard();
+ var effectiveSnap = this.applySchemaPaginationSnapshot(paginationSnap);
+ this._schemaPaginationSnapshot = effectiveSnap;
+ this._lastKnownSchemaPagination = effectiveSnap;
+ if (effectiveSnap && _.isFinite(effectiveSnap.pageSize) && effectiveSnap.pageSize > 0) {
+ this._schemaCanonicalPagination = {
+ pageSize: effectiveSnap.pageSize,
+ offset: _.isFinite(effectiveSnap.offset)
+ ? Math.max(0, effectiveSnap.offset)
+ : 0
+ };
+ this.persistSchemaCanonicalToCache();
+ this.syncSchemaCollectionQueryParamsToPagination(
+ effectiveSnap.pageSize,
+ effectiveSnap.offset
+ );
+ }
+ this.renderTableLayoutView(onTableRenderDone);
},
showLoader: function() {
- this.$('.fontLoader').show();
- this.$('.tableOverlay').show()
+ /* Match SearchResultLayoutView — Bootstrap `.show` + SCSS in loader.scss */
+ this.$('.fontLoader:not(.for-ignore)').addClass('show');
+ this.$('.tableOverlay').addClass('show');
+ /* TableLayout spinner uses .for-ignore so collection.request never shows it; schema uses manual push — show explicitly. */
+ if (this._schemaTableLayout && this._schemaTableLayout.$) {
+ this._schemaTableLayout.$('div[data-id="r_tableSpinner"]').addClass('show');
+ }
},
hideLoader: function(argument) {
- this.$('.fontLoader').hide();
- this.$('.tableOverlay').hide();
+ this.$('.fontLoader:not(.for-ignore)').removeClass('show');
+ this.$('.tableOverlay').removeClass('show');
+ if (this._schemaTableLayout && this._schemaTableLayout.$) {
+ this._schemaTableLayout.$('div[data-id="r_tableSpinner"]').removeClass('show');
+ }
},
- renderTableLayoutView: function() {
+ renderTableLayoutView: function(onDone) {
var that = this;
require(['utils/TableLayout'], function(TableLayout) {
var columnCollection = Backgrid.Columns.extend({}),
columns = new columnCollection(that.getSchemaTableColumns());
- that.RSchemaTableLayoutView.show(new TableLayout(_.extend({}, that.commonTableOptions, {
- columns: columns
- })));
+ var snap = that._schemaPaginationSnapshot ||
+ {
+ pageSize: that.schemaCollection.state.pageSize || 100,
+ offset: 0
+ };
+ var ps = snap.pageSize;
+ var off = _.isFinite(snap.offset) ? Math.max(0, snap.offset) : 0;
+ var table = new TableLayout(_.extend({}, that.commonTableOptions, {
+ columns: columns,
+ clientAtlasPagination: true,
+ atlasShowLoaderBeforeFetch: function() {
+ that.showLoader();
+ },
+ atlasApproximateDatasetTotal: function() {
+ return that.getMergedApproximateTotal();
+ },
+ atlasPaginationOpts: {
+ offset: off,
+ limit: ps,
+ count: ps,
+ fetchCollection: function(opts) {
+ that.handleSchemaAtlasFetch(opts);
+ }
+ }
+ }));
+ that._schemaTableLayout = table;
+ that.RSchemaTableLayoutView.show(table);
+ setTimeout(function() {
+ that.syncSchemaAtlasTableOffsets();
+ that.persistSchemaCanonicalFromTableLayout();
+ if (table.renderAtlasPagination) {
+ table.renderAtlasPagination();
+ }
+ if (_.isFunction(onDone)) {
+ onDone();
+ }
+ }, 0);
that.$('.multiSelectTag').hide();
Utils.generatePopover({
el: that.$('[data-id="showMoreLess"]'),
@@ -199,11 +1233,12 @@ define(['require',
}
if (this.schemaTableAttribute) {
_.each(_.keys(this.schemaTableAttribute), function(key) {
- if (key !== "position") {
+ if (key !== "position" && key !== "description") {
col[key] = {
label: key.capitalize(),
cell: "html",
editable: false,
+ sortable: false,
className: "searchTableName",
formatter: _.extend({}, Backgrid.CellFormatter.prototype, {
fromRaw: function(rawValue, model) {
@@ -270,8 +1305,15 @@ define(['require',
return obj.typeName;
}),
callback: function() {
- that.fetchCollection();
- that.arr = [];
+ that.showLoader();
+ var guids = (multiple && multiple.length) ?
+ _.map(multiple, function(x) { return x.id; }) : [guid];
+ guids = _.chain(guids).compact().uniq().value();
+ that.refreshChildEntitiesAfterClassification(guids, function() {
+ that.hideLoader();
+ /* Do not fetch parent entity — child GET + merge updates the row (React parity). */
+ that.arr = [];
+ });
},
hideLoader: that.hideLoader.bind(that),
showLoader: that.showLoader.bind(that),
@@ -294,18 +1336,55 @@ define(['require',
showLoader: that.showLoader.bind(that),
hideLoader: that.hideLoader.bind(that),
callback: function() {
- that.fetchCollection();
+ that.showLoader();
+ that.refreshChildEntitiesAfterClassification([guid], function() {
+ that.hideLoader();
+ /* Do not fetch parent entity — child GET + merge updates the row (React parity). */
+ });
}
});
},
- onCheckDeletedEntity: function(e) {
- if (e.target.checked) {
- if (this.deleteObj.length) {
- this.schemaCollection.fullCollection.reset(this.activeObj.concat(this.deleteObj));
+ /**
+ * After toggling "Show historical entities", the grid collection is rebuilt but
+ * TableLayout / queryParams / canonical offset could still reflect the previous
+ * page (e.g. offset 100 on page 2). Reset to first page before generateTableData
+ * so captureSchemaPaginationSnapshot + applySchemaPaginationSnapshot stay in sync.
+ */
+ resetSchemaPaginationToFirstPageForToggle: function() {
+ var col = this.schemaCollection;
+ var tl = this._schemaTableLayout;
+ var ps = 100;
+ if (tl && _.isFinite(tl.limit) && tl.limit > 0) {
+ ps = tl.limit;
+ } else if (col && col.state && _.isFinite(col.state.pageSize) && col.state.pageSize > 0) {
+ ps = col.state.pageSize;
+ } else if (this._schemaCanonicalPagination &&
+ _.isFinite(this._schemaCanonicalPagination.pageSize) &&
+ this._schemaCanonicalPagination.pageSize > 0) {
+ ps = this._schemaCanonicalPagination.pageSize;
+ }
+ this._schemaCanonicalPagination = { pageSize: ps, offset: 0 };
+ this._lastKnownSchemaPagination = { pageSize: ps, offset: 0 };
+ this.syncSchemaCollectionQueryParamsToPagination(ps, 0);
+ if (col && col.state) {
+ col.state.pageSize = ps;
+ if (col.state.firstPage === 0) {
+ col.state.currentPage = 0;
+ } else {
+ col.state.currentPage = 1;
+ }
+ }
+ if (tl) {
+ tl.offset = 0;
+ if (_.isFinite(ps) && ps > 0) {
+ tl.limit = ps;
}
- } else {
- this.schemaCollection.fullCollection.reset(this.activeObj)
}
+ this.persistSchemaCanonicalToCache();
+ },
+ onCheckDeletedEntity: function() {
+ this.resetSchemaPaginationToFirstPageForToggle();
+ this.generateTableData();
}
});
return SchemaTableLayoutView;