Skip to content
Snippets Groups Projects
Unverified Commit a136ddc4 authored by Cal Herries's avatar Cal Herries Committed by GitHub
Browse files

Show models in the native query editor sidebar (#25494)


* Remove the title from all panes and put it in the sidebar header component, next to the back button

* Return cards with db metadata

* Load all questions into DatabaseTablesPane

* Only load questions from the selected database

* Create ModelPane

* remove mapDispatchToProps

* Style ModelPane and reuse FieldPane for model fields

* Use FieldList for TablePane and ModelPane

* Load connected tables

* Create ConnectedTablesList

* Restyle DatabaseTablesPane

* Adjust styles

* smaller ModelPaneIcon

* ModelPaneText font weights

* description spacing

* icon colors and spacing

* appease the linter for real

* Only load models for a given database

* Add model ID next to each model

* Remove TODO

* Undo changes to database api

* Refactor files

* Add _target="blank" for model "See it" link

* Pluralize copy

* Fix i18n

* Change back TablePane from tsx -> jsx

* Tidy

* Don't show models if there aren't any

* Tidy (consistency)

* Add BE tests

* Do a read-check for the database

* Revert "Add BE tests"

This reverts commit 8d18f4e12636e32fda2b5b670124d69237fce5bd.

* Revert "Only load models for a given database"

This reverts commit 028ba43f20d55fef0aaf484d3557e75ae5501758.

* Change endpoint to database/:id/models

* Tidy

* Use name in SQL, not display name

* Add e2e test for models pane

* Tidy

* Fix e2e test - no more "back" text after redesign

* Convert SchemaPane to tsx

* Revert "Do a read-check for the database"

This reverts commit 550fc0eb31268d204b6dc7c853b9d2fc7ad50ecc.

* Fix endpoint for nil edit info

* Fix listQuestions path

* Rename test

* Remove prn

* Batch of suggested changes

* Convert ModelPane to typescript

* Convert to tsx

* Replace any

* Tidy

* Add permissions tests for GET api/database/:id/models

* Remove redundant code

* Fix typecheck error for getQuestionFromCard

* Rename models -> sortedModels

* Remove comment

* Use useMemo

* Remove /api/database/:id/models endpoint, use api/search instead

* Correct docstring

* Remove separate listModelsForDatabase endpoint

Co-authored-by: default avatarMaz Ameli <maz@metabase.com>
parent 8359ca55
No related branches found
No related tags found
No related merge requests found
Showing
with 435 additions and 100 deletions
......@@ -50,7 +50,7 @@ export const HeaderTitleContainer = styled.span<{
align-items: center;
font-size: 1.17em;
font-weight: 900;
font-weight: bold;
margin-top: 0;
margin-bottom: 0;
......@@ -59,7 +59,7 @@ export const HeaderTitleContainer = styled.span<{
`;
export const CloseButton = styled.a`
color: ${color("text-medium")};
color: ${color("text-dark")};
text-decoration: none;
margin-left: auto;
......
......@@ -41,12 +41,11 @@ function SidebarHeader({ className, title, icon, onBack, onClose }: Props) {
hasOnBackHandler: !!onBack,
});
const hasHeaderIcon = onBack || icon;
return (
<HeaderRoot className={className}>
<HeaderTitleContainer variant={headerVariant} onClick={onBack}>
{hasHeaderIcon && <HeaderIcon name={icon || "chevronleft"} />}
{onBack && <HeaderIcon name="chevronleft" />}
{icon && <HeaderIcon name={icon} />}
{hasDefaultBackButton ? t`Back` : title}
</HeaderTitleContainer>
{onClose && (
......
import React from "react";
import { t } from "ttag";
import Table from "metabase-lib/lib/metadata/Table";
import {
NodeListContainer,
NodeListIcon,
NodeListItemIcon,
NodeListItemLink,
NodeListItemName,
NodeListTitle,
NodeListTitleText,
} from "./NodeList.styled";
interface ConnectedTableListProps {
tables: Table[];
onTableClick: (table: Table) => void;
}
const ConnectedTableList = ({
tables,
onTableClick,
}: ConnectedTableListProps) => (
<NodeListContainer>
<NodeListTitle>
<NodeListIcon name="connections" size="14" />
<NodeListTitleText>{t`${tables.length} connections`}</NodeListTitleText>
</NodeListTitle>
{tables.map(table => (
<li key={table.id}>
<NodeListItemLink onClick={() => onTableClick(table)}>
<NodeListItemIcon name="table" />
<NodeListItemName>{table.displayName()}</NodeListItemName>
</NodeListItemLink>
</li>
))}
</NodeListContainer>
);
export default ConnectedTableList;
......@@ -11,16 +11,28 @@ import TablePane from "./TablePane";
import FieldPane from "./FieldPane";
import SegmentPane from "./SegmentPane";
import MetricPane from "./MetricPane";
import ModelPane from "./ModelPane";
const PANES = {
database: DatabasePane, // displays either schemas or tables in a database
schema: SchemaPane, // displays tables in a schema
table: TablePane, // displays fields in a table
field: FieldPane,
model: ModelPane, // displays columns of a model
segment: SegmentPane,
metric: MetricPane,
};
const TITLE_ICONS = {
database: "database",
schema: "folder",
table: "table",
field: "field",
segment: "segment",
metric: "metric",
model: "model",
};
export default class DataReference extends Component {
constructor(props, context) {
super(props, context);
......@@ -84,11 +96,14 @@ export default class DataReference extends Component {
let title = null;
let content = null;
let icon = null;
if (stack.length === 0) {
title = t`Data Reference`;
content = <MainPane {...this.props} show={this.show} />;
} else {
const page = stack[stack.length - 1];
title = page.item.name;
icon = TITLE_ICONS[page.type];
const Pane = PANES[page.type];
content = Pane && (
<Pane
......@@ -102,6 +117,7 @@ export default class DataReference extends Component {
return (
<SidebarContent
title={title}
icon={icon}
onBack={stack.length > 0 ? this.back : null}
onClose={this.close}
>
......
......@@ -7,10 +7,6 @@ const DatabaseSchemasPane = ({ database, show, ...props }) => {
return (
<div>
<div className="ml1 my2 flex align-center justify-between border-bottom pb1">
<div className="flex align-center">
<Icon name="database" className="text-medium pr1" size={14} />
<h3 className="text-wrap">{database.name}</h3>
</div>
<div className="flex align-center">
<Icon name="folder" className="text-light pr1" size={12} />
<span className="text-medium">{database.schemas.length}</span>
......
import styled from "@emotion/styled";
import { color } from "metabase/lib/colors";
import _TableInfo from "metabase/components/MetadataInfo/TableInfo";
import { space } from "metabase/styled-components/theme";
export const TableInfo = styled(_TableInfo)`
padding: 1em 0;
border-bottom: 1px solid ${color("border")};
export const ModelId = styled.span`
font-size: 0.75rem;
color: ${color("text-medium")};
margin-left: ${space(0)};
`;
import React, { useMemo } from "react";
import { ngettext, msgid } from "ttag";
import _ from "underscore";
import Tables from "metabase/entities/tables";
import Search from "metabase/entities/search";
import type { Card } from "metabase-types/api";
import type { State } from "metabase-types/store";
import Database from "metabase-lib/lib/metadata/Database";
import {
NodeListItemLink,
NodeListItemName,
NodeListItemIcon,
NodeListTitle,
NodeListContainer,
NodeListIcon,
NodeListTitleText,
} from "../NodeList.styled";
import { ModelId } from "./DatabaseTablesPane.styled";
interface DatabaseTablesPaneProps {
show: (type: string, item: unknown) => void;
database: Database;
models: Card[];
}
const DatabaseTablesPane = ({
database,
show,
models,
}: DatabaseTablesPaneProps) => {
const tables = useMemo(
() => database.tables.sort((a, b) => a.name.localeCompare(b.name)),
[database.tables],
);
const sortedModels = useMemo(
() => models?.sort((a, b) => a.name.localeCompare(b.name)),
[models],
);
return sortedModels ? (
<NodeListContainer>
{sortedModels?.length ? (
<>
<NodeListTitle>
<NodeListIcon name="model" />
<NodeListTitleText>
{ngettext(
msgid`${sortedModels.length} model`,
`${sortedModels.length} models`,
sortedModels.length,
)}
</NodeListTitleText>
</NodeListTitle>
<ul>
{sortedModels.map(model => (
<li key={model.id}>
<NodeListItemLink onClick={() => show("model", model)}>
<NodeListItemIcon name="model" />
<NodeListItemName>{model.name}</NodeListItemName>
<ModelId>{`#${model.id}`}</ModelId>
</NodeListItemLink>
</li>
))}
</ul>
<br></br>
</>
) : null}
<NodeListTitle>
<NodeListIcon name="table" />
<NodeListTitleText>
{ngettext(
msgid`${tables.length} table`,
`${tables.length} tables`,
tables.length,
)}
</NodeListTitleText>
</NodeListTitle>
<ul>
{tables.map(table => (
<li key={table.id}>
<NodeListItemLink onClick={() => show("table", table)}>
<NodeListItemIcon name="table" />
<NodeListItemName>{table.name}</NodeListItemName>
</NodeListItemLink>
</li>
))}
</ul>
</NodeListContainer>
) : null;
};
export default _.compose(
Tables.loadList({
query: (_state: State, props: DatabaseTablesPaneProps) => ({
dbId: props.database.id,
}),
loadingAndErrorWrapper: false,
}),
Search.loadList({
query: (_state: State, props: DatabaseTablesPaneProps) => ({
models: "dataset",
table_db_id: props.database.id,
}),
loadingAndErrorWrapper: false,
listName: "models",
}),
)(DatabaseTablesPane);
export { default } from "./DatabaseTablesPane";
......@@ -3,15 +3,10 @@ import React from "react";
import PropTypes from "prop-types";
import { t } from "ttag";
import cx from "classnames";
import Icon from "metabase/components/Icon";
import Card from "metabase/components/Card";
const DetailPane = ({ name, description, extra, values }) => (
<div className="ml1">
<div className="flex align-center">
<Icon name="field" className="text-medium pr1" size={16} />
<h3 className="text-wrap">{name}</h3>
</div>
<p className={cx("text-spaced", { "text-medium": !description })}>
{description || t`No description`}
</p>
......
import React from "react";
import { t, ngettext, msgid } from "ttag";
import { getSemanticTypeIcon } from "metabase/lib/schema_metadata";
import Field from "metabase-lib/lib/metadata/Field";
import {
NodeListItemLink,
NodeListItemName,
NodeListItemIcon,
NodeListTitle,
NodeListContainer,
NodeListIcon,
NodeListTitleText,
} from "./NodeList.styled";
interface FieldListProps {
fields: Field[];
onFieldClick: (field: Field) => any;
}
const FieldList = ({ fields, onFieldClick }: FieldListProps) => (
<NodeListContainer>
<NodeListTitle>
<NodeListIcon name="table2" size="12" />
<NodeListTitleText>
{ngettext(
msgid`${fields.length} column`,
`${fields.length} columns`,
fields.length,
)}
</NodeListTitleText>
</NodeListTitle>
{fields.map(field => {
const tooltip = field.semantic_type ? null : t`Unknown type`;
return (
<li key={field.getUniqueId()}>
<NodeListItemLink onClick={() => onFieldClick(field)}>
<NodeListItemIcon
name={getSemanticTypeIcon(field.semantic_type, "warning")}
tooltip={tooltip}
/>
<NodeListItemName>{field.name}</NodeListItemName>
</NodeListItemLink>
</li>
);
})}
</NodeListContainer>
);
export default FieldList;
......@@ -10,13 +10,7 @@ function FieldPane({ field }: Props) {
const dimension = field.dimension();
return dimension ? (
<div>
<div className="flex align-center px2">
<Icon name="field" className="text-medium pr1" size={16} />
<h3 className="text-wrap">{field.name}</h3>
</div>
<DimensionInfo dimension={dimension} showAllFieldValues />
</div>
<DimensionInfo dimension={dimension} showAllFieldValues />
) : null;
}
......
......@@ -2,9 +2,14 @@
import React from "react";
import PropTypes from "prop-types";
import { t } from "ttag";
import Icon from "metabase/components/Icon";
import Databases from "metabase/entities/databases";
import {
NodeListItemLink,
NodeListItemName,
NodeListItemIcon,
} from "./NodeList.styled";
const MainPane = ({ databases, show }) => (
<div>
<p className="mt2 mb3 text-spaced">
......@@ -15,14 +20,11 @@ const MainPane = ({ databases, show }) => (
databases
.filter(db => !db.is_saved_questions)
.map(database => (
<li className="mb1" key={database.id}>
<a
onClick={() => show("database", database)}
className="p1 flex align-center no-decoration bg-medium-hover"
>
<Icon name="database" className="pr1 text-medium" size={14} />
<h3 className="text-wrap">{database.name}</h3>
</a>
<li key={database.id}>
<NodeListItemLink onClick={() => show("database", database)}>
<NodeListItemIcon name="database" />
<NodeListItemName>{database.name}</NodeListItemName>
</NodeListItemLink>
</li>
))}
</ul>
......
......@@ -87,7 +87,6 @@ class MetricPane extends Component {
return (
<DetailPane
name={metricName}
description={metric.description}
useForCurrentQuestion={useForCurrentQuestion}
usefulQuestions={usefulQuestions}
......
import styled from "@emotion/styled";
import { space } from "metabase/styled-components/theme";
import { color } from "metabase/lib/colors";
import Icon from "metabase/components/Icon";
export const ModelPaneDetail = styled.div`
color: ${color("text-medium")};
display: flex;
align-items: center;
padding: 0.25em ${space(1)};
font-weight: 700;
`;
export const ModelPaneDetailLinkText = styled.span`
margin-left: ${space(1)};
`;
export const ModelPaneDetailLink = styled.a`
display: flex;
align-items: center;
color: ${color("brand")};
`;
export const ModelPaneDetailText = styled.span`
margin-left: ${space(1)};
font-weight: normal;
`;
export const ModelPaneDescription = styled.div`
padding: 0 ${space(1)} ${space(2)} ${space(1)};
`;
export const ModelPaneIcon = styled(Icon)`
margin-top: 1px;
width: 12px;
`;
import React from "react";
import { connect } from "react-redux";
import { t, jt } from "ttag";
import _ from "underscore";
import DateTime from "metabase/components/DateTime";
import {
Description,
EmptyDescription,
} from "metabase/components/MetadataInfo/MetadataInfo.styled";
import Questions from "metabase/entities/questions";
import { getQuestionFromCard } from "metabase/query_builder/selectors";
import type { Card } from "metabase-types/api";
import type { State } from "metabase-types/store";
import Question from "metabase-lib/lib/Question";
import FieldList from "../FieldList";
import {
ModelPaneDetail,
ModelPaneDetailLink,
ModelPaneDetailLinkText,
ModelPaneDetailText,
ModelPaneIcon,
ModelPaneDescription,
} from "./ModelPane.styled";
interface ModelPaneProps {
show: (type: string, item: unknown) => void;
model: Card;
question: Question;
}
const mapStateToProps = (state: State, props: ModelPaneProps) => ({
question: getQuestionFromCard(state, props.model),
});
const ModelPane = ({ show, question }: ModelPaneProps) => {
const table = question.table();
return (
<div>
<ModelPaneDescription>
{question.description() ? (
<Description>{question.description()}</Description>
) : (
<EmptyDescription>{t`No description`}</EmptyDescription>
)}
</ModelPaneDescription>
<ModelPaneDetail>
<ModelPaneDetailLink
href={question.getUrl()}
target="_blank"
rel="noreferrer"
>
<ModelPaneIcon name="share" />
<ModelPaneDetailLinkText>{t`See it`}</ModelPaneDetailLinkText>
</ModelPaneDetailLink>
</ModelPaneDetail>
<ModelPaneDetail>
<ModelPaneIcon name="label" />
<ModelPaneDetailText>{t`ID #${question.id()}`}</ModelPaneDetailText>
</ModelPaneDetail>
<ModelPaneDetail>
<ModelPaneIcon name="calendar" />
<ModelPaneDetailText>
{jt`Last edited ${(
<DateTime
key="day"
unit="day"
value={question.lastEditInfo().timestamp}
/>
)}`}
</ModelPaneDetailText>
</ModelPaneDetail>
{table?.fields && (
<FieldList fields={table.fields} onFieldClick={f => show("field", f)} />
)}
</div>
);
};
export default _.compose(
Questions.load({
id: (_state: State, props: ModelPaneProps) => props.model.id,
entityAlias: "model",
}),
connect(mapStateToProps),
)(ModelPane);
export { default } from "./ModelPane";
import styled from "@emotion/styled";
import Icon from "metabase/components/Icon";
import { color } from "metabase/lib/colors";
import { space } from "metabase/styled-components/theme";
export const NodeListItemName = styled.span`
font-weight: 700;
margin-left: ${space(1)};
`;
export const NodeListIcon = styled(Icon)`
margin-top: 1px;
width: ${space(2)};
`;
export const NodeListItemIcon = styled(Icon)`
color: ${color("focus")};
margin-top: 1px;
width: ${space(2)};
`;
export const NodeListItemLink = styled.a`
border-radius: 8px;
display: flex;
align-items: center;
color: ${color("brand")};
font-weight: 700;
overflow-wrap: anywhere;
word-break: break-word;
word-wrap: anywhere;
display: flex;
padding: ${space(1)};
text-decoration: none;
:hover {
background-color: ${color("bg-medium")};
}
`;
export const NodeListContainer = styled.ul`
padding-top: ${space(2)};
`;
export const NodeListTitle = styled.div`
display: flex;
align-items: center;
font-weight: 700;
padding: ${space(1)} ${space(1)} ${space(1)} 6px;
`;
export const NodeListTitleText = styled.span`
margin-left: ${space(1)};
`;
/* eslint-disable react/prop-types */
import React, { Component } from "react";
import PropTypes from "prop-types";
import Icon from "metabase/components/Icon";
import Schemas from "metabase/entities/schemas";
class SchemaPaneInner extends Component {
render() {
const { schema, show } = this.props;
const tables = schema.tables.sort((a, b) => a.name.localeCompare(b.name));
return (
<div>
<div className="ml1 my2 flex align-center justify-between border-bottom pb1">
<div className="flex align-center">
<Icon name="folder" className="text-medium pr1" size={14} />
<h3 className="text-wrap">{schema.name}</h3>
</div>
<div className="flex align-center">
<Icon name="table2" className="text-light pr1" size={12} />
<span className="text-medium">{tables.length}</span>
</div>
</div>
<ul>
{tables.map(table => (
<li key={table.id}>
<a
className="flex-full flex p1 text-bold text-brand text-wrap no-decoration bg-medium-hover"
onClick={() => show("table", table)}
>
{table.name}
</a>
</li>
))}
</ul>
</div>
);
}
}
const SchemaPane = Schemas.load({ id: (state, { schema }) => schema.id })(
SchemaPaneInner,
);
SchemaPane.propTypes = {
show: PropTypes.func.isRequired,
schema: PropTypes.object.isRequired,
};
export default SchemaPane;
import React from "react";
import PropTypes from "prop-types";
import React, { useMemo } from "react";
import Icon from "metabase/components/Icon";
import Table from "metabase/entities/tables";
import Schemas from "metabase/entities/schemas";
import { State } from "metabase-types/store";
import Schema from "metabase-lib/lib/metadata/Schema";
const DatabaseTablesPane = ({ database, show }) => {
const tables = database.tables.sort((a, b) => a.name.localeCompare(b.name));
interface Props {
show: (type: string, item: unknown) => void;
schema: Schema;
}
const SchemaPaneInner = ({ schema, show }: Props) => {
const tables = useMemo(
() => schema.tables.sort((a, b) => a.name.localeCompare(b.name)),
[schema.tables],
);
return (
<div>
<div className="ml1 my2 flex align-center justify-between border-bottom pb1">
<div className="flex align-center">
<Icon name="database" className="text-medium pr1" size={14} />
<h3 className="text-wrap">{database.name}</h3>
</div>
<div className="flex align-center">
<Icon name="table2" className="text-light pr1" size={12} />
<span className="text-medium">{tables.length}</span>
</div>
</div>
<ul>
{tables.map(table => (
<li key={table.id}>
......@@ -34,13 +38,8 @@ const DatabaseTablesPane = ({ database, show }) => {
);
};
DatabaseTablesPane.propTypes = {
show: PropTypes.func.isRequired,
database: PropTypes.object.isRequired,
};
const SchemaPane = Schemas.load({
id: (_state: State, { schema }: Props) => schema.id,
})(SchemaPaneInner);
export default Table.loadList({
query: (_state, props) => ({
dbId: props.database?.id,
}),
})(DatabaseTablesPane);
export default SchemaPane;
......@@ -141,7 +141,6 @@ class SegmentPane extends Component {
return (
<DetailPane
name={segmentName}
description={segment.description}
useForCurrentQuestion={useForCurrentQuestion}
usefulQuestions={usefulQuestions}
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment