From 87eec29f11273b8736a5f68a9f5f15403cd1535c Mon Sep 17 00:00:00 2001
From: Anton Kulyk <kuliks.anton@gmail.com>
Date: Wed, 21 Sep 2022 19:22:38 +0100
Subject: [PATCH] Prototype model relationships section for the detail page
 (#25524)

* Fetch foreign keys for main model table

* Add `foreignTables` method to `Table`

* Show related tables in the info panel

* Fix duplicates
---
 .../src/metabase-lib/lib/metadata/Table.ts    | 10 ++++
 .../ModelDetailPage/ModelDetailPage.tsx       |  5 +-
 .../ModelInfoSidePanel.styled.tsx             |  8 ++-
 .../ModelInfoSidePanel/ModelInfoSidePanel.tsx | 22 ++++-----
 .../ModelRelationships.styled.tsx             | 35 +++++++++++++
 .../ModelInfoSidePanel/ModelRelationships.tsx | 49 +++++++++++++++++++
 .../ModelDetailPage/ModelDetailPage.tsx       | 35 +++++++++++--
 7 files changed, 146 insertions(+), 18 deletions(-)
 create mode 100644 frontend/src/metabase/models/components/ModelDetailPage/ModelInfoSidePanel/ModelRelationships.styled.tsx
 create mode 100644 frontend/src/metabase/models/components/ModelDetailPage/ModelInfoSidePanel/ModelRelationships.tsx

diff --git a/frontend/src/metabase-lib/lib/metadata/Table.ts b/frontend/src/metabase-lib/lib/metadata/Table.ts
index d47904d0c83..1f1f21736e2 100644
--- a/frontend/src/metabase-lib/lib/metadata/Table.ts
+++ b/frontend/src/metabase-lib/lib/metadata/Table.ts
@@ -139,6 +139,16 @@ class TableInner extends Base {
     return fks.map(fk => new Table(fk.origin.table));
   }
 
+  foreignTables(): Table[] {
+    if (!Array.isArray(this.fields)) {
+      return [];
+    }
+    return this.fields
+      .filter(field => field.isFK() && field.fk_target_field_id)
+      .map(field => this.metadata.field(field.fk_target_field_id)?.table)
+      .filter(Boolean);
+  }
+
   primaryKeys(): { field: Field; index: number }[] {
     const pks = [];
     this.fields.forEach((field, index) => {
diff --git a/frontend/src/metabase/models/components/ModelDetailPage/ModelDetailPage.tsx b/frontend/src/metabase/models/components/ModelDetailPage/ModelDetailPage.tsx
index 6b46dba4055..f990cb51dd0 100644
--- a/frontend/src/metabase/models/components/ModelDetailPage/ModelDetailPage.tsx
+++ b/frontend/src/metabase/models/components/ModelDetailPage/ModelDetailPage.tsx
@@ -9,6 +9,7 @@ import * as Urls from "metabase/lib/urls";
 
 import type { Card } from "metabase-types/api";
 import type Question from "metabase-lib/lib/Question";
+import type Table from "metabase-lib/lib/metadata/Table";
 
 import ModelActionDetails from "./ModelActionDetails";
 import ModelInfoSidePanel from "./ModelInfoSidePanel";
@@ -26,12 +27,13 @@ import {
 
 interface Props {
   model: Question;
+  mainTable?: Table | null;
   onChangeModel: (model: Card) => void;
 }
 
 type ModelTab = "schema" | "actions" | "usage";
 
-function ModelDetailPage({ model, onChangeModel }: Props) {
+function ModelDetailPage({ model, mainTable, onChangeModel }: Props) {
   const [tab, setTab] = useState<ModelTab>("usage");
 
   const modelCard = model.card();
@@ -93,6 +95,7 @@ function ModelDetailPage({ model, onChangeModel }: Props) {
       </ModelMain>
       <ModelInfoSidePanel
         model={model}
+        mainTable={mainTable}
         onChangeDescription={handleChangeDescription}
       />
     </RootLayout>
diff --git a/frontend/src/metabase/models/components/ModelDetailPage/ModelInfoSidePanel/ModelInfoSidePanel.styled.tsx b/frontend/src/metabase/models/components/ModelDetailPage/ModelInfoSidePanel/ModelInfoSidePanel.styled.tsx
index fad43074b65..881f8db9129 100644
--- a/frontend/src/metabase/models/components/ModelDetailPage/ModelInfoSidePanel/ModelInfoSidePanel.styled.tsx
+++ b/frontend/src/metabase/models/components/ModelDetailPage/ModelInfoSidePanel/ModelInfoSidePanel.styled.tsx
@@ -26,13 +26,17 @@ export const ModelInfoTitle = styled.span`
   padding-left: 4px;
 `;
 
-const commonInfoTextStyle = css`
+export const valueBlockStyle = css`
   display: block;
   margin-top: 0.5rem;
-  color: ${color("text-medium")};
   padding-left: 4px;
 `;
 
+const commonInfoTextStyle = css`
+  ${valueBlockStyle}
+  color: ${color("text-medium")};
+`;
+
 export const ModelInfoText = styled.span`
   ${commonInfoTextStyle}
 `;
diff --git a/frontend/src/metabase/models/components/ModelDetailPage/ModelInfoSidePanel/ModelInfoSidePanel.tsx b/frontend/src/metabase/models/components/ModelDetailPage/ModelInfoSidePanel/ModelInfoSidePanel.tsx
index 8c632cd7844..b98c138af7a 100644
--- a/frontend/src/metabase/models/components/ModelDetailPage/ModelInfoSidePanel/ModelInfoSidePanel.tsx
+++ b/frontend/src/metabase/models/components/ModelDetailPage/ModelInfoSidePanel/ModelInfoSidePanel.tsx
@@ -1,11 +1,11 @@
-import React, { useMemo } from "react";
+import React from "react";
 import { t } from "ttag";
 
-import * as Urls from "metabase/lib/urls";
-
-import type { Card, Table } from "metabase-types/api";
+import type { Card } from "metabase-types/api";
 import type Question from "metabase-lib/lib/Question";
+import type Table from "metabase-lib/lib/metadata/Table";
 
+import ModelRelationships from "./ModelRelationships";
 import {
   ModelInfoPanel,
   ModelInfoTitle,
@@ -17,17 +17,16 @@ import {
 
 interface Props {
   model: Question;
+  mainTable?: Table | null;
   onChangeDescription: (description: string | null) => void;
 }
 
-function ModelInfoSidePanel({ model, onChangeDescription }: Props) {
+function ModelInfoSidePanel({ model, mainTable, onChangeDescription }: Props) {
   const modelCard = model.card() as Card;
 
   const canWrite = model.canWrite();
   const description = model.description();
 
-  const backingTable = useMemo(() => model.query().sourceTable(), [model]);
-
   return (
     <ModelInfoPanel>
       <ModelInfoSection>
@@ -43,19 +42,18 @@ function ModelInfoSidePanel({ model, onChangeDescription }: Props) {
           onChange={onChangeDescription}
         />
       </ModelInfoSection>
+      <ModelRelationships model={model} mainTable={mainTable} />
       {modelCard.creator && (
         <ModelInfoSection>
           <ModelInfoTitle>{t`Contact`}</ModelInfoTitle>
           <ModelInfoText>{modelCard.creator.common_name}</ModelInfoText>
         </ModelInfoSection>
       )}
-      {backingTable && (
+      {mainTable && (
         <ModelInfoSection>
           <ModelInfoTitle>{t`Backing table`}</ModelInfoTitle>
-          <ModelInfoLink
-            to={backingTable.newQuestion().getUrl({ clean: false })}
-          >
-            {backingTable.displayName()}
+          <ModelInfoLink to={mainTable.newQuestion().getUrl({ clean: false })}>
+            {mainTable.displayName()}
           </ModelInfoLink>
         </ModelInfoSection>
       )}
diff --git a/frontend/src/metabase/models/components/ModelDetailPage/ModelInfoSidePanel/ModelRelationships.styled.tsx b/frontend/src/metabase/models/components/ModelDetailPage/ModelInfoSidePanel/ModelRelationships.styled.tsx
new file mode 100644
index 00000000000..27a1cdc1265
--- /dev/null
+++ b/frontend/src/metabase/models/components/ModelDetailPage/ModelInfoSidePanel/ModelRelationships.styled.tsx
@@ -0,0 +1,35 @@
+import styled from "@emotion/styled";
+
+import Link from "metabase/core/components/Link";
+import Icon from "metabase/components/Icon";
+
+import { color, darken } from "metabase/lib/colors";
+
+import { valueBlockStyle } from "./ModelInfoSidePanel.styled";
+
+export const List = styled.ul`
+  ${valueBlockStyle}
+
+  li:not(:first-of-type) {
+    margin-top: 6px;
+  }
+`;
+
+export const ListItemName = styled.span`
+  display: block;
+`;
+
+export const ListItemLink = styled(Link)`
+  display: flex;
+  align-items: center;
+
+  color: ${color("brand")};
+
+  ${ListItemName} {
+    margin-left: 4px;
+  }
+
+  &:hover {
+    color: ${darken("brand")};
+  }
+`;
diff --git a/frontend/src/metabase/models/components/ModelDetailPage/ModelInfoSidePanel/ModelRelationships.tsx b/frontend/src/metabase/models/components/ModelDetailPage/ModelInfoSidePanel/ModelRelationships.tsx
new file mode 100644
index 00000000000..02b4dd84a1f
--- /dev/null
+++ b/frontend/src/metabase/models/components/ModelDetailPage/ModelInfoSidePanel/ModelRelationships.tsx
@@ -0,0 +1,49 @@
+import React, { useMemo } from "react";
+import { t } from "ttag";
+import _ from "underscore";
+
+import Icon from "metabase/components/Icon";
+
+import type Question from "metabase-lib/lib/Question";
+import type Table from "metabase-lib/lib/metadata/Table";
+
+import { ModelInfoTitle, ModelInfoSection } from "./ModelInfoSidePanel.styled";
+import { List, ListItemLink, ListItemName } from "./ModelRelationships.styled";
+
+interface Props {
+  model: Question;
+  mainTable?: Table | null;
+}
+
+function ModelRelationships({ model, mainTable }: Props) {
+  const relatedTables = useMemo(() => {
+    const tablesMainTablePointsTo = model.table()?.foreignTables() || [];
+    const tablesPointingToMainTable = mainTable?.connectedTables() || [];
+    return _.uniq(
+      [...tablesMainTablePointsTo, ...tablesPointingToMainTable],
+      table => table.id,
+    );
+  }, [model, mainTable]);
+
+  if (relatedTables.length <= 0) {
+    return null;
+  }
+
+  return (
+    <ModelInfoSection>
+      <ModelInfoTitle>{t`Relationships`}</ModelInfoTitle>
+      <List>
+        {relatedTables.map(table => (
+          <li key={table.id}>
+            <ListItemLink to={table.newQuestion().getUrl()}>
+              <Icon name="table" />
+              <ListItemName>{table.displayName()}</ListItemName>
+            </ListItemLink>
+          </li>
+        ))}
+      </List>
+    </ModelInfoSection>
+  );
+}
+
+export default ModelRelationships;
diff --git a/frontend/src/metabase/models/containers/ModelDetailPage/ModelDetailPage.tsx b/frontend/src/metabase/models/containers/ModelDetailPage/ModelDetailPage.tsx
index bb3ed6c5d2e..46a1ca505e9 100644
--- a/frontend/src/metabase/models/containers/ModelDetailPage/ModelDetailPage.tsx
+++ b/frontend/src/metabase/models/containers/ModelDetailPage/ModelDetailPage.tsx
@@ -1,4 +1,4 @@
-import React from "react";
+import React, { useEffect, useMemo, useState } from "react";
 import _ from "underscore";
 import { connect } from "react-redux";
 
@@ -7,6 +7,7 @@ import { useOnMount } from "metabase/hooks/use-on-mount";
 
 import { getMetadata } from "metabase/selectors/metadata";
 import Questions from "metabase/entities/questions";
+import Tables from "metabase/entities/tables";
 
 import { loadMetadataForCard } from "metabase/query_builder/actions";
 
@@ -17,6 +18,7 @@ import type { Card as LegacyCardType } from "metabase-types/types/Card";
 import type { State } from "metabase-types/store";
 
 import Question from "metabase-lib/lib/Question";
+import Table from "metabase-lib/lib/metadata/Table";
 
 type OwnProps = {
   params: {
@@ -34,6 +36,7 @@ type StateProps = {
 
 type DispatchProps = {
   loadMetadataForCard: (card: LegacyCardType) => void;
+  fetchTableForeignKeys: (params: { id: Table["id"] }) => void;
   onChangeModel: (card: Card) => void;
 };
 
@@ -47,15 +50,41 @@ function mapStateToProps(state: State, props: OwnProps & EntityLoaderProps) {
 
 const mapDispatchToProps = {
   loadMetadataForCard,
+  fetchTableForeignKeys: Tables.actions.fetchForeignKeys,
   onChangeModel: Questions.actions.update,
 };
 
-function ModelDetailPage({ model, loadMetadataForCard, onChangeModel }: Props) {
+function ModelDetailPage({
+  model,
+  loadMetadataForCard,
+  fetchTableForeignKeys,
+  onChangeModel,
+}: Props) {
+  const [hasFetchedTableMetadata, setHasFetchedTableMetadata] = useState(false);
+
+  const mainTable = useMemo(
+    () => (model.isStructured() ? model.query().sourceTable() : null),
+    [model],
+  );
+
   useOnMount(() => {
     loadMetadataForCard(model.card());
   });
 
-  return <ModelDetailPageView model={model} onChangeModel={onChangeModel} />;
+  useEffect(() => {
+    if (mainTable && !hasFetchedTableMetadata) {
+      setHasFetchedTableMetadata(true);
+      fetchTableForeignKeys({ id: mainTable.id });
+    }
+  }, [mainTable, hasFetchedTableMetadata, fetchTableForeignKeys]);
+
+  return (
+    <ModelDetailPageView
+      model={model}
+      mainTable={mainTable}
+      onChangeModel={onChangeModel}
+    />
+  );
 }
 
 export default _.compose(
-- 
GitLab