From 5294948e6a158a38b6cbf02708973fcdd2f2bb80 Mon Sep 17 00:00:00 2001 From: Oisin Coveney <oisin@metabase.com> Date: Thu, 24 Oct 2024 16:57:14 +0300 Subject: [PATCH] Convert `ChartSettings` to a functional component (#48994) --- .../components/ChartSettings.tsx | 529 ------------------ .../ChartSettings.styled.tsx | 0 .../ChartSettings/ChartSettings.tsx | 415 ++++++++++++++ .../ChartSettings.unit.spec.tsx | 8 +- .../ChartSettings/ChartSettingsFooter.tsx | 29 + .../components/ChartSettings/index.ts | 1 + .../components/ChartSettings/types.ts | 45 ++ 7 files changed, 493 insertions(+), 534 deletions(-) delete mode 100644 frontend/src/metabase/visualizations/components/ChartSettings.tsx rename frontend/src/metabase/visualizations/components/{ => ChartSettings}/ChartSettings.styled.tsx (100%) create mode 100644 frontend/src/metabase/visualizations/components/ChartSettings/ChartSettings.tsx rename frontend/src/metabase/visualizations/components/{ => ChartSettings}/ChartSettings.unit.spec.tsx (97%) create mode 100644 frontend/src/metabase/visualizations/components/ChartSettings/ChartSettingsFooter.tsx create mode 100644 frontend/src/metabase/visualizations/components/ChartSettings/index.ts create mode 100644 frontend/src/metabase/visualizations/components/ChartSettings/types.ts diff --git a/frontend/src/metabase/visualizations/components/ChartSettings.tsx b/frontend/src/metabase/visualizations/components/ChartSettings.tsx deleted file mode 100644 index 8560873b7a1..00000000000 --- a/frontend/src/metabase/visualizations/components/ChartSettings.tsx +++ /dev/null @@ -1,529 +0,0 @@ -import { assocIn } from "icepick"; -import { Component } from "react"; -import * as React from "react"; -import { t } from "ttag"; -import _ from "underscore"; - -import Button from "metabase/core/components/Button"; -import Radio from "metabase/core/components/Radio"; -import CS from "metabase/css/core/index.css"; -import { - extractRemappings, - getVisualizationTransformed, -} from "metabase/visualizations"; -import Visualization from "metabase/visualizations/components/Visualization"; -import { updateSeriesColor } from "metabase/visualizations/lib/series"; -import { - getClickBehaviorSettings, - getComputedSettings, - getSettingsWidgets, - updateSettings, -} from "metabase/visualizations/lib/settings"; -import { getSettingDefinitionsForColumn } from "metabase/visualizations/lib/settings/column"; -import { keyForSingleSeries } from "metabase/visualizations/lib/settings/series"; -import { getSettingsWidgetsForSeries } from "metabase/visualizations/lib/settings/visualization"; -import type Question from "metabase-lib/v1/Question"; -import { getColumnKey } from "metabase-lib/v1/queries/utils/column-key"; -import type { - Dashboard, - DashboardCard, - DatasetColumn, - RawSeries, - Series, - VisualizationSettings, -} from "metabase-types/api"; - -import type { ComputedVisualizationSettings } from "../types"; - -import { - ChartSettingsFooterRoot, - ChartSettingsListContainer, - ChartSettingsMenu, - ChartSettingsPreview, - ChartSettingsRoot, - ChartSettingsVisualizationContainer, - SectionContainer, - SectionWarnings, -} from "./ChartSettings.styled"; -import ChartSettingsWidgetList from "./ChartSettingsWidgetList"; -import ChartSettingsWidgetPopover from "./ChartSettingsWidgetPopover"; - -// section names are localized -const DEFAULT_TAB_PRIORITY = [t`Data`]; - -/** - * @deprecated HOCs are deprecated - */ -function withTransientSettingState( - ComposedComponent: React.ComponentType<ChartSettingsProps>, -) { - return class extends React.Component< - ChartSettingsProps, - { settings?: VisualizationSettings } - > { - static displayName = `withTransientSettingState[${ - ComposedComponent.displayName || ComposedComponent.name - }]`; - - constructor(props: ChartSettingsProps) { - super(props); - this.state = { - settings: props.settings, - }; - } - - UNSAFE_componentWillReceiveProps(nextProps: ChartSettingsProps) { - if (this.props.settings !== nextProps.settings) { - this.setState({ settings: nextProps.settings }); - } - } - - render() { - return ( - <ComposedComponent - {...this.props} - settings={this.state.settings} - onChange={(settings: VisualizationSettings) => - this.setState({ settings }) - } - onDone={(settings: VisualizationSettings) => - this.props.onChange?.(settings || this.state.settings) - } - /> - ); - } - }; -} - -// this type is not full, we need to extend it later -export interface Widget { - id: string; - section: string; - hidden?: boolean; - props: Record<string, unknown>; - title?: string; - widget: (() => JSX.Element | null) | undefined; -} - -export interface ChartSettingsProps { - className?: string; - dashboard?: Dashboard; - dashcard?: DashboardCard; - initial?: { section: string; widget?: Widget }; - onCancel?: () => void; - onDone?: (settings: VisualizationSettings) => void; - onReset?: () => void; - onChange?: ( - settings: ComputedVisualizationSettings, - question?: Question, - ) => void; - onClose?: () => void; - rawSeries?: RawSeries[]; - settings?: VisualizationSettings; - widgets?: Widget[]; - series: Series; - computedSettings?: ComputedVisualizationSettings; - isDashboard?: boolean; - question?: Question; - addField?: () => void; - noPreview?: boolean; -} - -interface ChartSettingsState { - currentSection: string | null; - currentWidget: Widget | null; - popoverRef?: HTMLElement | null; - warnings?: string[]; -} - -class ChartSettings extends Component<ChartSettingsProps, ChartSettingsState> { - constructor(props: ChartSettingsProps) { - super(props); - this.state = { - currentSection: (props.initial && props.initial.section) || null, - currentWidget: (props.initial && props.initial.widget) || null, - }; - } - - componentDidUpdate(prevProps: ChartSettingsProps) { - const { initial } = this.props; - if (!_.isEqual(initial, prevProps.initial)) { - this.setState({ - currentSection: (initial && initial.section) || null, - currentWidget: (initial && initial.widget) || null, - }); - } - } - - handleShowSection = (section: string) => { - this.setState({ - currentSection: section, - currentWidget: null, - }); - }; - - // allows a widget to temporarily replace itself with a different widget - handleShowWidget = (widget: Widget, ref: HTMLElement | null) => { - this.setState({ popoverRef: ref, currentWidget: widget }); - }; - - // go back to previously selected section - handleEndShowWidget = () => { - this.setState({ currentWidget: null, popoverRef: null }); - }; - - handleResetSettings = () => { - const originalCardSettings = - this.props.dashcard?.card.visualization_settings; - const clickBehaviorSettings = getClickBehaviorSettings(this._getSettings()); - - this.props.onChange?.({ - ...originalCardSettings, - ...clickBehaviorSettings, - }); - }; - - handleChangeSettings = ( - changedSettings: VisualizationSettings, - question: Question, - ) => { - this.props.onChange?.( - updateSettings(this._getSettings(), changedSettings), - question, - ); - }; - - handleChangeSeriesColor = (seriesKey: string, color: string) => { - this.props.onChange?.( - updateSeriesColor(this._getSettings(), seriesKey, color), - ); - }; - - handleDone = () => { - this.props.onDone?.(this._getSettings()); - this.props.onClose?.(); - }; - - handleCancel = () => { - this.props.onClose?.(); - }; - - _getSettings() { - return ( - this.props.settings || this.props.series[0].card.visualization_settings - ); - } - - _getComputedSettings() { - return this.props.computedSettings || {}; - } - - _getWidgets(): Widget[] { - if (this.props.widgets) { - return this.props.widgets; - } else { - const { isDashboard, dashboard } = this.props; - const transformedSeries = this._getTransformedSeries(); - - return getSettingsWidgetsForSeries( - transformedSeries, - this.handleChangeSettings, - isDashboard, - { dashboardId: dashboard?.id }, - ); - } - } - - // TODO: move this logic out of the React component - _getRawSeries() { - const { series } = this.props; - const settings = this._getSettings(); - const rawSeries = assocIn( - series, - [0, "card", "visualization_settings"], - settings, - ); - return rawSeries; - } - _getTransformedSeries() { - const rawSeries = this._getRawSeries(); - const { series: transformedSeries } = getVisualizationTransformed( - extractRemappings(rawSeries), - ); - return transformedSeries; - } - - columnHasSettings(col: DatasetColumn) { - const { series } = this.props; - const settings = this._getSettings() || {}; - const settingsDefs = getSettingDefinitionsForColumn(series, col); - const computedSettings = getComputedSettings(settingsDefs, col, settings); - - return getSettingsWidgets( - settingsDefs, - settings, - computedSettings, - col, - _.noop, - { - series, - }, - ).some(widget => !widget.hidden); - } - - getStyleWidget = (widgets: Widget[]): Widget | null => { - const series = this._getTransformedSeries(); - const settings = this._getComputedSettings(); - const { currentWidget } = this.state; - const seriesSettingsWidget = - currentWidget && widgets.find(widget => widget.id === "series_settings"); - - const display = series?.[0]?.card?.display; - // In the pie the chart, clicking on the "measure" settings menu will only - // open a formatting widget, and we don't want the style widget (used only - // for dimension) to override that - if (display === "pie" && currentWidget?.id === "column_settings") { - return null; - } - - //We don't want to show series settings widget for waterfall charts - if (display === "waterfall" || !seriesSettingsWidget) { - return null; - } - - if (currentWidget.props?.seriesKey !== undefined) { - return { - ...seriesSettingsWidget, - props: { - ...seriesSettingsWidget.props, - initialKey: currentWidget.props.seriesKey, - }, - }; - } else if (currentWidget.props?.initialKey) { - const hasBreakouts = - settings["graph.dimensions"] && settings["graph.dimensions"].length > 1; - - if (hasBreakouts) { - return null; - } - - const singleSeriesForColumn = series.find(single => { - const metricColumn = single.data.cols[1]; - if (metricColumn) { - return ( - getColumnKey(metricColumn) === currentWidget?.props?.initialKey - ); - } - }); - - if (singleSeriesForColumn) { - return { - ...seriesSettingsWidget, - props: { - ...seriesSettingsWidget.props, - initialKey: keyForSingleSeries(singleSeriesForColumn), - }, - }; - } - } - - return null; - }; - - getFormattingWidget = (widgets: Widget[]): Widget | null => { - const { currentWidget } = this.state; - const widget = - currentWidget && widgets.find(widget => widget.id === currentWidget.id); - - if (widget) { - return { ...widget, props: { ...widget.props, ...currentWidget.props } }; - } - - return null; - }; - - render() { - const { - className, - question, - addField, - noPreview = false, - dashboard, - dashcard, - isDashboard = false, - } = this.props; - const { popoverRef } = this.state; - - const settings = this._getSettings(); - const widgets = this._getWidgets(); - const rawSeries = this._getRawSeries(); - - const widgetsById: Record<string, Widget> = {}; - const sections: Record<string, Widget[]> = {}; - - for (const widget of widgets) { - widgetsById[widget.id] = widget; - if (widget.widget && !widget.hidden) { - sections[widget.section] = sections[widget.section] || []; - sections[widget.section].push(widget); - } - } - - // Move settings from the "undefined" section in the first tab - if (sections["undefined"] && Object.values(sections).length > 1) { - const extra = sections["undefined"]; - delete sections["undefined"]; - Object.values(sections)[0].unshift(...extra); - } - - const sectionNames = Object.keys(sections); - - // This sorts the section radio buttons. - const sectionSortOrder = [ - "data", - "display", - "axes", - // include all section names so any forgotten sections are sorted to the end - ...sectionNames.map(x => x.toLowerCase()), - ]; - sectionNames.sort((a, b) => { - const [aIdx, bIdx] = [a, b].map(x => - sectionSortOrder.indexOf(x.toLowerCase()), - ); - return aIdx - bIdx; - }); - - const currentSection = - this.state.currentSection && sections[this.state.currentSection] - ? this.state.currentSection - : _.find(DEFAULT_TAB_PRIORITY, name => name in sections) || - sectionNames[0]; - - const visibleWidgets = sections[currentSection] || []; - - // This checks whether the current section contains a column settings widget - // at the top level. If it does, we avoid hiding the section tabs and - // overriding the sidebar title. - const currentSectionHasColumnSettings = ( - sections[currentSection] || [] - ).some((widget: Widget) => widget.id === "column_settings"); - - const extraWidgetProps = { - // NOTE: special props to support adding additional fields - question: question, - addField: addField, - onShowWidget: this.handleShowWidget, - onEndShowWidget: this.handleEndShowWidget, - currentSectionHasColumnSettings, - columnHasSettings: (col: DatasetColumn) => this.columnHasSettings(col), - onChangeSeriesColor: (seriesKey: string, color: string) => - this.handleChangeSeriesColor(seriesKey, color), - }; - - const sectionPicker = ( - <SectionContainer isDashboard={isDashboard}> - <Radio - value={currentSection} - onChange={this.handleShowSection} - options={sectionNames} - optionNameFn={v => v} - optionValueFn={v => v} - optionKeyFn={v => v} - variant="underlined" - /> - </SectionContainer> - ); - - const onReset = - !_.isEqual(settings, {}) && (settings || {}).virtual_card == null // resetting virtual cards wipes the text and broke the UI (metabase#14644) - ? this.handleResetSettings - : null; - - const showSectionPicker = - // don't show section tabs for a single section - sectionNames.length > 1 && - // hide the section picker if the only widget is column_settings - !( - visibleWidgets.length === 1 && - visibleWidgets[0].id === "column_settings" && - // and this section doesn't doesn't have that as a direct child - !currentSectionHasColumnSettings - ); - - // default layout with visualization - return ( - <ChartSettingsRoot className={className}> - <ChartSettingsMenu data-testid="chartsettings-sidebar"> - {showSectionPicker && sectionPicker} - <ChartSettingsListContainer className={CS.scrollShow}> - <ChartSettingsWidgetList - widgets={visibleWidgets} - extraWidgetProps={extraWidgetProps} - /> - </ChartSettingsListContainer> - </ChartSettingsMenu> - {!noPreview && ( - <ChartSettingsPreview> - <SectionWarnings warnings={this.state.warnings} size={20} /> - <ChartSettingsVisualizationContainer> - <Visualization - className={CS.spread} - rawSeries={rawSeries} - showTitle - isEditing - isDashboard - dashboard={dashboard} - dashcard={dashcard} - isSettings - showWarnings - onUpdateVisualizationSettings={this.handleChangeSettings} - onUpdateWarnings={(warnings: string[]) => - this.setState({ warnings }) - } - /> - </ChartSettingsVisualizationContainer> - <ChartSettingsFooter - onDone={this.handleDone} - onCancel={this.handleCancel} - onReset={onReset} - /> - </ChartSettingsPreview> - )} - <ChartSettingsWidgetPopover - anchor={popoverRef as HTMLElement} - widgets={[ - this.getStyleWidget(widgets), - this.getFormattingWidget(widgets), - ].filter((widget): widget is Widget => !!widget)} - handleEndShowWidget={this.handleEndShowWidget} - /> - </ChartSettingsRoot> - ); - } -} - -const ChartSettingsFooter = ({ - onDone, - onCancel, - onReset, -}: { - onDone: () => void; - onCancel: () => void; - onReset: (() => void) | null; -}) => ( - <ChartSettingsFooterRoot> - {onReset && ( - <Button - borderless - icon="refresh" - onClick={onReset} - >{t`Reset to defaults`}</Button> - )} - <Button onClick={onCancel}>{t`Cancel`}</Button> - <Button primary onClick={onDone}>{t`Done`}</Button> - </ChartSettingsFooterRoot> -); - -export { ChartSettings }; - -export const ChartSettingsWithState = withTransientSettingState(ChartSettings); diff --git a/frontend/src/metabase/visualizations/components/ChartSettings.styled.tsx b/frontend/src/metabase/visualizations/components/ChartSettings/ChartSettings.styled.tsx similarity index 100% rename from frontend/src/metabase/visualizations/components/ChartSettings.styled.tsx rename to frontend/src/metabase/visualizations/components/ChartSettings/ChartSettings.styled.tsx diff --git a/frontend/src/metabase/visualizations/components/ChartSettings/ChartSettings.tsx b/frontend/src/metabase/visualizations/components/ChartSettings/ChartSettings.tsx new file mode 100644 index 00000000000..7c62b16fd01 --- /dev/null +++ b/frontend/src/metabase/visualizations/components/ChartSettings/ChartSettings.tsx @@ -0,0 +1,415 @@ +import { assocIn } from "icepick"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { t } from "ttag"; +import _ from "underscore"; + +import Radio from "metabase/core/components/Radio"; +import CS from "metabase/css/core/index.css"; +import { + extractRemappings, + getVisualizationTransformed, +} from "metabase/visualizations"; +import { ChartSettingsFooter } from "metabase/visualizations/components/ChartSettings/ChartSettingsFooter"; +import Visualization from "metabase/visualizations/components/Visualization"; +import { updateSeriesColor } from "metabase/visualizations/lib/series"; +import { + getClickBehaviorSettings, + getComputedSettings, + getSettingsWidgets, + updateSettings, +} from "metabase/visualizations/lib/settings"; +import { getSettingDefinitionsForColumn } from "metabase/visualizations/lib/settings/column"; +import { keyForSingleSeries } from "metabase/visualizations/lib/settings/series"; +import { getSettingsWidgetsForSeries } from "metabase/visualizations/lib/settings/visualization"; +import type Question from "metabase-lib/v1/Question"; +import { getColumnKey } from "metabase-lib/v1/queries/utils/column-key"; +import type { DatasetColumn, VisualizationSettings } from "metabase-types/api"; + +import ChartSettingsWidgetList from "../ChartSettingsWidgetList"; +import ChartSettingsWidgetPopover from "../ChartSettingsWidgetPopover"; + +import { + ChartSettingsListContainer, + ChartSettingsMenu, + ChartSettingsPreview, + ChartSettingsRoot, + ChartSettingsVisualizationContainer, + SectionContainer, + SectionWarnings, +} from "./ChartSettings.styled"; +import type { + ChartSettingsProps, + ChartSettingsWithStateProps, + Widget, +} from "./types"; + +// section names are localized +const DEFAULT_TAB_PRIORITY = [t`Data`]; + +export const ChartSettings = ({ + initial, + settings: propSettings, + series, + computedSettings: propComputedSettings, + onChange, + isDashboard = false, + noPreview = false, + dashboard, + dashcard, + onDone, + onClose, + question, + className, + widgets: propWidgets, +}: ChartSettingsProps) => { + const [currentSection, setCurrentSection] = useState<string | null>( + initial?.section ?? null, + ); + const [currentWidget, setCurrentWidget] = useState<Widget | null>( + initial?.widget ?? null, + ); + const [popoverRef, setPopoverRef] = useState<HTMLElement | null>(); + const [warnings, setWarnings] = useState(); + + const chartSettings = useMemo( + () => propSettings || series[0].card.visualization_settings, + [series, propSettings], + ); + + const computedSettings = useMemo( + () => propComputedSettings || {}, + [propComputedSettings], + ); + + const handleChangeSettings = useCallback( + (changedSettings: VisualizationSettings, question: Question) => { + onChange?.(updateSettings(chartSettings, changedSettings), question); + }, + [chartSettings, onChange], + ); + + const chartSettingsRawSeries = useMemo( + () => assocIn(series, [0, "card", "visualization_settings"], chartSettings), + [chartSettings, series], + ); + + const transformedSeries = useMemo(() => { + const { series: transformedSeries } = getVisualizationTransformed( + extractRemappings(chartSettingsRawSeries), + ); + return transformedSeries; + }, [chartSettingsRawSeries]); + + const widgets = useMemo( + () => + propWidgets || + getSettingsWidgetsForSeries( + transformedSeries, + handleChangeSettings, + isDashboard, + { dashboardId: dashboard?.id }, + ), + [ + propWidgets, + transformedSeries, + handleChangeSettings, + isDashboard, + dashboard?.id, + ], + ); + + const columnHasSettings = useCallback( + (col: DatasetColumn) => { + const settings = chartSettings || {}; + const settingsDefs = getSettingDefinitionsForColumn(series, col); + const getComputedSettingsResult = getComputedSettings( + settingsDefs, + col, + settings, + ); + + return getSettingsWidgets( + settingsDefs, + settings, + getComputedSettingsResult, + col, + _.noop, + { + series: series, + }, + ).some(widget => !widget.hidden); + }, + [chartSettings, series], + ); + + const styleWidget = useMemo(() => { + const seriesSettingsWidget = + currentWidget && widgets.find(widget => widget.id === "series_settings"); + + const display = transformedSeries?.[0]?.card?.display; + // In the pie the chart, clicking on the "measure" settings menu will only + // open a formatting widget, and we don't want the style widget (used only + // for dimension) to override that + if (display === "pie" && currentWidget?.id === "column_settings") { + return null; + } + + //We don't want to show series settings widget for waterfall charts + if (display === "waterfall" || !seriesSettingsWidget) { + return null; + } + + if (currentWidget.props?.seriesKey !== undefined) { + return { + ...seriesSettingsWidget, + props: { + ...seriesSettingsWidget.props, + initialKey: currentWidget.props.seriesKey, + }, + }; + } else if (currentWidget.props?.initialKey) { + const hasBreakouts = + computedSettings["graph.dimensions"] && + computedSettings["graph.dimensions"].length > 1; + + if (hasBreakouts) { + return null; + } + + const singleSeriesForColumn = transformedSeries.find(single => { + const metricColumn = single.data.cols[1]; + if (metricColumn) { + return ( + getColumnKey(metricColumn) === currentWidget?.props?.initialKey + ); + } + }); + + if (singleSeriesForColumn) { + return { + ...seriesSettingsWidget, + props: { + ...seriesSettingsWidget.props, + initialKey: keyForSingleSeries(singleSeriesForColumn), + }, + }; + } + } + + return null; + }, [computedSettings, currentWidget, transformedSeries, widgets]); + + const formattingWidget = useMemo(() => { + const widget = + currentWidget && widgets.find(widget => widget.id === currentWidget.id); + + if (widget) { + return { ...widget, props: { ...widget.props, ...currentWidget.props } }; + } + + return null; + }, [currentWidget, widgets]); + + const handleShowSection = useCallback((section: string) => { + setCurrentSection(section); + setCurrentWidget(null); + }, []); + + // allows a widget to temporarily replace itself with a different widget + const handleShowWidget = useCallback( + (widget: Widget, ref: HTMLElement | null) => { + setPopoverRef(ref); + setCurrentWidget(widget); + }, + [], + ); + + // go back to previously selected section + const handleEndShowWidget = useCallback(() => { + setPopoverRef(null); + setCurrentWidget(null); + }, []); + + const handleResetSettings = useCallback(() => { + const originalCardSettings = dashcard?.card.visualization_settings; + const clickBehaviorSettings = getClickBehaviorSettings(chartSettings); + + onChange?.({ + ...originalCardSettings, + ...clickBehaviorSettings, + }); + }, [chartSettings, dashcard?.card.visualization_settings, onChange]); + + const handleChangeSeriesColor = useCallback( + (seriesKey: string, color: string) => { + onChange?.(updateSeriesColor(chartSettings, seriesKey, color)); + }, + [chartSettings, onChange], + ); + + const handleDone = useCallback(() => { + onDone?.(chartSettings); + onClose?.(); + }, [chartSettings, onClose, onDone]); + + const handleCancel = useCallback(() => { + onClose?.(); + }, [onClose]); + + const sections: Record<string, Widget[]> = useMemo(() => { + const sectionObj: Record<string, Widget[]> = {}; + for (const widget of widgets) { + if (widget.widget && !widget.hidden) { + sectionObj[widget.section] = sectionObj[widget.section] || []; + sectionObj[widget.section].push(widget); + } + } + + // Move settings from the "undefined" section in the first tab + if (sectionObj["undefined"] && Object.values(sectionObj).length > 1) { + const extra = sectionObj["undefined"]; + delete sectionObj["undefined"]; + Object.values(sectionObj)[0].unshift(...extra); + } + return sectionObj; + }, [widgets]); + + const sectionNames = Object.keys(sections); + + // This sorts the section radio buttons. + const sectionSortOrder = [ + "data", + "display", + "axes", + // include all section names so any forgotten sections are sorted to the end + ...sectionNames.map(x => x.toLowerCase()), + ]; + sectionNames.sort((a, b) => { + const [aIdx, bIdx] = [a, b].map(x => + sectionSortOrder.indexOf(x.toLowerCase()), + ); + return aIdx - bIdx; + }); + + const chartSettingCurrentSection = useMemo( + () => + currentSection && sections[currentSection] + ? currentSection + : _.find(DEFAULT_TAB_PRIORITY, name => name in sections) || + sectionNames[0], + [currentSection, sectionNames, sections], + ); + + const visibleWidgets = sections[chartSettingCurrentSection] || []; + + const currentSectionHasColumnSettings = ( + sections[chartSettingCurrentSection] || [] + ).some((widget: Widget) => widget.id === "column_settings"); + + const extraWidgetProps = { + // NOTE: special props to support adding additional fields + question, + onShowWidget: handleShowWidget, + onEndShowWidget: handleEndShowWidget, + currentSectionHasColumnSettings, + columnHasSettings, + onChangeSeriesColor: handleChangeSeriesColor, + }; + + const onResetToDefault = + // resetting virtual cards wipes the text and broke the UI (metabase#14644) + !_.isEqual(chartSettings, {}) && (chartSettings || {}).virtual_card == null + ? handleResetSettings + : null; + + const showSectionPicker = + // don't show section tabs for a single section + sectionNames.length > 1 && + // hide the section picker if the only widget is column_settings + !( + visibleWidgets.length === 1 && + visibleWidgets[0].id === "column_settings" && + // and this section doesn't have that as a direct child + !currentSectionHasColumnSettings + ); + + return ( + <ChartSettingsRoot className={className}> + <ChartSettingsMenu data-testid="chartsettings-sidebar"> + {showSectionPicker && ( + <SectionContainer isDashboard={false}> + <Radio + value={chartSettingCurrentSection ?? undefined} + onChange={handleShowSection} + options={sectionNames} + optionNameFn={v => v} + optionValueFn={v => v} + optionKeyFn={v => v} + variant="underlined" + /> + </SectionContainer> + )} + <ChartSettingsListContainer className={CS.scrollShow}> + <ChartSettingsWidgetList + widgets={visibleWidgets} + extraWidgetProps={extraWidgetProps} + /> + </ChartSettingsListContainer> + </ChartSettingsMenu> + {!noPreview && ( + <ChartSettingsPreview> + <SectionWarnings warnings={warnings} size={20} /> + <ChartSettingsVisualizationContainer> + <Visualization + className={CS.spread} + rawSeries={chartSettingsRawSeries} + showTitle + isEditing + isDashboard + dashboard={dashboard} + dashcard={dashcard} + isSettings + showWarnings + onUpdateVisualizationSettings={handleChangeSettings} + onUpdateWarnings={setWarnings} + /> + </ChartSettingsVisualizationContainer> + <ChartSettingsFooter + onDone={handleDone} + onCancel={handleCancel} + onReset={onResetToDefault} + /> + </ChartSettingsPreview> + )} + <ChartSettingsWidgetPopover + anchor={popoverRef as HTMLElement} + widgets={[styleWidget, formattingWidget].filter( + (widget): widget is Widget => !!widget, + )} + handleEndShowWidget={handleEndShowWidget} + /> + </ChartSettingsRoot> + ); +}; + +export const ChartSettingsWithState = (props: ChartSettingsWithStateProps) => { + const [tempSettings, setTempSettings] = useState(props.settings); + + useEffect(() => { + if (props.settings) { + setTempSettings(props.settings); + } + }, [props.settings]); + + const onDone = (settings: VisualizationSettings) => + props.onChange?.(settings ?? tempSettings); + + return ( + <ChartSettings + {...props} + onChange={setTempSettings} + onDone={onDone} + settings={tempSettings} + /> + ); +}; diff --git a/frontend/src/metabase/visualizations/components/ChartSettings.unit.spec.tsx b/frontend/src/metabase/visualizations/components/ChartSettings/ChartSettings.unit.spec.tsx similarity index 97% rename from frontend/src/metabase/visualizations/components/ChartSettings.unit.spec.tsx rename to frontend/src/metabase/visualizations/components/ChartSettings/ChartSettings.unit.spec.tsx index 9703c7d48ef..583c395ed5d 100644 --- a/frontend/src/metabase/visualizations/components/ChartSettings.unit.spec.tsx +++ b/frontend/src/metabase/visualizations/components/ChartSettings/ChartSettings.unit.spec.tsx @@ -1,11 +1,6 @@ import userEvent from "@testing-library/user-event"; import { fireEvent, renderWithProviders, screen } from "__support__/ui"; -import { - ChartSettings, - type ChartSettingsProps, - type Widget, -} from "metabase/visualizations/components/ChartSettings"; import registerVisualizations from "metabase/visualizations/register"; import { createMockCard, @@ -14,6 +9,9 @@ import { createMockVisualizationSettings, } from "metabase-types/api/mocks"; +import { ChartSettings } from "./ChartSettings"; +import type { ChartSettingsProps, Widget } from "./types"; + registerVisualizations(); const DEFAULT_PROPS = { diff --git a/frontend/src/metabase/visualizations/components/ChartSettings/ChartSettingsFooter.tsx b/frontend/src/metabase/visualizations/components/ChartSettings/ChartSettingsFooter.tsx new file mode 100644 index 00000000000..19a76b70476 --- /dev/null +++ b/frontend/src/metabase/visualizations/components/ChartSettings/ChartSettingsFooter.tsx @@ -0,0 +1,29 @@ +import { t } from "ttag"; + +import Button from "metabase/core/components/Button"; + +import { ChartSettingsFooterRoot } from "./ChartSettings.styled"; + +type ChartSettingsFooterProps = { + onDone: () => void; + onCancel: () => void; + onReset: (() => void) | null; +}; + +export const ChartSettingsFooter = ({ + onDone, + onCancel, + onReset, +}: ChartSettingsFooterProps) => ( + <ChartSettingsFooterRoot> + {onReset && ( + <Button + borderless + icon="refresh" + onClick={onReset} + >{t`Reset to defaults`}</Button> + )} + <Button onClick={onCancel}>{t`Cancel`}</Button> + <Button primary onClick={onDone}>{t`Done`}</Button> + </ChartSettingsFooterRoot> +); diff --git a/frontend/src/metabase/visualizations/components/ChartSettings/index.ts b/frontend/src/metabase/visualizations/components/ChartSettings/index.ts new file mode 100644 index 00000000000..50271a0d71d --- /dev/null +++ b/frontend/src/metabase/visualizations/components/ChartSettings/index.ts @@ -0,0 +1 @@ +export * from "./ChartSettings"; diff --git a/frontend/src/metabase/visualizations/components/ChartSettings/types.ts b/frontend/src/metabase/visualizations/components/ChartSettings/types.ts new file mode 100644 index 00000000000..d3db2bfe55f --- /dev/null +++ b/frontend/src/metabase/visualizations/components/ChartSettings/types.ts @@ -0,0 +1,45 @@ +import type { ComputedVisualizationSettings } from "metabase/visualizations/types"; +import type Question from "metabase-lib/v1/Question"; +import type { + Dashboard, + DashboardCard, + Series, + VisualizationSettings, +} from "metabase-types/api"; + +// this type is not full, we need to extend it later +export type Widget = { + id: string; + section: string; + hidden?: boolean; + props: Record<string, unknown>; + title?: string; + widget: (() => JSX.Element | null) | undefined; +}; + +export type ChartSettingsWithStateProps = { + className?: string; + isDashboard?: boolean; + dashboard?: Dashboard; + dashcard?: DashboardCard; + initial?: { + section: string; + widget?: Widget; + }; + onClose?: () => void; + series: Series; + computedSettings?: ComputedVisualizationSettings; + question?: Question; + noPreview?: boolean; + widgets?: Widget[]; + + onChange?: ( + settings: ComputedVisualizationSettings, + question?: Question, + ) => void; + settings?: VisualizationSettings; +}; + +export type ChartSettingsProps = ChartSettingsWithStateProps & { + onDone?: (settings: VisualizationSettings) => void; +}; -- GitLab