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/17 09:13:02 UTC
(atlas) branch master updated: ATLAS-5184: Incomplete Metadata Display for Entities in Atlas Search Results (#570)
This is an automated email from the ASF dual-hosted git repository.
ppawar pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/atlas.git
The following commit(s) were added to refs/heads/master by this push:
new 30e3f2ea7 ATLAS-5184: Incomplete Metadata Display for Entities in Atlas Search Results (#570)
30e3f2ea7 is described below
commit 30e3f2ea7ff9628ade92deb1afc5cd2385e1db43
Author: Prasad Pawar <49...@users.noreply.github.com>
AuthorDate: Tue Mar 17 14:42:57 2026 +0530
ATLAS-5184: Incomplete Metadata Display for Entities in Atlas Search Results (#570)
* ATLAS-5184: Incomplete Metadata Display for Entities in Atlas Search Results
* 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 | 406 +++++++++++
.../EntityDetailTabs/RelationshipCardSkeleton.tsx | 47 ++
.../EntityDetailTabs/RelationshipLineage.tsx | 53 +-
.../EntityDetailTabs/RelationshipsTab.tsx | 586 +++++++++++++---
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 | 780 +++++++++++++++++++++
.../js/views/graph/RelationshipLayoutView.js | 540 +++++++++-----
25 files changed, 3499 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..dc7ea748e
--- /dev/null
+++ b/dashboard/src/views/DetailPage/EntityDetailTabs/RelationshipCard.tsx
@@ -0,0 +1,406 @@
+/*
+ * 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
+ showTypeNameInDisplay?: 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,
+ showTypeNameInDisplay = false,
+ 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
+ const displayText =
+ ref?.displayText ||
+ ref?.attributes?.name ||
+ item?.displayText ||
+ item?.attributes?.name ||
+ item?.qualifiedName ||
+ item?.guid ||
+ 'N/A'
+ const typeName = ref?.typeName || item?.typeName || ''
+ if (showTypeNameInDisplay && typeName) {
+ return `${displayText} (${typeName})`
+ }
+ return displayText
+ },
+ [referredEntities, showTypeNameInDisplay]
+ )
+
+ 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..c6e07e8e2 100644
--- a/dashboard/src/views/DetailPage/EntityDetailTabs/RelationshipsTab.tsx
+++ b/dashboard/src/views/DetailPage/EntityDetailTabs/RelationshipsTab.tsx
@@ -21,37 +21,95 @@ 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>
+ >({});
+ const [showInitialSkeletons, setShowInitialSkeletons] = useState<boolean>(true);
+ const initialSkeletonTimerRef = useRef<ReturnType<typeof setTimeout> | null>(
+ null
+ );
+
+ useEffect(() => {
+ setInitialLoadDone(false);
+ setCardData({});
+ setCardTotalCounts({});
+ setShowInitialSkeletons(true);
+ fetchStartedRef.current = false;
+
+ if (initialSkeletonTimerRef.current) {
+ clearTimeout(initialSkeletonTimerRef.current);
+ initialSkeletonTimerRef.current = null;
+ }
+ initialSkeletonTimerRef.current = setTimeout(() => {
+ setShowInitialSkeletons(false);
+ initialSkeletonTimerRef.current = null;
+ }, 5000);
+
+ return () => {
+ if (initialSkeletonTimerRef.current) {
+ clearTimeout(initialSkeletonTimerRef.current);
+ }
+ };
+ }, [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 +125,363 @@ 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: offset === 0
+ };
+ };
+
+ 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 || isEmpty(relationNames)) {
+ return;
+ }
+
+ setCardLoading(true);
+ let completedCount = 0;
+ const totalCount = relationNames.length;
+
+ relationNames.forEach((relationName) => {
+ fetchRelationshipData(relationName, 0, undefined, {
+ showDeleted: true
+ })
+ .then((response) => {
+ const entities = response.entities;
+ const entityList = isArray(entities)
+ ? entities
+ : !isEmpty(entities)
+ ? [entities]
+ : [];
+ setCardData((prev) => ({ ...prev, [relationName]: entityList }));
+ if (response.approximateCount !== undefined) {
+ setCardTotalCounts((prev) => ({
+ ...prev,
+ [relationName]: response.approximateCount
+ }));
+ }
+ setSortByNameByAttr((prev) => ({
+ ...prev,
+ [relationName]: prev[relationName] ?? false
+ }));
+ setShowDeletedByAttr((prev) => ({
+ ...prev,
+ [relationName]: prev[relationName] ?? true
+ }));
+ })
+ .catch((err) => {
+ console.error(`Error fetching relationship ${relationName}:`, err);
+ setCardData((prev) => ({ ...prev, [relationName]: [] }));
+ setSortByNameByAttr((prev) => ({
+ ...prev,
+ [relationName]: prev[relationName] ?? false
+ }));
+ setShowDeletedByAttr((prev) => ({
+ ...prev,
+ [relationName]: prev[relationName] ?? true
+ }));
+ })
+ .finally(() => {
+ completedCount += 1;
+ if (completedCount >= totalCount) {
+ setInitialLoadDone(true);
+ 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]);
+
+ useEffect(() => {
+ if (!isEmpty(relationNames)) {
+ setShowInitialSkeletons(false);
+ if (initialSkeletonTimerRef.current) {
+ clearTimeout(initialSkeletonTimerRef.current);
+ initialSkeletonTimerRef.current = null;
+ }
+ }
+ }, [relationNames]);
+
+ // 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
+ }));
+ } 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)
+ * During loading, uses relationNames in pairs for stable layout.
+ */
+ const relationshipColumns = useMemo((): string[][] => {
+ const loadedNames = Object.keys(cardData);
+ const allLoaded = relationNames.length > 0 && relationNames.every((r) => r in cardData);
+ const names = allLoaded ? loadedNames : relationNames;
+
+ 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 || !allLoaded) {
+ for (let i = 0; i < emptyCards.length; i += 2) {
+ const chunk = emptyCards.slice(i, i + 2);
+ columns.push(chunk);
+ }
+ }
+
+ return columns;
+ }, [cardData, cardTotalCounts, checked, relationNames]);
return (
<Grid container marginTop={0} className="properties-container">
@@ -165,26 +533,78 @@ 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
+ <>
+ {showInitialSkeletons ? (
+ <div className="relationship-cards-grid relationship-cards-grid--custom">
+ <div className="relationship-cards-column">
+ <RelationshipCardSkeleton />
+ <RelationshipCardSkeleton />
+ </div>
+ </div>
+ ) : isEmpty(relationNames) ||
+ (relationNames.every((r) => r in cardData) &&
+ Object.values(cardData).every((arr) => isEmpty(arr)) &&
+ !checked) ? (
+ <div className="relationship-cards-empty">
+ No relationship data available
+ </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];
+ const hasData = attributeName in cardData;
+ if (hasData) {
+ if (isEmpty(data) && !checked) {
+ return null;
+ }
+ return (
+ <RelationshipCard
+ key={attributeName}
+ attributeName={attributeName}
+ data={data ?? []}
+ referredEntities={referredEntities}
+ showEmptyValues={checked}
+ showTypeNameInDisplay={relationNames.length > 1}
+ 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}
+ />
+ );
+ }
+ return (
+ <RelationshipCardSkeleton key={attributeName} />
+ );
+ })}
+ </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..98e22a8fc 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: $white;
+ color: $color_jungle_green_approx;
+ 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: $color_jungle_green_approx;
+ font-family: inherit;
+ letter-spacing: 0.01em;
+ -webkit-font-smoothing: antialiased;
+ }
+
+ .relationship-card-count {
+ margin-left: 6px;
+ font-size: 13px;
+ font-weight: 500;
+ color: $color_jungle_green_approx;
+ }
+
+ .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: $white;
+ 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..b0444cc40
--- /dev/null
+++ b/dashboardv2/public/js/views/detail_page/RelationshipCardsLayoutView.js
@@ -0,0 +1,780 @@
+/**
+ * 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;
+ },
+ onRender: function() {
+ 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);
+ });
+ }
+ }
+
+ if (_.isEmpty(relationDefs) && this.entityDefCollection && this.entity && this.entity.typeName) {
+ var entityDef = this.entityDefCollection.fullCollection.findWhere({ name: this.entity.typeName });
+
+ if (entityDef) {
+ this.attributeDefs = Utils.getNestedSuperTypeObj({
+ data: entityDef.toJSON(),
+ collection: this.entityDefCollection,
+ attrMerge: true,
+ seperateRelatioshipAttr: true
+ });
+ relationDefs = (this.attributeDefs && this.attributeDefs.relationshipAttributeDefs) || [];
+ } else {
+ console.warn("[RelationshipCardsLayoutView] EntityDef not found for type:", this.entity.typeName);
+ }
+ }
+
+ // If still empty, try to extract from entity.relationshipAttributes
+ if (_.isEmpty(relationDefs) && this.entity && this.entity.relationshipAttributes) {
+ var relationNames = _.uniq(_.keys(this.entity.relationshipAttributes));
+ 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));
+ })));
+
+ 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: offset === 0
+ };
+ },
+ 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();
+
+ return $.ajax({
+ url: apiUrl,
+ data: params,
+ contentType: "application/json",
+ dataType: "json",
+ type: "GET",
+ success: function(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;
+
+ if (!this.guid) {
+ console.warn("[RelationshipCardsLayoutView] No GUID provided, cannot fetch cards");
+ return;
+ }
+
+ var relationNames = this.getRelationNames();
+
+ if (_.isEmpty(relationNames)) {
+ this.renderCards();
+ return;
+ }
+
+ if (_.isFunction(this.onDataLoading)) {
+ this.onDataLoading(true);
+ }
+ this.renderCards();
+
+ 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 completedCount = 0;
+ var totalCount = relationNames.length;
+
+ _.each(relationNames, function(name) {
+ that.fetchRelationshipData(name, 0, undefined, { showDeleted: true })
+ .then(
+ function(response) {
+ processResponse(name, response);
+ that.renderCards();
+ return response;
+ },
+ function(error) {
+ console.error("[RelationshipCardsLayoutView] API error for", name, ":", error);
+ processResponse(name, null);
+ that.renderCards();
+ return null;
+ }
+ )
+ .always(function() {
+ completedCount += 1;
+ if (completedCount >= totalCount) {
+ 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);
+ 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 relationNames = this.getRelationNames();
+ var showTypeNameInDisplay = relationNames.length > 1;
+ 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 typeName = (ref && ref.typeName) || item.typeName || "";
+ var displayLabel = showTypeNameInDisplay && typeName
+ ? displayText + " (" + typeName + ")"
+ : displayText;
+ 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(displayLabel) + "</a>" :
+ "<span class='relationship-card-link'>" + _.escape(displayLabel) + "</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 or during loading)
+ * @param {string[]} names - list of relation names to use (relationNames when loading, keys(cardData) when loaded)
+ */
+ computeColumnsFromNames: function(names) {
+ 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 || !_.every(names, function(n) { return _.has(that.cardData, n); })) {
+ 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;
+ var relationNames = this.getRelationNames();
+
+ if (_.isEmpty(relationNames)) {
+ this.$el.html("<div class='relationship-cards-loader'><i class='fa fa-spinner fa-spin'></i></div>");
+ return;
+ }
+
+ var allLoaded = _.every(relationNames, function(name) {
+ return _.has(that.cardData, name);
+ });
+ var namesForColumns = allLoaded ? _.keys(this.cardData) : relationNames;
+
+ var columns = this.computeColumnsFromNames(namesForColumns);
+ 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 hasData = _.has(that.cardData, name);
+ if (hasData) {
+ var list = that.cardData[name] || [];
+ var cardHtml = that.buildCardHtml(name, list, that.cardCounts[name]);
+ if (cardHtml) {
+ html += cardHtml;
+ cardCount++;
+ }
+ } else {
+ html += that.buildSkeletonCardHtml();
+ }
+ });
+ html += "</div>";
+ });
+ html += "</div>";
+
+ var allLoadedAndEmpty = _.every(relationNames, function(n) {
+ return _.has(that.cardData, n) && _.isEmpty(that.cardData[n]);
+ });
+ if (cardCount === 0 && allLoadedAndEmpty && !this.showEmptyValues) {
+ html = "<div class='relationship-cards-empty'>No relationship data available</div>";
+ }
+
+ if (this.$el && this.$el.length) {
+ this.$el.html(html);
+ this.bindCardEvents();
+ } else {
+ console.warn("[RelationshipCardsLayoutView] $el not available, cannot render cards");
+ }
+ }
+ });
+
+ 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");
+
+ if (!name) {
+ console.warn("[RelationshipCardsLayoutView] No name found on scroll container");
+ return;
+ }
+
+ if (that.loadingByName[name]) {
+ return;
+ }
+ if (that.resettingByName[name]) {
+ return;
+ }
+ if (that.exhaustedByName[name]) {
+ return;
+ }
+
+ var list = that.cardData[name] || [];
+ var count = that.getTotalCountForName(name, list.length);
+ var canLoadMore = count > list.length;
+
+ if (!canLoadMore) {
+ 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);
+
+ that.lastScrollTopByName[name] = container[0].scrollTop;
+
+ if (!isNearBottom || !isScrollingDown) {
+ return;
+ }
+
+ var timeSinceLastTrigger = now - (that.lastTriggerAtByName[name] || 0);
+ if (timeSinceLastTrigger < 400) {
+ return;
+ }
+
+ if (that.lastRequestSizeByName[name] === list.length) {
+ return;
+ }
+
+ 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..35d04261a 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,44 @@ 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;
+
+ 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;
+
+ 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) {
+ if (isLoading) {
+ this.$(".fontLoader").show();
+ } else {
+ this.$(".fontLoader").hide();
+ }
+ }, this);
},
createData: function(entity) {
var that = this,
@@ -89,15 +130,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 +153,147 @@ define([
return { nodes: nodes, links: links };
},
onRender: function() {
- this.ui.zoomControl.hide();
- this.$el.addClass("auto-height");
+ this.isRendered = true;
+
+ // Initialize: show card view by default (checked = Card)
+ this.ui.relationshipViewToggle.prop('checked', true);
+ this.relationshipViewToggle(true);
},
onShow: function(argument) {
- if (this.graphData && _.isEmpty(this.graphData.links)) {
- this.noRelationship();
- } else {
- this.createGraph(this.graphData);
+ // 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();
+
+ this.ensureCardsView();
+ },
+ ensureCardsView: function(forceRefresh) {
+ var that = this;
+
+ if (this.relationshipCardsViewInstance) {
+ if (forceRefresh) {
+ this.relationshipCardsViewInstance.cardData = {};
+ this.relationshipCardsViewInstance.cardCounts = {};
+ this.relationshipCardsViewInstance.fetchInitialCards();
+ } else {
+ var hasData = _.keys(this.relationshipCardsViewInstance.cardData || {}).length > 0;
+ if (!hasData) {
+ this.relationshipCardsViewInstance.fetchInitialCards();
+ } else {
+ this.relationshipCardsViewInstance.renderCards();
+ }
+ }
+ return;
+ }
+ if (this.cardsViewLoadInProgress) {
+ return;
+ }
+ this.cardsViewLoadInProgress = true;
+ require(['views/detail_page/RelationshipCardsLayoutView'], function(RelationshipCardsLayoutView) {
+ 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);
+ } 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) {
+ panel.removeClass("slide-to-left").addClass("slide-from-left");
+ } else {
+ 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() {
+ 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 +329,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,8 +383,8 @@ 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;
@@ -263,11 +392,31 @@ define([
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 +447,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 +512,74 @@ 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) {
+ if (d && d.value && d.value.guid == that.guid) {
+ return;
+ }
+ 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 +589,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 +634,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 +686,58 @@ define([
});
},
relationshipViewToggle: function(checked) {
- this.ui.relationshipDetailTable.toggleClass("visible invisible");
- this.ui.relationshipSVG.toggleClass("visible invisible");
+ var that = this;
+ // In the original code: checked = Table, unchecked = Graph
+ // So we need to invert the logic
if (checked) {
+ // 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();
+
+ // 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) {
+ that.relationshipCardsViewInstance.renderCards();
+ }
+ }, 100);
} else {
+ // 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)) {
+ // Clear existing graph and recreate
+ this.$("svg").empty();
+ this.createGraph(this.graphData);
+ } else if (this.graphData && _.isEmpty(this.graphData.links)) {
+ this.noRelationship();
+ }
+ }
+ },
+
+ onDestroy: function() {
+ this.cardsViewLoadInProgress = false;
+ if (this.relationshipCardsViewInstance) {
+ this.relationshipCardsViewInstance.destroy();
+ this.relationshipCardsViewInstance = null;
}
}
}
);
return RelationshipLayoutView;
-});
\ No newline at end of file
+});