diff --git a/frontend/src/metabase/lib/formatting/types.ts b/frontend/src/metabase/lib/formatting/types.ts index 21414dfbe0b3cb4400822d4e6e2b674758913fcf..57b69026373d7ff4484c209c26059e670392882f 100644 --- a/frontend/src/metabase/lib/formatting/types.ts +++ b/frontend/src/metabase/lib/formatting/types.ts @@ -9,6 +9,7 @@ export interface OptionsType extends TimeOnlyOptions { click_behavior?: any; clicked?: any; column?: any; + column_title?: string; compact?: boolean; date_abbreviate?: boolean; date_format?: string; @@ -30,6 +31,7 @@ export interface OptionsType extends TimeOnlyOptions { removeDay?: boolean; removeYear?: boolean; rich?: boolean; + show_mini_bar?: boolean; suffix?: string; type?: string; view_as?: string | null; diff --git a/frontend/src/metabase/visualizations/components/TableSimple/TableCell.jsx b/frontend/src/metabase/visualizations/components/TableSimple/TableCell.tsx similarity index 72% rename from frontend/src/metabase/visualizations/components/TableSimple/TableCell.jsx rename to frontend/src/metabase/visualizations/components/TableSimple/TableCell.tsx index 8fc199429f88c5bbd6b83c6f84a08838747b37a8..7d7d5c9fa4bcb282d94c7c8c04baa1792f8c2748 100644 --- a/frontend/src/metabase/visualizations/components/TableSimple/TableCell.jsx +++ b/frontend/src/metabase/visualizations/components/TableSimple/TableCell.tsx @@ -1,9 +1,9 @@ -/* eslint-disable react/prop-types */ -import { useCallback, useMemo } from "react"; +import { useCallback, useMemo, isValidElement } from "react"; import cx from "classnames"; import ExternalLink from "metabase/core/components/ExternalLink"; +import type { OptionsType } from "metabase/lib/formatting/types"; import { formatValue } from "metabase/lib/formatting"; import { getTableCellClickedObject, @@ -11,11 +11,31 @@ import { isColumnRightAligned, } from "metabase/visualizations/lib/table"; import { getColumnExtent } from "metabase/visualizations/lib/utils"; + +import type { + DatasetColumn, + DatasetData, + RowValue, + RowValues, + Series, + VisualizationSettings, +} from "metabase-types/api"; +import type { ClickObject } from "metabase-lib"; import { isID, isFK } from "metabase-lib/types/utils/isa"; import MiniBar from "../MiniBar"; import { CellRoot, CellContent } from "./TableCell.styled"; +type GetCellDataOpts = { + value: RowValue; + clicked: ClickObject; + extraData: Record<string, unknown>; + cols: DatasetColumn[]; + rows: RowValues[]; + columnIndex: number; + columnSettings: OptionsType; +}; + function getCellData({ value, clicked, @@ -24,7 +44,7 @@ function getCellData({ rows, columnIndex, columnSettings, -}) { +}: GetCellDataOpts) { if (value == null) { return "-"; } @@ -46,7 +66,25 @@ function getCellData({ }); } -function TableCell({ +interface TableCellProps { + value: RowValue; + data: DatasetData; + series: Series; + settings: VisualizationSettings; + rowIndex: number; + columnIndex: number; + isPivoted: boolean; + getCellBackgroundColor: ( + value: RowValue, + rowIndex: number, + columnName: string, + ) => string | undefined; + getExtraDataForClick: (clickObject: ClickObject) => Record<string, unknown>; + checkIsVisualizationClickable: (clickObject: ClickObject) => boolean; + onVisualizationClick?: (clickObject: ClickObject) => void; +} + +export function TableCell({ value, data, series, @@ -58,7 +96,7 @@ function TableCell({ getExtraDataForClick, checkIsVisualizationClickable, onVisualizationClick, -}) { +}: TableCellProps) { const { rows, cols } = data; const column = cols[columnIndex]; const columnSettings = settings.column(column); @@ -66,6 +104,7 @@ function TableCell({ const clickedRowData = useMemo( () => getTableClickedObjectRowData( + // @ts-expect-error -- visualizations/lib/table should be typed series, rowIndex, columnIndex, @@ -107,13 +146,13 @@ function TableCell({ [value, clicked, extraData, cols, rows, columnIndex, columnSettings], ); - const isLink = cellData && cellData.type === ExternalLink; + const isLink = isValidElement(cellData) && cellData.type === ExternalLink; const isClickable = !isLink; const onClick = useCallback( e => { if (checkIsVisualizationClickable(clicked)) { - onVisualizationClick({ + onVisualizationClick?.({ ...clicked, element: e.currentTarget, extraData, @@ -155,5 +194,3 @@ function TableCell({ </CellRoot> ); } - -export default TableCell; diff --git a/frontend/src/metabase/visualizations/components/TableSimple/TableSimple.jsx b/frontend/src/metabase/visualizations/components/TableSimple/TableSimple.tsx similarity index 83% rename from frontend/src/metabase/visualizations/components/TableSimple/TableSimple.jsx rename to frontend/src/metabase/visualizations/components/TableSimple/TableSimple.tsx index 55eb9c1aa35fd2a1247a42bc575d61341cfe9b4f..e70b4be91ee334cd88d97e543e198772fb63a1ad 100644 --- a/frontend/src/metabase/visualizations/components/TableSimple/TableSimple.jsx +++ b/frontend/src/metabase/visualizations/components/TableSimple/TableSimple.tsx @@ -1,4 +1,3 @@ -/* eslint-disable react/prop-types */ import { useCallback, useLayoutEffect, useMemo, useState, useRef } from "react"; import { getIn } from "icepick"; import _ from "underscore"; @@ -8,9 +7,19 @@ import { Ellipsified } from "metabase/core/components/Ellipsified"; import { isPositiveInteger } from "metabase/lib/number"; import { isColumnRightAligned } from "metabase/visualizations/lib/table"; + +import type { + Card, + DatasetColumn, + DatasetData, + RowValue, + Series, + VisualizationSettings, +} from "metabase-types/api"; +import type { ClickObject } from "metabase-lib"; import { isID } from "metabase-lib/types/utils/isa"; -import TableCell from "./TableCell"; +import { TableCell } from "./TableCell"; import TableFooter from "./TableFooter"; import { Root, @@ -21,11 +30,13 @@ import { SortIcon, } from "./TableSimple.styled"; -function getBoundingClientRectSafe(ref) { +function getBoundingClientRectSafe(ref: { + current?: HTMLElement | null; +}): Partial<DOMRect> { return ref.current?.getBoundingClientRect?.() ?? {}; } -function formatCellValueForSorting(value, column) { +function formatCellValueForSorting(value: RowValue, column: DatasetColumn) { if (typeof value === "string") { if (isID(column) && isPositiveInteger(value)) { return parseInt(value, 10); @@ -39,7 +50,23 @@ function formatCellValueForSorting(value, column) { return value; } -function TableSimple({ +interface TableSimpleProps { + card: Card; + data: DatasetData; + series: Series; + settings: VisualizationSettings; + height: number; + isDashboard?: boolean; + isEditing?: boolean; + isPivoted: boolean; + className?: string; + getColumnTitle: (colIndex: number) => string; + getExtraDataForClick: (clickObject: ClickObject) => Record<string, unknown>; + onVisualizationClick?: (clickObject: ClickObject) => void; + visualizationIsClickable?: (clickObject: ClickObject) => boolean; +} + +function TableSimpleInner({ card, data, series, @@ -51,7 +78,7 @@ function TableSimple({ visualizationIsClickable, getColumnTitle, getExtraDataForClick, -}) { +}: TableSimpleProps) { const [page, setPage] = useState(0); const [pageSize, setPageSize] = useState(1); const [sortColumn, setSortColumn] = useState(null); @@ -62,7 +89,7 @@ function TableSimple({ const firstRowRef = useRef(null); useLayoutEffect(() => { - const { height: headerHeight } = getBoundingClientRectSafe(headerRef); + const { height: headerHeight = 0 } = getBoundingClientRectSafe(headerRef); const { height: footerHeight = 0 } = getBoundingClientRectSafe(footerRef); const { height: rowHeight = 0 } = getBoundingClientRectSafe(firstRowRef); const currentPageSize = Math.floor( @@ -87,10 +114,10 @@ function TableSimple({ const checkIsVisualizationClickable = useCallback( clickedItem => { - return ( + return Boolean( onVisualizationClick && - visualizationIsClickable && - visualizationIsClickable(clickedItem) + visualizationIsClickable && + visualizationIsClickable(clickedItem), ); }, [onVisualizationClick, visualizationIsClickable], @@ -217,7 +244,7 @@ function TableSimple({ ); } -export default ExplicitSize({ +export const TableSimple = ExplicitSize<TableSimpleProps>({ refreshMode: props => props.isDashboard && !props.isEditing ? "debounceLeading" : "throttle", -})(TableSimple); +})(TableSimpleInner); diff --git a/frontend/src/metabase/visualizations/components/TableSimple/index.ts b/frontend/src/metabase/visualizations/components/TableSimple/index.ts index 600fdb7f87c649f85afcab3d9d97de365d580c07..4f4c724ab2533a1b84ac72b071f451eddcb7994f 100644 --- a/frontend/src/metabase/visualizations/components/TableSimple/index.ts +++ b/frontend/src/metabase/visualizations/components/TableSimple/index.ts @@ -1,2 +1 @@ -// eslint-disable-next-line import/no-default-export -- deprecated usage -export { default } from "./TableSimple"; +export * from "./TableSimple"; diff --git a/frontend/src/metabase/visualizations/types/visualization.ts b/frontend/src/metabase/visualizations/types/visualization.ts index 0ea73629cbd4f80b6bf40624ed57aa1f646a3491..3af55d834eee2231ee927aa08c3015d2e7d9af09 100644 --- a/frontend/src/metabase/visualizations/types/visualization.ts +++ b/frontend/src/metabase/visualizations/types/visualization.ts @@ -1,5 +1,6 @@ import type { Card, + DatasetColumn, DatasetData, RawSeries, Series, @@ -10,6 +11,8 @@ import type { ClickObject } from "metabase/visualizations/types"; import type { ColorGetter } from "metabase/static-viz/lib/colors"; import type { OptionsType } from "metabase/lib/formatting/types"; import type { IconName, IconProps } from "metabase/ui"; + +import type Metadata from "metabase-lib/metadata/Metadata"; import type Query from "metabase-lib/queries/Query"; import type { HoveredObject } from "./hover"; @@ -46,6 +49,7 @@ export interface VisualizationProps { series: Series; card: Card; data: DatasetData; + metadata: Metadata; rawSeries: RawSeries; settings: ComputedVisualizationSettings; headerIcon: IconProps; @@ -88,6 +92,24 @@ export interface VisualizationProps { onUpdateWarnings?: any; } +export type ColumnSettingDefinition<TValue, TProps = unknown> = { + title?: string; + hint?: string; + widget?: string | React.ComponentType<any>; + default?: TValue; + props?: TProps; + inline?: boolean; + readDependencies?: string[]; + getDefault?: (col: DatasetColumn) => TValue; + getHidden?: (col: DatasetColumn, settings: OptionsType) => boolean; + getProps?: ( + col: DatasetColumn, + settings: OptionsType, + onChange: (value: TValue) => void, + extra: { series: Series }, + ) => TProps; +}; + export type VisualizationSettingDefinition<TValue, TProps = void> = { section?: string; title?: string; diff --git a/frontend/src/metabase/visualizations/visualizations/Table.jsx b/frontend/src/metabase/visualizations/visualizations/Table.tsx similarity index 82% rename from frontend/src/metabase/visualizations/visualizations/Table.jsx rename to frontend/src/metabase/visualizations/visualizations/Table.tsx index f352fcfebb8f77adee7cab12850c5ba6d7708904..a9f386c6302612f5b53808a70047163f006d3489 100644 --- a/frontend/src/metabase/visualizations/visualizations/Table.jsx +++ b/frontend/src/metabase/visualizations/visualizations/Table.tsx @@ -1,12 +1,10 @@ -/* eslint-disable react/prop-types */ import { Component } from "react"; - import { t } from "ttag"; import _ from "underscore"; import cx from "classnames"; -import * as DataGrid from "metabase/lib/data_grid"; -import { getOptionFromColumn } from "metabase/visualizations/lib/settings/utils"; + import { formatColumn } from "metabase/lib/formatting"; +import * as DataGrid from "metabase/lib/data_grid"; import ChartSettingLinkUrlInput from "metabase/visualizations/components/settings/ChartSettingLinkUrlInput"; import ChartSettingsTableFormatting, { @@ -20,12 +18,19 @@ import { getTitleForColumn, isPivoted as _isPivoted, } from "metabase/visualizations/lib/settings/column"; - +import { getOptionFromColumn } from "metabase/visualizations/lib/settings/utils"; +import { getDefaultPivotColumn } from "metabase/visualizations/lib/utils"; import { getDefaultSize, getMinSize, } from "metabase/visualizations/shared/utils/sizes"; -import { getDefaultPivotColumn } from "metabase/visualizations/lib/utils"; + +import type { + DatasetColumn, + DatasetData, + Series, + VisualizationSettings, +} from "metabase-types/api"; import * as Lib from "metabase-lib"; import Question from "metabase-lib/Question"; import { @@ -40,10 +45,20 @@ import { import { findColumnIndexForColumnSetting } from "metabase-lib/queries/utils/dataset"; import * as Q_DEPRECATED from "metabase-lib/queries/utils"; -import TableSimple from "../components/TableSimple"; +import type { ColumnSettingDefinition, VisualizationProps } from "../types"; +import { TableSimple } from "../components/TableSimple"; import TableInteractive from "../components/TableInteractive/TableInteractive.jsx"; -export default class Table extends Component { +interface TableProps extends VisualizationProps { + isShowingDetailsOnlyColumns?: boolean; +} + +interface TableState { + data: Pick<DatasetData, "cols" | "rows" | "results_timezone"> | null; + question: Question | null; +} + +class Table extends Component<TableProps, TableState> { static uiName = t`Table`; static identifier = "table"; static iconName = "table"; @@ -52,19 +67,15 @@ export default class Table extends Component { static minSize = getMinSize("table"); static defaultSize = getDefaultSize("table"); - static isSensible({ cols, rows }) { + static isSensible() { return true; } - static isLiveResizable(series) { + static isLiveResizable() { return false; } - static checkRenderable([ - { - data: { cols, rows }, - }, - ]) { + static checkRenderable() { // scalar can always be rendered, nothing needed here } @@ -77,8 +88,8 @@ export default class Table extends Component { title: t`Pivot table`, widget: "toggle", inline: true, - getHidden: ([{ card, data }]) => data && data.cols.length !== 3, - getDefault: ([{ card, data }]) => { + getHidden: ([{ data }]: Series) => data && data.cols.length !== 3, + getDefault: ([{ card, data }]: Series) => { if ( !data || data.cols.length !== 3 || @@ -100,20 +111,18 @@ export default class Table extends Component { { data: { cols, rows }, }, - ]) => { + ]: Series) => { return getDefaultPivotColumn(cols, rows)?.name; }, - getProps: ( - [ - { - data: { cols }, - }, - ], - settings, - ) => ({ + getProps: ([ + { + data: { cols }, + }, + ]: Series) => ({ options: cols.filter(isDimension).map(getOptionFromColumn), }), - getHidden: (series, settings) => !settings["table.pivot"], + getHidden: (series: Series, settings: VisualizationSettings) => + !settings["table.pivot"], readDependencies: ["table.pivot"], persistDefault: true, }, @@ -121,7 +130,10 @@ export default class Table extends Component { section: t`Columns`, title: t`Cell column`, widget: "field", - getDefault: ([{ data }], { "table.pivot_column": pivotCol }) => { + getDefault: ( + [{ data }]: Series, + { "table.pivot_column": pivotCol }: VisualizationSettings, + ) => { // We try to show numeric values in pivot cells, but if none are // available, we fall back to the last column in the unpivoted table const nonPivotCols = data.cols.filter(c => c.name !== pivotCol); @@ -129,24 +141,15 @@ export default class Table extends Component { const { name } = nonPivotCols.find(isMetric) || lastCol || {}; return name; }, - getProps: ( - [ - { - data: { cols }, - }, - ], - settings, - ) => ({ + getProps: ([ + { + data: { cols }, + }, + ]: Series) => ({ options: cols.map(getOptionFromColumn), }), - getHidden: ( - [ - { - data: { cols }, - }, - ], - settings, - ) => !settings["table.pivot"], + getHidden: (series: Series, settings: VisualizationSettings) => + !settings["table.pivot"], readDependencies: ["table.pivot", "table.pivot_column"], persistDefault: true, }, @@ -156,19 +159,16 @@ export default class Table extends Component { section: t`Conditional Formatting`, widget: ChartSettingsTableFormatting, default: [], - getProps: (series, settings) => ({ + getProps: (series: Series, settings: VisualizationSettings) => ({ cols: series[0].data.cols.filter(isFormattable), isPivoted: settings["table.pivot"], }), - getHidden: ( - [ - { - data: { cols }, - }, - ], - settings, - ) => cols.filter(isFormattable).length === 0, + getHidden: ([ + { + data: { cols }, + }, + ]: Series) => cols.filter(isFormattable).length === 0, readDependencies: ["table.pivot"], }, "table._cell_background_getter": { @@ -177,8 +177,8 @@ export default class Table extends Component { { data: { rows, cols }, }, - ], - settings, + ]: Series, + settings: VisualizationSettings, ) { return makeCellBackgroundGetter( rows, @@ -191,8 +191,11 @@ export default class Table extends Component { }, }; - static columnSettings = column => { - const settings = { + static columnSettings = (column: DatasetColumn) => { + const settings: Record< + string, + ColumnSettingDefinition<unknown, unknown> + > = { column_title: { title: t`Column title`, widget: "input", @@ -200,6 +203,7 @@ export default class Table extends Component { }, click_behavior: {}, }; + if (isNumber(column)) { settings["show_mini_bar"] = { title: t`Show a mini bar chart`, @@ -250,7 +254,7 @@ export default class Table extends Component { settings["view_as"] !== "link" && settings["view_as"] !== "email_link", readDependencies: ["view_as"], getProps: ( - col, + column, settings, onChange, { @@ -276,7 +280,7 @@ export default class Table extends Component { getHidden: (_, settings) => settings["view_as"] !== "link", readDependencies: ["view_as"], getProps: ( - col, + column, settings, onChange, { @@ -297,19 +301,16 @@ export default class Table extends Component { return settings; }; - constructor(props) { - super(props); - - this.state = { - data: null, - }; - } + state: TableState = { + data: null, + question: null, + }; UNSAFE_componentWillMount() { this._updateData(this.props); } - UNSAFE_componentWillReceiveProps(newProps) { + UNSAFE_componentWillReceiveProps(newProps: VisualizationProps) { if ( newProps.series !== this.props.series || !_.isEqual(newProps.settings, this.props.settings) @@ -318,7 +319,7 @@ export default class Table extends Component { } } - _updateData({ series, settings, metadata }) { + _updateData({ series, settings, metadata }: VisualizationProps) { const [{ card, data }] = series; if (Table.isPivoted(series, settings)) { @@ -335,18 +336,12 @@ export default class Table extends Component { (col, index) => index !== pivotIndex && index !== cellIndex, ); this.setState({ - data: DataGrid.pivot( - data, - normalIndex, - pivotIndex, - cellIndex, - settings, - ), + data: DataGrid.pivot(data, normalIndex, pivotIndex, cellIndex), }); } else { const { cols, rows, results_timezone } = data; const columnSettings = settings["table.columns"]; - const columnIndexes = columnSettings + const columnIndexes = (columnSettings || []) .filter( columnSetting => columnSetting.enabled || this.props.isShowingDetailsOnlyColumns, @@ -362,6 +357,7 @@ export default class Table extends Component { rows: rows.map(row => columnIndexes.map(i => row[i])), results_timezone, }, + // construct a Question that is in-sync with query results // cache it here for performance reasons question: new Question(card, metadata), @@ -371,7 +367,7 @@ export default class Table extends Component { // shared helpers for table implementations - getColumnTitle = columnIndex => { + getColumnTitle = (columnIndex: number) => { const cols = this.state.data && this.state.data.cols; if (!cols) { return null; @@ -380,7 +376,7 @@ export default class Table extends Component { return getTitleForColumn(cols[columnIndex], series, settings); }; - getColumnSortDirection = columnIndex => { + getColumnSortDirection = (columnIndex: number) => { const { question, data } = this.state; if (!question || !data) { return; @@ -410,7 +406,7 @@ export default class Table extends Component { const { series, isDashboard, settings } = this.props; const { data } = this.state; const isPivoted = Table.isPivoted(series, settings); - const areAllColumnsHidden = data.cols.length === 0; + const areAllColumnsHidden = data?.cols.length === 0; const TableComponent = isDashboard ? TableSimple : TableInteractive; if (!data) { @@ -450,3 +446,6 @@ export default class Table extends Component { ); } } + +// eslint-disable-next-line import/no-default-export +export default Table;