diff --git a/frontend/src/metabase/query_builder/components/notebook/NotebookStep.jsx b/frontend/src/metabase/query_builder/components/notebook/NotebookStep.jsx deleted file mode 100644 index caf4451c3bd7937a90df14e26ffc6c3d370cef44..0000000000000000000000000000000000000000 --- a/frontend/src/metabase/query_builder/components/notebook/NotebookStep.jsx +++ /dev/null @@ -1,268 +0,0 @@ -/* eslint-disable react/prop-types */ -import React from "react"; - -import { t } from "ttag"; -import _ from "underscore"; - -import styled from "@emotion/styled"; - -import { color as c, lighten, darken, alpha } from "metabase/lib/colors"; - -import Tooltip from "metabase/core/components/Tooltip"; -import Icon from "metabase/components/Icon"; -import Button from "metabase/core/components/Button"; -import ExpandingContent from "metabase/components/ExpandingContent"; - -import NotebookStepPreview from "./NotebookStepPreview"; - -import DataStep from "./steps/DataStep"; -import JoinStep from "./steps/JoinStep"; -import ExpressionStep from "./steps/ExpressionStep"; -import FilterStep from "./steps/FilterStep"; -import AggregateStep from "./steps/AggregateStep"; -import BreakoutStep from "./steps/BreakoutStep"; -import SummarizeStep from "./steps/SummarizeStep"; -import SortStep from "./steps/SortStep"; -import LimitStep from "./steps/LimitStep"; -import { - StepActionsContainer, - StepBody, - StepContent, - StepHeader, - StepButtonContainer, - StepRoot, -} from "./NotebookStep.styled"; - -// TODO -const STEP_UI = { - data: { - title: t`Data`, - component: DataStep, - getColor: () => c("brand"), - }, - join: { - title: t`Join data`, - icon: "join_left_outer", - component: JoinStep, - priority: 1, - getColor: () => c("brand"), - }, - expression: { - title: t`Custom column`, - icon: "add_data", - component: ExpressionStep, - transparent: true, - getColor: () => c("bg-dark"), - }, - filter: { - title: t`Filter`, - icon: "filter", - component: FilterStep, - priority: 10, - getColor: () => c("filter"), - }, - summarize: { - title: t`Summarize`, - icon: "sum", - component: SummarizeStep, - priority: 5, - getColor: () => c("summarize"), - }, - aggregate: { - title: t`Aggregate`, - icon: "sum", - component: AggregateStep, - priority: 5, - getColor: () => c("summarize"), - }, - breakout: { - title: t`Breakout`, - icon: "segment", - component: BreakoutStep, - priority: 1, - getColor: () => c("accent4"), - }, - sort: { - title: t`Sort`, - icon: "smartscalar", - component: SortStep, - compact: true, - transparent: true, - getColor: () => c("bg-dark"), - }, - limit: { - title: t`Row limit`, - icon: "list", - component: LimitStep, - compact: true, - transparent: true, - getColor: () => c("bg-dark"), - }, -}; - -function getTestId(step) { - const { type, stageIndex, itemIndex } = step; - return `step-${type}-${stageIndex || 0}-${itemIndex || 0}`; -} - -export default class NotebookStep extends React.Component { - state = { - showPreview: false, - }; - - render() { - const { - step, - openStep, - isLastStep, - isLastOpened, - updateQuery, - reportTimezone, - sourceQuestion, - } = this.props; - const { showPreview } = this.state; - - const { - title, - getColor, - component: NotebookStepComponent, - } = STEP_UI[step.type] || {}; - - const color = getColor(); - const canPreview = step.previewQuery && step.previewQuery.isValid(); - const showPreviewButton = !showPreview && canPreview; - - const largeActionButtons = - isLastStep && - _.any(step.actions, action => !STEP_UI[action.type].compact); - - const actions = []; - actions.push( - ...step.actions.map(action => { - const stepUi = STEP_UI[action.type]; - - return { - priority: stepUi.priority, - button: ( - <ActionButton - mr={isLastStep ? 2 : 1} - mt={isLastStep ? 2 : null} - color={stepUi.getColor()} - large={largeActionButtons} - {...stepUi} - key={`actionButton_${stepUi.title}`} - onClick={() => action.action({ query: step.query, openStep })} - /> - ), - }; - }), - ); - - actions.sort((a, b) => (b.priority || 0) - (a.priority || 0)); - const actionButtons = actions.map(action => action.button); - - return ( - <ExpandingContent isInitiallyOpen={!isLastOpened} isOpen> - <StepRoot - className="hover-parent hover--visibility" - data-testid={getTestId(step)} - > - <StepHeader color={color}> - {title} - <Icon - name="close" - className="ml-auto cursor-pointer text-light text-medium-hover hover-child" - tooltip={t`Remove`} - onClick={() => updateQuery(step.revert(step.query))} - data-testid="remove-step" - /> - </StepHeader> - - {NotebookStepComponent && ( - <StepBody> - <StepContent> - <NotebookStepComponent - color={color} - step={step} - query={step.query} - sourceQuestion={sourceQuestion} - updateQuery={updateQuery} - isLastOpened={isLastOpened} - reportTimezone={reportTimezone} - /> - </StepContent> - <StepButtonContainer> - <ActionButton - ml={[1, 2]} - className={ - !showPreviewButton ? "hidden disabled" : "text-brand-hover" - } - icon="play" - title={t`Preview`} - color={c("text-light")} - transparent - onClick={() => this.setState({ showPreview: true })} - /> - </StepButtonContainer> - </StepBody> - )} - - {showPreview && canPreview && ( - <NotebookStepPreview - step={step} - onClose={() => this.setState({ showPreview: false })} - /> - )} - - {actionButtons.length > 0 && ( - <StepActionsContainer data-testid="action-buttons"> - {actionButtons} - </StepActionsContainer> - )} - </StepRoot> - </ExpandingContent> - ); - } -} - -const ColorButton = styled(Button)` - border: none; - color: ${({ color }) => color}; - background-color: ${({ color, transparent }) => - transparent ? null : alpha(color, 0.2)}; - &:hover { - color: ${({ color }) => darken(color, 0.115)}; - background-color: ${({ color, transparent }) => - transparent ? lighten(color, 0.5) : alpha(color, 0.35)}; - } - transition: background 300ms; -`; - -const ActionButton = ({ - icon, - title, - color, - transparent, - large, - onClick, - ...props -}) => { - const label = large ? title : null; - const button = ( - <ColorButton - icon={icon} - small={!large} - color={color} - transparent={transparent} - iconVertical={large} - iconSize={large ? 18 : 14} - onClick={onClick} - aria-label={label} - {...props} - > - {label} - </ColorButton> - ); - - return large ? button : <Tooltip tooltip={title}>{button}</Tooltip>; -}; diff --git a/frontend/src/metabase/query_builder/components/notebook/NotebookStep/ActionButton.tsx b/frontend/src/metabase/query_builder/components/notebook/NotebookStep/ActionButton.tsx new file mode 100644 index 0000000000000000000000000000000000000000..8d7307d71fd54f0fa45b596b73a66a04d54634e1 --- /dev/null +++ b/frontend/src/metabase/query_builder/components/notebook/NotebookStep/ActionButton.tsx @@ -0,0 +1,51 @@ +import React from "react"; +import Tooltip from "metabase/core/components/Tooltip"; +import { ColorButton } from "./NotebookStep.styled"; + +interface ActionButtonProps { + className?: string; + + icon?: string; + title: string; + color: string; + transparent?: boolean; + large?: boolean; + onClick: () => void; + + // styled-system props + mt?: number | number[]; + mr?: number | number[]; + ml?: number | number[]; +} + +function ActionButton({ + icon, + title, + color, + transparent, + large, + onClick, + ...props +}: ActionButtonProps) { + const label = large ? title : undefined; + + const button = ( + <ColorButton + icon={icon} + small={!large} + color={color} + transparent={transparent} + iconVertical={large} + iconSize={large ? 18 : 14} + aria-label={label} + onClick={onClick} + {...props} + > + {label} + </ColorButton> + ); + + return large ? button : <Tooltip tooltip={title}>{button}</Tooltip>; +} + +export default ActionButton; diff --git a/frontend/src/metabase/query_builder/components/notebook/NotebookStep.styled.tsx b/frontend/src/metabase/query_builder/components/notebook/NotebookStep/NotebookStep.styled.tsx similarity index 61% rename from frontend/src/metabase/query_builder/components/notebook/NotebookStep.styled.tsx rename to frontend/src/metabase/query_builder/components/notebook/NotebookStep/NotebookStep.styled.tsx index baa9258251389f4eee49fbf47df93c444095119f..3b4cf72909910a1044fdebd1e6e958cc0a257314 100644 --- a/frontend/src/metabase/query_builder/components/notebook/NotebookStep.styled.tsx +++ b/frontend/src/metabase/query_builder/components/notebook/NotebookStep/NotebookStep.styled.tsx @@ -1,4 +1,6 @@ import styled from "@emotion/styled"; +import Button from "metabase/core/components/Button"; +import { alpha, darken, lighten } from "metabase/lib/colors"; import { breakpointMinSmall } from "metabase/styled-components/theme"; const getPercentage = (number: number): string => { @@ -46,3 +48,21 @@ export const StepButtonContainer = styled.div` export const StepActionsContainer = styled.div` margin-top: 0.5rem; `; + +interface ColorButtonProps { + color: string; + transparent?: boolean; +} + +export const ColorButton = styled(Button)<ColorButtonProps>` + border: none; + color: ${({ color }) => color}; + background-color: ${({ color, transparent }) => + transparent ? null : alpha(color, 0.2)}; + &:hover { + color: ${({ color }) => darken(color, 0.115)}; + background-color: ${({ color, transparent }) => + transparent ? lighten(color, 0.5) : alpha(color, 0.35)}; + } + transition: background 300ms; +`; diff --git a/frontend/src/metabase/query_builder/components/notebook/NotebookStep/NotebookStep.tsx b/frontend/src/metabase/query_builder/components/notebook/NotebookStep/NotebookStep.tsx new file mode 100644 index 0000000000000000000000000000000000000000..2c53c1f2ed9d7b612c081017aa1e710e4ddbe6bb --- /dev/null +++ b/frontend/src/metabase/query_builder/components/notebook/NotebookStep/NotebookStep.tsx @@ -0,0 +1,168 @@ +import React, { useCallback, useMemo } from "react"; +import { t } from "ttag"; + +import { color as c } from "metabase/lib/colors"; +import { useToggle } from "metabase/hooks/use-toggle"; + +import Icon from "metabase/components/Icon"; +import ExpandingContent from "metabase/components/ExpandingContent"; + +import type Question from "metabase-lib/Question"; +import type StructuredQuery from "metabase-lib/queries/StructuredQuery"; + +import { + NotebookStep as INotebookStep, + NotebookStepAction, +} from "../lib/steps.types"; +import NotebookStepPreview from "../NotebookStepPreview"; + +import { STEP_UI } from "./steps"; +import ActionButton from "./ActionButton"; +import { + StepActionsContainer, + StepBody, + StepContent, + StepHeader, + StepButtonContainer, + StepRoot, +} from "./NotebookStep.styled"; + +function hasLargeButton(action: NotebookStepAction) { + return !STEP_UI[action.type].compact; +} + +function getTestId(step: INotebookStep) { + const { type, stageIndex, itemIndex } = step; + return `step-${type}-${stageIndex || 0}-${itemIndex || 0}`; +} + +interface NotebookStepProps { + step: INotebookStep; + sourceQuestion?: Question; + isLastStep: boolean; + isLastOpened: boolean; + reportTimezone?: string; + openStep: (id: string) => void; + updateQuery: (query: StructuredQuery) => Promise<void>; +} + +function NotebookStep({ + step, + sourceQuestion, + isLastStep, + isLastOpened, + reportTimezone, + openStep, + updateQuery, +}: NotebookStepProps) { + const [isPreviewOpen, { turnOn: openPreview, turnOff: closePreview }] = + useToggle(false); + + const actionButtons = useMemo(() => { + const actions = []; + const hasLargeActionButtons = + isLastStep && step.actions.some(hasLargeButton); + + actions.push( + ...step.actions.map(action => { + const stepUi = STEP_UI[action.type]; + return { + priority: stepUi.priority, + button: ( + <ActionButton + key={`actionButton_${stepUi.title}`} + mr={isLastStep ? 2 : 1} + mt={isLastStep ? 2 : undefined} + color={stepUi.getColor()} + large={hasLargeActionButtons} + {...stepUi} + onClick={() => action.action({ query: step.query, openStep })} + /> + ), + }; + }), + ); + + actions.sort((a, b) => (b.priority || 0) - (a.priority || 0)); + + return actions.map(action => action.button); + }, [step.query, step.actions, isLastStep, openStep]); + + const handleClickRevert = useCallback(() => { + const reverted = step.revert?.(step.query); + if (reverted) { + updateQuery(reverted); + } + }, [step, updateQuery]); + + const { + title, + getColor, + component: NotebookStepComponent, + } = STEP_UI[step.type] || {}; + + const color = getColor(); + const canPreview = step?.previewQuery?.isValid?.(); + const hasPreviewButton = !isPreviewOpen && canPreview; + + return ( + <ExpandingContent isInitiallyOpen={!isLastOpened} isOpen> + <StepRoot + className="hover-parent hover--visibility" + data-testid={getTestId(step)} + > + <StepHeader color={color}> + {title} + <Icon + name="close" + className="ml-auto cursor-pointer text-light text-medium-hover hover-child" + tooltip={t`Remove`} + onClick={handleClickRevert} + data-testid="remove-step" + /> + </StepHeader> + + {NotebookStepComponent && ( + <StepBody> + <StepContent> + <NotebookStepComponent + color={color} + step={step} + query={step.query} + sourceQuestion={sourceQuestion} + updateQuery={updateQuery} + isLastOpened={isLastOpened} + reportTimezone={reportTimezone} + /> + </StepContent> + <StepButtonContainer> + <ActionButton + ml={[1, 2]} + className={ + !hasPreviewButton ? "hidden disabled" : "text-brand-hover" + } + icon="play" + title={t`Preview`} + color={c("text-light")} + transparent + onClick={openPreview} + /> + </StepButtonContainer> + </StepBody> + )} + + {canPreview && isPreviewOpen && ( + <NotebookStepPreview step={step} onClose={closePreview} /> + )} + + {actionButtons.length > 0 && ( + <StepActionsContainer data-testid="action-buttons"> + {actionButtons} + </StepActionsContainer> + )} + </StepRoot> + </ExpandingContent> + ); +} + +export default NotebookStep; diff --git a/frontend/src/metabase/query_builder/components/notebook/NotebookStep/index.ts b/frontend/src/metabase/query_builder/components/notebook/NotebookStep/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..db7282c00b0ce92ba04068b3fec87a609fa99a1e --- /dev/null +++ b/frontend/src/metabase/query_builder/components/notebook/NotebookStep/index.ts @@ -0,0 +1 @@ +export { default } from "./NotebookStep"; diff --git a/frontend/src/metabase/query_builder/components/notebook/NotebookStep/steps.ts b/frontend/src/metabase/query_builder/components/notebook/NotebookStep/steps.ts new file mode 100644 index 0000000000000000000000000000000000000000..63bc38e8b3d9707f0ba4e2f9e9925391878c480f --- /dev/null +++ b/frontend/src/metabase/query_builder/components/notebook/NotebookStep/steps.ts @@ -0,0 +1,92 @@ +import React from "react"; +import { t } from "ttag"; + +import { color } from "metabase/lib/colors"; + +import DataStep from "../steps/DataStep"; +import JoinStep from "../steps/JoinStep"; +import ExpressionStep from "../steps/ExpressionStep"; +import FilterStep from "../steps/FilterStep"; +import AggregateStep from "../steps/AggregateStep"; +import BreakoutStep from "../steps/BreakoutStep"; +import SummarizeStep from "../steps/SummarizeStep"; +import SortStep from "../steps/SortStep"; +import LimitStep from "../steps/LimitStep"; + +export type StepUIItem = { + title: string; + icon?: string; + priority?: number; + transparent?: boolean; + compact?: boolean; + getColor: () => string; + + // Remove any once all step components are typed + component: React.ComponentType<any>; +}; + +export const STEP_UI: Record<string, StepUIItem> = { + data: { + title: t`Data`, + component: DataStep, + getColor: () => color("brand"), + }, + join: { + title: t`Join data`, + icon: "join_left_outer", + component: JoinStep, + priority: 1, + getColor: () => color("brand"), + }, + expression: { + title: t`Custom column`, + icon: "add_data", + component: ExpressionStep, + transparent: true, + getColor: () => color("bg-dark"), + }, + filter: { + title: t`Filter`, + icon: "filter", + component: FilterStep, + priority: 10, + getColor: () => color("filter"), + }, + summarize: { + title: t`Summarize`, + icon: "sum", + component: SummarizeStep, + priority: 5, + getColor: () => color("summarize"), + }, + aggregate: { + title: t`Aggregate`, + icon: "sum", + component: AggregateStep, + priority: 5, + getColor: () => color("summarize"), + }, + breakout: { + title: t`Breakout`, + icon: "segment", + component: BreakoutStep, + priority: 1, + getColor: () => color("accent4"), + }, + sort: { + title: t`Sort`, + icon: "smartscalar", + component: SortStep, + compact: true, + transparent: true, + getColor: () => color("bg-dark"), + }, + limit: { + title: t`Row limit`, + icon: "list", + component: LimitStep, + compact: true, + transparent: true, + getColor: () => color("bg-dark"), + }, +}; diff --git a/frontend/src/metabase/query_builder/components/notebook/NotebookSteps/NotebookSteps.tsx b/frontend/src/metabase/query_builder/components/notebook/NotebookSteps/NotebookSteps.tsx index 557295b328305cd7fdd4b09fa5007ae5ff3742c2..728b81dc3e6255aa6fd3a9d29c1595c968c68a6b 100644 --- a/frontend/src/metabase/query_builder/components/notebook/NotebookSteps/NotebookSteps.tsx +++ b/frontend/src/metabase/query_builder/components/notebook/NotebookSteps/NotebookSteps.tsx @@ -4,7 +4,7 @@ import type Question from "metabase-lib/Question"; import type StructuredQuery from "metabase-lib/queries/StructuredQuery"; import { getQuestionSteps } from "../lib/steps"; -import { NotebookStep as INotebookStep } from "../lib/steps.types"; +import { NotebookStep as INotebookStep, OpenSteps } from "../lib/steps.types"; import NotebookStep from "../NotebookStep"; import { Container } from "./NotebookSteps.styled"; @@ -16,8 +16,6 @@ interface NotebookStepsProps { updateQuestion: (question: Question) => Promise<void>; } -type OpenSteps = { [key: string]: boolean }; - function getInitialOpenSteps(question: Question): OpenSteps { const isNew = !question.table(); return isNew @@ -94,7 +92,6 @@ function NotebookSteps({ reportTimezone={reportTimezone} updateQuery={onChange} openStep={handleStepOpen} - closeStep={handleStepClose} /> ); })} diff --git a/frontend/src/metabase/query_builder/components/notebook/lib/steps.ts b/frontend/src/metabase/query_builder/components/notebook/lib/steps.ts index f5d8a08026667e15bd50a0ce89cdd45584ffd7c1..90dfb7241f87212a1d12ceb562db5c80faeb53db 100644 --- a/frontend/src/metabase/query_builder/components/notebook/lib/steps.ts +++ b/frontend/src/metabase/query_builder/components/notebook/lib/steps.ts @@ -3,7 +3,7 @@ import _ from "underscore"; import type Question from "metabase-lib/Question"; import type StructuredQuery from "metabase-lib/queries/StructuredQuery"; -import { NotebookStep, NotebookStepFn } from "./steps.types"; +import { NotebookStep, NotebookStepFn, OpenSteps } from "./steps.types"; // This converts an MBQL query into a sequence of notebook "steps", with special logic to determine which steps are // allowed to be added at every other step, generating a preview query at each step, how to delete a step, @@ -148,7 +148,7 @@ export function getQuestionSteps(question: Question, openSteps = {}) { export function getStageSteps( stageQuery: StructuredQuery, stageIndex: number, - openSteps: Record<NotebookStep["id"], boolean>, + openSteps: OpenSteps, ) { const getId = (step: NotebookStepDef, itemIndex: number | null) => { const isValidItemIndex = itemIndex != null && itemIndex > 0; diff --git a/frontend/src/metabase/query_builder/components/notebook/lib/steps.types.ts b/frontend/src/metabase/query_builder/components/notebook/lib/steps.types.ts index 0b5e06525e6328cecd0e6ac75e4c46246c5f02c1..41b348e4ca7e24c0225b519fea5769c1411b59bc 100644 --- a/frontend/src/metabase/query_builder/components/notebook/lib/steps.types.ts +++ b/frontend/src/metabase/query_builder/components/notebook/lib/steps.types.ts @@ -53,3 +53,5 @@ export interface NotebookStepUiComponentProps { isLastOpened: boolean; reportTimezone: string; } + +export type OpenSteps = Record<NotebookStep["id"], boolean>; diff --git a/frontend/src/metabase/query_builder/components/notebook/steps/JoinStep.jsx b/frontend/src/metabase/query_builder/components/notebook/steps/JoinStep.jsx index a8eda937f6296b3ffc279498583cc45bf8054299..1c638525ffe0176101ccdac7d6da4ad6ce9d52d0 100644 --- a/frontend/src/metabase/query_builder/components/notebook/steps/JoinStep.jsx +++ b/frontend/src/metabase/query_builder/components/notebook/steps/JoinStep.jsx @@ -56,8 +56,8 @@ const stepShape = { clean: PropTypes.func.isRequired, actions: PropTypes.array.isRequired, }; -stepShape.previous = stepShape; -stepShape.next = stepShape; +stepShape.previous = PropTypes.shape(stepShape); +stepShape.next = PropTypes.shape(stepShape); const joinStepPropTypes = { query: PropTypes.object.isRequired,