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