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/03/16 11:46:38 UTC
(atlas) branch ATLAS-5184 created (now 773ed9e10)
This is an automated email from the ASF dual-hosted git repository.
ppawar pushed a change to branch ATLAS-5184
in repository https://gitbox.apache.org/repos/asf/atlas.git
at 773ed9e10 ATLAS-5184: Incomplete Metadata Display for Entities in Atlas Search Results
This branch includes the following new commits:
new 773ed9e10 ATLAS-5184: Incomplete Metadata Display for Entities in Atlas Search Results
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-5184: Incomplete Metadata Display for Entities in Atlas Search Results
Posted by pp...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
ppawar pushed a commit to branch ATLAS-5184
in repository https://gitbox.apache.org/repos/asf/atlas.git
commit 773ed9e10bc921d1b1ebbc130de9fb889ff0228f
Author: Prasad Pawar <pr...@freestoneinfotech.com>
AuthorDate: Mon Mar 16 17:13:54 2026 +0530
ATLAS-5184: Incomplete Metadata Display for Entities in Atlas Search Results
---
dashboard/package.json | 1 +
.../src/api/apiMethods/detailpageApiMethod.ts | 116 ++-
.../src/api/apiMethods/entityFormApiMethod.ts | 2 +-
dashboard/src/api/apiMethods/searchApiMethod.ts | 11 +-
dashboard/src/api/apiUrlLinks/detailpageUrl.ts | 7 +-
dashboard/src/styles/detailPage.scss | 402 ++++++++++
.../BusinessMetadataAtrributeForm.tsx | 62 +-
.../src/views/DetailPage/EntityDetailPage.tsx | 15 +-
.../PropertiesTab/BMAttributesFields.tsx | 27 +-
.../EntityDetailTabs/RelationshipCard.tsx | 400 ++++++++++
.../EntityDetailTabs/RelationshipCardSkeleton.tsx | 47 ++
.../EntityDetailTabs/RelationshipLineage.tsx | 53 +-
.../EntityDetailTabs/RelationshipsTab.tsx | 558 ++++++++++++--
dashboard/src/views/SearchResult/SearchResult.tsx | 159 +++-
.../src/views/SideBar/SideBarTree/EntitiesTree.tsx | 2 +-
dashboardv2/gruntfile.js | 5 +-
dashboardv2/public/css/scss/relationship.scss | 507 ++++++++++++
dashboardv2/public/css/scss/table.scss | 1 +
.../BusinessMetadataAttributeItemView_tmpl.html | 2 +-
.../RelationshipCardsLayoutView_tmpl.html | 18 +
.../graph/RelationshipLayoutView_tmpl.html | 11 +-
dashboardv2/public/js/utils/UrlLinks.js | 28 +-
.../js/views/detail_page/DetailPageLayoutView.js | 61 +-
.../detail_page/RelationshipCardsLayoutView.js | 856 +++++++++++++++++++++
.../js/views/graph/RelationshipLayoutView.js | 606 ++++++++++-----
25 files changed, 3607 insertions(+), 350 deletions(-)
diff --git a/dashboard/package.json b/dashboard/package.json
index 50b3f1275..6cb8364f1 100644
--- a/dashboard/package.json
+++ b/dashboard/package.json
@@ -4,6 +4,7 @@
"version": "0.0.0",
"type": "module",
"scripts": {
+ "postinstall": "chmod +x node_modules/@esbuild/linux-x64/bin/esbuild 2>/dev/null || true",
"dev": "vite",
"build": "tsc && vite build",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 200",
diff --git a/dashboard/src/api/apiMethods/detailpageApiMethod.ts b/dashboard/src/api/apiMethods/detailpageApiMethod.ts
index b74a98454..323d92aa9 100644
--- a/dashboard/src/api/apiMethods/detailpageApiMethod.ts
+++ b/dashboard/src/api/apiMethods/detailpageApiMethod.ts
@@ -22,14 +22,15 @@ import {
detailPageBusinessMetadataApiUrl,
detailPageLabelApiUrl,
detailPageRauditApiUrl,
- detailPageRelationshipApiUrl
+ detailPageRelationshipApiUrl,
+ detailPageRelationshipAttributesApiUrl
} from "../apiUrlLinks/detailpageUrl";
import { _get } from "./apiMethod";
const getDetailPageData = (guid: string, params: object, header?: string) => {
const config = {
method: "GET",
- params: params
+ params: { ...params, ignoreRelationships: true }
};
return _get(detailpageApiUrl(guid, header), config);
};
@@ -85,6 +86,114 @@ const getDetailPageRelationship = (guid: string) => {
return _get(detailPageRelationshipApiUrl(guid), config);
};
+// TODO: Mock data for relationship attributes (disabled now that API is used).
+// const MOCK_DATA_CONFIG: Record<string, number> = {
+// tables: 500,
+// inputToProcess: 150,
+// "outputTo Process": 3,
+// ddlQueries: 230,
+// outputFromProcesses: 1,
+// model: 0
+// };
+//
+// const getMockRelationshipAttributes = (
+// params: { limit?: number; offset?: number; attributeName?: string }
+// ) => {
+// const limit = params.limit || 100;
+// const offset = params.offset || 0;
+// const attributeName = params.attributeName;
+//
+// const generateMockItem = (index: number, type: string) => {
+// const cleanType = type.replace(/\s+/g, "");
+// const displayIndex = offset + index + 1;
+//
+// return {
+// guid: `mock-guid-${cleanType}-${displayIndex}`,
+// typeName: "hive_table",
+// entityStatus: "ACTIVE",
+// displayText: `hivetable${displayIndex}`,
+// relationshipType: `hive_table_db`,
+// relationshipGuid: `mock-rel-guid-${cleanType}-${displayIndex}`,
+// relationshipStatus: "ACTIVE",
+// relationshipAttributes: {
+// typeName: `hive_table_db`
+// },
+// qualifiedName: `db88232_1.hivetable${displayIndex}@cm`
+// };
+// };
+//
+// if (attributeName) {
+// const totalCount = MOCK_DATA_CONFIG[attributeName] || 0;
+// const remainingCount = Math.max(0, totalCount - offset);
+// const itemCount = Math.min(limit, remainingCount);
+//
+// const mockData = [];
+// for (let i = 0; i < itemCount; i++) {
+// mockData.push(generateMockItem(i, attributeName));
+// }
+//
+// return {
+// data: {
+// relationshipAttributes: {
+// [attributeName]: mockData
+// },
+// totalCounts: {
+// [attributeName]: totalCount
+// }
+// }
+// };
+// }
+//
+// const mockResponse: any = {
+// data: {
+// relationshipAttributes: {},
+// totalCounts: {}
+// }
+// };
+//
+// const relationshipTypes = [
+// "tables",
+// "inputToProcess",
+// "outputTo Process",
+// "ddlQueries",
+// "outputFromProcesses",
+// "model"
+// ];
+//
+// relationshipTypes.forEach((type) => {
+// const totalCount = MOCK_DATA_CONFIG[type] || 0;
+// const itemCount = Math.min(limit, totalCount);
+//
+// const mockData = [];
+// for (let i = 0; i < itemCount; i++) {
+// mockData.push(generateMockItem(i, type));
+// }
+//
+// mockResponse.data.relationshipAttributes[type] = mockData;
+// mockResponse.data.totalCounts[type] = totalCount;
+// });
+//
+// return mockResponse;
+// };
+
+const getDetailPageRelationshipAttributes = async (
+ guid: string,
+ params: { limit?: number; offset?: number; attributeName?: string }
+) => {
+ const config = {
+ method: "GET",
+ params: params
+ };
+
+ try {
+ return await _get(detailPageRelationshipAttributesApiUrl(guid), config);
+ } catch (error: any) {
+ // Mock fallback disabled now that real API is used.
+ // return getMockRelationshipAttributes(params);
+ throw error;
+ }
+};
+
export {
getDetailPageData,
getDetailPageAuditData,
@@ -92,5 +201,6 @@ export {
getAuditData,
getLabels,
getEntityBusinessMetadata,
- getDetailPageRelationship
+ getDetailPageRelationship,
+ getDetailPageRelationshipAttributes
};
diff --git a/dashboard/src/api/apiMethods/entityFormApiMethod.ts b/dashboard/src/api/apiMethods/entityFormApiMethod.ts
index 1abe8c4d5..36d17764e 100644
--- a/dashboard/src/api/apiMethods/entityFormApiMethod.ts
+++ b/dashboard/src/api/apiMethods/entityFormApiMethod.ts
@@ -48,7 +48,7 @@ const createEntity = (params: object) => {
const getEntity = (guid: string, method: string, params: any) => {
const config: importTmplConfigs = {
method: method,
- params: params,
+ params: { ...params, ignoreRelationships: true },
};
return _get(getEntityUrl(guid), config);
};
diff --git a/dashboard/src/api/apiMethods/searchApiMethod.ts b/dashboard/src/api/apiMethods/searchApiMethod.ts
index fea515635..bf32a5832 100644
--- a/dashboard/src/api/apiMethods/searchApiMethod.ts
+++ b/dashboard/src/api/apiMethods/searchApiMethod.ts
@@ -49,9 +49,18 @@ const getRelationShip = (params: any) => {
return fetchApi(searchApiUrl("relationship"), config);
};
+const getRelationShipV2 = (params: any) => {
+ const config: any = {
+ method: "GET",
+ ...params
+ };
+ return fetchApi(searchApiUrl("relationship"), config);
+};
+
export {
getBasicSearchResult,
getRelationShipResult,
getGlobalSearchResult,
- getRelationShip
+ getRelationShip,
+ getRelationShipV2
};
diff --git a/dashboard/src/api/apiUrlLinks/detailpageUrl.ts b/dashboard/src/api/apiUrlLinks/detailpageUrl.ts
index 5b130028a..59466c4cf 100644
--- a/dashboard/src/api/apiUrlLinks/detailpageUrl.ts
+++ b/dashboard/src/api/apiUrlLinks/detailpageUrl.ts
@@ -48,6 +48,10 @@ const detailPageRelationshipApiUrl = (guid: string) => {
return `${getBaseApiUrl("urlV2")}/relationship/guid/${guid}`;
};
+const detailPageRelationshipAttributesApiUrl = (guid: string) => {
+ return `${entitiesApiUrl()}/guid/${guid}/relationshipAttributes`;
+};
+
export {
detailpageApiUrl,
detailPageAuditApiUrl,
@@ -55,5 +59,6 @@ export {
auditApiurl,
detailPageLabelApiUrl,
detailPageBusinessMetadataApiUrl,
- detailPageRelationshipApiUrl
+ detailPageRelationshipApiUrl,
+ detailPageRelationshipAttributesApiUrl
};
diff --git a/dashboard/src/styles/detailPage.scss b/dashboard/src/styles/detailPage.scss
index 2583c0cb7..0322027e3 100644
--- a/dashboard/src/styles/detailPage.scss
+++ b/dashboard/src/styles/detailPage.scss
@@ -151,6 +151,408 @@ pre.code-block .json-string {
display: none;
}
+.relationship-cards-grid {
+ display: grid;
+ grid-template-columns: repeat(1, minmax(0, 1fr));
+ gap: 12px;
+ align-items: start;
+}
+
+@media (min-width: 900px) {
+ .relationship-cards-grid {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+}
+
+@media (min-width: 1200px) {
+ .relationship-cards-grid {
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ }
+}
+
+@media (min-width: 1400px) {
+ .relationship-cards-grid {
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ }
+}
+
+/* Custom column layout: >5 records = 1/col, 1-5 and 0 = 2/col */
+.relationship-cards-grid--custom {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 12px;
+ align-items: flex-start;
+}
+
+.relationship-cards-column {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ min-width: 280px;
+ flex: 0 0 auto;
+}
+
+@media (max-width: 599px) {
+ .relationship-cards-column {
+ min-width: 100%;
+ }
+}
+
+.relationship-cards-empty {
+ grid-column: 1 / -1;
+ text-align: center;
+ color: #9ca3af;
+ font-size: 0.875rem;
+}
+
+.relationship-cards-grid--custom .relationship-cards-empty {
+ width: 100%;
+ flex: 1 1 100%;
+}
+
+.relationship-card {
+ border: 1px solid #e2e8f0;
+ border-radius: 10px;
+ background-color: #ffffff;
+ box-shadow: 0 1px 2px rgba(15, 23, 42, 0.08);
+ min-height: 80px;
+}
+
+.relationship-card__header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 8px 12px;
+ border-top-left-radius: 10px;
+ border-top-right-radius: 10px;
+ background-color: #f2f6f7;
+ color: #37bb9b;
+}
+
+.relationship-card__title {
+ font-size: 0.875rem;
+ font-weight: 500;
+ text-transform: capitalize;
+}
+
+.relationship-card__count {
+ margin-left: 6px;
+ font-size: 0.75rem;
+ color: #37bb9b;
+}
+
+.relationship-card__actions {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ color: #64748b;
+}
+
+.relationship-card__action-button {
+ background: transparent;
+ border: 1px solid transparent;
+ border-radius: 4px;
+ padding: 2px 6px;
+ cursor: pointer;
+ color: inherit;
+}
+
+.relationship-card__action-button.is-active {
+ color: #2563eb;
+}
+
+.relationship-card__toggle {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ font-size: 0.75rem;
+ color: #64748b;
+}
+
+.relationship-card__toggle input {
+ margin: 0;
+}
+
+.relationship-card__body {
+ overflow-y: auto;
+ padding: 8px 12px;
+ flex: 1 1 auto;
+ display: flex;
+ flex-direction: column;
+}
+
+.relationship-card__body--empty {
+ overflow: hidden;
+}
+
+.relationship-card__inline-loader {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ min-height: 40px;
+ width: 100%;
+}
+
+.relationship-card__content {
+ display: flex;
+ flex-direction: column;
+}
+
+.relationship-card__search {
+ padding: 6px 12px;
+}
+
+.relationship-card__search-input {
+ width: 100%;
+ border: 1px solid #d1d5db;
+ border-radius: 6px;
+ padding: 6px 10px;
+ font-size: 0.75rem;
+ color: #1f2937;
+ background-color: #f9fafb;
+}
+
+.relationship-card__search-input::placeholder {
+ color: #9ca3af;
+}
+
+.relationship-card__search-input:focus {
+ outline: 2px solid #c7d2fe;
+ outline-offset: 1px;
+}
+
+.relationship-card__list {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ text-align: left;
+}
+
+.relationship-card__item {
+ font-size: 0.875rem;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ line-height: 1.5;
+}
+
+.relationship-card__link {
+ color: #2563eb;
+ text-decoration: none;
+}
+
+.relationship-card__link:hover {
+ text-decoration: underline;
+}
+
+.relationship-card__text {
+ color: #334155;
+}
+
+.relationship-card__empty {
+ padding: 8px 0;
+ font-size: 0.875rem;
+ color: #9ca3af;
+ align-self: flex-start;
+ text-align: center;
+ width: 100%;
+}
+
+.relationship-card__no-match {
+ padding: 8px 0;
+ font-size: 0.875rem;
+ color: #9ca3af;
+ align-self: flex-start;
+}
+
+.relationship-card__footer {
+ border-top: 1px solid #e2e8f0;
+ padding: 0;
+ text-align: center;
+ font-size: 0.75rem;
+ color: #9ca3af;
+ margin-top: auto;
+ display: flex;
+ align-items: center;
+ min-height: 24px;
+}
+
+.relationship-card__footer-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+ padding: 0 12px;
+ height: 24px;
+ line-height: 24px;
+ width: 100%;
+}
+
+.relationship-card__page-limit {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ font-size: 0.75rem;
+ color: #64748b;
+}
+
+.relationship-card__page-limit input {
+ width: 64px;
+ border: 1px solid #d1d5db;
+ border-radius: 4px;
+ padding: 2px 6px;
+ font-size: 0.75rem;
+ color: #1f2937;
+ background-color: #f9fafb;
+}
+
+.relationship-card__loading {
+ padding: 8px 0;
+ text-align: center;
+ font-size: 0.75rem;
+ color: #6b7280;
+ display: flex;
+ justify-content: center;
+}
+
+.relationship-card__load-more {
+ margin-top: 6px;
+ padding: 6px 0;
+ font-size: 0.75rem;
+ text-align: center;
+ color: #9ca3af;
+ min-height: 28px;
+}
+
+/* Relationship Card Skeleton Loader */
+.relationship-card-skeleton {
+ border: 1px solid #e2e8f0;
+ border-radius: 10px;
+ background-color: #ffffff;
+ box-shadow: 0 1px 2px rgba(15, 23, 42, 0.08);
+ min-height: 80px;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+}
+
+.relationship-card-skeleton__header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 8px 12px;
+ border-top-left-radius: 10px;
+ border-top-right-radius: 10px;
+ background-color: #f2f6f7;
+ border-bottom: 1px solid #e2e8f0;
+}
+
+.relationship-card-skeleton__title-wrap {
+ display: flex;
+ align-items: baseline;
+ gap: 6px;
+}
+
+.relationship-card-skeleton__line {
+ display: block;
+ height: 14px;
+ background: linear-gradient(
+ 90deg,
+ #e2e8f0 25%,
+ #f1f5f9 50%,
+ #e2e8f0 75%
+ );
+ background-size: 200% 100%;
+ animation: relationship-skeleton-pulse 1.5s ease-in-out infinite;
+ border-radius: 4px;
+
+ &--title {
+ width: 100px;
+ }
+
+ &--count {
+ width: 36px;
+ }
+
+ &--short {
+ width: 60%;
+ }
+
+ &--footer {
+ width: 80px;
+ height: 12px;
+ }
+}
+
+.relationship-card-skeleton__actions {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.relationship-card-skeleton__box {
+ display: block;
+ background: linear-gradient(
+ 90deg,
+ #e2e8f0 25%,
+ #f1f5f9 50%,
+ #e2e8f0 75%
+ );
+ background-size: 200% 100%;
+ animation: relationship-skeleton-pulse 1.5s ease-in-out infinite;
+ border-radius: 4px;
+
+ &--button {
+ width: 28px;
+ height: 24px;
+ }
+
+ &--checkbox {
+ width: 80px;
+ height: 16px;
+ }
+
+ &--input {
+ width: 60px;
+ height: 24px;
+ }
+}
+
+.relationship-card-skeleton__content {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ padding: 12px;
+}
+
+.relationship-card-skeleton__body {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ flex: 1;
+ min-height: 80px;
+}
+
+.relationship-card-skeleton__footer {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ margin-top: auto;
+ padding-top: 8px;
+ border-top: 1px solid #e2e8f0;
+}
+
+@keyframes relationship-skeleton-pulse {
+ 0%,
+ 100% {
+ background-position: 200% 0;
+ }
+ 50% {
+ background-position: -200% 0;
+ }
+}
+
.long-descriptions,
.long-descriptions * {
display: contents;
diff --git a/dashboard/src/views/BusinessMetadata/BusinessMetadataAtrributeForm.tsx b/dashboard/src/views/BusinessMetadata/BusinessMetadataAtrributeForm.tsx
index 91f2653d7..6c360731c 100644
--- a/dashboard/src/views/BusinessMetadata/BusinessMetadataAtrributeForm.tsx
+++ b/dashboard/src/views/BusinessMetadata/BusinessMetadataAtrributeForm.tsx
@@ -465,31 +465,31 @@ const BusinessMetadataAttributeForm = ({
watched?.[index]?.multiValueSelect) ||
isEmpty(editbmAttribute)) && (
<>
- <Controller
- control={control}
- name={`attributeDefs.${index}.multiValueSelect` as const}
- key={`attributeDefs.${index}.multiValueSelect`}
- data-cy={`attributeDefs.${index}.multiValueSelect`}
- defaultValue={field?.multiValueSelect}
- render={({ field: { value, onChange } }) => (
- <>
- <Grid
- container
- columnSpacing={{ xs: 1, sm: 2, md: 2 }}
- marginBottom="1rem"
- alignItems="center"
- >
- <Grid item md={3} textAlign="right">
- <InputLabel>Enable Multivalues</InputLabel>
- </Grid>
- <Grid item md={7}>
+ <Controller
+ control={control}
+ name={`attributeDefs.${index}.multiValueSelect` as const}
+ key={`attributeDefs.${index}.multiValueSelect`}
+ data-cy={`attributeDefs.${index}.multiValueSelect`}
+ defaultValue={field?.multiValueSelect}
+ render={({ field: { value, onChange } }) => (
+ <>
+ <Grid
+ container
+ columnSpacing={{ xs: 1, sm: 2, md: 2 }}
+ marginBottom="1rem"
+ alignItems="center"
+ >
+ <Grid item md={3} textAlign="right">
+ <InputLabel>Enable Multivalues</InputLabel>
+ </Grid>
+ <Grid item md={7}>
<Stack direction="row" spacing={2} alignItems="center">
- <FormControlLabel
- control={
- <Checkbox
- disabled={isEmpty(editbmAttribute) ? false : true}
- size="small"
- checked={value}
+ <FormControlLabel
+ control={
+ <Checkbox
+ disabled={isEmpty(editbmAttribute) ? false : true}
+ size="small"
+ checked={value}
onChange={(e) => {
onChange(e.target.checked);
// Reset cardinality toggle when multivalues is unchecked
@@ -500,10 +500,10 @@ const BusinessMetadataAttributeForm = ({
);
}
}}
- />
- }
- label={undefined}
/>
+ }
+ label={undefined}
+ />
{value && (
<Controller
control={control}
@@ -535,10 +535,10 @@ const BusinessMetadataAttributeForm = ({
)}
</Stack>
</Grid>
- </Grid>
- </>
- )}
- />
+ </Grid>
+ </>
+ )}
+ />
</>
)}
{watched?.[index] && watched?.[index]?.typeName == "string" && (
diff --git a/dashboard/src/views/DetailPage/EntityDetailPage.tsx b/dashboard/src/views/DetailPage/EntityDetailPage.tsx
index dfec7b491..994d3adef 100644
--- a/dashboard/src/views/DetailPage/EntityDetailPage.tsx
+++ b/dashboard/src/views/DetailPage/EntityDetailPage.tsx
@@ -160,9 +160,11 @@ const EntityDetailPage: React.FC = () => {
removeTab("raudits");
}
+ // To render profile tab check for attribute "profileData" or typeName = "hive_db","hbase_namespace"
+ // Match Classic UI: check if profileData is not undefined (even if null/empty)
if (
!isEmpty(entity) &&
- (!isEmpty(entity?.attributes?.["profileData"]) ||
+ (entity?.attributes?.["profileData"] !== undefined ||
entity.typeName == "hive_db" ||
entity.typeName == "hbase_namespace")
) {
@@ -529,10 +531,17 @@ const EntityDetailPage: React.FC = () => {
<LinkTab label="Export/Import Audits" />
)}
{!isEmpty(entity) &&
- (!isEmpty(entity?.attributes?.["profileData"]) ||
+ (entity?.attributes?.["profileData"] !== undefined ||
entity.typeName == "hive_db" ||
entity.typeName == "hbase_namespace") && (
- <LinkTab label="Table" />
+ <LinkTab
+ label={
+ entity.typeName == "hive_db" ||
+ entity.typeName == "hbase_namespace"
+ ? "Tables"
+ : "Table"
+ }
+ />
)}
{taskTabEnabled && uiTaskTabEnabled && <LinkTab label="Tasks" />}
</Tabs>
diff --git a/dashboard/src/views/DetailPage/EntityDetailTabs/PropertiesTab/BMAttributesFields.tsx b/dashboard/src/views/DetailPage/EntityDetailTabs/PropertiesTab/BMAttributesFields.tsx
index e5da5fb97..9017f961e 100644
--- a/dashboard/src/views/DetailPage/EntityDetailTabs/PropertiesTab/BMAttributesFields.tsx
+++ b/dashboard/src/views/DetailPage/EntityDetailTabs/PropertiesTab/BMAttributesFields.tsx
@@ -157,8 +157,24 @@ const BMAttributesFields = ({ obj, control, index }: any) => {
<TextField
margin="none"
fullWidth
- onChange={(text) => {
- field.onChange(text);
+ onChange={(e) => {
+ const inputValue = e.target.value;
+ // For numeric types, allow string input while typing, convert to number on blur
+ if (typeName !== "string") {
+ // Store as string to allow partial input (e.g., "-", "1.", etc.)
+ field.onChange(inputValue);
+ } else {
+ field.onChange(inputValue);
+ }
+ }}
+ onBlur={(e) => {
+ // On blur, convert valid numeric strings to numbers
+ if (typeName !== "string" && e.target.value !== "") {
+ const numValue = Number(e.target.value);
+ if (!isNaN(numValue)) {
+ field.onChange(numValue);
+ }
+ }
}}
variant="outlined"
size="small"
@@ -169,10 +185,13 @@ const BMAttributesFields = ({ obj, control, index }: any) => {
? typeof field.value === "string"
? field.value
: ""
- : typeof field.value === "number"
- ? field.value
+ : field.value !== null && field.value !== undefined
+ ? String(field.value)
: ""
}
+ inputProps={{
+ step: typeName === "float" || typeName === "double" ? "any" : undefined
+ }}
/>
)}
</div>
diff --git a/dashboard/src/views/DetailPage/EntityDetailTabs/RelationshipCard.tsx b/dashboard/src/views/DetailPage/EntityDetailTabs/RelationshipCard.tsx
new file mode 100644
index 000000000..f21e5a0af
--- /dev/null
+++ b/dashboard/src/views/DetailPage/EntityDetailTabs/RelationshipCard.tsx
@@ -0,0 +1,400 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
+import { Link } from 'react-router-dom'
+import { isEmpty } from '@utils/Utils'
+import { CircularProgress, LightTooltip } from '@components/muiComponents'
+
+type RelationshipCardProps = {
+ attributeName: string
+ data: any[]
+ referredEntities: any
+ showEmptyValues: boolean
+ onLoadMore?: (attributeName: string) => void
+ totalCount?: number
+ isLoading?: boolean
+ isResetting?: boolean
+ isSorted?: boolean
+ showDeleted?: boolean
+ pageLimit?: number
+ onToggleSort?: (attributeName: string) => void
+ onToggleShowDeleted?: (attributeName: string) => void
+ onPageLimitChange?: (attributeName: string, value: string) => void
+ onPageLimitSubmit?: (attributeName: string) => void
+}
+
+function RelationshipCard({
+ attributeName,
+ data,
+ referredEntities,
+ showEmptyValues: _showEmptyValues,
+ onLoadMore,
+ totalCount,
+ isLoading,
+ isResetting,
+ isSorted,
+ showDeleted,
+ pageLimit,
+ onToggleSort,
+ onToggleShowDeleted,
+ onPageLimitChange,
+ onPageLimitSubmit
+}: RelationshipCardProps) {
+ const scrollRef = useRef<HTMLDivElement | null>(null)
+ const resolvedTotal = useMemo(() => {
+ if (typeof totalCount === 'number') return totalCount
+ return data.length
+ }, [totalCount, data.length])
+
+ const canLoadMore = resolvedTotal > data.length
+ const lastRequestSizeRef = useRef<number | null>(null)
+ const prevScrollRef = useRef<{ height: number; top: number } | null>(null)
+ const lastScrollTopRef = useRef<number>(0)
+ const loadRequestedRef = useRef<boolean>(false)
+ const scrollDebounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(
+ null
+ )
+ const [searchQuery, setSearchQuery] = useState<string>('')
+
+ const getDisplayText = useCallback(
+ (item: any) => {
+ const ref = item?.guid ? referredEntities?.[item.guid] : null
+ return (
+ ref?.displayText ||
+ ref?.attributes?.name ||
+ item?.displayText ||
+ item?.attributes?.name ||
+ item?.qualifiedName ||
+ item?.guid ||
+ 'N/A'
+ )
+ },
+ [referredEntities]
+ )
+
+ const handleSearchChange = useCallback(
+ (event: React.ChangeEvent<HTMLInputElement>) => {
+ setSearchQuery(event.currentTarget.value)
+ },
+ []
+ )
+
+ const handleSearchKeyDown = useCallback(
+ (event: React.KeyboardEvent<HTMLInputElement>) => {
+ if (event.key === 'Escape') {
+ setSearchQuery('')
+ }
+ },
+ []
+ )
+
+ const handleSearchKeyUp = useCallback(
+ (event: React.KeyboardEvent<HTMLInputElement>) => {
+ setSearchQuery(event.currentTarget.value)
+ },
+ []
+ )
+
+ const handleKeyDown = (
+ event: React.KeyboardEvent<HTMLElement>,
+ href?: string
+ ) => {
+ if (!href) return
+ if (event.key === 'Enter' || event.key === ' ') {
+ event.preventDefault()
+ window.location.href = href
+ }
+ }
+
+ const handleSortToggle = () => {
+ onToggleSort?.(attributeName)
+ }
+
+ const handleShowDeletedToggle = (
+ event: React.ChangeEvent<HTMLInputElement>
+ ) => {
+ event.stopPropagation()
+ onToggleShowDeleted?.(attributeName)
+ }
+
+ const handlePageLimitInputChange = (
+ event: React.ChangeEvent<HTMLInputElement>
+ ) => {
+ onPageLimitChange?.(attributeName, event.currentTarget.value)
+ }
+
+ const handlePageLimitKeyDown = (
+ event: React.KeyboardEvent<HTMLInputElement>
+ ) => {
+ if (event.key === 'Enter') {
+ onPageLimitSubmit?.(attributeName)
+ }
+ }
+
+ const handleLoadMore = useCallback(() => {
+ if (loadRequestedRef.current || !canLoadMore) {
+ return
+ }
+ loadRequestedRef.current = true
+ const container = scrollRef.current
+ if (container) {
+ prevScrollRef.current = {
+ height: container.scrollHeight,
+ top: container.scrollTop
+ }
+ }
+ onLoadMore?.(attributeName)
+ }, [canLoadMore, onLoadMore])
+
+ const handleScroll = useCallback(() => {
+ const container = scrollRef.current
+ if (!container || loadRequestedRef.current || !canLoadMore) {
+ return
+ }
+ const distanceFromBottom =
+ container.scrollHeight - container.scrollTop - container.clientHeight
+ const isNearBottom = distanceFromBottom <= 4
+ const isScrollingDown = container.scrollTop > lastScrollTopRef.current
+ lastScrollTopRef.current = container.scrollTop
+ if (!isNearBottom || !isScrollingDown) {
+ if (scrollDebounceTimerRef.current) {
+ clearTimeout(scrollDebounceTimerRef.current)
+ scrollDebounceTimerRef.current = null
+ }
+ return
+ }
+ if (lastRequestSizeRef.current === data.length) {
+ return
+ }
+ if (scrollDebounceTimerRef.current) {
+ clearTimeout(scrollDebounceTimerRef.current)
+ }
+ scrollDebounceTimerRef.current = setTimeout(() => {
+ scrollDebounceTimerRef.current = null
+ if (loadRequestedRef.current || lastRequestSizeRef.current === data.length) {
+ return
+ }
+ lastRequestSizeRef.current = data.length
+ handleLoadMore()
+ }, 400)
+ }, [canLoadMore, data.length, handleLoadMore])
+
+ useEffect(() => {
+ if (!isLoading) {
+ loadRequestedRef.current = false
+ }
+ }, [isLoading])
+
+ useEffect(() => {
+ return () => {
+ if (scrollDebounceTimerRef.current) {
+ clearTimeout(scrollDebounceTimerRef.current)
+ scrollDebounceTimerRef.current = null
+ }
+ }
+ }, [])
+
+ useEffect(() => {
+ lastRequestSizeRef.current = null
+ const container = scrollRef.current
+ const prevScroll = prevScrollRef.current
+ if (container && prevScroll) {
+ prevScrollRef.current = null
+ const applyScroll = () => {
+ const maxScroll = container.scrollHeight - container.clientHeight
+ if (maxScroll > 0) {
+ const scrollUpBy = Math.floor(container.clientHeight * 0.3)
+ container.scrollTop = Math.max(0, maxScroll - scrollUpBy)
+ } else {
+ container.scrollTop = 0
+ }
+ }
+ requestAnimationFrame(() => {
+ requestAnimationFrame(applyScroll)
+ })
+ }
+ }, [data.length])
+
+ const showSearch = data.length > 1
+ const isEmptyCard = resolvedTotal === 0 && isEmpty(data)
+ const isZeroOrOneRecord = data.length <= 1
+ const bodyStyle = useMemo(() => {
+ if (isZeroOrOneRecord) {
+ return { minHeight: 72 }
+ }
+ const maxVisibleRows = 9
+ const rowHeight = 22
+ const bodyPadding = 16
+ const visibleRows = Math.min(data.length, maxVisibleRows)
+ const baseHeight = visibleRows * rowHeight + bodyPadding
+ const maxHeight = maxVisibleRows * rowHeight + bodyPadding
+ return { height: Math.min(baseHeight, maxHeight), minHeight: 0 }
+ }, [data.length, isZeroOrOneRecord])
+
+ const filteredData = useMemo(() => {
+ const query = searchQuery.trim().toLowerCase()
+ if (!query) return data
+ return data.filter((item) => {
+ const displayText = getDisplayText(item)
+ const qualifiedName = item?.qualifiedName || ''
+ const name = item?.attributes?.name || ''
+ const searchTarget = `${displayText} ${qualifiedName} ${name}`.toLowerCase()
+ return searchTarget.includes(query)
+ })
+ }, [data, getDisplayText, searchQuery])
+
+ return (
+ <div className='relationship-card'>
+ <div className='relationship-card__header'>
+ <div className='relationship-card__title'>
+ {attributeName}
+ <span className='relationship-card__count'>
+ ({resolvedTotal})
+ </span>
+ </div>
+ <div className='relationship-card__actions'>
+ <button
+ type='button'
+ className={`relationship-card__action-button${
+ isSorted ? ' is-active' : ''
+ }`}
+ onClick={handleSortToggle}
+ aria-label='Toggle sort by name'
+ >
+ <i className={`fa ${isSorted ? 'fa-sort-alpha-asc' : 'fa-sort'}`} />
+ </button>
+ <label className='relationship-card__toggle'>
+ <input
+ type='checkbox'
+ checked={!!showDeleted}
+ onChange={handleShowDeletedToggle}
+ aria-label='Show deleted relationships'
+ />
+ <span>Show deleted</span>
+ </label>
+ </div>
+ </div>
+ <div className='relationship-card__content'>
+ {showSearch && (
+ <div className='relationship-card__search'>
+ <input
+ className='relationship-card__search-input'
+ type='text'
+ placeholder='Search...'
+ aria-label='Search relationship records'
+ value={searchQuery}
+ onChange={handleSearchChange}
+ onKeyDown={handleSearchKeyDown}
+ onKeyUp={handleSearchKeyUp}
+ />
+ </div>
+ )}
+ <div
+ ref={scrollRef}
+ onScroll={handleScroll}
+ className={`relationship-card__body${
+ isEmptyCard ? ' relationship-card__body--empty' : ''
+ }`}
+ style={bodyStyle}
+ >
+ {isResetting ? (
+ <div className='relationship-card__inline-loader'>
+ <CircularProgress size={16} />
+ </div>
+ ) : isEmpty(data) ? (
+ <div className='relationship-card__empty'>
+ No records to display
+ </div>
+ ) : searchQuery.trim() && isEmpty(filteredData) ? (
+ <div className='relationship-card__no-match'>
+ No record found for "{searchQuery}"
+ </div>
+ ) : (
+ <ul className='relationship-card__list'>
+ {filteredData.map((item, index) => {
+ const itemGuid = item?.guid
+ const href = itemGuid ? `/detailPage/${itemGuid}` : ''
+ const displayText = getDisplayText(item)
+ return (
+ <li
+ key={`${attributeName}-${index}`}
+ className='relationship-card__item'
+ >
+ {href ? (
+ <LightTooltip title={displayText}>
+ <Link
+ to={href}
+ tabIndex={0}
+ aria-label={`Open ${displayText}`}
+ onKeyDown={(event) =>
+ handleKeyDown(event, href)
+ }
+ className='relationship-card__link'
+ >
+ {displayText}
+ </Link>
+ </LightTooltip>
+ ) : (
+ <span className='relationship-card__text'>
+ {displayText}
+ </span>
+ )}
+ </li>
+ )
+ })}
+ </ul>
+ )}
+ {canLoadMore && resolvedTotal > 0 && (
+ <div className='relationship-card__load-more'>
+ Scroll to load more data
+ </div>
+ )}
+ {isLoading && (
+ <div className='relationship-card__loading'>
+ <CircularProgress size={16} />
+ </div>
+ )}
+ </div>
+ <div className='relationship-card__footer'>
+ <div className='relationship-card__footer-row'>
+ <span>
+ Showing {data.length} of {resolvedTotal}
+ </span>
+ <div className='relationship-card__page-limit'>
+ <label htmlFor={`page-limit-${attributeName}`}>
+ Limit
+ </label>
+ <input
+ id={`page-limit-${attributeName}`}
+ type='number'
+ min={1}
+ max={resolvedTotal}
+ value={pageLimit && pageLimit > 0 ? pageLimit : ''}
+ onChange={handlePageLimitInputChange}
+ onKeyDown={handlePageLimitKeyDown}
+ aria-label='Page limit'
+ />
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ )
+}
+
+export default RelationshipCard
diff --git a/dashboard/src/views/DetailPage/EntityDetailTabs/RelationshipCardSkeleton.tsx b/dashboard/src/views/DetailPage/EntityDetailTabs/RelationshipCardSkeleton.tsx
new file mode 100644
index 000000000..210f69738
--- /dev/null
+++ b/dashboard/src/views/DetailPage/EntityDetailTabs/RelationshipCardSkeleton.tsx
@@ -0,0 +1,47 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to you under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+function RelationshipCardSkeleton() {
+ return (
+ <div className='relationship-card-skeleton' aria-hidden='true'>
+ <div className='relationship-card-skeleton__header'>
+ <div className='relationship-card-skeleton__title-wrap'>
+ <span className='relationship-card-skeleton__line relationship-card-skeleton__line--title' />
+ <span className='relationship-card-skeleton__line relationship-card-skeleton__line--count' />
+ </div>
+ <div className='relationship-card-skeleton__actions'>
+ <span className='relationship-card-skeleton__box relationship-card-skeleton__box--button' />
+ <span className='relationship-card-skeleton__box relationship-card-skeleton__box--checkbox' />
+ </div>
+ </div>
+ <div className='relationship-card-skeleton__content'>
+ <div className='relationship-card-skeleton__body'>
+ <span className='relationship-card-skeleton__line' />
+ <span className='relationship-card-skeleton__line' />
+ <span className='relationship-card-skeleton__line' />
+ <span className='relationship-card-skeleton__line relationship-card-skeleton__line--short' />
+ </div>
+ <div className='relationship-card-skeleton__footer'>
+ <span className='relationship-card-skeleton__line relationship-card-skeleton__line--footer' />
+ <span className='relationship-card-skeleton__box relationship-card-skeleton__box--input' />
+ </div>
+ </div>
+ </div>
+ )
+}
+
+export default RelationshipCardSkeleton
diff --git a/dashboard/src/views/DetailPage/EntityDetailTabs/RelationshipLineage.tsx b/dashboard/src/views/DetailPage/EntityDetailTabs/RelationshipLineage.tsx
index 7820a0ef6..61a86a8f3 100644
--- a/dashboard/src/views/DetailPage/EntityDetailTabs/RelationshipLineage.tsx
+++ b/dashboard/src/views/DetailPage/EntityDetailTabs/RelationshipLineage.tsx
@@ -20,6 +20,7 @@
import {
Button,
Chip,
+ CircularProgress,
IconButton,
InputBase,
Paper,
@@ -34,7 +35,7 @@ import {
isArray,
isEmpty
} from "@utils/Utils";
-import { useEffect, useRef, useState } from "react";
+import { useEffect, useMemo, useRef, useState } from "react";
import * as d3 from "d3";
import { Link as RouterLink, useLocation, useParams } from "react-router-dom";
import { entityStateReadOnly, graphIcon } from "@utils/Enum";
@@ -74,8 +75,19 @@ const CustomLink = ({
);
};
-const RelationshipLineage = ({ entity }: { entity: Record<string, any> }) => {
+const RelationshipLineage = ({
+ entity,
+ relationshipAttributes,
+ isLoading
+}: {
+ entity: Record<string, any>;
+ relationshipAttributes?: Record<string, any[]>;
+ isLoading?: boolean;
+}) => {
const entityData = cloneDeep(entity);
+ if (relationshipAttributes) {
+ entityData.relationshipAttributes = relationshipAttributes;
+ }
const { guid } = useParams();
const location = useLocation();
const searchParams = new URLSearchParams(location.search);
@@ -117,7 +129,7 @@ const RelationshipLineage = ({ entity }: { entity: Record<string, any> }) => {
return { nodes: nodes, links: links };
};
- let graphData = createData(entityData);
+ const graphData = useMemo(() => createData(entityData), [entityData]);
const createGraph = (data) => {
// Use getBoundingClientRect to get the actual width and height
const svgElement = svgRef?.current;
@@ -409,10 +421,26 @@ const RelationshipLineage = ({ entity }: { entity: Record<string, any> }) => {
};
useEffect(() => {
- if (svgRef?.current) {
+ if (!svgRef?.current) {
+ return;
+ }
+ d3.select(svgRef.current).selectAll("*").remove();
+ if (!isEmpty(graphData.links)) {
createGraph(graphData);
}
- }, []);
+ }, [graphData]);
+
+ if (isLoading) {
+ return (
+ <Stack
+ alignItems="center"
+ justifyContent="center"
+ sx={{ minHeight: "320px" }}
+ >
+ <CircularProgress />
+ </Stack>
+ );
+ }
if (graphData && isEmpty(graphData.links)) {
return <Typography>No relationship data found</Typography>;
@@ -587,7 +615,7 @@ const RelationshipLineage = ({ entity }: { entity: Record<string, any> }) => {
const valObj = { ...val, entityName: name };
if (searchString) {
- if (name.search(new RegExp(searchString, "i")) !== -1) {
+ if (name.toLowerCase().includes(searchString.toLowerCase())) {
listString.push(getElement(valObj));
} else {
continue;
@@ -600,7 +628,7 @@ const RelationshipLineage = ({ entity }: { entity: Record<string, any> }) => {
listString.push(getElement(data));
}
return (
- <Stack sx={{ background: "white" }} minHeight={"150px"} maxWidth="400px">
+ <Stack sx={{ background: "white" }} minHeight={"150px"} maxWidth="520px">
{/* {listString?.length > 1 && ( */}
<Paper
variant="outlined"
@@ -626,7 +654,14 @@ const RelationshipLineage = ({ entity }: { entity: Record<string, any> }) => {
</Paper>
{/* )} */}
- <Stack gap={"0.875rem"}>{listString}</Stack>
+ <Stack
+ gap={"0.875rem"}
+ paddingLeft="0.5rem"
+ maxHeight="220px"
+ sx={{ overflowY: "auto" }}
+ >
+ {listString}
+ </Stack>
</Stack>
);
};
@@ -735,7 +770,7 @@ const RelationshipLineage = ({ entity }: { entity: Record<string, any> }) => {
</Stack>
<Stack
- maxHeight={"350px"}
+ maxHeight={"320px"}
gap={1}
padding="0.875rem"
textAlign="left"
diff --git a/dashboard/src/views/DetailPage/EntityDetailTabs/RelationshipsTab.tsx b/dashboard/src/views/DetailPage/EntityDetailTabs/RelationshipsTab.tsx
index 9f5413b9b..7799a22a1 100644
--- a/dashboard/src/views/DetailPage/EntityDetailTabs/RelationshipsTab.tsx
+++ b/dashboard/src/views/DetailPage/EntityDetailTabs/RelationshipsTab.tsx
@@ -21,37 +21,81 @@ import {
Grid,
Stack,
ToggleButton,
- ToggleButtonGroup,
- Typography
+ ToggleButtonGroup
} from "@mui/material";
-import { useMemo, useState } from "react";
-import { TableLayout } from "@components/Table/TableLayout";
-import { getValues } from "@components/commonComponents";
-import { customSortByObjectKeys, isArray, isEmpty } from "@utils/Utils";
+import { useState, useEffect, useRef, useMemo } from "react";
+import { isArray, isEmpty } from "@utils/Utils";
import { EntityDetailTabProps } from "@models/entityDetailType";
import RelationshipLineage from "./RelationshipLineage";
import { AntSwitch } from "@utils/Muiutils";
+import RelationshipCard from "./RelationshipCard";
+import RelationshipCardSkeleton from "./RelationshipCardSkeleton";
+import { getRelationShipV2 } from "@api/apiMethods/searchApiMethod";
+import { useParams } from "react-router-dom";
+import { serverError } from "@utils/Utils";
+import { useSelector } from "react-redux";
+import { EntityState } from "@models/relationshipSearchType";
const RelationshipsTab: React.FC<EntityDetailTabProps> = ({
entity,
- referredEntities,
- loading
+ referredEntities
}) => {
- const { relationshipAttributes = {} } = entity || {};
- let columnData = { ...relationshipAttributes };
-
- let rowData = [];
- for (let key in columnData) {
- rowData.push({ [key]: columnData[key] });
- }
-
- let nonEmptyRowData = rowData.filter((obj) => {
- const entries = Object.entries(obj);
- return entries.every(([_key, value]) => !isEmpty(value));
- });
+ const { guid } = useParams<{ guid: string }>();
+ const toastId = useRef<any>(null);
+ const fetchStartedRef = useRef<boolean>(false);
+ const { entityData } = useSelector((state: EntityState) => state.entity);
+ const entityTypeName = entity?.typeName;
const [alignment, setAlignment] = useState<string>("table");
const [checked, setChecked] = useState<boolean>(false);
+
+ // Card view state
+ const [cardData, setCardData] = useState<Record<string, any[]>>({});
+ const [cardTotalCounts, setCardTotalCounts] = useState<Record<string, number>>({});
+ const [cardLoading, setCardLoading] = useState<boolean>(false);
+ const [cardLoadingByName, setCardLoadingByName] = useState<
+ Record<string, boolean>
+ >({});
+ const [initialLoadDone, setInitialLoadDone] = useState<boolean>(false);
+ const [sortByNameByAttr, setSortByNameByAttr] = useState<
+ Record<string, boolean>
+ >({});
+ const [showDeletedByAttr, setShowDeletedByAttr] = useState<
+ Record<string, boolean>
+ >({});
+ const [pageLimitByAttr, setPageLimitByAttr] = useState<
+ Record<string, number>
+ >({});
+ const [cardResettingByName, setCardResettingByName] = useState<
+ Record<string, boolean>
+ >({});
+
+ useEffect(() => {
+ if (entityTypeName) {
+ console.log("Relationship tab entity type:", entityTypeName);
+ }
+ }, [entityTypeName]);
+
+ useEffect(() => {
+ setInitialLoadDone(false);
+ setCardData({});
+ setCardTotalCounts({});
+ fetchStartedRef.current = false;
+ }, [guid]);
+
+ const relationNames = useMemo((): string[] => {
+ if (!entityTypeName || !entityData?.entityDefs) {
+ return [];
+ }
+ const entityDef = entityData.entityDefs.find(
+ (def: { name: string }) => def.name === entityTypeName
+ );
+ const relationshipDefs = (entityDef?.relationshipAttributeDefs || []) as Array<{ name: string }>;
+ const names = relationshipDefs
+ .map((def) => def.name)
+ .filter(Boolean) as string[];
+ return [...new Set(names)];
+ }, [entityData, entityTypeName]);
const handleChange = (
_event: React.MouseEvent<HTMLElement>,
@@ -67,53 +111,359 @@ const RelationshipsTab: React.FC<EntityDetailTabProps> = ({
setChecked(event.target.checked);
};
- const defaultColumns = useMemo(
- () => [
- {
- id: "relationKey",
- accessorKey: "relationKey",
- accessorFn: (row: Record<string, any>) => Object.keys(row)[0] || "",
- cell: (info: any) => {
- const keyName: string = info.getValue();
- const values = info.row.original;
- return (
- <Typography fontWeight="600">{`${keyName} ${
- isArray(values[keyName]) && !isEmpty(values[keyName])
- ? `(${values[keyName].length})`
- : ""
- }`}</Typography>
- );
- },
- header: "Key",
- width: "30%"
- },
- {
- id: "relationValue",
- accessorKey: "relationValue",
- accessorFn: (row: Record<string, any>) => row[Object.keys(row)[0]],
- cell: (info: any) => {
- const keyName: string = Object.keys(info.row.original)[0];
- const value = info.getValue();
- return (
- <span className="value-text">
- {getValues(
- value,
- columnData,
- entity,
- undefined,
- "properties",
- referredEntities,
- undefined,
- keyName
- )}
- </span>
- );
- },
- header: "Value"
+ // Fetch initial relationship attributes data
+ const getPageLimit = (relationName: string, totalCount?: number) => {
+ const defaultLimit =
+ typeof totalCount === "number" && totalCount < 100 ? totalCount : 100;
+ const currentLimit = pageLimitByAttr[relationName];
+ if (typeof currentLimit !== "number") {
+ return defaultLimit;
+ }
+ if (typeof totalCount === "number" && currentLimit > totalCount) {
+ return totalCount;
+ }
+ return currentLimit;
+ };
+
+ const getRelationshipParams = (
+ relationName: string,
+ offset: number,
+ totalCount?: number,
+ overrides?: {
+ isSorted?: boolean;
+ showDeleted?: boolean;
+ limit?: number;
+ }
+ ) => {
+ const isSorted =
+ typeof overrides?.isSorted === "boolean"
+ ? overrides.isSorted
+ : !!sortByNameByAttr[relationName];
+ const showDeleted =
+ typeof overrides?.showDeleted === "boolean"
+ ? overrides.showDeleted
+ : !!showDeletedByAttr[relationName];
+ const limit =
+ typeof overrides?.limit === "number"
+ ? overrides.limit
+ : getPageLimit(relationName, totalCount);
+ return {
+ limit,
+ offset,
+ guid,
+ sortBy: isSorted ? "name" : undefined,
+ sortOrder: isSorted ? "ASCENDING" : undefined,
+ disableDefaultSorting: !isSorted,
+ excludeDeletedEntities: !showDeleted,
+ includeSubClassifications: true,
+ includeSubTypes: true,
+ includeClassificationAttributes: true,
+ relation: relationName,
+ getApproximateCount: true
+ };
+ };
+
+ const fetchRelationshipData = async (
+ relationName: string,
+ offset: number,
+ totalCount?: number,
+ overrides?: {
+ isSorted?: boolean;
+ showDeleted?: boolean;
+ limit?: number;
+ }
+ ) => {
+ const params = getRelationshipParams(
+ relationName,
+ offset,
+ totalCount,
+ overrides
+ );
+ const response = await getRelationShipV2({ params });
+ return {
+ entities: response?.data?.entities || [],
+ approximateCount:
+ response?.data?.approximateCount ?? response?.data?.totalCount
+ };
+ };
+
+ const fetchInitialRelationshipData = async () => {
+ if (!guid || initialLoadDone || cardLoading || isEmpty(relationNames)) {
+ return;
+ }
+
+ setCardLoading(true);
+ try {
+ const newCardData: Record<string, any[]> = {};
+ const newTotalCounts: Record<string, number> = {};
+ const nextSortByNameByAttr: Record<string, boolean> = {};
+ const nextShowDeletedByAttr: Record<string, boolean> = {};
+
+ const promises = relationNames.map((relationName) =>
+ fetchRelationshipData(relationName, 0, undefined, {
+ showDeleted: true
+ })
+ .then((response) => ({ relationName, response }))
+ .catch((err) => {
+ console.error(`Error fetching relationship ${relationName}:`, err);
+ return { relationName, response: null };
+ })
+ );
+
+ const results = await Promise.all(promises);
+
+ for (const { relationName, response } of results) {
+ if (!response) {
+ newCardData[relationName] = [];
+ nextSortByNameByAttr[relationName] = sortByNameByAttr[relationName] ?? false;
+ nextShowDeletedByAttr[relationName] = showDeletedByAttr[relationName] ?? true;
+ continue;
+ }
+ const entities = response.entities;
+ if (isArray(entities)) {
+ newCardData[relationName] = entities;
+ } else if (!isEmpty(entities)) {
+ newCardData[relationName] = [entities];
+ } else {
+ newCardData[relationName] = [];
+ }
+ if (response.approximateCount !== undefined) {
+ newTotalCounts[relationName] = response.approximateCount;
+ }
+ nextSortByNameByAttr[relationName] =
+ sortByNameByAttr[relationName] ?? false;
+ nextShowDeletedByAttr[relationName] =
+ showDeletedByAttr[relationName] ?? true;
}
- ],
- []
- );
+
+ setCardData(newCardData);
+ setCardTotalCounts(newTotalCounts);
+ setSortByNameByAttr((prev) => ({ ...nextSortByNameByAttr, ...prev }));
+ setShowDeletedByAttr((prev) => ({ ...nextShowDeletedByAttr, ...prev }));
+ setInitialLoadDone(true);
+ } catch (error: any) {
+ console.error("Error fetching relationship attributes:", error);
+ serverError(error, toastId);
+ } finally {
+ setCardLoading(false);
+ }
+ };
+
+ // Load initial data when tab is switched to card/graph view
+ useEffect(() => {
+ if (
+ (alignment === "table" || alignment === "graph") &&
+ guid &&
+ !initialLoadDone &&
+ !cardLoading &&
+ !isEmpty(relationNames)
+ ) {
+ if (fetchStartedRef.current) {
+ return;
+ }
+ fetchStartedRef.current = true;
+ fetchInitialRelationshipData().finally(() => {
+ fetchStartedRef.current = false;
+ });
+ }
+ }, [alignment, guid, relationNames, initialLoadDone, cardLoading]);
+
+ // Handle load more for a specific card
+ const handleCardLoadMore = async (attributeName: string) => {
+ if (!guid || cardLoadingByName[attributeName]) {
+ return;
+ }
+ const existingData = cardData[attributeName] || [];
+ setCardLoadingByName((prev) => ({ ...prev, [attributeName]: true }));
+ try {
+ const totalCount = cardTotalCounts[attributeName];
+ const response = await fetchRelationshipData(
+ attributeName,
+ existingData.length,
+ totalCount
+ );
+ const updated = [...existingData, ...(response.entities || [])];
+ setCardData((prev) => ({
+ ...prev,
+ [attributeName]: updated
+ }));
+ if (response.approximateCount !== undefined) {
+ setCardTotalCounts((prev) => ({
+ ...prev,
+ [attributeName]: response.approximateCount
+ }));
+ }
+ } catch (error: any) {
+ console.error("Error loading more relationship data:", error);
+ serverError(error, toastId);
+ } finally {
+ setCardLoadingByName((prev) => ({ ...prev, [attributeName]: false }));
+ }
+ };
+
+ useEffect(() => {
+ if (isEmpty(cardTotalCounts)) {
+ return;
+ }
+ setPageLimitByAttr((prev) => {
+ const next = { ...prev };
+ Object.keys(cardTotalCounts).forEach((relationName) => {
+ const totalCount = cardTotalCounts[relationName];
+ const defaultLimit = totalCount < 100 ? totalCount : 100;
+ const current = next[relationName];
+ if (typeof current !== "number") {
+ next[relationName] = defaultLimit;
+ return;
+ }
+ if (current > totalCount) {
+ next[relationName] = totalCount;
+ }
+ });
+ return next;
+ });
+ }, [cardTotalCounts]);
+
+ const resetRelationshipData = async (
+ relationName: string,
+ overrides?: {
+ isSorted?: boolean;
+ showDeleted?: boolean;
+ limit?: number;
+ }
+ ) => {
+ if (!guid || cardLoadingByName[relationName]) {
+ return;
+ }
+ setCardResettingByName((prev) => ({ ...prev, [relationName]: true }));
+ try {
+ const totalCount = cardTotalCounts[relationName];
+ const response = await fetchRelationshipData(
+ relationName,
+ 0,
+ totalCount,
+ overrides
+ );
+ const entities = response.entities;
+ const updated = isArray(entities)
+ ? entities
+ : !isEmpty(entities)
+ ? [entities]
+ : [];
+ setCardData((prev) => ({
+ ...prev,
+ [relationName]: updated
+ }));
+ if (response.approximateCount !== undefined) {
+ setCardTotalCounts((prev) => ({
+ ...prev,
+ [relationName]: response.approximateCount
+ }));
+ }
+ } catch (error: any) {
+ console.error("Error resetting relationship data:", error);
+ serverError(error, toastId);
+ } finally {
+ setCardResettingByName((prev) => ({ ...prev, [relationName]: false }));
+ }
+ };
+
+ const handleToggleSort = (relationName: string) => {
+ const nextSorted = !sortByNameByAttr[relationName];
+ setSortByNameByAttr((prev) => ({
+ ...prev,
+ [relationName]: nextSorted
+ }));
+ resetRelationshipData(relationName, { isSorted: nextSorted });
+ };
+
+ const handleToggleShowDeleted = (relationName: string) => {
+ const nextShowDeleted = !showDeletedByAttr[relationName];
+ setShowDeletedByAttr((prev) => ({
+ ...prev,
+ [relationName]: nextShowDeleted
+ }));
+ resetRelationshipData(relationName, { showDeleted: nextShowDeleted });
+ };
+
+ const handlePageLimitChange = (relationName: string, value: string) => {
+ const parsed = parseInt(value, 10);
+ if (!Number.isFinite(parsed)) {
+ setPageLimitByAttr((prev) => ({ ...prev, [relationName]: 0 }));
+ return;
+ }
+ setPageLimitByAttr((prev) => ({ ...prev, [relationName]: parsed }));
+ };
+
+ const handlePageLimitSubmit = (relationName: string) => {
+ const totalCount = cardTotalCounts[relationName] ?? 0;
+ const defaultLimit = totalCount < 100 ? totalCount : 100;
+ const currentLimit = pageLimitByAttr[relationName];
+ let nextLimit = currentLimit;
+ if (!Number.isFinite(currentLimit) || currentLimit <= 0) {
+ nextLimit = defaultLimit;
+ }
+ if (totalCount > 0 && nextLimit > totalCount) {
+ nextLimit = totalCount;
+ }
+ setPageLimitByAttr((prev) => ({ ...prev, [relationName]: nextLimit }));
+ resetRelationshipData(relationName, { limit: nextLimit });
+ };
+
+ /**
+ * Computes column layout based on record count:
+ * - >5 records: 1 card per column
+ * - 1-5 records: 2 cards per column
+ * - 0 records: 2 cards per column (when showEmptyValues is true)
+ */
+ const relationshipColumns = useMemo((): string[][] => {
+ const names = Object.keys(cardData);
+ if (isEmpty(names)) {
+ return [];
+ }
+
+ const getCount = (name: string) =>
+ cardTotalCounts[name] ?? cardData[name]?.length ?? 0;
+
+ const largeCards: string[] = [];
+ const smallCards: string[] = [];
+ const emptyCards: string[] = [];
+
+ names.forEach((name) => {
+ const count = getCount(name);
+ if (count > 5) {
+ largeCards.push(name);
+ } else if (count >= 1 && count <= 5) {
+ smallCards.push(name);
+ } else {
+ emptyCards.push(name);
+ }
+ });
+
+ const sortNames = (arr: string[]) =>
+ arr.sort((a, b) => a.localeCompare(b));
+
+ sortNames(largeCards);
+ sortNames(smallCards);
+ sortNames(emptyCards);
+
+ const columns: string[][] = [];
+
+ largeCards.forEach((name) => columns.push([name]));
+
+ for (let i = 0; i < smallCards.length; i += 2) {
+ const chunk = smallCards.slice(i, i + 2);
+ columns.push(chunk);
+ }
+
+ if (checked) {
+ for (let i = 0; i < emptyCards.length; i += 2) {
+ const chunk = emptyCards.slice(i, i + 2);
+ columns.push(chunk);
+ }
+ }
+
+ return columns;
+ }, [cardData, cardTotalCounts, checked]);
return (
<Grid container marginTop={0} className="properties-container">
@@ -165,26 +515,68 @@ const RelationshipsTab: React.FC<EntityDetailTabProps> = ({
)}
</Stack>
- {alignment == "graph" && <RelationshipLineage entity={entity} />}
+ {alignment == "graph" && (
+ <RelationshipLineage
+ entity={entity}
+ relationshipAttributes={cardData}
+ isLoading={cardLoading && Object.keys(cardData).length === 0}
+ />
+ )}
{alignment == "table" && (
- <TableLayout
- data={
- !isEmpty(checked ? rowData : nonEmptyRowData)
- ? customSortByObjectKeys(checked ? rowData : nonEmptyRowData)
- : []
- }
- columns={defaultColumns.filter(
- (value) => Object.keys(value).length !== 0
+ <>
+ {cardLoading && Object.keys(cardData).length === 0 ? (
+ <div className="relationship-cards-grid">
+ {relationNames.map((name) => (
+ <RelationshipCardSkeleton key={name} />
+ ))}
+ </div>
+ ) : (
+ <div className="relationship-cards-grid relationship-cards-grid--custom">
+ {relationshipColumns.map((columnNames, colIndex) => (
+ <div
+ key={`col-${colIndex}`}
+ className="relationship-cards-column"
+ >
+ {columnNames.map((attributeName) => {
+ const data = cardData[attributeName];
+ if (isEmpty(data) && !checked) {
+ return null;
+ }
+ return (
+ <RelationshipCard
+ key={attributeName}
+ attributeName={attributeName}
+ data={data}
+ referredEntities={referredEntities}
+ showEmptyValues={checked}
+ onLoadMore={handleCardLoadMore}
+ totalCount={cardTotalCounts[attributeName]}
+ isLoading={cardLoadingByName[attributeName]}
+ isResetting={cardResettingByName[attributeName]}
+ isSorted={!!sortByNameByAttr[attributeName]}
+ showDeleted={!!showDeletedByAttr[attributeName]}
+ pageLimit={getPageLimit(
+ attributeName,
+ cardTotalCounts[attributeName]
+ )}
+ onToggleSort={handleToggleSort}
+ onToggleShowDeleted={handleToggleShowDeleted}
+ onPageLimitChange={handlePageLimitChange}
+ onPageLimitSubmit={handlePageLimitSubmit}
+ />
+ );
+ })}
+ </div>
+ ))}
+ {Object.keys(cardData).length === 0 && !cardLoading && (
+ <div className="relationship-cards-empty">
+ No relationship data available
+ </div>
+ )}
+ </div>
)}
- emptyText="No Records found!"
- isFetching={loading}
- columnVisibility={false}
- columnSort={false}
- showPagination={false}
- showRowSelection={false}
- tableFilters={false}
- />
+ </>
)}
</Stack>
</Grid>
diff --git a/dashboard/src/views/SearchResult/SearchResult.tsx b/dashboard/src/views/SearchResult/SearchResult.tsx
index fc4603194..a026e1175 100644
--- a/dashboard/src/views/SearchResult/SearchResult.tsx
+++ b/dashboard/src/views/SearchResult/SearchResult.tsx
@@ -90,6 +90,8 @@ const SearchResult = ({ classificationParams, glossaryTypeParams, hideFilters }:
const toastId: any = useRef(null);
const [updateTable, setUpdateTable] = useState(moment.now());
const { entityData } = useSelector((state: EntityState) => state.entity);
+ const { businessMetaData } = useSelector((state: any) => state.businessMetaData);
+ const { businessMetadataDefs } = businessMetaData || {};
const [pageCount, setPageCount] = useState<number>(0);
const [totalCount, setTotalCount] = useState<number>(0);
const [isEmptyData, setIsEmptyData] = useState(false);
@@ -433,18 +435,26 @@ const SearchResult = ({ classificationParams, glossaryTypeParams, hideFilters }:
{
accessorFn: (row: any) => row.typeName,
accessorKey: "typeName",
- cell: (info: any) => (
- <LightTooltip title={`Search ${info.getValue()}`}>
- <Link
- className="text-blue text-decoration-none"
- to={{
- pathname: "/"
- }}
- >
- {info.getValue()}
- </Link>
- </LightTooltip>
- ),
+ cell: (info: any) => {
+ const typeName = info.getValue();
+ const searchParams = new URLSearchParams();
+ searchParams.set("query", typeName);
+ searchParams.set("searchType", "dsl");
+
+ return (
+ <LightTooltip title={`Search ${typeName}`}>
+ <Link
+ className="text-blue text-decoration-none"
+ to={{
+ pathname: "/search/searchResult",
+ search: searchParams.toString()
+ }}
+ >
+ {typeName}
+ </Link>
+ </LightTooltip>
+ );
+ },
header: "Type",
show: true
},
@@ -700,11 +710,120 @@ const SearchResult = ({ classificationParams, glossaryTypeParams, hideFilters }:
}
);
+ // Function to get business metadata attribute columns
+ const getBusinessMetadataAttributeCols = () => {
+ let businessMetadataAttrCols: any = [];
+ let businessAttributes: any = {};
+
+ // Get business attributes based on search type
+ if (searchParams.get("type") === "_ALL_ENTITY_TYPES") {
+ // For _ALL_ENTITY_TYPES, get all business metadata definitions
+ if (businessMetadataDefs && businessMetadataDefs.length > 0) {
+ businessMetadataDefs.forEach((bmDef: any) => {
+ const attributes = bmDef.attributeDefs || [];
+ if (attributes.length > 0) {
+ // Sort attributes by name to match Classic UI behavior
+ const sortedAttributes = [...attributes].sort((a: any, b: any) => {
+ if (a.name < b.name) return -1;
+ if (a.name > b.name) return 1;
+ return 0;
+ });
+ businessAttributes[bmDef.name] = sortedAttributes;
+ }
+ });
+ }
+ } else {
+ // For specific type, get from entity definition
+ const rawBusinessAttributes = typeDefEntityData?.businessAttributeDefs || {};
+ // Sort attributes within each business metadata
+ Object.keys(rawBusinessAttributes).forEach((bmName: string) => {
+ const attributes = rawBusinessAttributes[bmName];
+ if (Array.isArray(attributes)) {
+ businessAttributes[bmName] = [...attributes].sort((a: any, b: any) => {
+ if (a.name < b.name) return -1;
+ if (a.name > b.name) return 1;
+ return 0;
+ });
+ } else {
+ businessAttributes[bmName] = attributes;
+ }
+ });
+ }
+
+ // Create columns for each business metadata attribute
+ if (businessAttributes && Object.keys(businessAttributes).length > 0) {
+ Object.keys(businessAttributes).forEach((businessMetadataName: string) => {
+ const attributes = businessAttributes[businessMetadataName];
+ if (attributes && Array.isArray(attributes)) {
+ attributes.forEach((attr: any) => {
+ const attributeKey = `${businessMetadataName}.${attr.name}`;
+ businessMetadataAttrCols.push({
+ id: attributeKey, // Set id to match accessorKey for URL params tracking
+ accessorFn: (row: any) => {
+ // Business metadata attributes are flattened in the API response
+ // Format: attributes["bm1.BM_attr1"] (matching Classic UI behavior)
+ // Try flattened format first
+ if (row.attributes?.[attributeKey] !== undefined) {
+ return row.attributes[attributeKey];
+ }
+ // Fallback to nested format: attributes[bmName][attrName]
+ const bmData = row.attributes?.[businessMetadataName];
+ if (bmData && bmData[attr.name] !== undefined) {
+ return bmData[attr.name];
+ }
+ return null;
+ },
+ accessorKey: attributeKey,
+ header: attributeKey, // Display as "bm1.BM_attr1" format
+ cell: (info: any) => {
+ const rowData = info.row.original;
+ // Try flattened format first: attributes["bm1.BM_attr1"]
+ let attrValue = rowData.attributes?.[attributeKey];
+
+ // Fallback to nested format: attributes[bmName][attrName]
+ if (attrValue === undefined || attrValue === null) {
+ const bmData = rowData.attributes?.[businessMetadataName];
+ attrValue = bmData?.[attr.name];
+ }
+
+ if (attrValue === undefined || attrValue === null || attrValue === "") {
+ return <span>NA</span>;
+ }
+
+ // Use getValues helper to format the value properly
+ // Pass attributeKey as the name so getValues can access attributes[attributeKey]
+ return getValues(
+ info,
+ typeDefEntityData,
+ {
+ ...attr,
+ name: attributeKey,
+ isBusinessAttributes: true
+ }
+ );
+ },
+ show: false, // Default to unchecked/hidden
+ enableSorting:
+ attr.typeName?.search(
+ /(string|date|boolean|int|number|byte|float|long|double|short)/i
+ ) == 0
+ });
+ });
+ }
+ });
+ }
+
+ return businessMetadataAttrCols;
+ };
+
+ let businessMetadataAttributeCols = getBusinessMetadataAttributeCols();
+
let dynamicColumns: any = customSortBy(
[
...(entitytAttributeDefsCol || []),
...(relationshipAttributeDefsCol || []),
- ...(superTypeAttributeDefsCol || [])
+ ...(superTypeAttributeDefsCol || []),
+ ...(businessMetadataAttributeCols || [])
],
["header"]
);
@@ -862,19 +981,21 @@ const SearchResult = ({ classificationParams, glossaryTypeParams, hideFilters }:
let hideColumns: any = {};
for (let col of columns) {
+ // If column is explicitly in URL params, show it (true = visible in React Table)
if (
!isEmpty(columnsParams) &&
columnsParams.split(",").includes(col.accessorKey)
) {
hideColumns[col.accessorKey] = true;
- } else if (col.show == false) {
- hideColumns[col.accessorKey] = false;
- } else if (
- !isEmpty(columnsParams) &&
- columnsParams.split(",").includes(col.accessorKey) == false
- ) {
+ }
+ // If column has show: false (like business metadata), hide it by default (false = hidden in React Table)
+ else if (col.show === false) {
hideColumns[col.accessorKey] = false;
}
+ // For columns with show: true or default columns, show them
+ else {
+ hideColumns[col.accessorKey] = true;
+ }
}
return hideColumns;
diff --git a/dashboard/src/views/SideBar/SideBarTree/EntitiesTree.tsx b/dashboard/src/views/SideBar/SideBarTree/EntitiesTree.tsx
index 262c7dcdf..74ffd5448 100644
--- a/dashboard/src/views/SideBar/SideBarTree/EntitiesTree.tsx
+++ b/dashboard/src/views/SideBar/SideBarTree/EntitiesTree.tsx
@@ -161,7 +161,7 @@ const EntitiesTree = ({ sideBarOpen, searchTerm }: Props) => {
) => {
const rootEntityChildren: ChildrenInterface = {
gType: "Entity",
- guid: addOnEntities[0],
+ guid: addOnEntities[0],
id: addOnEntities[0],
name: addOnEntities[0],
type: allEntityTypesData?.category,
diff --git a/dashboardv2/gruntfile.js b/dashboardv2/gruntfile.js
index ddf1cf719..85fa372b4 100644
--- a/dashboardv2/gruntfile.js
+++ b/dashboardv2/gruntfile.js
@@ -50,6 +50,7 @@ module.exports = function(grunt) {
},
connect: {
server: {
+
options: {
port: 9999,
base: distPath,
@@ -224,11 +225,11 @@ module.exports = function(grunt) {
bootstrap: {
files: [
{
- src: nodeModulePath + 'bootstrap/dist/js/bootstrap-patched.min.js',
+ src: nodeModulePath + 'bootstrap/dist/js/bootstrap.min.js',
dest: libPath + 'bootstrap/js/bootstrap.min.js'
},
{
- src: nodeModulePath + 'bootstrap/dist/css/bootstrap-patched.min.css',
+ src: nodeModulePath + 'bootstrap/dist/css/bootstrap.min.css',
dest: libPath + 'bootstrap/css/bootstrap.min.css'
}
]
diff --git a/dashboardv2/public/css/scss/relationship.scss b/dashboardv2/public/css/scss/relationship.scss
index 7bf256072..5c7005bb1 100644
--- a/dashboardv2/public/css/scss/relationship.scss
+++ b/dashboardv2/public/css/scss/relationship.scss
@@ -19,6 +19,7 @@
position: absolute;
height: 100%;
width: 100%;
+ min-height: 700px;
overflow: hidden;
z-index: 1;
@@ -26,10 +27,516 @@
fill: none;
stroke-width: 1.5px;
}
+
+ svg {
+ min-height: 700px;
+ height: 100%;
+ }
+
+ .relationship-node-icon,
+ .relationship-node-label,
+ .relationship-node-count {
+ pointer-events: none;
+ }
}
.relationship-node-details {
&.box-panel {
top: 0;
}
+
+ &.slide-to-left {
+ right: 0 !important;
+ left: 0 !important;
+ }
+}
+
+// Relationship Cards Styles - Match React UI structure and breakpoints
+.relationship-cards-container {
+ padding: 0;
+ min-height: 600px;
+
+ .relationship-cards-controls {
+ margin-bottom: 12px;
+ display: flex;
+ justify-content: flex-end;
+ padding: 0 4px;
+ }
+
+ .relationship-cards-loader {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ min-height: 400px;
+ }
+
+ .relationship-cards-empty {
+ text-align: center;
+ color: #999;
+ padding: 32px;
+ }
+}
+
+// Grid - same structure as React UI: direct children are relationship-card
+.relationship-cards-grid {
+ display: grid;
+ grid-template-columns: repeat(1, minmax(0, 1fr));
+ gap: 12px;
+ align-items: start;
+ padding: 16px;
+}
+
+@media (min-width: 900px) {
+ .relationship-cards-grid {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+}
+
+@media (min-width: 1200px) {
+ .relationship-cards-grid {
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ }
+}
+
+@media (min-width: 1400px) {
+ .relationship-cards-grid {
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ }
+}
+
+.relationship-cards-grid .relationship-cards-empty {
+ grid-column: 1 / -1;
+}
+
+/* Custom column layout: >5 records = 1/col, 1-5 and 0 = 2/col (matches React UI) */
+.relationship-cards-grid--custom {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 12px;
+ align-items: flex-start;
+}
+
+.relationship-cards-column {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ min-width: 280px;
+ flex: 0 0 auto;
+}
+
+@media (max-width: 599px) {
+ .relationship-cards-column {
+ min-width: 100%;
+ }
+}
+
+.relationship-cards-grid--custom .relationship-cards-empty {
+ width: 100%;
+ flex: 1 1 100%;
+}
+
+.relationship-card {
+ display: flex;
+ flex-direction: column;
+ width: 100%;
+ min-width: 0;
+ min-height: 80px;
+ border: 1px solid #e2e8f0;
+ border-radius: 10px;
+ box-shadow: 0 1px 2px rgba(15, 23, 42, 0.08);
+ overflow: hidden;
+ background: #fff;
+ height: auto;
+
+ .relationship-card-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 8px 12px;
+ border-top-left-radius: 10px;
+ border-top-right-radius: 10px;
+ background-color: #f2f6f7;
+ color: #37bb9b;
+ border-bottom: 1px solid #e2e8f0;
+
+ .relationship-card-title-wrap {
+ display: flex;
+ align-items: baseline;
+ }
+
+ .relationship-card-title {
+ font-size: 15px;
+ font-weight: 600;
+ text-transform: capitalize;
+ color: #1e7a6a;
+ font-family: inherit;
+ letter-spacing: 0.01em;
+ -webkit-font-smoothing: antialiased;
+ }
+
+ .relationship-card-count {
+ margin-left: 6px;
+ font-size: 13px;
+ font-weight: 500;
+ color: #1e7a6a;
+ }
+
+ .relationship-card-actions {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ color: #64748b;
+ }
+
+ .relationship-card-action-button {
+ background: transparent;
+ border: 1px solid transparent;
+ border-radius: 4px;
+ padding: 2px 6px;
+ cursor: pointer;
+ color: inherit;
+
+ &.is-active {
+ color: #2563eb;
+ }
+ }
+
+ .relationship-card-toggle {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ font-size: 13px;
+ color: #64748b;
+
+ input {
+ margin: 0;
+ }
+ }
+ }
+
+ .relationship-card-content {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ padding: 12px;
+ min-height: 0;
+ overflow: hidden;
+
+ .relationship-card-search {
+ margin-bottom: 6px;
+
+ .relationship-card-search-input {
+ width: 100%;
+ border: 1px solid #d1d5db;
+ border-radius: 6px;
+ padding: 6px 10px;
+ font-size: 12px;
+ color: #1f2937;
+ background-color: #f9fafb;
+ }
+ }
+
+ .relationship-card-scroll {
+ flex: 1;
+ overflow-y: auto;
+ overflow-x: hidden;
+ min-height: 0;
+ position: relative;
+
+ &::-webkit-scrollbar {
+ width: 8px;
+ }
+
+ &::-webkit-scrollbar-track {
+ background: #f1f1f1;
+ border-radius: 4px;
+ }
+
+ &::-webkit-scrollbar-thumb {
+ background: #888;
+ border-radius: 4px;
+
+ &:hover {
+ background: #555;
+ }
+ }
+ }
+
+ .relationship-card-list {
+ list-style: none;
+ padding: 0;
+ margin: 0;
+ text-align: left;
+
+ .relationship-card-item {
+ padding: 8px 0;
+ border-bottom: 1px solid #f0f0f0;
+
+ &:last-child {
+ border-bottom: none;
+ }
+
+ &:hover {
+ background-color: #f9f9f9;
+ }
+
+ &.has-border {
+ border-bottom: 1px solid #f0f0f0;
+ }
+
+ .relationship-card-link {
+ color: #1976d2;
+ text-decoration: none;
+ font-size: 13px;
+ font-family: inherit;
+ line-height: 1.5;
+ word-break: break-word;
+
+ &:hover {
+ text-decoration: underline;
+ }
+ }
+
+ .value-text {
+ word-break: break-word;
+ font-size: 13px;
+ font-family: inherit;
+ line-height: 1.5;
+ color: #333;
+
+ a {
+ color: #1976d2;
+ text-decoration: none;
+ font-size: 13px;
+ font-family: inherit;
+
+ &:hover {
+ text-decoration: underline;
+ }
+ }
+ }
+ }
+
+ .relationship-card-empty {
+ color: #999;
+ font-style: italic;
+ padding: 16px;
+ text-align: center;
+ }
+ }
+
+ .relationship-card-loader {
+ display: flex;
+ justify-content: center;
+ padding: 16px;
+ }
+
+ .relationship-card-inline-loader {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ min-height: 40px;
+ width: 100%;
+ }
+ }
+
+ .relationship-card-footer {
+ border-top: 1px solid #e2e8f0;
+ margin-top: auto;
+ padding: 8px 12px;
+
+ .relationship-card-footer-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ min-height: 24px;
+ }
+
+ .relationship-card-count {
+ font-size: 13px;
+ color: #64748b;
+ font-family: inherit;
+ flex-shrink: 0;
+ }
+
+ .relationship-card-page-limit {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+
+ label {
+ font-size: 13px;
+ color: #64748b;
+ margin: 0;
+ white-space: nowrap;
+ }
+
+ .relationship-card-page-limit-input {
+ width: 60px;
+ padding: 4px 8px;
+ font-size: 12px;
+ border: 1px solid #d1d5db;
+ border-radius: 4px;
+ }
+ }
+ }
+}
+
+.relationship-card--empty {
+ .relationship-card-scroll--empty {
+ overflow: hidden;
+ }
+}
+
+.relationship-card-load-more {
+ margin-top: 6px;
+ padding: 6px 0;
+ font-size: 12px;
+ text-align: center;
+ color: #9ca3af;
+}
+
+.relationship-card-no-match {
+ padding: 8px 0;
+ font-size: 12px;
+ color: #9ca3af;
+}
+
+.entity-relationship-table-toggle {
+ width: 100%;
+ display: flex;
+ position: relative;
+ margin-top: 12px;
+ justify-content: flex-end;
+ align-items: center;
+}
+
+/* Relationship Card Skeleton Loader */
+.relationship-card-skeleton {
+ border: 1px solid #e2e8f0;
+ border-radius: 10px;
+ background-color: #ffffff;
+ box-shadow: 0 1px 2px rgba(15, 23, 42, 0.08);
+ min-height: 80px;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+}
+
+.relationship-card-skeleton__header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 8px 12px;
+ border-top-left-radius: 10px;
+ border-top-right-radius: 10px;
+ background-color: #f2f6f7;
+ border-bottom: 1px solid #e2e8f0;
+}
+
+.relationship-card-skeleton__title-wrap {
+ display: flex;
+ align-items: baseline;
+ gap: 6px;
+}
+
+.relationship-card-skeleton__line {
+ display: block;
+ height: 14px;
+ background: linear-gradient(
+ 90deg,
+ #e2e8f0 25%,
+ #f1f5f9 50%,
+ #e2e8f0 75%
+ );
+ background-size: 200% 100%;
+ animation: relationship-skeleton-pulse 1.5s ease-in-out infinite;
+ border-radius: 4px;
+
+ &--title {
+ width: 100px;
+ }
+
+ &--count {
+ width: 36px;
+ }
+
+ &--short {
+ width: 60%;
+ }
+
+ &--footer {
+ width: 80px;
+ height: 12px;
+ }
+}
+
+.relationship-card-skeleton__actions {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.relationship-card-skeleton__box {
+ display: block;
+ background: linear-gradient(
+ 90deg,
+ #e2e8f0 25%,
+ #f1f5f9 50%,
+ #e2e8f0 75%
+ );
+ background-size: 200% 100%;
+ animation: relationship-skeleton-pulse 1.5s ease-in-out infinite;
+ border-radius: 4px;
+
+ &--button {
+ width: 28px;
+ height: 24px;
+ }
+
+ &--checkbox {
+ width: 80px;
+ height: 16px;
+ }
+
+ &--input {
+ width: 60px;
+ height: 24px;
+ }
+}
+
+.relationship-card-skeleton__content {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ padding: 12px;
+}
+
+.relationship-card-skeleton__body {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ flex: 1;
+ min-height: 80px;
+}
+
+.relationship-card-skeleton__footer {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ margin-top: auto;
+ padding-top: 8px;
+ border-top: 1px solid #e2e8f0;
+}
+
+@keyframes relationship-skeleton-pulse {
+ 0%,
+ 100% {
+ background-position: 200% 0;
+ }
+ 50% {
+ background-position: -200% 0;
+ }
}
\ No newline at end of file
diff --git a/dashboardv2/public/css/scss/table.scss b/dashboardv2/public/css/scss/table.scss
index f59c096fc..905b5eddf 100644
--- a/dashboardv2/public/css/scss/table.scss
+++ b/dashboardv2/public/css/scss/table.scss
@@ -44,6 +44,7 @@ tr.empty {
.type-node-details,
.relationship-detail-table {
position: relative;
+ min-height: 600px;
.hide-empty-value {
.hide-row {
diff --git a/dashboardv2/public/js/templates/business_metadata/BusinessMetadataAttributeItemView_tmpl.html b/dashboardv2/public/js/templates/business_metadata/BusinessMetadataAttributeItemView_tmpl.html
index 8ffb689b4..8b0ac5e26 100644
--- a/dashboardv2/public/js/templates/business_metadata/BusinessMetadataAttributeItemView_tmpl.html
+++ b/dashboardv2/public/js/templates/business_metadata/BusinessMetadataAttributeItemView_tmpl.html
@@ -70,7 +70,7 @@
<div class="col-sm-8">
<div class="row">
<div class="col-sm-6">
- <input type="checkbox" class="form-check-input multi-value-select" data-id="multiValueSelectStatus">
+ <input type="checkbox" class="form-check-input multi-value-select" data-id="multiValueSelectStatus">
</div>
<div class="col-sm-6" data-id="cardinalityToggleContainer" style="display: none;">
<div class="btn-group" role="group" data-id="cardinalityToggle">
diff --git a/dashboardv2/public/js/templates/detail_page/RelationshipCardsLayoutView_tmpl.html b/dashboardv2/public/js/templates/detail_page/RelationshipCardsLayoutView_tmpl.html
new file mode 100644
index 000000000..704beef7b
--- /dev/null
+++ b/dashboardv2/public/js/templates/detail_page/RelationshipCardsLayoutView_tmpl.html
@@ -0,0 +1,18 @@
+<!--
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+-->
+<div class="relationship-cards-container" data-id="relationshipCardsContainer"></div>
diff --git a/dashboardv2/public/js/templates/graph/RelationshipLayoutView_tmpl.html b/dashboardv2/public/js/templates/graph/RelationshipLayoutView_tmpl.html
index acf9bb3a3..aeb7174e7 100644
--- a/dashboardv2/public/js/templates/graph/RelationshipLayoutView_tmpl.html
+++ b/dashboardv2/public/js/templates/graph/RelationshipLayoutView_tmpl.html
@@ -18,10 +18,10 @@
<div class="btn-group pull-left">
<span class="pull-left">Graph</span>
<label class="switch pull-left">
- <input type="checkbox" checked class="switch-input" name="relationshipViewToggle" value="text">
+ <input type="checkbox" class="switch-input" name="relationshipViewToggle" value="text">
<span class="switch-slider"></span>
</label>
- <span class="pull-left">Table</span>
+ <span class="pull-left">Card</span>
</div>
<div class="pull-right" data-id="zoomControl">
<div class="btn-group pull-right">
@@ -52,8 +52,8 @@
</div>
</div>
</div>
-<div class="relationship-detail-table">
- <div class="entity-relationship-table-toggle">
+<div class="relationship-detail-table" style="display: none;">
+ <div class="entity-relationship-table-toggle" style="padding: 12px 16px;">
<div class="pretty p-switch p-fill">
<input type="checkbox" data-id="noValueToggle" />
<div class="state p-primary">
@@ -61,6 +61,9 @@
</div>
</div>
</div>
+ <div data-id="relationshipCardsView" style="position: relative;top: 40px;">
+ <!-- Card view will be rendered here -->
+ </div>
<table class="table table-quickMenu bold-key" data-id="relationshipDetailTable">
<thead>
<tr>
diff --git a/dashboardv2/public/js/utils/UrlLinks.js b/dashboardv2/public/js/utils/UrlLinks.js
index c6b1af06a..0594b3e0f 100644
--- a/dashboardv2/public/js/utils/UrlLinks.js
+++ b/dashboardv2/public/js/utils/UrlLinks.js
@@ -95,19 +95,25 @@ define(['require', 'utils/Enums', 'utils/Utils', 'underscore'], function(require
name = options.name,
minExtInfo = options.minExtInfo;
if (guid && name && associatedGuid) {
- return entitiesUrl + '/guid/' + guid + '/classification/' + name + '?associatedEntityGuid=' + associatedGuid;
+ return entitiesUrl + '/guid/' + guid + '/classification/' + name + '?associatedEntityGuid=' + associatedGuid + '&ignoreRelationships=true';
} else if (guid && name) {
- entitiesUrl += '/guid/' + guid + '/classification/' + name;
+ return entitiesUrl + '/guid/' + guid + '/classification/' + name + '?ignoreRelationships=true';
} else if (guid && !name) {
entitiesUrl += '/guid/' + guid;
}
}
- if (!minExtInfo) {
- return entitiesUrl;
- } else {
- return entitiesUrl += '?minExtInfo=' + (minExtInfo);
+ if (entitiesUrl.indexOf('/guid/') !== -1) {
+ var queryParams = ['ignoreRelationships=true'];
+ if (options && options.minExtInfo !== undefined && options.minExtInfo !== false) {
+ queryParams.unshift('minExtInfo=' + options.minExtInfo);
+ }
+ return entitiesUrl + '?' + queryParams.join('&');
+ }
+ if (options && options.minExtInfo) {
+ return entitiesUrl + '?minExtInfo=' + (options.minExtInfo);
}
+ return entitiesUrl;
},
relationApiUrl: function(options) {
var relationsUrl = this.baseUrlV2 + '/relationship';
@@ -120,10 +126,10 @@ define(['require', 'utils/Enums', 'utils/Utils', 'underscore'], function(require
return relationsUrl;
},
entityLabelsAPIUrl: function(guid) {
- return this.entitiesApiUrl({ guid: guid }) + "/labels";
+ return this.baseUrlV2 + '/entity/guid/' + guid + '/labels';
},
entityHeaderApiUrl: function(guid) {
- return this.entitiesApiUrl({ guid: guid }) + "/header"
+ return this.baseUrlV2 + '/entity/guid/' + guid + '/header';
},
entitiesTraitsApiUrl: function(token) {
if (token) {
@@ -191,9 +197,15 @@ define(['require', 'utils/Enums', 'utils/Utils', 'underscore'], function(require
return relationshipUrl
}
},
+ relationshipSearchV2ApiUrl: function() {
+ return this.baseUrlV2 + '/search/relationship';
+ },
relationshipSearchApiUrl: function() {
return this.baseUrlV2 + '/search/relations';
},
+ relationshipAttributesApiUrl: function(guid) {
+ return this.entitiesApiUrl() + '/guid/' + guid + '/relationshipAttributes';
+ },
schemaApiUrl: function(guid) {
var lineageUrl = this.baseUrl + '/lineage';
if (guid) {
diff --git a/dashboardv2/public/js/views/detail_page/DetailPageLayoutView.js b/dashboardv2/public/js/views/detail_page/DetailPageLayoutView.js
index afd6da380..0c4328f54 100644
--- a/dashboardv2/public/js/views/detail_page/DetailPageLayoutView.js
+++ b/dashboardv2/public/js/views/detail_page/DetailPageLayoutView.js
@@ -116,6 +116,7 @@ define(['require',
events["click " + this.ui.deleteTerm] = 'onClickTermCross';
events["click " + this.ui.addTag] = 'onClickAddTagBtn';
events["click " + this.ui.tablist] = function(e) {
+ var that = this;
var tabValue = $(e.currentTarget).attr('role');
Utils.setUrl({
url: Utils.getUrlState.getQueryUrl().queyParams[0],
@@ -124,7 +125,15 @@ define(['require',
trigger: false,
updateTabState: true
});
-
+ if (tabValue === 'relationship') {
+ if (this.detailPageObj) {
+ this.ensureRelationshipView();
+ } else {
+ this.listenToOnce(this.collection, 'reset', function() {
+ that.ensureRelationshipView();
+ });
+ }
+ }
};
return events;
},
@@ -144,7 +153,7 @@ define(['require',
this.listenTo(this.collection, 'reset', function() {
this.entityObject = this.collection.first().toJSON();
var collectionJSON = this.entityObject.entity;
- this.activeEntityDef = this.entityDefCollection.fullCollection.find({ name: collectionJSON.typeName });
+ this.activeEntityDef = this.entityDefCollection.fullCollection.findWhere({ name: collectionJSON.typeName });
if (!this.activeEntityDef) {
Utils.backButtonClick();
Utils.notifyError({
@@ -320,7 +329,11 @@ define(['require',
if (obj.businessMetadataCollection) {
this.renderEntityBusinessMetadataView(obj);
}
- this.renderRelationshipLayoutView(obj);
+ this.detailPageObj = obj;
+ if ((this.value && this.value.tabActive === 'relationship') ||
+ this.$('.tab-content .tab-pane[role="relationship"]').hasClass('active')) {
+ this.renderRelationshipLayoutView(obj);
+ }
this.renderAuditTableLayoutView(obj);
this.renderTagTableLayoutView(obj);
if (Globals.isTasksEnabled && Globals.isUiTasksTabEnabled) { this.renderPendingTaskTableLayoutView(); }
@@ -385,6 +398,15 @@ define(['require',
});
}
}, this);
+ this.$('a[href="#tab-relationship"]').on('shown.bs.tab', function() {
+ if (that.detailPageObj) {
+ that.ensureRelationshipView();
+ } else {
+ that.listenToOnce(that.collection, 'reset', function() {
+ that.ensureRelationshipView();
+ });
+ }
+ });
},
onRender: function() {
var that = this;
@@ -438,6 +460,22 @@ define(['require',
this.$('.nav.nav-tabs').find('[role="' + this.value.tabActive + '"]').addClass('active').siblings().removeClass('active');
this.$('.tab-content').find('[role="' + this.value.tabActive + '"]').addClass('active').siblings().removeClass('active');
$("html, body").animate({ scrollTop: (this.$('.tab-content').offset().top + 1200) }, 1000);
+ if (this.value.tabActive === 'relationship') {
+ this.ensureRelationshipView();
+ }
+ } else if (this.$('.tab-content .tab-pane[role="relationship"]').hasClass('active')) {
+ this.ensureRelationshipView();
+ }
+ },
+ ensureRelationshipView: function() {
+ if (!this.detailPageObj) {
+ return;
+ }
+ var relationshipView = this.RRelationshipLayoutView.currentView;
+ if (relationshipView && relationshipView.ensureCardsView) {
+ relationshipView.ensureCardsView(false);
+ } else {
+ this.renderRelationshipLayoutView(this.detailPageObj);
}
},
onShow: function() {
@@ -467,6 +505,18 @@ define(['require',
return [];
}
},
+ getEntityDefForRelationships: function() {
+ if (this.activeEntityDef) {
+ var data = this.activeEntityDef.toJSON();
+ return Utils.getNestedSuperTypeObj({
+ data: data,
+ collection: this.entityDefCollection,
+ attrMerge: true,
+ seperateRelatioshipAttr: true
+ });
+ }
+ return {};
+ },
onClickTagCross: function(e) {
var that = this,
tagName = $(e.currentTarget).parent().text().split('@')[0],
@@ -663,7 +713,10 @@ define(['require',
renderRelationshipLayoutView: function(obj) {
var that = this;
require(['views/graph/RelationshipLayoutView'], function(RelationshipLayoutView) {
- that.RRelationshipLayoutView.show(new RelationshipLayoutView(obj));
+ var relationshipObj = _.extend({}, obj, {
+ attributeDefs: that.getEntityDefForRelationships()
+ });
+ that.RRelationshipLayoutView.show(new RelationshipLayoutView(relationshipObj));
});
},
renderSchemaLayoutView: function(obj) {
diff --git a/dashboardv2/public/js/views/detail_page/RelationshipCardsLayoutView.js b/dashboardv2/public/js/views/detail_page/RelationshipCardsLayoutView.js
new file mode 100644
index 000000000..17171c2b4
--- /dev/null
+++ b/dashboardv2/public/js/views/detail_page/RelationshipCardsLayoutView.js
@@ -0,0 +1,856 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+define([
+ "require",
+ "backbone",
+ "hbs!tmpl/detail_page/RelationshipCardsLayoutView_tmpl",
+ "utils/UrlLinks",
+ "utils/Utils"
+], function(require, Backbone, RelationshipCardsLayoutViewTmpl, UrlLinks, Utils) {
+ "use strict";
+
+ var RelationshipCardsLayoutView = Backbone.Marionette.LayoutView.extend({
+ template: RelationshipCardsLayoutViewTmpl,
+ ui: {
+ container: "[data-id='relationshipCardsContainer']"
+ },
+ initialize: function(options) {
+ _.extend(this, _.pick(options, "entity", "guid", "attributeDefs", "entityDefCollection", "referredEntities", "showEmptyValues", "onDataLoaded", "onDataLoading"));
+ this.cardData = {};
+ this.cardCounts = {};
+ this.loadingByName = {};
+ this.resettingByName = {};
+ this.searchByName = {};
+ this.lastRequestSizeByName = {};
+ this.lastTriggerAtByName = {};
+ this.lastScrollTopByName = {};
+ this.scrollRestoreByName = {};
+ this.exhaustedByName = {};
+ this.sortByName = {};
+ this.showDeletedByName = {};
+ this.pageLimitByName = {};
+ this.isInitialLoading = false;
+
+ console.log("[RelationshipCardsLayoutView] Initialized with:", {
+ guid: this.guid,
+ entityTypeName: this.entity && this.entity.typeName,
+ hasAttributeDefs: !!this.attributeDefs,
+ hasEntityDefCollection: !!this.entityDefCollection,
+ showEmptyValues: this.showEmptyValues
+ });
+ },
+ onRender: function() {
+ console.log("[RelationshipCardsLayoutView] onRender called, fetching initial cards");
+ this.fetchInitialCards();
+ },
+ getRelationNames: function() {
+ var relationDefs = [];
+ if (this.attributeDefs) {
+ relationDefs = this.attributeDefs.relationshipAttributeDefs || this.attributeDefs.relationships || [];
+ if (_.isEmpty(relationDefs) && _.isArray(this.attributeDefs)) {
+ relationDefs = _.filter(this.attributeDefs, function(def) {
+ return def && (def.relationshipTypeName || def.relationshipType);
+ });
+ }
+ }
+
+ console.log("[RelationshipCardsLayoutView] Getting relation names, attributeDefs type:", typeof this.attributeDefs, "isArray:", _.isArray(this.attributeDefs), "relationshipAttributeDefs:", this.attributeDefs && this.attributeDefs.relationshipAttributeDefs ? this.attributeDefs.relationshipAttributeDefs.length : 0, "relationDefs count:", relationDefs.length);
+
+ if (_.isEmpty(relationDefs) && this.entityDefCollection && this.entity && this.entity.typeName) {
+ console.log("[RelationshipCardsLayoutView] Extracting relationDefs from entityDefCollection for type:", this.entity.typeName);
+
+ var entityDef = this.entityDefCollection.fullCollection.findWhere({ name: this.entity.typeName });
+
+ if (entityDef) {
+ console.log("[RelationshipCardsLayoutView] Found entityDef, extracting nested attributes");
+ this.attributeDefs = Utils.getNestedSuperTypeObj({
+ data: entityDef.toJSON(),
+ collection: this.entityDefCollection,
+ attrMerge: true,
+ seperateRelatioshipAttr: true
+ });
+ relationDefs = (this.attributeDefs && this.attributeDefs.relationshipAttributeDefs) || [];
+ console.log("[RelationshipCardsLayoutView] Extracted relationDefs count:", relationDefs.length);
+ console.log("[RelationshipCardsLayoutView] Full attributeDefs:", this.attributeDefs);
+ } else {
+ console.warn("[RelationshipCardsLayoutView] EntityDef not found for type:", this.entity.typeName);
+ console.log("[RelationshipCardsLayoutView] Available entity types:",
+ this.entityDefCollection.fullCollection.map(function(m) { return m.get('name'); })
+ );
+ }
+ }
+
+ // If still empty, try to extract from entity.relationshipAttributes
+ if (_.isEmpty(relationDefs) && this.entity && this.entity.relationshipAttributes) {
+ console.log("[RelationshipCardsLayoutView] Extracting relation names from entity.relationshipAttributes");
+ var relationNames = _.uniq(_.keys(this.entity.relationshipAttributes));
+ console.log("[RelationshipCardsLayoutView] Relation names from entity:", relationNames);
+ return relationNames;
+ }
+
+ var relationNames = _.uniq(_.compact(_.map(relationDefs, function(def) {
+ if (!def) return null;
+ return def.name || def.attributeName || (def.attribute && (def.attribute.name || def.attribute.attributeName));
+ })));
+
+ console.log("[RelationshipCardsLayoutView] Relation names extracted:", relationNames);
+ return relationNames;
+ },
+ getPageLimit: function(relationName, totalCount) {
+ var count = _.isNumber(totalCount) ? totalCount : parseInt(totalCount, 10);
+ var defaultLimit = _.isFinite(count) && count < 100 ? count : 100;
+ var currentLimit = this.pageLimitByName[relationName];
+ if (!_.isNumber(currentLimit)) {
+ return defaultLimit;
+ }
+ if (_.isFinite(count) && currentLimit > count) {
+ return count;
+ }
+ return currentLimit;
+ },
+ getRelationshipParams: function(relationName, offset, totalCount, overrides) {
+ var isSorted = _.isBoolean(overrides && overrides.isSorted)
+ ? overrides.isSorted
+ : !!this.sortByName[relationName];
+ var showDeleted = _.isBoolean(overrides && overrides.showDeleted)
+ ? overrides.showDeleted
+ : !!this.showDeletedByName[relationName];
+ var limit = _.isNumber(overrides && overrides.limit)
+ ? overrides.limit
+ : this.getPageLimit(relationName, totalCount);
+ return {
+ limit: limit,
+ offset: offset,
+ guid: this.guid,
+ sortBy: isSorted ? "name" : undefined,
+ sortOrder: isSorted ? "ASCENDING" : undefined,
+ disableDefaultSorting: !isSorted,
+ excludeDeletedEntities: !showDeleted,
+ includeSubClassifications: true,
+ includeSubTypes: true,
+ includeClassificationAttributes: true,
+ relation: relationName,
+ getApproximateCount: true
+ };
+ },
+ fetchRelationshipData: function(relationName, offset, totalCount, overrides) {
+ var params = {
+ limit: 2,
+ offset: offset,
+ guid: this.guid,
+ sortBy: "name",
+ sortOrder: "ASCENDING",
+ disableDefaultSorting: true,
+ excludeDeletedEntities: true,
+ includeSubClassifications: true,
+ includeSubTypes: true,
+ includeClassificationAttributes: true,
+ relation: relationName,
+ getApproximateCount: true
+ };
+ params = this.getRelationshipParams(relationName, offset, totalCount, overrides);
+
+ var apiUrl = UrlLinks.relationshipSearchV2ApiUrl();
+ console.log("[RelationshipCardsLayoutView] Fetching relationship data:", {
+ relationName: relationName,
+ offset: offset,
+ limit: params.limit,
+ guid: this.guid,
+ apiUrl: apiUrl,
+ fullUrl: apiUrl + "?" + $.param(params)
+ });
+
+ return $.ajax({
+ url: apiUrl,
+ data: params,
+ contentType: "application/json",
+ dataType: "json",
+ type: "GET",
+ success: function(response) {
+ console.log("[RelationshipCardsLayoutView] API SUCCESS for", relationName, "- Full response:", response);
+ },
+ error: function(xhr, status, error) {
+ console.error("[RelationshipCardsLayoutView] API ERROR for", relationName, ":", {
+ status: status,
+ error: error,
+ responseText: xhr.responseText,
+ statusCode: xhr.status
+ });
+ }
+ });
+ },
+ fetchInitialCards: function() {
+ var that = this;
+ console.log("[RelationshipCardsLayoutView] fetchInitialCards called, guid:", this.guid);
+
+ if (!this.guid) {
+ console.warn("[RelationshipCardsLayoutView] No GUID provided, cannot fetch cards");
+ return;
+ }
+
+ var relationNames = this.getRelationNames();
+ console.log("[RelationshipCardsLayoutView] Relation names to fetch:", relationNames);
+
+ if (_.isEmpty(relationNames)) {
+ console.log("[RelationshipCardsLayoutView] No relation names found, rendering empty state");
+ this.renderCards();
+ return;
+ }
+
+ this.isInitialLoading = true;
+ if (_.isFunction(this.onDataLoading)) {
+ this.onDataLoading(true);
+ }
+ this.renderCards();
+
+ console.log("[RelationshipCardsLayoutView] Starting parallel fetch for", relationNames.length, "relationships");
+
+ var processResponse = function(name, response) {
+ if (!response) {
+ that.cardData[name] = [];
+ that.cardCounts[name] = 0;
+ if (_.isUndefined(that.sortByName[name])) { that.sortByName[name] = false; }
+ if (_.isUndefined(that.showDeletedByName[name])) { that.showDeletedByName[name] = true; }
+ that.pageLimitByName[name] = that.getPageLimit(name, 0);
+ return;
+ }
+ var entities = response.entities ? response.entities : [];
+ if (response && response.referredEntities) {
+ 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;
+ } else {
+ that.cardCounts[name] = that.cardData[name].length;
+ }
+ if (_.isUndefined(that.sortByName[name])) {
+ that.sortByName[name] = false;
+ }
+ if (_.isUndefined(that.showDeletedByName[name])) {
+ that.showDeletedByName[name] = true;
+ }
+ if (_.isUndefined(that.pageLimitByName[name])) {
+ that.pageLimitByName[name] = that.getPageLimit(name, that.cardCounts[name]);
+ }
+ var updatedCount = that.getTotalCountForName(name, that.cardData[name].length);
+ if (updatedCount <= that.cardData[name].length) {
+ that.exhaustedByName[name] = true;
+ }
+ };
+
+ var promises = _.map(relationNames, function(name) {
+ return that.fetchRelationshipData(name, 0, undefined, { showDeleted: true })
+ .then(
+ function(response) {
+ console.log("[RelationshipCardsLayoutView] API response for", name, ":", {
+ entitiesCount: response && response.entities ? (_.isArray(response.entities) ? response.entities.length : 1) : 0,
+ approximateCount: response && response.approximateCount,
+ totalCount: response && response.totalCount
+ });
+ processResponse(name, response);
+ return response;
+ },
+ function(error) {
+ console.error("[RelationshipCardsLayoutView] API error for", name, ":", error);
+ processResponse(name, null);
+ return null;
+ }
+ );
+ });
+
+ $.when.apply($, promises).always(function() {
+ console.log("[RelationshipCardsLayoutView] All API calls completed, cardData:", {
+ relationshipCount: _.keys(that.cardData).length,
+ relationships: _.keys(that.cardData),
+ totalEntities: _.reduce(that.cardData, function(sum, entities) { return sum + entities.length; }, 0)
+ });
+
+ that.isInitialLoading = false;
+ that.renderCards();
+ if (_.isFunction(that.onDataLoaded)) {
+ var loadedCounts = {};
+ _.each(that.cardData, function(values, key) {
+ loadedCounts[key] = values ? values.length : 0;
+ });
+ that.onDataLoaded({
+ data: _.clone(that.cardData),
+ counts: _.clone(that.cardCounts),
+ loadedCounts: loadedCounts,
+ referredEntities: _.clone(that.referredEntities)
+ });
+ }
+ if (_.isFunction(that.onDataLoading)) {
+ that.onDataLoading(false);
+ }
+ });
+ },
+ getTotalCountForName: function(name, fallbackCount) {
+ var totalCount = this.cardCounts[name];
+ var parsed = _.isNumber(totalCount) ? totalCount : parseInt(totalCount, 10);
+ if (!_.isFinite(parsed)) {
+ return _.isNumber(fallbackCount) ? fallbackCount : 0;
+ }
+ return parsed;
+ },
+ handleLoadMore: function(relationName, containerEl) {
+ var that = this;
+ if (this.loadingByName[relationName]) {
+ return;
+ }
+ if (this.resettingByName[relationName]) {
+ return;
+ }
+ var existing = this.cardData[relationName] || [];
+ var totalCount = this.getTotalCountForName(relationName, existing.length);
+ if (this.exhaustedByName[relationName] || totalCount <= existing.length) {
+ return;
+ }
+ if (containerEl && containerEl.length) {
+ this.scrollRestoreByName[relationName] = {
+ height: containerEl[0].scrollHeight,
+ top: containerEl[0].scrollTop
+ };
+ }
+ this.loadingByName[relationName] = true;
+ this.renderCards();
+ if (_.isFunction(this.onDataLoading)) {
+ this.onDataLoading(true);
+ }
+ var totalCount = that.cardCounts[relationName];
+ this.fetchRelationshipData(relationName, existing.length, totalCount).then(function(response) {
+ var entities = response && response.entities ? response.entities : [];
+ if (response && response.referredEntities) {
+ that.referredEntities = _.extend({}, that.referredEntities, response.referredEntities);
+ }
+ var newItems = _.isArray(entities) ? entities : (entities ? [entities] : []);
+ that.cardData[relationName] = existing.concat(newItems);
+ if (!_.isUndefined(response.approximateCount)) {
+ that.cardCounts[relationName] = response.approximateCount;
+ } else if (!_.isUndefined(response.totalCount)) {
+ that.cardCounts[relationName] = response.totalCount;
+ }
+ var updatedCount = that.getTotalCountForName(relationName, that.cardData[relationName].length);
+ if (newItems.length === 0 || updatedCount <= that.cardData[relationName].length) {
+ that.exhaustedByName[relationName] = true;
+ }
+ that.renderCards();
+ if (_.isFunction(that.onDataLoaded)) {
+ var loadedCounts = {};
+ _.each(that.cardData, function(values, key) {
+ loadedCounts[key] = values ? values.length : 0;
+ });
+ that.onDataLoaded({
+ data: _.clone(that.cardData),
+ counts: _.clone(that.cardCounts),
+ loadedCounts: loadedCounts,
+ referredEntities: _.clone(that.referredEntities)
+ });
+ }
+ }).fail(function(error) {
+ Utils.notifyError({
+ content: (error && error.responseJSON && error.responseJSON.errorMessage) || "Failed to load relationship data"
+ });
+ }).always(function() {
+ that.loadingByName[relationName] = false;
+ if (_.isFunction(that.onDataLoading)) {
+ that.onDataLoading(false);
+ }
+ });
+ },
+ resetRelationshipData: function(relationName, overrides) {
+ var that = this;
+ if (this.loadingByName[relationName]) {
+ return;
+ }
+ this.resettingByName[relationName] = true;
+ this.lastRequestSizeByName[relationName] = null;
+ this.lastTriggerAtByName[relationName] = 0;
+ this.lastScrollTopByName[relationName] = 0;
+ this.renderCards();
+ var totalCount = this.cardCounts[relationName];
+ this.fetchRelationshipData(relationName, 0, totalCount, overrides).then(function(response) {
+ var entities = response && response.entities ? response.entities : [];
+ if (response && response.referredEntities) {
+ that.referredEntities = _.extend({}, that.referredEntities, response.referredEntities);
+ }
+ 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;
+ }
+ that.exhaustedByName[relationName] = false;
+ }).fail(function(error) {
+ Utils.notifyError({
+ content: (error && error.responseJSON && error.responseJSON.errorMessage) || "Failed to load relationship data"
+ });
+ }).always(function() {
+ that.resettingByName[relationName] = false;
+ that.renderCards();
+ });
+ },
+ buildCardHtml: function(name, list, totalCount) {
+ var that = this;
+ if (_.isEmpty(list) && !this.showEmptyValues && !this.resettingByName[name]) {
+ return "";
+ }
+ var count = this.getTotalCountForName(name, list.length);
+ var searchQuery = this.searchByName[name] || "";
+ var normalizedQuery = searchQuery.trim().toLowerCase();
+ var filteredList = normalizedQuery
+ ? _.filter(list, function(item) {
+ var ref = item && item.guid ? that.referredEntities && that.referredEntities[item.guid] : null;
+ var displayText = (ref && (ref.displayText || (ref.attributes && ref.attributes.name))) ||
+ item.displayText || (item.attributes && item.attributes.name) || item.qualifiedName || item.guid || "N/A";
+ var qualifiedName = item && item.qualifiedName ? item.qualifiedName : "";
+ var nameValue = item && item.attributes && item.attributes.name ? item.attributes.name : "";
+ var searchTarget = (displayText + " " + qualifiedName + " " + nameValue).toLowerCase();
+ return searchTarget.includes(normalizedQuery);
+ })
+ : list;
+ var itemsHtml = _.map(filteredList, function(item) {
+ var ref = item && item.guid ? that.referredEntities && that.referredEntities[item.guid] : null;
+ var displayText = (ref && (ref.displayText || (ref.attributes && ref.attributes.name))) ||
+ item.displayText || (item.attributes && item.attributes.name) || item.qualifiedName || item.guid || "N/A";
+ var href = item && item.guid ? "#/detailPage/" + item.guid : "";
+ var qualifiedName = item && item.qualifiedName ? item.qualifiedName : "";
+ var nameValue = item && item.attributes && item.attributes.name ? item.attributes.name : "";
+ var searchText = _.escape((displayText + " " + qualifiedName + " " + nameValue).toLowerCase());
+ return "<li class='relationship-card-item' data-search='" + searchText + "'>" +
+ (href ? "<a class='relationship-card-link' href='" + href + "'>" + _.escape(displayText) + "</a>" :
+ "<span class='relationship-card-link'>" + _.escape(displayText) + "</span>") +
+ "</li>";
+ }).join("");
+
+ var canLoadMore = !this.exhaustedByName[name] && count > list.length;
+ var showSearch = list.length > 1;
+ var rowHeight = 30;
+ var bodyPadding = 8;
+ var maxVisibleRows = showSearch ? 8 : 9;
+ var visibleRows = Math.min(filteredList.length || 0, maxVisibleRows);
+ var scrollHeight = filteredList.length
+ ? (visibleRows * rowHeight + bodyPadding)
+ : 0;
+ var isZeroOrOneRecord = list.length <= 1;
+ var scrollStyle = isZeroOrOneRecord
+ ? "style='min-height:72px;overflow-y:auto;'"
+ : "style='max-height:" + scrollHeight + "px; overflow-y: auto;'";
+ var emptyClass = list.length ? "" : " relationship-card--empty";
+ var noMatchHtml = "<div class='relationship-card-no-match' style='display:none'></div>";
+ var loadMoreHtml = canLoadMore
+ ? "<div class='relationship-card-load-more'>Scroll to load more data</div>"
+ : "";
+ var loaderHtml = this.loadingByName[name] && canLoadMore
+ ? "<div class='relationship-card-loader'><i class='fa fa-spinner fa-spin'></i></div>"
+ : "";
+ var resetLoaderHtml = this.resettingByName[name]
+ ? "<div class='relationship-card-inline-loader'><i class='fa fa-spinner fa-spin'></i></div>"
+ : "";
+ if (this.resettingByName[name]) {
+ loadMoreHtml = "";
+ loaderHtml = "";
+ }
+ var searchHtml = showSearch
+ ? "<div class='relationship-card-search'><input class='relationship-card-search-input' data-name='" + _.escape(name) + "' type='text' placeholder='Search...' value='" + _.escape(searchQuery) + "'/></div>"
+ : "";
+ var sortIcon = this.sortByName[name] ? "fa-sort-alpha-asc" : "fa-sort";
+ var headerActions = "<div class='relationship-card-actions'>" +
+ "<button type='button' class='relationship-card-action-button" + (this.sortByName[name] ? " is-active" : "") + "' data-action='toggleSort' data-name='" + _.escape(name) + "' aria-label='Toggle sort by name'>" +
+ "<i class='fa " + sortIcon + "'></i></button>" +
+ "<label class='relationship-card-toggle'>" +
+ "<input type='checkbox' data-action='toggleDeleted' data-name='" + _.escape(name) + "' " + (this.showDeletedByName[name] ? "checked" : "") + " />" +
+ "<span>Show deleted</span></label>" +
+ "</div>";
+ var pageLimit = this.getPageLimit(name, count);
+ var footerHtml = "<div class='relationship-card-footer'>" +
+ "<div class='relationship-card-footer-row'>" +
+ "<span class='relationship-card-count'>Showing " + list.length + " of " + count + "</span>" +
+ "<div class='relationship-card-page-limit'>" +
+ "<label for='page-limit-" + _.escape(name) + "'>Limit</label>" +
+ "<input id='page-limit-" + _.escape(name) + "' class='relationship-card-page-limit-input' type='number' min='1' max='" + count + "' data-name='" + _.escape(name) + "' value='" + pageLimit + "' />" +
+ "</div>" +
+ "</div>" +
+ "</div>";
+
+ return "<div class='relationship-card panel panel-default" + emptyClass + "' data-name='" + _.escape(name) + "'>" +
+ "<div class='relationship-card-header'>" +
+ "<div class='relationship-card-title-wrap'><span class='relationship-card-title'>" + _.escape(name) + "</span><span class='relationship-card-count'> (" + count + ")</span></div>" +
+ headerActions + "</div>" +
+ "<div class='relationship-card-content'>" +
+ searchHtml +
+ "<div class='relationship-card-scroll" + (list.length ? "" : " relationship-card-scroll--empty") + "' data-name='" + _.escape(name) + "' " + scrollStyle + ">" +
+ (this.resettingByName[name] ? resetLoaderHtml : (itemsHtml ? "<ul class='relationship-card-list'>" + itemsHtml + "</ul>" : "<div class='relationship-card-empty'>No records to display</div>")) +
+ noMatchHtml +
+ loadMoreHtml +
+ loaderHtml +
+ "</div>" +
+ footerHtml +
+ "</div>" +
+ "</div>";
+ },
+ /**
+ * Computes column layout based on record count (matches React UI):
+ * - >5 records: 1 card per column
+ * - 1-5 records: 2 cards per column
+ * - 0 records: 2 cards per column (when showEmptyValues is true)
+ */
+ computeColumns: function() {
+ var names = _.keys(this.cardData);
+ if (_.isEmpty(names)) {
+ return [];
+ }
+ var that = this;
+ var getCount = function(name) {
+ var total = that.cardCounts[name];
+ var parsed = _.isNumber(total) ? total : parseInt(total, 10);
+ if (!_.isFinite(parsed)) {
+ return (that.cardData[name] || []).length;
+ }
+ return parsed;
+ };
+ var largeCards = [];
+ var smallCards = [];
+ var emptyCards = [];
+ _.each(names, function(name) {
+ var count = getCount(name);
+ if (count > 5) {
+ largeCards.push(name);
+ } else if (count >= 1 && count <= 5) {
+ smallCards.push(name);
+ } else {
+ emptyCards.push(name);
+ }
+ });
+ var sortNames = function(arr) {
+ return arr.sort(function(a, b) { return a.localeCompare(b); });
+ };
+ sortNames(largeCards);
+ sortNames(smallCards);
+ sortNames(emptyCards);
+ var columns = [];
+ _.each(largeCards, function(name) {
+ columns.push([name]);
+ });
+ for (var i = 0; i < smallCards.length; i += 2) {
+ columns.push(smallCards.slice(i, i + 2));
+ }
+ if (this.showEmptyValues) {
+ for (var j = 0; j < emptyCards.length; j += 2) {
+ columns.push(emptyCards.slice(j, j + 2));
+ }
+ }
+ return columns;
+ },
+ buildSkeletonCardHtml: function() {
+ return "<div class='relationship-card-skeleton' aria-hidden='true'>" +
+ "<div class='relationship-card-skeleton__header'>" +
+ "<div class='relationship-card-skeleton__title-wrap'>" +
+ "<span class='relationship-card-skeleton__line relationship-card-skeleton__line--title'></span>" +
+ "<span class='relationship-card-skeleton__line relationship-card-skeleton__line--count'></span>" +
+ "</div>" +
+ "<div class='relationship-card-skeleton__actions'>" +
+ "<span class='relationship-card-skeleton__box relationship-card-skeleton__box--button'></span>" +
+ "<span class='relationship-card-skeleton__box relationship-card-skeleton__box--checkbox'></span>" +
+ "</div>" +
+ "</div>" +
+ "<div class='relationship-card-skeleton__content'>" +
+ "<div class='relationship-card-skeleton__body'>" +
+ "<span class='relationship-card-skeleton__line'></span>" +
+ "<span class='relationship-card-skeleton__line'></span>" +
+ "<span class='relationship-card-skeleton__line'></span>" +
+ "<span class='relationship-card-skeleton__line relationship-card-skeleton__line--short'></span>" +
+ "</div>" +
+ "<div class='relationship-card-skeleton__footer'>" +
+ "<span class='relationship-card-skeleton__line relationship-card-skeleton__line--footer'></span>" +
+ "<span class='relationship-card-skeleton__box relationship-card-skeleton__box--input'></span>" +
+ "</div>" +
+ "</div>" +
+ "</div>";
+ },
+ renderCards: function() {
+ var that = this;
+ console.log("[RelationshipCardsLayoutView] renderCards called, isInitialLoading:", this.isInitialLoading);
+
+ if (this.isInitialLoading) {
+ var relationNames = this.getRelationNames();
+ if (!_.isEmpty(relationNames)) {
+ var skeletonHtml = "<div class='relationship-cards-grid relationship-cards-grid--custom'>";
+ for (var p = 0; p < relationNames.length; p += 2) {
+ skeletonHtml += "<div class='relationship-cards-column'>";
+ skeletonHtml += that.buildSkeletonCardHtml();
+ if (p + 1 < relationNames.length) {
+ skeletonHtml += that.buildSkeletonCardHtml();
+ }
+ skeletonHtml += "</div>";
+ }
+ skeletonHtml += "</div>";
+ this.$el.html(skeletonHtml);
+ } else {
+ this.$el.html("<div class='relationship-cards-loader'><i class='fa fa-spinner fa-spin'></i></div>");
+ }
+ return;
+ }
+
+ var columns = this.computeColumns();
+ var html = "<div class='relationship-cards-grid relationship-cards-grid--custom'>";
+ var cardCount = 0;
+ _.each(columns, function(columnNames) {
+ html += "<div class='relationship-cards-column'>";
+ _.each(columnNames, function(name) {
+ var list = that.cardData[name] || [];
+ var cardHtml = that.buildCardHtml(name, list, that.cardCounts[name]);
+ if (cardHtml) {
+ html += cardHtml;
+ cardCount++;
+ }
+ });
+ html += "</div>";
+ });
+ html += "</div>";
+
+ console.log("[RelationshipCardsLayoutView] Built", cardCount, "cards");
+
+ if (cardCount === 0) {
+ console.log("[RelationshipCardsLayoutView] No cards to display, showing empty state");
+ html = "<div class='relationship-cards-empty'>No relationship data available</div>";
+ }
+
+ console.log("[RelationshipCardsLayoutView] Setting HTML to container");
+ if (this.$el && this.$el.length) {
+ this.$el.html(html);
+ this.bindCardEvents();
+ } else {
+ console.warn("[RelationshipCardsLayoutView] $el not available, cannot render cards");
+ }
+ console.log("[RelationshipCardsLayoutView] Cards rendered successfully");
+ }
+ });
+
+ RelationshipCardsLayoutView.prototype.bindCardEvents = function() {
+ var that = this;
+ this.$el.find(".relationship-card-action-button")
+ .off("click")
+ .on("click", function(e) {
+ e.preventDefault();
+ var name = $(e.currentTarget).data("name");
+ if (!name || that.resettingByName[name]) {
+ return;
+ }
+ var nextSorted = !that.sortByName[name];
+ that.sortByName[name] = nextSorted;
+ that.resetRelationshipData(name, { isSorted: nextSorted });
+ });
+
+ this.$el.find("input[data-action='toggleDeleted']")
+ .off("change")
+ .on("change", function(e) {
+ var name = $(e.currentTarget).data("name");
+ if (!name || that.resettingByName[name]) {
+ return;
+ }
+ var isChecked = $(e.currentTarget).is(":checked");
+ that.showDeletedByName[name] = isChecked;
+ that.resetRelationshipData(name, { showDeleted: isChecked });
+ });
+
+ this.$el.find(".relationship-card-page-limit-input")
+ .off("keydown change")
+ .on("change", function(e) {
+ var name = $(e.currentTarget).data("name");
+ if (!name) {
+ return;
+ }
+ var rawValue = $(e.currentTarget).val();
+ var parsed = parseInt(rawValue, 10);
+ if (!_.isFinite(parsed)) {
+ that.pageLimitByName[name] = 0;
+ return;
+ }
+ that.pageLimitByName[name] = parsed;
+ })
+ .on("keydown", function(e) {
+ if (e.key !== "Enter") {
+ return;
+ }
+ var name = $(e.currentTarget).data("name");
+ if (!name || that.resettingByName[name]) {
+ return;
+ }
+ var count = that.getTotalCountForName(name, 0);
+ var defaultLimit = count < 100 ? count : 100;
+ var currentLimit = that.pageLimitByName[name];
+ var nextLimit = currentLimit;
+ if (!_.isFinite(nextLimit) || nextLimit <= 0) {
+ nextLimit = defaultLimit;
+ }
+ if (count > 0 && nextLimit > count) {
+ nextLimit = count;
+ }
+ that.pageLimitByName[name] = nextLimit;
+ that.resetRelationshipData(name, { limit: nextLimit });
+ });
+
+ this.$el.find(".relationship-card-search-input")
+ .off("keyup keypress keydown")
+ .on("keyup keypress", function(e) {
+ var name = $(e.currentTarget).data("name");
+ that.searchByName[name] = $(e.currentTarget).val() || "";
+ that.applySearchFilter(name);
+ })
+ .on("keydown", function(e) {
+ if (e.key === "Escape") {
+ var name = $(e.currentTarget).data("name");
+ that.searchByName[name] = "";
+ $(e.currentTarget).val("");
+ that.applySearchFilter(name);
+ }
+ });
+
+ this.$el.find(".relationship-card-scroll")
+ .off("scroll")
+ .on("scroll", function(e) {
+ var container = $(e.currentTarget);
+ var name = container.data("name");
+
+ console.log("[RelationshipCardsLayoutView] Scroll event triggered for:", name);
+
+ if (!name) {
+ console.warn("[RelationshipCardsLayoutView] No name found on scroll container");
+ return;
+ }
+
+ if (that.loadingByName[name]) {
+ console.log("[RelationshipCardsLayoutView] Already loading for:", name);
+ return;
+ }
+ if (that.resettingByName[name]) {
+ console.log("[RelationshipCardsLayoutView] Resetting, skip load more:", name);
+ return;
+ }
+ if (that.exhaustedByName[name]) {
+ console.log("[RelationshipCardsLayoutView] Exhausted, no more data for:", name);
+ return;
+ }
+
+ var list = that.cardData[name] || [];
+ var count = that.getTotalCountForName(name, list.length);
+ var canLoadMore = count > list.length;
+
+ console.log("[RelationshipCardsLayoutView] Scroll state:", {
+ name: name,
+ currentCount: list.length,
+ totalCount: count,
+ canLoadMore: canLoadMore,
+ scrollTop: container[0].scrollTop,
+ scrollHeight: container[0].scrollHeight,
+ clientHeight: container[0].clientHeight
+ });
+
+ if (!canLoadMore) {
+ console.log("[RelationshipCardsLayoutView] Cannot load more, all data loaded");
+ return;
+ }
+
+ var now = Date.now();
+ var distanceFromBottom = container[0].scrollHeight - container[0].scrollTop - container[0].clientHeight;
+ var isNearBottom = distanceFromBottom <= 10;
+ var isScrollingDown = container[0].scrollTop > (that.lastScrollTopByName[name] || 0);
+
+ console.log("[RelationshipCardsLayoutView] Scroll position check:", {
+ distanceFromBottom: distanceFromBottom,
+ isNearBottom: isNearBottom,
+ isScrollingDown: isScrollingDown,
+ lastScrollTop: that.lastScrollTopByName[name]
+ });
+
+ that.lastScrollTopByName[name] = container[0].scrollTop;
+
+ if (!isNearBottom || !isScrollingDown) {
+ console.log("[RelationshipCardsLayoutView] Not near bottom or not scrolling down");
+ return;
+ }
+
+ var timeSinceLastTrigger = now - (that.lastTriggerAtByName[name] || 0);
+ if (timeSinceLastTrigger < 400) {
+ console.log("[RelationshipCardsLayoutView] Too soon since last trigger:", timeSinceLastTrigger, "ms");
+ return;
+ }
+
+ if (that.lastRequestSizeByName[name] === list.length) {
+ console.log("[RelationshipCardsLayoutView] Already requested for this size:", list.length);
+ return;
+ }
+
+ console.log("[RelationshipCardsLayoutView] Triggering load more for:", name);
+ that.lastRequestSizeByName[name] = list.length;
+ that.lastTriggerAtByName[name] = now;
+ that.handleLoadMore(name, container);
+ });
+
+ _.each(this.scrollRestoreByName, function(scroll, name) {
+ var container = that.$el.find(".relationship-card-scroll[data-name='" + name + "']");
+ if (container.length && scroll) {
+ var nextTop = container[0].scrollHeight - scroll.height + scroll.top;
+ container[0].scrollTop = nextTop;
+ }
+ });
+ this.scrollRestoreByName = {};
+
+ _.each(this.searchByName, function(_value, name) {
+ that.applySearchFilter(name);
+ });
+ };
+
+ RelationshipCardsLayoutView.prototype.applySearchFilter = function(name) {
+ var rawQuery = (this.searchByName[name] || "").trim();
+ var query = rawQuery.toLowerCase();
+ var card = this.$el.find(".relationship-card[data-name='" + name + "']");
+ if (!card.length) {
+ return;
+ }
+ var items = card.find(".relationship-card-item");
+ var noMatch = card.find(".relationship-card-no-match");
+ if (!query) {
+ items.show();
+ noMatch.hide();
+ return;
+ }
+ var visibleCount = 0;
+ items.each(function() {
+ var searchText = ($(this).data("search") || "").toString();
+ if (searchText.includes(query)) {
+ $(this).show();
+ visibleCount += 1;
+ } else {
+ $(this).hide();
+ }
+ });
+ if (visibleCount === 0) {
+ noMatch.text("No record found for \"" + rawQuery + "\"").show();
+ } else {
+ noMatch.hide();
+ }
+ };
+
+ return RelationshipCardsLayoutView;
+});
diff --git a/dashboardv2/public/js/views/graph/RelationshipLayoutView.js b/dashboardv2/public/js/views/graph/RelationshipLayoutView.js
index a51fa7b25..dc270d601 100644
--- a/dashboardv2/public/js/views/graph/RelationshipLayoutView.js
+++ b/dashboardv2/public/js/views/graph/RelationshipLayoutView.js
@@ -40,7 +40,9 @@ define([
template: RelationshipLayoutViewtmpl,
className: "resizeGraph",
/** Layout sub regions */
- regions: {},
+ regions: {
+ relationshipCardsView: '[data-id="relationshipCardsView"]'
+ },
/** ui selector cache */
ui: {
@@ -48,11 +50,13 @@ define([
searchNode: '[data-id="searchNode"]',
relationshipViewToggle: 'input[name="relationshipViewToggle"]',
relationshipDetailTable: "[data-id='relationshipDetailTable']",
+ relationshipDetailTableContainer: ".relationship-detail-table",
relationshipSVG: "[data-id='relationshipSVG']",
relationshipDetailValue: "[data-id='relationshipDetailValue']",
zoomControl: "[data-id='zoomControl']",
boxClose: '[data-id="box-close"]',
- noValueToggle: "[data-id='noValueToggle']"
+ noValueToggle: "[data-id='noValueToggle']",
+ relationshipCardsView: "[data-id='relationshipCardsView']"
},
/** ui events hash */
@@ -71,6 +75,7 @@ define([
inputType: this.ui.noValueToggle,
tableEl: this.ui.relationshipDetailValue
});
+ this.updateCardsShowEmptyValues(e.currentTarget.checked);
};
return events;
},
@@ -80,8 +85,56 @@ define([
* @constructs
*/
initialize: function(options) {
- _.extend(this, _.pick(options, "entity", "entityName", "guid", "actionCallBack", "attributeDefs"));
+ _.extend(this, _.pick(options, "entity", "entityName", "guid", "actionCallBack", "attributeDefs", "referredEntities", "entityDefCollection"));
this.graphData = this.createData(this.entity);
+ this.relationshipCardCounts = {};
+ this.relationshipLoadedCounts = {};
+ this.cardsViewLoadInProgress = false;
+
+ console.log("[RelationshipLayoutView] Initialized with:", {
+ guid: this.guid,
+ entityTypeName: this.entity && this.entity.typeName,
+ hasAttributeDefs: !!this.attributeDefs,
+ hasEntityDefCollection: !!this.entityDefCollection
+ });
+
+ this.handleRelationshipDataUpdate = _.bind(function(payload) {
+ var relationshipAttributes = payload && payload.data ? payload.data : payload;
+ var relationshipCounts = payload && payload.counts ? payload.counts : this.relationshipCardCounts;
+ var loadedCounts = payload && payload.loadedCounts ? payload.loadedCounts : this.relationshipLoadedCounts;
+ var referredEntities = payload && payload.referredEntities ? payload.referredEntities : null;
+ console.log("[RelationshipLayoutView] Relationship data updated:", {
+ relationshipCount: relationshipAttributes ? _.keys(relationshipAttributes).length : 0,
+ relationshipNames: relationshipAttributes ? _.keys(relationshipAttributes) : []
+ });
+
+ if (!relationshipAttributes) {
+ return;
+ }
+ this.entity.relationshipAttributes = relationshipAttributes;
+ this.relationshipCardCounts = relationshipCounts || {};
+ this.relationshipLoadedCounts = loadedCounts || {};
+ if (referredEntities) {
+ this.referredEntities = _.extend({}, this.referredEntities, referredEntities);
+ }
+ this.graphData = this.createData(this.entity);
+ if (!this.ui.relationshipViewToggle.is(':checked')) {
+ this.$("svg").empty();
+ if (this.graphData && _.isEmpty(this.graphData.links)) {
+ this.noRelationship();
+ } else if (this.graphData) {
+ this.createGraph(this.graphData);
+ }
+ }
+ }, this);
+ this.handleRelationshipLoading = _.bind(function(isLoading) {
+ console.log("[RelationshipLayoutView] Loading state:", isLoading);
+ if (isLoading) {
+ this.$(".fontLoader").show();
+ } else {
+ this.$(".fontLoader").hide();
+ }
+ }, this);
},
createData: function(entity) {
var that = this,
@@ -89,15 +142,22 @@ define([
nodes = {};
if (entity && entity.relationshipAttributes) {
_.each(entity.relationshipAttributes, function(obj, key) {
- if (!_.isEmpty(obj)) {
+ var relationValue = obj;
+ if (relationValue && relationValue.entities) {
+ relationValue = relationValue.entities;
+ }
+ if (relationValue && !_.isArray(relationValue)) {
+ relationValue = [relationValue];
+ }
+ if (!_.isEmpty(relationValue)) {
links.push({
source: nodes[that.entity.typeName] ||
(nodes[that.entity.typeName] = _.extend({ name: that.entity.typeName }, { value: entity })),
target: nodes[key] ||
(nodes[key] = _.extend({
name: key
- }, { value: obj })),
- value: obj
+ }, { value: relationValue })),
+ value: relationValue
});
}
});
@@ -105,94 +165,169 @@ define([
return { nodes: nodes, links: links };
},
onRender: function() {
- this.ui.zoomControl.hide();
- this.$el.addClass("auto-height");
+ console.log("[RelationshipLayoutView] onRender called");
+ this.isRendered = true;
+
+ // Initialize: show card view by default (checked = Card)
+ this.ui.relationshipViewToggle.prop('checked', true);
+ this.relationshipViewToggle(true);
+
+ console.log("[RelationshipLayoutView] Initial view set to Card");
},
onShow: function(argument) {
- if (this.graphData && _.isEmpty(this.graphData.links)) {
- this.noRelationship();
- } else {
- this.createGraph(this.graphData);
+ console.log("[RelationshipLayoutView] onShow called");
+
+ // Always create graph on show if graph view is active
+ var isGraphView = !this.ui.relationshipViewToggle.is(':checked');
+ if (isGraphView) {
+ if (this.graphData && _.isEmpty(this.graphData.links)) {
+ this.noRelationship();
+ } else if (this.graphData) {
+ this.createGraph(this.graphData);
+ }
}
this.createTable();
+
+ // Initialize card view (but don't show it yet if in graph view)
+ console.log("[RelationshipLayoutView] Initializing card view in onShow");
+ this.ensureCardsView();
+ },
+ ensureCardsView: function(forceRefresh) {
+ var that = this;
+ console.log("[RelationshipLayoutView] ensureCardsView called, instance exists:", !!this.relationshipCardsViewInstance, "forceRefresh:", !!forceRefresh);
+ console.log("[RelationshipLayoutView] Region available:", !!this.relationshipCardsView);
+ console.log("[RelationshipLayoutView] Entity:", this.entity);
+ console.log("[RelationshipLayoutView] GUID:", this.guid);
+ console.log("[RelationshipLayoutView] AttributeDefs:", this.attributeDefs);
+ console.log("[RelationshipLayoutView] EntityDefCollection:", !!this.entityDefCollection);
+
+ if (this.relationshipCardsViewInstance) {
+ if (forceRefresh) {
+ console.log("[RelationshipLayoutView] Force refresh: triggering fetchInitialCards");
+ this.relationshipCardsViewInstance.cardData = {};
+ this.relationshipCardsViewInstance.cardCounts = {};
+ this.relationshipCardsViewInstance.fetchInitialCards();
+ } else {
+ var hasData = _.keys(this.relationshipCardsViewInstance.cardData || {}).length > 0;
+ if (!hasData) {
+ console.log("[RelationshipLayoutView] Card view exists but no data, fetching");
+ this.relationshipCardsViewInstance.fetchInitialCards();
+ } else {
+ this.relationshipCardsViewInstance.renderCards();
+ }
+ }
+ return;
+ }
+ if (this.cardsViewLoadInProgress) {
+ console.log("[RelationshipLayoutView] Card view load already in progress, skipping");
+ return;
+ }
+ this.cardsViewLoadInProgress = true;
+ console.log("[RelationshipLayoutView] Creating new card view instance");
+ require(['views/detail_page/RelationshipCardsLayoutView'], function(RelationshipCardsLayoutView) {
+ console.log("[RelationshipLayoutView] RelationshipCardsLayoutView module loaded");
+ try {
+ if (!that.relationshipCardsView) {
+ console.warn("[RelationshipLayoutView] Region not available");
+ that.cardsViewLoadInProgress = false;
+ return;
+ }
+ that.relationshipCardsViewInstance = new RelationshipCardsLayoutView({
+ entity: that.entity,
+ guid: that.guid,
+ attributeDefs: that.attributeDefs,
+ entityDefCollection: that.entityDefCollection,
+ referredEntities: that.referredEntities || {},
+ showEmptyValues: false,
+ onDataLoaded: that.handleRelationshipDataUpdate,
+ onDataLoading: that.handleRelationshipLoading
+ });
+ that.relationshipCardsView.show(that.relationshipCardsViewInstance);
+ console.log("[RelationshipLayoutView] Card view shown in region");
+ } catch (err) {
+ console.error("[RelationshipLayoutView] Error showing cards view:", err);
+ } finally {
+ that.cardsViewLoadInProgress = false;
+ }
+ }, function(err) {
+ console.error("[RelationshipLayoutView] Failed to load RelationshipCardsLayoutView:", err);
+ that.cardsViewLoadInProgress = false;
+ });
+ },
+ updateCardsShowEmptyValues: function(showEmptyValues) {
+ if (!this.relationshipCardsViewInstance) {
+ return;
+ }
+ this.relationshipCardsViewInstance.showEmptyValues = !!showEmptyValues;
+ this.relationshipCardsViewInstance.renderCards();
},
noRelationship: function() {
this.$("svg").html('<text x="50%" y="50%" alignment-baseline="middle" text-anchor="middle">No relationship data found</text>');
},
toggleInformationSlider: function(options) {
- if (options.open && !this.$(".relationship-details").hasClass("open")) {
- this.$(".relationship-details").addClass("open");
- } else if (options.close && this.$(".relationship-details").hasClass("open")) {
- d3.selectAll("circle").attr("stroke", "none");
- this.$(".relationship-details").removeClass("open");
+ var panel = this.$(".relationship-node-details");
+ if (options && options.close) {
+ console.log("[RelationshipLayoutView] Closing information slider");
+ panel.removeClass("slide-to-left").addClass("slide-from-left");
+ } else {
+ console.log("[RelationshipLayoutView] Toggling information slider");
+ if (panel.hasClass("slide-to-left")) {
+ panel.removeClass("slide-to-left").addClass("slide-from-left");
+ } else {
+ panel.removeClass("slide-from-left").addClass("slide-to-left");
+ }
}
},
- toggleBoxPanel: function(options) {
- var el = options && options.el,
- nodeDetailToggler = options && options.nodeDetailToggler,
- currentTarget = options.currentTarget;
- this.$el.find(".show-box-panel").removeClass("show-box-panel");
- if (el && el.addClass) {
- el.addClass("show-box-panel");
- }
- this.$("circle.node-detail-highlight").removeClass("node-detail-highlight");
+ toggleBoxPanel: function() {
+ console.log("[RelationshipLayoutView] Closing box panel");
+ this.$(".relationship-node-details").removeClass("slide-to-left").addClass("slide-from-left");
},
searchNode: function(e) {
- var $el = $(e.currentTarget);
- this.updateRelationshipDetails(_.extend({}, $el.data(), { searchString: $el.val() }));
- },
- updateRelationshipDetails: function(options) {
- var data = options.obj.value,
- typeName = data.typeName || options.obj.name,
- searchString = _.escape(options.searchString),
+ var searchString = $(e.currentTarget).val(),
listString = "",
+ data = this.selectedNodeData,
+ typeName = this.selectedNodeType,
+ activeEntityColor = "#00b98b",
+ deletedEntityColor = "#BB5838",
+ defaultEntityColor = "#e0e0e0",
+ normalizeEntity = function(entity) {
+ if (!entity) {
+ return entity;
+ }
+ if (_.isString(entity)) {
+ entity = { guid: entity };
+ }
+ if (entity.guid && this.referredEntities && this.referredEntities[entity.guid]) {
+ return _.extend({}, entity, this.referredEntities[entity.guid]);
+ }
+ return entity;
+ }.bind(this),
+ getdefault = function(options) {
+ return "<pre class='entity-type-name' style='color:" + options.color + "'>" + options.name + "</pre>";
+ },
+ getWithButton = function(options) {
+ var name = options.name,
+ guid = options.options.guid,
+ entityTypeButton = "";
+ if (guid) {
+ if (options.entity) {
+ entityTypeButton = "<a href='#/detailPage/" + guid + "' class='entity-type-name' style='color:" + options.color + "'>" + name + "</a>";
+ } else if (options.relationship) {
+ entityTypeButton = "<a href='#/relationshipDetailPage/" + guid + "' class='entity-type-name' style='color:" + options.color + "'>" + name + "</a>";
+ } else {
+ entityTypeButton = "<a href='#/detailPage/" + guid + "' class='entity-type-name' style='color:" + options.color + "'>" + name + "</a>";
+ }
+ } else {
+ entityTypeButton = "<pre class='entity-type-name' style='color:" + options.color + "'>" + name + "</pre>";
+ }
+ return entityTypeButton;
+ },
getEntityTypelist = function(options) {
- var activeEntityColor = "#4a90e2",
- deletedEntityColor = "#BB5838",
- entityTypeHtml = "<pre>",
- getdefault = function(obj) {
- var options = obj.options,
- status = Enums.entityStateReadOnly[options.entityStatus || options.status] ? " deleted-relation" : "",
- guid = options.guid,
- entityColor = obj.color,
- name = obj.name,
- typeName = options.typeName;
- if (typeName === "AtlasGlossaryTerm") {
- return '<li class=' + status + '>' +
- '<a style="color:' + entityColor + '" href="#!/glossary/' + guid + '?guid=' + guid + '&gType=term&viewType=term&fromView=entity">' + name + ' (' + typeName + ')</a>' +
- '</li>';
- } else {
- return "<li class=" + status + ">" +
- "<a style='color:" + entityColor + "' href=#!/detailPage/" + guid + "?tabActive=relationship>" + name + " (" + typeName + ")</a>" +
- "</li>";
- }
- },
- getWithButton = function(obj) {
- var options = obj.options,
- status = Enums.entityStateReadOnly[options.entityStatus || options.status] ? " deleted-relation" : "",
- guid = options.guid,
- entityColor = obj.color,
- name = obj.name,
- typeName = options.typeName,
- relationship = obj.relationship || false,
- entity = obj.entity || false,
- icon = '<i class="fa fa-trash"></i>',
- title = "Deleted";
- if (relationship) {
- icon = '<i class="fa fa-long-arrow-right"></i>';
- status = Enums.entityStateReadOnly[options.relationshipStatus || options.status] ? "deleted-relation" : "";
- title = "Relationship Deleted";
- }
- return "<li class=" + status + ">" +
- "<a style='color:" + entityColor + "' href=#!/detailPage/" + options.guid + "?tabActive=relationship>" + _.escape(name) + " (" + options.typeName + ")</a>" +
- '<button type="button" title="' + title + '" class="btn btn-sm deleteBtn deletedTableBtn btn-action ">' + icon + '</button>' +
- "</li>";
- };
-
- var name = options.entityName ? options.entityName : Utils.getName(options, "displayText");
- if (options.entityStatus == "ACTIVE") {
+ var name = options.entityName ? options.entityName : Utils.getName(options, "displayText"),
+ entityTypeHtml = "";
+ if (options.entityStatus == "ACTIVE" || options.status == "ACTIVE") {
if (options.relationshipStatus == "ACTIVE") {
- entityTypeHtml = getdefault({
+ entityTypeHtml = getWithButton({
color: activeEntityColor,
options: options,
name: name
@@ -228,25 +363,53 @@ define([
var entityTypeButton = getEntityTypelist(options);
return entityTypeButton;
};
+ var buildEntityObj = function(item) {
+ var normalized = normalizeEntity(item);
+ var ref = normalized && normalized.guid ? this.referredEntities && this.referredEntities[normalized.guid] : null;
+ var displayText = (ref && (ref.displayText || (ref.attributes && ref.attributes.name))) ||
+ normalized.displayText ||
+ (normalized.attributes && normalized.attributes.name) ||
+ normalized.qualifiedName ||
+ normalized.guid ||
+ "N/A";
+ var typeName = normalized.typeName || (ref && ref.typeName) || this.selectedNodeType;
+ return _.extend({}, ref, normalized, {
+ entityName: displayText,
+ typeName: typeName
+ });
+ }.bind(this);
+ var buildListItem = function(item) {
+ var name = item.entityName || Utils.getName(item, "displayText");
+ var href = item.guid ? "#/detailPage/" + item.guid : "";
+ var isDeleted = (item.entityStatus || item.status) == "DELETED";
+ var color = isDeleted ? deletedEntityColor : activeEntityColor;
+ var content = href
+ ? "<a href='" + href + "' class='entity-type-name' style='color:" + color + "'>" + _.escape(name) + "</a>"
+ : "<span class='entity-type-name' style='color:" + color + "'>" + _.escape(name) + "</span>";
+ return "<li class='entity-list-item'>" + content + "</li>";
+ };
if (_.isArray(data)) {
+ data = _.map(data, function(item) {
+ return buildEntityObj(item);
+ });
if (data.length > 1) {
this.ui.searchNode.show();
}
- _.each(_.sortBy(data, "displayText"), function(val) {
- var name = Utils.getName(val, "displayText"),
- valObj = _.extend({}, val, { entityName: name });
+ _.each(_.sortBy(data, "entityName"), function(val) {
+ var name = val.entityName || Utils.getName(val, "displayText");
if (searchString) {
- if (name.search(new RegExp(searchString, "i")) != -1) {
- listString += getElement(valObj);
+ if (name.toLowerCase().includes(searchString.toLowerCase())) {
+ listString += buildListItem(val);
} else {
return;
}
} else {
- listString += getElement(valObj);
+ listString += buildListItem(val);
}
});
} else {
- listString += getElement(data);
+ data = buildEntityObj(data);
+ listString += buildListItem(data);
}
this.$("[data-id='entityList']").html(listString);
},
@@ -254,20 +417,47 @@ define([
//Ref - http://bl.ocks.org/fancellu/2c782394602a93921faff74e594d1bb1
var that = this,
- width = this.$("svg").width(),
- height = this.$("svg").height(),
+ width = this.$("svg").width() || 1200,
+ height = Math.max(this.$("svg").height() || 600, 600),
nodes = d3.values(data.nodes),
links = data.links;
+ console.log("[RelationshipLayoutView] Creating graph with dimensions:", {
+ width: width,
+ height: height,
+ nodeCount: nodes.length,
+ linkCount: links.length
+ });
+
var activeEntityColor = "#00b98b",
deletedEntityColor = "#BB5838",
defaultEntityColor = "#e0e0e0",
selectedNodeColor = "#4a90e2";
+ var getNodeCount = function(node) {
+ if (!node) {
+ return 0;
+ }
+ if (node.name === that.entity.typeName) {
+ return 0;
+ }
+ if (that.relationshipLoadedCounts && _.has(that.relationshipLoadedCounts, node.name)) {
+ var count = that.relationshipLoadedCounts[node.name];
+ var parsedCount = _.isNumber(count) ? count : parseInt(count, 10);
+ if (_.isFinite(parsedCount)) {
+ return parsedCount;
+ }
+ }
+ if (_.isArray(node.value)) {
+ return node.value.length;
+ }
+ return node.value ? 1 : 0;
+ };
var svg = d3
.select(this.$("svg")[0])
.attr("viewBox", "0 0 " + width + " " + height)
- .attr("enable-background", "new 0 0 " + width + " " + height),
+ .attr("enable-background", "new 0 0 " + width + " " + height)
+ .style("min-height", "600px"),
node,
path;
@@ -298,47 +488,45 @@ define([
.attr("orient", "auto")
.attr("markerWidth", 6)
.attr("markerHeight", 6)
+ .attr("xoverflow", "visible")
.append("svg:path")
- .attr("d", "M 0,-5 L 10 ,0 L 0,5")
+ .attr("d", "M 0,-5 L 10,0 L 0,5")
.attr("fill", function(d) {
- return d == "deletedLink" ? deletedEntityColor : activeEntityColor;
+ return d === "deletedLink" ? deletedEntityColor : activeEntityColor;
})
- .style("stroke", "none");
-
- var forceLink = d3
- .forceLink()
- .id(function(d) {
- return d.id;
- })
- .distance(function(d) {
- return 100;
- })
- .strength(1);
+ .attr("stroke", "none");
var simulation = d3
- .forceSimulation()
- .force("link", forceLink)
- .force("charge", d3.forceManyBody())
- .force("center", d3.forceCenter(width / 2, height / 2));
-
- update();
+ .forceSimulation(nodes)
+ .force(
+ "link",
+ d3
+ .forceLink(links)
+ .id(function(d) {
+ return d.name;
+ })
+ .distance(150)
+ )
+ .force("charge", d3.forceManyBody().strength(-300))
+ .force("center", d3.forceCenter(width / 2, height / 2))
+ .force("collision", d3.forceCollide().radius(50));
- function update() {
path = container
- .append("svg:g")
+ .append("g")
.selectAll("path")
.data(links)
.enter()
- .append("svg:path")
+ .append("path")
.attr("class", "relatioship-link")
+ .attr("marker-end", function(d) {
+ return isAllEntityRelationDeleted({ data: d, type: "link" }) ? "url(#deletedLink)" : "url(#activeLink)";
+ })
.attr("stroke", function(d) {
- return getPathColor({ data: d, type: "path" });
- })
- .attr("marker-end", function(d) {
- return "url(#" + (isAllEntityRelationDeleted({ data: d }) ? "deletedLink" : "activeLink") + ")";
+ return isAllEntityRelationDeleted({ data: d, type: "link" }) ? deletedEntityColor : activeEntityColor;
});
node = container
+ .append("g")
.selectAll(".node")
.data(nodes)
.enter()
@@ -365,70 +553,80 @@ define([
.drag()
.on("start", dragstarted)
.on("drag", dragged)
- );
-
- var circleContainer = node.append("g");
+ .on("end", dragended)
+ );
- circleContainer
- .append("circle")
- .attr("cx", 0)
- .attr("cy", 0)
- .attr("r", function(d) {
- d.radius = 25;
- return d.radius;
+ node.append("circle")
+ .attr("r", function() {
+ return 25;
})
.attr("fill", function(d) {
- if (d && d.value && d.value.guid == that.guid) {
- if (isAllEntityRelationDeleted({ data: d, type: "node" })) {
- return deletedEntityColor;
- } else {
+ if (d.name === that.entity.typeName) {
return selectedNodeColor;
- }
- } else if (isAllEntityRelationDeleted({ data: d, type: "node" })) {
- return deletedEntityColor;
} else {
- return activeEntityColor;
+ return isAllEntityRelationDeleted({ data: d, type: "node" }) ? deletedEntityColor : activeEntityColor;
}
})
- .attr("typename", function(d) {
- return d.name;
+ .attr("stroke", "#fff")
+ .attr("stroke-width", "2px")
+ .style("cursor", "pointer")
+ .on("click", function(d) {
+ console.log("[RelationshipLayoutView] Node clicked:", d.name);
+ if (d && d.value && d.value.guid == that.guid) {
+ return;
+ }
+ console.log("[RelationshipLayoutView] Node payload:", {
+ name: d.name,
+ value: d.value,
+ referredEntitiesCount: that.referredEntities ? _.keys(that.referredEntities).length : 0
+ });
+ that.selectedNodeData = d.value;
+ that.selectedNodeType = d.name;
+
+ // Show the panel
+ var panel = that.$(".relationship-node-details");
+ panel.removeClass("slide-to-left").addClass("slide-from-left");
+
+ // Trigger after a small delay to ensure DOM is ready
+ setTimeout(function() {
+ panel.removeClass("slide-from-left").addClass("slide-to-left");
+ that.searchNode({ currentTarget: that.ui.searchNode });
+ }, 10);
});
- circleContainer
- .append("text")
+ node.append("text")
.attr("x", 0)
.attr("y", 0)
- .attr("dy", 25 - 17)
+ .attr("dy", function() {
+ return 25 - 17;
+ })
.attr("text-anchor", "middle")
.style("font-family", "FontAwesome")
- .style("font-size", function(d) {
- return "25px";
- })
+ .style("font-size", "25px")
+ .attr("class", "relationship-node-icon")
.text(function(d) {
var iconObj = Enums.graphIcon[d.name];
if (iconObj && iconObj.textContent) {
return iconObj.textContent;
- } else {
- if (d && _.isArray(d.value) && d.value.length > 1) {
- return "\uf0c5";
- } else {
- return "\uf016";
- }
}
+ if (d && _.isArray(d.value) && d.value.length > 1) {
+ return "\uf0c5";
+ }
+ return "\uf016";
})
- .attr("fill", function(d) {
- return "#fff";
- });
+ .attr("fill", "#fff");
- var countBox = circleContainer.append("g");
+ var countBox = node.append("g");
countBox
.append("circle")
.attr("cx", 18)
.attr("cy", -20)
+ .attr("class", "relationship-node-count")
.attr("r", function(d) {
- if (_.isArray(d.value) && d.value.length > 1) {
- return 10;
+ var count = getNodeCount(d);
+ if (count > 1) {
+ return 9;
}
});
@@ -438,44 +636,37 @@ define([
.attr("dy", -16)
.attr("text-anchor", "middle")
.attr("fill", defaultEntityColor)
+ .attr("class", "relationship-node-count")
.text(function(d) {
- if (_.isArray(d.value) && d.value.length > 1) {
- return d.value.length;
+ var count = getNodeCount(d);
+ if (count > 1) {
+ return count;
}
});
- node.append("text")
+ node
+ .append("text")
.attr("x", -15)
.attr("y", "35")
+ .attr("class", "relationship-node-label")
.text(function(d) {
return d.name;
});
- simulation.nodes(nodes).on("tick", ticked);
-
- simulation.force("link").links(links);
- }
-
- function ticked() {
+ simulation.on("tick", function() {
path.attr("d", function(d) {
- var diffX = d.target.x - d.source.x,
- diffY = d.target.y - d.source.y,
- // Length of path from center of source node to center of target node
- pathLength = Math.sqrt(diffX * diffX + diffY * diffY),
- // x and y distances from center to outside edge of target node
- offsetX = (diffX * d.target.radius) / pathLength,
- offsetY = (diffY * d.target.radius) / pathLength;
-
- return "M" + d.source.x + "," + d.source.y + "A" + pathLength + "," + pathLength + " 0 0,1 " + (d.target.x - offsetX) + "," + (d.target.y - offsetY);
+ var dx = d.target.x - d.source.x,
+ dy = d.target.y - d.source.y,
+ dr = Math.sqrt(dx * dx + dy * dy);
+ return "M" + d.source.x + "," + d.source.y + "A" + dr + "," + dr + " 0 0,1 " + d.target.x + "," + d.target.y;
});
node.attr("transform", function(d) {
return "translate(" + d.x + "," + d.y + ")";
});
- }
+ });
function dragstarted(d) {
- d3.event.sourceEvent.stopPropagation();
if (d && d.value && d.value.guid != that.guid) {
if (!d3.event.active) simulation.alphaTarget(0.3).restart();
d.fx = d.x;
@@ -490,6 +681,12 @@ define([
}
}
+ function dragended(d) {
+ if (d && d.value && d.value.guid != that.guid) {
+ if (!d3.event.active) simulation.alphaTarget(0);
+ }
+ }
+
function getPathColor(options) {
return isAllEntityRelationDeleted(options) ? deletedEntityColor : activeEntityColor;
}
@@ -536,18 +733,77 @@ define([
});
},
relationshipViewToggle: function(checked) {
- this.ui.relationshipDetailTable.toggleClass("visible invisible");
- this.ui.relationshipSVG.toggleClass("visible invisible");
+ var that = this;
+
+ console.log("[RelationshipLayoutView] Toggle switched, checked:", checked, "(true=Table/Card, false=Graph)");
+ // In the original code: checked = Table, unchecked = Graph
+ // So we need to invert the logic
if (checked) {
+ console.log("[RelationshipLayoutView] Switching to Card view");
+
+ // Show card view (checked = Table)
+ this.ui.relationshipSVG.addClass("invisible").hide();
+ this.ui.relationshipDetailTableContainer.show();
this.ui.zoomControl.hide();
this.$el.addClass("auto-height");
+ this.ui.relationshipDetailTable.hide();
+ this.ui.relationshipDetailValue.hide();
+ this.ui.relationshipCardsView.show();
+
+ var cardsEl = this.$(this.ui.relationshipCardsView);
+ console.log("[RelationshipLayoutView] Card view container visibility:", {
+ tableContainerVisible: this.$(this.ui.relationshipDetailTableContainer).is(':visible'),
+ cardsViewVisible: cardsEl.is(':visible'),
+ cardsViewDisplay: cardsEl.css('display'),
+ cardsViewHTML: cardsEl.length ? cardsEl.html().substring(0, 100) : '(no element)'
+ });
+
+ console.log("[RelationshipLayoutView] Card view container shown, calling ensureCardsView");
+
+ // Render card view if not already rendered
+ this.ensureCardsView();
+
+ // Force a re-render after a short delay to ensure DOM is ready
+ setTimeout(function() {
+ if (that.relationshipCardsViewInstance) {
+ console.log("[RelationshipLayoutView] Force rendering cards after delay");
+ that.relationshipCardsViewInstance.renderCards();
+ }
+ }, 100);
} else {
+ console.log("[RelationshipLayoutView] Switching to Graph view");
+
+ // Show graph view (unchecked = Graph)
+ this.ui.relationshipSVG.removeClass("invisible").show();
+ this.ui.relationshipDetailTableContainer.hide();
this.ui.zoomControl.show();
this.$el.removeClass("auto-height");
+ this.ui.relationshipDetailTable.show();
+ this.ui.relationshipDetailValue.show();
+ this.ui.relationshipCardsView.hide();
+
+ // Ensure graph is created if it hasn't been created yet
+ if (this.graphData && !_.isEmpty(this.graphData.links)) {
+ console.log("[RelationshipLayoutView] Recreating graph with", this.graphData.links.length, "links");
+ // Clear existing graph and recreate
+ this.$("svg").empty();
+ this.createGraph(this.graphData);
+ } else if (this.graphData && _.isEmpty(this.graphData.links)) {
+ console.log("[RelationshipLayoutView] No relationship data to display");
+ this.noRelationship();
+ }
+ }
+ },
+
+ onDestroy: function() {
+ this.cardsViewLoadInProgress = false;
+ if (this.relationshipCardsViewInstance) {
+ this.relationshipCardsViewInstance.destroy();
+ this.relationshipCardsViewInstance = null;
}
}
}
);
return RelationshipLayoutView;
-});
\ No newline at end of file
+});