diff --git a/frontend/src/metabase-types/api/dashboard.ts b/frontend/src/metabase-types/api/dashboard.ts index d24ad00101d80f72900d7aa9624a3b2d820f3320..dadf526e04e7a5f0b934e644602b1b3523d85b59 100644 --- a/frontend/src/metabase-types/api/dashboard.ts +++ b/frontend/src/metabase-types/api/dashboard.ts @@ -4,7 +4,9 @@ import type { ParameterId, Parameter, } from "metabase-types/types/Parameter"; + import type { CardId, SavedCard } from "metabase-types/types/Card"; +import type { WritebackAction } from "./writeback"; import type { Dataset } from "./dataset"; @@ -41,6 +43,7 @@ export type DashboardOrderedCard = BaseDashboardOrderedCard & { card: SavedCard; parameter_mappings?: DashboardParameterMapping[] | null; series?: SavedCard[]; + action?: WritebackAction; }; export type DashboardParameterMapping = { diff --git a/frontend/src/metabase-types/api/data-app.ts b/frontend/src/metabase-types/api/data-app.ts index 24e816ff1aadf35fd547a4c7a388af773f3d8e01..1ecd4ed434bf419984ddd6719111414afb64f86a 100644 --- a/frontend/src/metabase-types/api/data-app.ts +++ b/frontend/src/metabase-types/api/data-app.ts @@ -6,7 +6,8 @@ import { DashboardParameterMapping, } from "./dashboard"; import { WritebackAction } from "./writeback"; -import { FormType } from "./writeback-form-settings"; +import { ActionDisplayType } from "./writeback-form-settings"; +import { Card } from "./card"; export type DataAppId = number; export type DataAppPage = Dashboard; @@ -45,7 +46,6 @@ export type ActionParametersMapping = Pick< export interface ActionDashboardCard extends Omit<BaseDashboardOrderedCard, "parameter_mappings"> { - action_id: number | null; action?: WritebackAction; card_id?: number; // model card id for the associated action @@ -54,6 +54,6 @@ export interface ActionDashboardCard [key: string]: unknown; "button.label"?: string; click_behavior?: ClickBehavior; - actionDisplayType?: FormType; + actionDisplayType?: ActionDisplayType; }; } diff --git a/frontend/src/metabase-types/api/mocks/data-app.ts b/frontend/src/metabase-types/api/mocks/data-app.ts index 526a6441b1c99866dbd7db361934c64b1302fda8..e6c990334a0fe304a4d1be275b56fd252bdebeb5 100644 --- a/frontend/src/metabase-types/api/mocks/data-app.ts +++ b/frontend/src/metabase-types/api/mocks/data-app.ts @@ -30,7 +30,6 @@ export const createMockDashboardActionButton = ({ ...opts }: Partial<ActionDashboardCard> = {}): ActionDashboardCard => ({ id: 1, - action_id: null, parameter_mappings: null, visualization_settings: merge( { diff --git a/frontend/src/metabase-types/api/mocks/writeback.ts b/frontend/src/metabase-types/api/mocks/writeback.ts index 3e259832b65190a133bb725d6f0173d0b7d8e448..2465062c35f9bdc53250a54d699febb01f4c6df0 100644 --- a/frontend/src/metabase-types/api/mocks/writeback.ts +++ b/frontend/src/metabase-types/api/mocks/writeback.ts @@ -2,6 +2,7 @@ import { Card, QueryAction, WritebackAction, + WritebackQueryAction, QueryActionCard, } from "metabase-types/api"; import { createMockCard } from "./card"; @@ -25,7 +26,7 @@ export const createMockQueryAction = ({ ...opts }: Partial<WritebackAction & QueryAction> = {}): WritebackAction => { return { - id: 1, + action_id: 1, type: "query", card_id: card.id, card, @@ -37,3 +38,30 @@ export const createMockQueryAction = ({ ...opts, }; }; + +export const createMockImplictQueryAction = ( + options: Partial<WritebackQueryAction>, +): WritebackQueryAction => ({ + card_id: 1, + name: "", + description: "", + "updated-at": new Date().toISOString(), + "created-at": new Date().toISOString(), + slug: "", + parameters: [ + { + id: "id", + target: ["variable", ["template-tag", "id"]], + type: "type/Integer", + }, + { + id: "name", + target: ["variable", ["template-tag", "name"]], + type: "type/Text", + }, + ] as WritebackAction["parameters"], + type: "implicit", + visualization_settings: undefined, + card: createMockQueryActionCard(), + ...options, +}); diff --git a/frontend/src/metabase-types/api/writeback-form-settings.ts b/frontend/src/metabase-types/api/writeback-form-settings.ts index e8bd542a017047b6e50d67c169b8b6a43cc7c053..4651d498d29b6e5812273f9087a3d0eb334675b1 100644 --- a/frontend/src/metabase-types/api/writeback-form-settings.ts +++ b/frontend/src/metabase-types/api/writeback-form-settings.ts @@ -1,6 +1,6 @@ import type { ParameterId } from "./parameters"; -export type FormType = "inline" | "modal"; +export type ActionDisplayType = "form" | "button"; export type FieldType = "string" | "number" | "date" | "category"; export type DateInputType = "date" | "datetime" | "monthyear" | "quarteryear"; @@ -38,7 +38,7 @@ export interface FieldSettings { export type FieldSettingsMap = Record<ParameterId, FieldSettings>; export interface ActionFormSettings { name?: string; - type: FormType; + type: ActionDisplayType; description?: string; fields: FieldSettingsMap; submitButtonLabel?: string; diff --git a/frontend/src/metabase-types/api/writeback.ts b/frontend/src/metabase-types/api/writeback.ts index cda2f510edaf8150db42d1266970fad8f92b1d98..30c6bc144009cd61ee2f3c63e56b4e21a12897cd 100644 --- a/frontend/src/metabase-types/api/writeback.ts +++ b/frontend/src/metabase-types/api/writeback.ts @@ -8,7 +8,8 @@ export interface WritebackParameter extends Parameter { export type WritebackActionType = "http" | "query" | "implicit"; export interface WritebackActionBase { - id: number; + id?: number; + action_id?: number; model_id?: number; slug?: string; name: string; diff --git a/frontend/src/metabase/components/EmptyState.jsx b/frontend/src/metabase/components/EmptyState.jsx index a97d558482b3dcb8aa741b4ea929ba483e4852bf..95a7c23949dab12fb71040d5d08cf73ee8aa967a 100644 --- a/frontend/src/metabase/components/EmptyState.jsx +++ b/frontend/src/metabase/components/EmptyState.jsx @@ -48,9 +48,10 @@ const EmptyState = ({ link, illustrationElement, onActionClick, + className, ...rest }) => ( - <div> + <div className={className}> <EmptyStateHeader> {illustrationElement && ( <EmptyStateIllustration>{illustrationElement}</EmptyStateIllustration> diff --git a/frontend/src/metabase/containers/ActionPicker/ActionPicker.styled.tsx b/frontend/src/metabase/containers/ActionPicker/ActionPicker.styled.tsx index 5cf4ba6d3e4e031223f7aaab65bd5b6778fc57c5..24ab073f0845ed181a489d55649d29fcee219d0a 100644 --- a/frontend/src/metabase/containers/ActionPicker/ActionPicker.styled.tsx +++ b/frontend/src/metabase/containers/ActionPicker/ActionPicker.styled.tsx @@ -1,34 +1,42 @@ import _ from "underscore"; import styled from "@emotion/styled"; -import { color } from "metabase/lib/colors"; +import { color, lighten } from "metabase/lib/colors"; +import { space } from "metabase/styled-components/theme"; -import { SidebarItem } from "metabase/dashboard/components/ClickBehaviorSidebar/SidebarItem"; +import UnstyledEmptyState from "metabase/components/EmptyState"; -export const ActionSidebarItem = styled(SidebarItem.Selectable)<{ - hasDescription?: boolean; -}>` - align-items: ${props => (props.hasDescription ? "flex-start" : "center")}; - margin-top: 2px; +export const ModelActionList = styled.div` + margin-bottom: ${space(2)}; + &:not(:last-child) { + border-bottom: 1px solid ${color("border")}; + } `; -export const ActionSidebarItemIcon = styled(SidebarItem.Icon)<{ - isSelected?: boolean; -}>` - .Icon { - color: ${props => - props.isSelected ? color("text-white") : color("brand")}; - } +export const ModelTitle = styled.h4` + color: ${color("text-dark")}; + margin-bottom: ${space(2)}; + display: flex; + align-items: center; `; -export const ActionDescription = styled.span<{ isSelected?: boolean }>` - width: 95%; - margin-top: 2px; +export const ActionItem = styled.li` + padding-left: ${space(3)}; + margin-bottom: ${space(2)}; + color: ${color("brand")}; + cursor: pointer; + font-weight: bold; + &:hover: { + color: ${lighten("brand", 0.1)}; + } +`; - color: ${props => - props.isSelected ? color("text-white") : color("text-medium")}; +export const EmptyState = styled(UnstyledEmptyState)` + margin-bottom: ${space(2)}; `; -export const ClickMappingsContainer = styled.div` - margin-top: 1rem; +export const EmptyModelStateContainer = styled.div` + padding-bottom: ${space(2)}; + color: ${color("text-medium")}; + text-align: center; `; diff --git a/frontend/src/metabase/containers/ActionPicker/ActionPicker.tsx b/frontend/src/metabase/containers/ActionPicker/ActionPicker.tsx index a7c28796f4c277b60806d3acd929ac0eff787880..78f7702b0e1153be1fdf24b240ece90439b9acc6 100644 --- a/frontend/src/metabase/containers/ActionPicker/ActionPicker.tsx +++ b/frontend/src/metabase/containers/ActionPicker/ActionPicker.tsx @@ -1,86 +1,99 @@ -import React, { useState } from "react"; +import React from "react"; import { t } from "ttag"; +import _ from "underscore"; -import Button from "metabase/core/components/Button"; -import EmptyState from "metabase/components/EmptyState"; +import Icon from "metabase/components/Icon"; import Actions from "metabase/entities/actions"; -import type { WritebackAction } from "metabase-types/api"; +import Questions from "metabase/entities/questions"; + +import type { Card, WritebackAction } from "metabase-types/api"; import type { State } from "metabase-types/store"; -import ModelPicker from "../ModelPicker"; -import ActionOptionItem from "./ActionOptionItem"; +import Link from "metabase/core/components/Link"; +import Button from "metabase/core/components/Button"; +import { + ModelTitle, + ActionItem, + ModelActionList, + EmptyState, + EmptyModelStateContainer, +} from "./ActionPicker.styled"; export default function ActionPicker({ - value, - onChange, + modelIds, + onClick, }: { - value: WritebackAction | undefined; - onChange: (value: WritebackAction) => void; + modelIds: number[]; + onClick: (action: WritebackAction) => void; }) { - const [modelId, setModelId] = useState<number | undefined>(value?.model_id); - return ( <div className="scroll-y"> - {!modelId ? ( - <ModelPicker value={modelId} onChange={setModelId} /> - ) : ( - <> - <Button - icon="arrow_left" - borderless - onClick={() => setModelId(undefined)} - > - {t`Select Model`} - </Button> - - <ConnectedModelActionPicker - modelId={modelId} - value={value} - onChange={onChange} - /> - </> + {modelIds.map(modelId => ( + <ConnectedModelActionPicker + key={modelId} + modelId={modelId} + onClick={onClick} + /> + ))} + {!modelIds.length && ( + <EmptyState + message={t`No models found`} + action={t`Create new model`} + link={"/model/new"} + /> )} </div> ); } function ModelActionPicker({ - value, - onChange, + onClick, + model, actions, }: { - value: WritebackAction | undefined; - onChange: (newValue: WritebackAction) => void; + onClick: (newValue: WritebackAction) => void; + model: Card; actions: WritebackAction[]; }) { - if (!actions?.length) { - return ( - <EmptyState - message={t`There are no actions for this model`} - action={t`Create new action`} - link={"/action/create"} - /> - ); - } - return ( - <ul> - {actions?.map(action => ( - <ActionOptionItem - name={action.name} - description={action.description} - isSelected={action.id === value?.id} - key={action.slug} - onClick={() => onChange(action)} - /> - ))} - </ul> + <ModelActionList> + <ModelTitle> + <Icon name="model" size={16} className="mr2" /> + {model.name} + </ModelTitle> + {actions?.length ? ( + <ul> + {actions?.map(action => ( + <ActionItem onClick={() => onClick(action)} key={action.id}> + {action.name} + </ActionItem> + ))} + </ul> + ) : ( + <EmptyModelStateContainer> + <div>{t`There are no actions for this model`}</div> + <Button + as={Link} + to={`/action/create?model-id=${model.id}`} + borderless + > + {t`Create new action`} + </Button> + </EmptyModelStateContainer> + )} + </ModelActionList> ); } -const ConnectedModelActionPicker = Actions.loadList({ - query: (state: State, props: { modelId?: number | null }) => ({ - "model-id": props?.modelId, +const ConnectedModelActionPicker = _.compose( + Questions.load({ + id: (state: State, props: { modelId?: number | null }) => props?.modelId, + entityAlias: "model", + }), + Actions.loadList({ + query: (state: State, props: { modelId?: number | null }) => ({ + "model-id": props?.modelId, + }), }), -})(ModelActionPicker); +)(ModelActionPicker); diff --git a/frontend/src/metabase/dashboard/actions/cards.js b/frontend/src/metabase/dashboard/actions/cards.js index de2f843e45cb7d58d31f3b90008c25128b327536..cae93fa5644e8860bf2f8deeb8e165581042849b 100644 --- a/frontend/src/metabase/dashboard/actions/cards.js +++ b/frontend/src/metabase/dashboard/actions/cards.js @@ -1,4 +1,5 @@ import _ from "underscore"; +import { t } from "ttag"; import { createAction } from "metabase/lib/redux"; @@ -85,22 +86,74 @@ export const addTextDashCardToDashboard = function ({ dashId }) { }); }; -export const addActionDashCardToDashboard = ({ dashId }) => { - const virtualActionsCard = { - ...createCard(), - display: "action", - archived: false, - }; - const dashcardOverrides = { - card: virtualActionsCard, - size_x: 2, - size_y: 1, - visualization_settings: { - virtual_card: virtualActionsCard, - }, +const esitmateCardSize = (displayType, action) => { + const BASE_HEIGHT = 3; + const HEIGHT_PER_FIELD = 1.5; + + if (displayType === "button") { + return { size_x: 2, size_y: 1 }; + } + + return { + size_x: 6, + size_y: Math.round( + BASE_HEIGHT + action.parameters.length * HEIGHT_PER_FIELD, + ), }; - return addDashCardToDashboard({ - dashId: dashId, - dashcardOverrides: dashcardOverrides, - }); }; + +export const addActionToDashboard = + async ({ dashId, action, displayType }) => + dispatch => { + const virtualActionsCard = { + ...createCard(), + display: "action", + archived: false, + }; + + const dashcardOverrides = { + action, + card_id: action.model_id, + card: virtualActionsCard, + ...esitmateCardSize(displayType, action), + visualization_settings: { + actionDisplayType: displayType ?? "button", + virtual_card: virtualActionsCard, + "button.label": action.name ?? action.id, + action_slug: action.slug, + }, + }; + dispatch( + addDashCardToDashboard({ + dashId: dashId, + dashcardOverrides: dashcardOverrides, + }), + ); + }; + +export const addLinkToDashboard = + async ({ dashId, clickBehavior }) => + dispatch => { + const virtualActionsCard = { + ...createCard(), + display: "action", + archived: false, + }; + const dashcardOverrides = { + card: virtualActionsCard, + size_x: 2, + size_y: 1, + visualization_settings: { + virtual_card: virtualActionsCard, + "button.label": t`Link`, + click_behavior: clickBehavior, + actionDisplayType: "button", + }, + }; + dispatch( + addDashCardToDashboard({ + dashId: dashId, + dashcardOverrides: dashcardOverrides, + }), + ); + }; diff --git a/frontend/src/metabase/dashboard/actions/save.js b/frontend/src/metabase/dashboard/actions/save.js index f176e62fa547519409a6e907df6eb87e2412924a..0ad697f79f5c2eecfdab694131882ad86f032b35 100644 --- a/frontend/src/metabase/dashboard/actions/save.js +++ b/frontend/src/metabase/dashboard/actions/save.js @@ -77,7 +77,6 @@ export const saveDashboardAndCards = createThunkAction( // mark isAdded because addcard doesn't record the position return { ...result, - action_id: dc.action_id, col: dc.col, row: dc.row, size_x: dc.size_x, diff --git a/frontend/src/metabase/dashboard/actions/ui.js b/frontend/src/metabase/dashboard/actions/ui.js index 5536b270720b1f17f744622496a029ba4407a991..65328ff3bcd49fe33778a8eedcd0a0f3494441a2 100644 --- a/frontend/src/metabase/dashboard/actions/ui.js +++ b/frontend/src/metabase/dashboard/actions/ui.js @@ -1,5 +1,6 @@ import { createAction } from "metabase/lib/redux"; import { SIDEBAR_NAME } from "metabase/dashboard/constants"; +import { getSidebar } from "../selectors"; export const SET_SIDEBAR = "metabase/dashboard/SET_SIDEBAR"; export const setSidebar = createAction(SET_SIDEBAR); @@ -20,6 +21,15 @@ export const showClickBehaviorSidebar = dashcardId => dispatch => { } }; +export const toggleSidebar = name => (dispatch, getState) => { + const currentSidebarName = getSidebar(getState()).name; + if (currentSidebarName === name) { + dispatch(closeSidebar()); + } else { + dispatch(setSidebar({ name })); + } +}; + export const openAddQuestionSidebar = () => dispatch => { dispatch( setSidebar({ diff --git a/frontend/src/metabase/dashboard/components/AddActionSidebar/AddActionSidebar.styled.tsx b/frontend/src/metabase/dashboard/components/AddActionSidebar/AddActionSidebar.styled.tsx new file mode 100644 index 0000000000000000000000000000000000000000..be7fcd176b6daa14b690e9df8cbfef6f39be411a --- /dev/null +++ b/frontend/src/metabase/dashboard/components/AddActionSidebar/AddActionSidebar.styled.tsx @@ -0,0 +1,43 @@ +import styled from "@emotion/styled"; +import { color } from "metabase/lib/colors"; +import { space } from "metabase/styled-components/theme"; + +export const Heading = styled.h4` + color: ${color("text-dark")}; + font-size: 1.125rem; +`; + +export const SidebarContent = styled.div` + padding: 1rem 2rem; +`; + +export const BorderedSidebarContent = styled(SidebarContent)` + border-bottom: 1px solid ${color("border")}; +`; + +export const ClickBehaviorPickerText = styled.div` + color: ${color("text-medium")}; + margin-bottom: ${space(2)}; + margin-left: ${space(2)}; +`; + +export const BackButtonContainer = styled.div` + display: flex; + align-items: center; + cursor: pointer; + font-weight: bold; + + color: ${color("text-medium")}; + + &:hover { + color: ${color("brand")}; + } +`; + +export const BackButtonIconContainer = styled.div` + padding: 4px 6px; + margin-right: 8px; + + border: 1px solid ${color("border")}; + border-radius: 4px; +`; diff --git a/frontend/src/metabase/dashboard/components/AddActionSidebar/AddActionSidebar.tsx b/frontend/src/metabase/dashboard/components/AddActionSidebar/AddActionSidebar.tsx new file mode 100644 index 0000000000000000000000000000000000000000..366eb72807f7c0b2a478a45f461dc1f72cc23d29 --- /dev/null +++ b/frontend/src/metabase/dashboard/components/AddActionSidebar/AddActionSidebar.tsx @@ -0,0 +1,121 @@ +import React, { useState } from "react"; +import _ from "underscore"; +import { t } from "ttag"; +import { connect } from "react-redux"; + +import type { + ActionDisplayType, + Dashboard, + WritebackAction, + Card, + CustomDestinationClickBehavior, +} from "metabase-types/api"; +import type { State } from "metabase-types/store"; + +import Search from "metabase/entities/search"; + +import Sidebar from "metabase/dashboard/components/Sidebar"; +import ActionPicker from "metabase/containers/ActionPicker"; +import { + addActionToDashboard, + addLinkToDashboard, + closeSidebar, +} from "metabase/dashboard/actions"; + +import { ButtonOptions } from "./ButtonOptions"; +import { + Heading, + SidebarContent, + BorderedSidebarContent, +} from "./AddActionSidebar.styled"; + +const mapDispatchToProps = { + addAction: addActionToDashboard, + addLink: addLinkToDashboard, + closeSidebar, +}; + +interface ActionSidebarProps { + dashboard: Dashboard; + addAction: ({ + dashId, + action, + displayType, + }: { + dashId: number; + action: WritebackAction; + displayType: ActionDisplayType; + }) => void; + addLink: ({ + dashId, + clickBehavior, + }: { + dashId: number; + clickBehavior: CustomDestinationClickBehavior; + }) => void; + closeSidebar: () => void; + models: Card[]; + displayType: ActionDisplayType; +} + +function AddActionSidebarFn({ + dashboard, + addAction, + addLink, + closeSidebar, + models, + displayType, +}: ActionSidebarProps) { + const handleActionSelected = async (action: WritebackAction) => { + await addAction({ dashId: dashboard.id, action, displayType }); + }; + const modelIds = models?.map(model => model.id) ?? []; + const showButtonOptions = displayType === "button"; + const showActionPicker = displayType === "form"; + + return ( + <Sidebar> + <BorderedSidebarContent> + <Heading> + {t`Add a ${ + displayType === "button" ? t`button` : t`form` + } to the page`} + </Heading> + </BorderedSidebarContent> + + {showActionPicker && ( + <SidebarContent> + <ActionPicker modelIds={modelIds} onClick={handleActionSelected} /> + </SidebarContent> + )} + + {showButtonOptions && ( + <ButtonOptions + addLink={addLink} + closeSidebar={closeSidebar} + dashboard={dashboard} + ActionPicker={ + <SidebarContent> + <ActionPicker + modelIds={modelIds} + onClick={handleActionSelected} + /> + </SidebarContent> + } + /> + )} + </Sidebar> + ); +} + +export const AddActionSidebar = _.compose( + Search.loadList({ + query: (_state: State, props: any) => ({ + models: ["dataset"], + collection: props.dashboard.collection_id, + }), + loadingAndErrorWrapper: false, + listName: "models", + }), + connect(null, mapDispatchToProps), +)(AddActionSidebarFn); diff --git a/frontend/src/metabase/dashboard/components/AddActionSidebar/ButtonOptions.tsx b/frontend/src/metabase/dashboard/components/AddActionSidebar/ButtonOptions.tsx new file mode 100644 index 0000000000000000000000000000000000000000..c5b8912db14c59ddb4fb406034e246d55faca8b2 --- /dev/null +++ b/frontend/src/metabase/dashboard/components/AddActionSidebar/ButtonOptions.tsx @@ -0,0 +1,129 @@ +import React, { useState } from "react"; +import { t } from "ttag"; + +import type { + Dashboard, + CustomDestinationClickBehavior, + DashboardOrderedCard, +} from "metabase-types/api"; +import { clickBehaviorIsValid } from "metabase/lib/click-behavior"; + +import { BehaviorOption } from "metabase/dashboard/components/ClickBehaviorSidebar/TypeSelector/TypeSelector"; +import LinkOptions from "metabase/dashboard/components/ClickBehaviorSidebar/LinkOptions"; +import Icon from "metabase/components/Icon"; + +import { + ClickBehaviorPickerText, + SidebarContent, + BackButtonIconContainer, + BackButtonContainer, + BorderedSidebarContent, +} from "./AddActionSidebar.styled"; + +type ButtonType = "action" | "link" | null; + +// this type depends on no properties being null, but many of the components that use it expect nulls while +// the object is buing constructed :( +const emptyClickBehavior: any = { + type: "link", + linkType: null, + targetId: null, +}; + +export const ButtonOptions = ({ + addLink, + closeSidebar, + dashboard, + ActionPicker, +}: { + addLink: ({ + dashId, + clickBehavior, + }: { + dashId: number; + clickBehavior: CustomDestinationClickBehavior; + }) => void; + closeSidebar: () => void; + dashboard: Dashboard; + ActionPicker: React.ReactNode; +}) => { + const [buttonType, setButtonType] = useState<ButtonType>(null); + const [linkClickBehavior, setLinkClickBehavior] = + useState<CustomDestinationClickBehavior>(emptyClickBehavior); + + const showLinkPicker = buttonType === "link"; + const showActionPicker = buttonType === "action"; + + const handleClickBehaviorChange = ( + newClickBehavior: CustomDestinationClickBehavior, + ) => { + if (newClickBehavior.type !== "link") { + return; + } + if (clickBehaviorIsValid(newClickBehavior)) { + addLink({ dashId: dashboard.id, clickBehavior: newClickBehavior }); + closeSidebar(); + return; + } + setLinkClickBehavior(newClickBehavior); + }; + + return ( + <> + <ButtonTypePicker value={buttonType} onChange={setButtonType} /> + + {showLinkPicker && ( + <LinkOptions + clickBehavior={linkClickBehavior} + dashcard={{ card: { display: "action" } } as DashboardOrderedCard} // necessary for LinkOptions to work + parameters={[]} + updateSettings={handleClickBehaviorChange as any} + /> + )} + {showActionPicker && ActionPicker} + </> + ); +}; + +const ButtonTypePicker = ({ + value, + onChange, +}: { + value: ButtonType; + onChange: (type: ButtonType) => void; +}) => { + if (value) { + return ( + <BorderedSidebarContent> + <BackButtonContainer onClick={() => onChange(null)}> + <BackButtonIconContainer> + <Icon name="chevronleft" size={16} /> + </BackButtonIconContainer> + <div>{t`Change button type`}</div> + </BackButtonContainer> + </BorderedSidebarContent> + ); + } + + return ( + <SidebarContent> + <ClickBehaviorPickerText> + {t`What type of button do you want to add?`} + </ClickBehaviorPickerText> + <BehaviorOption + option={t`Perform action`} + selected={false} + icon="play" + hasNextStep + onClick={() => onChange("action")} + /> + <BehaviorOption + option={t`Go to a custom destination`} + selected={false} + icon="link" + hasNextStep + onClick={() => onChange("link")} + /> + </SidebarContent> + ); +}; diff --git a/frontend/src/metabase/dashboard/components/AddActionSidebar/index.ts b/frontend/src/metabase/dashboard/components/AddActionSidebar/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..82ec75ef22b9f69c5726d2a3b9e9f3db6fab4c47 --- /dev/null +++ b/frontend/src/metabase/dashboard/components/AddActionSidebar/index.ts @@ -0,0 +1 @@ +export * from "./AddActionSidebar"; diff --git a/frontend/src/metabase/dashboard/components/ClickBehaviorSidebar/ActionOptions/ActionOptions.styled.tsx b/frontend/src/metabase/dashboard/components/ClickBehaviorSidebar/ActionOptions/ActionOptions.styled.tsx index d8b033260e31c68e1c1a4f7a564d928ecb97d454..f6fb47d085c1f7089d77361655c2d90313c0f4f4 100644 --- a/frontend/src/metabase/dashboard/components/ClickBehaviorSidebar/ActionOptions/ActionOptions.styled.tsx +++ b/frontend/src/metabase/dashboard/components/ClickBehaviorSidebar/ActionOptions/ActionOptions.styled.tsx @@ -1,34 +1,6 @@ import _ from "underscore"; import styled from "@emotion/styled"; -import { color } from "metabase/lib/colors"; - -import { SidebarItem } from "../SidebarItem"; - -export const ActionSidebarItem = styled(SidebarItem.Selectable)<{ - hasDescription?: boolean; -}>` - align-items: ${props => (props.hasDescription ? "flex-start" : "center")}; - margin-top: 2px; -`; - -export const ActionSidebarItemIcon = styled(SidebarItem.Icon)<{ - isSelected?: boolean; -}>` - .Icon { - color: ${props => - props.isSelected ? color("text-white") : color("brand")}; - } -`; - -export const ActionDescription = styled.span<{ isSelected?: boolean }>` - width: 95%; - margin-top: 2px; - - color: ${props => - props.isSelected ? color("text-white") : color("text-medium")}; -`; - export const ClickMappingsContainer = styled.div` margin-top: 1rem; `; diff --git a/frontend/src/metabase/dashboard/components/ClickBehaviorSidebar/ActionOptions/ActionOptions.tsx b/frontend/src/metabase/dashboard/components/ClickBehaviorSidebar/ActionOptions/ActionOptions.tsx index 1b28a5f9b4f7b9c356a40ecd04747fd3f5ff2333..904f725b8fb942bcc3a614e1cb59eeee687c6aa5 100644 --- a/frontend/src/metabase/dashboard/components/ClickBehaviorSidebar/ActionOptions/ActionOptions.tsx +++ b/frontend/src/metabase/dashboard/components/ClickBehaviorSidebar/ActionOptions/ActionOptions.tsx @@ -1,9 +1,7 @@ import React, { useCallback } from "react"; -import { t } from "ttag"; import { connect } from "react-redux"; import { updateButtonActionMapping } from "metabase/dashboard/actions"; -import { updateSettings } from "metabase/visualizations/lib/settings"; import ActionPicker from "metabase/containers/ActionPicker"; @@ -15,13 +13,10 @@ import type { import type { State } from "metabase-types/store"; import type { UiParameter } from "metabase/parameters/types"; -import { Heading, SidebarContent } from "../ClickBehaviorSidebar.styled"; +import { SidebarContent } from "../ClickBehaviorSidebar.styled"; import ActionClickMappings from "./ActionClickMappings"; -import { - ClickMappingsContainer, - ActionPickerWrapper, -} from "./ActionOptions.styled"; +import { ClickMappingsContainer } from "./ActionOptions.styled"; import { ensureParamsHaveNames } from "./utils"; @@ -55,26 +50,6 @@ function ActionOptions({ }: ActionOptionsProps) { const selectedAction = dashcard.action; - const handleActionSelected = useCallback( - (action: WritebackAction) => { - onUpdateButtonActionMapping(dashcard.id, { - card_id: action.model_id, - action, - visualization_settings: updateSettings( - { - "button.label": action.name, - action_slug: action.slug, // :-( so hacky - }, - dashcard.visualization_settings, - ), - // Clean mappings from previous action - // as they're most likely going to be irrelevant - parameter_mappings: null, - }); - }, - [dashcard, onUpdateButtonActionMapping], - ); - const handleParameterMappingChange = useCallback( (parameter_mappings: ActionParametersMapping[] | null) => { onUpdateButtonActionMapping(dashcard.id, { @@ -84,36 +59,23 @@ function ActionOptions({ [dashcard, onUpdateButtonActionMapping], ); - return ( - <ActionPickerWrapper> - <ActionPicker value={selectedAction} onChange={handleActionSelected} /> - - {!!selectedAction && ( - <ClickMappingsContainer> - <ActionClickMappings - action={{ - ...selectedAction, - parameters: ensureParamsHaveNames(selectedAction.parameters), - }} - dashcard={dashcard} - parameters={parameters} - onChange={handleParameterMappingChange} - /> - </ClickMappingsContainer> - )} - </ActionPickerWrapper> - ); -} + if (!selectedAction) { + return null; + } -function ActionOptionsContainer(props: ActionOptionsProps) { return ( <SidebarContent> - <Heading className="text-medium">{t`Pick an action`}</Heading> - <ActionOptions - dashcard={props.dashcard} - parameters={props.parameters} - onUpdateButtonActionMapping={props.onUpdateButtonActionMapping} - /> + <ClickMappingsContainer> + <ActionClickMappings + action={{ + ...selectedAction, + parameters: ensureParamsHaveNames(selectedAction?.parameters ?? []), + }} + dashcard={dashcard} + parameters={parameters} + onChange={handleParameterMappingChange} + /> + </ClickMappingsContainer> </SidebarContent> ); } @@ -126,4 +88,4 @@ export default connect< >( null, mapDispatchToProps, -)(ActionOptionsContainer); +)(ActionOptions); diff --git a/frontend/src/metabase/dashboard/components/ClickBehaviorSidebar/ActionOptions/utils.ts b/frontend/src/metabase/dashboard/components/ClickBehaviorSidebar/ActionOptions/utils.ts index 1f0660e19bf8f8180966f0759b39382e6f2a592e..a49dd8ebdd9f1b1be11cecb63b81d6796821b74e 100644 --- a/frontend/src/metabase/dashboard/components/ClickBehaviorSidebar/ActionOptions/utils.ts +++ b/frontend/src/metabase/dashboard/components/ClickBehaviorSidebar/ActionOptions/utils.ts @@ -1,4 +1,5 @@ import _ from "underscore"; +import { humanize } from "metabase/lib/formatting"; import type { ActionDashboardCard, @@ -78,6 +79,6 @@ export function ensureParamsHaveNames( ): WritebackParameter[] { return parameters.map(parameter => ({ ...parameter, - name: parameter.name ?? parameter.id, + name: parameter.name ?? humanize(parameter.id), })); } diff --git a/frontend/src/metabase/dashboard/components/ClickBehaviorSidebar/ActionOptions/utils.unit.spec.ts b/frontend/src/metabase/dashboard/components/ClickBehaviorSidebar/ActionOptions/utils.unit.spec.ts index 1e05ae7c712ebd83bfc1cd116a9004918c59c5c5..083aa06fa14bd752839885427b4879c1948db04d 100644 --- a/frontend/src/metabase/dashboard/components/ClickBehaviorSidebar/ActionOptions/utils.unit.spec.ts +++ b/frontend/src/metabase/dashboard/components/ClickBehaviorSidebar/ActionOptions/utils.unit.spec.ts @@ -63,7 +63,6 @@ describe("turnDashCardParameterMappingsIntoClickBehaviorMappings", () => { const action = createMockQueryAction({ parameters: actionParameters }); const dashCard = createMockDashboardActionButton({ action, - action_id: action.id, parameter_mappings: dashCardParameterMappings, }); return { action, dashCard }; diff --git a/frontend/src/metabase/dashboard/components/ClickBehaviorSidebar/ClickBehaviorSidebarMainView/ClickBehaviorSidebarMainView.tsx b/frontend/src/metabase/dashboard/components/ClickBehaviorSidebar/ClickBehaviorSidebarMainView/ClickBehaviorSidebarMainView.tsx index 0967a49a8fecd03a6e22389514a84ed03ebc59af..fbea0fcd1e717e03fd28f83ea21940f0fd4f58e6 100644 --- a/frontend/src/metabase/dashboard/components/ClickBehaviorSidebar/ClickBehaviorSidebarMainView/ClickBehaviorSidebarMainView.tsx +++ b/frontend/src/metabase/dashboard/components/ClickBehaviorSidebar/ClickBehaviorSidebarMainView/ClickBehaviorSidebarMainView.tsx @@ -33,6 +33,14 @@ function ClickBehaviorOptions({ parameters, updateSettings, }: ClickBehaviorOptionsProps) { + if (dashcard.action) { + return ( + <ActionOptions + dashcard={dashcard as ActionDashboardCard} + parameters={parameters} + /> + ); + } if (clickBehavior.type === "link") { return ( <LinkOptions @@ -53,12 +61,7 @@ function ClickBehaviorOptions({ /> ); } - return ( - <ActionOptions - dashcard={dashcard as unknown as ActionDashboardCard} - parameters={parameters} - /> - ); + return null; } interface ClickBehaviorSidebarMainViewProps { @@ -88,21 +91,23 @@ function ClickBehaviorSidebarMainView({ return ( <> - <SidebarContentBordered> - <SidebarItem.Selectable - onClick={handleShowTypeSelector} - isSelected - padded={false} - > - <SelectedClickBehaviorItemIcon - name={currentOption?.icon || "unknown"} - /> - <SidebarItem.Content> - <SidebarItem.Name>{clickBehaviorOptionName}</SidebarItem.Name> - <SidebarItem.CloseIcon /> - </SidebarItem.Content> - </SidebarItem.Selectable> - </SidebarContentBordered> + {!dashcard.action && ( + <SidebarContentBordered> + <SidebarItem.Selectable + onClick={handleShowTypeSelector} + isSelected + padded={false} + > + <SelectedClickBehaviorItemIcon + name={currentOption?.icon || "unknown"} + /> + <SidebarItem.Content> + <SidebarItem.Name>{clickBehaviorOptionName}</SidebarItem.Name> + <SidebarItem.CloseIcon /> + </SidebarItem.Content> + </SidebarItem.Selectable> + </SidebarContentBordered> + )} <ClickBehaviorOptions clickBehavior={clickBehavior} diff --git a/frontend/src/metabase/dashboard/components/ClickBehaviorSidebar/TypeSelector/TypeSelector.tsx b/frontend/src/metabase/dashboard/components/ClickBehaviorSidebar/TypeSelector/TypeSelector.tsx index d1e4cc9b671c555e8e7f9cae133b0c2983cd70ad..888c4b4ab32125310d7e198277ed8c240772b098 100644 --- a/frontend/src/metabase/dashboard/components/ClickBehaviorSidebar/TypeSelector/TypeSelector.tsx +++ b/frontend/src/metabase/dashboard/components/ClickBehaviorSidebar/TypeSelector/TypeSelector.tsx @@ -22,7 +22,7 @@ interface BehaviorOptionProps { onClick: () => void; } -const BehaviorOption = ({ +export const BehaviorOption = ({ option, icon, onClick, diff --git a/frontend/src/metabase/dashboard/components/ClickBehaviorSidebar/utils.ts b/frontend/src/metabase/dashboard/components/ClickBehaviorSidebar/utils.ts index 67c656d0effe6f400060796652e4b94aae4042be..27dd4af4ca85d1c2dac9ab76d27abd71fc0f72ef 100644 --- a/frontend/src/metabase/dashboard/components/ClickBehaviorSidebar/utils.ts +++ b/frontend/src/metabase/dashboard/components/ClickBehaviorSidebar/utils.ts @@ -19,7 +19,6 @@ export const clickBehaviorOptions: ClickBehaviorOption[] = [ { value: "menu", icon: "popover" }, { value: "link", icon: "link" }, { value: "crossfilter", icon: "filter" }, - { value: "action", icon: "play" }, ]; export function getClickBehaviorOptionName( diff --git a/frontend/src/metabase/dashboard/components/Dashboard/Dashboard.jsx b/frontend/src/metabase/dashboard/components/Dashboard/Dashboard.jsx index e6e3c6f59e0cf7b22ae525ac0e4a250e6139978e..a3cd469ec0328b600bd2e754a002adeb0ea71f2d 100644 --- a/frontend/src/metabase/dashboard/components/Dashboard/Dashboard.jsx +++ b/frontend/src/metabase/dashboard/components/Dashboard/Dashboard.jsx @@ -87,8 +87,6 @@ class Dashboard extends Component { props: PropTypes.object, }).isRequired, closeSidebar: PropTypes.func.isRequired, - openAddQuestionSidebar: PropTypes.func.isRequired, - showAddQuestionSidebar: PropTypes.bool.isRequired, embedOptions: PropTypes.object, }; @@ -192,14 +190,6 @@ class Dashboard extends Component { }); }; - onToggleAddQuestionSidebar = () => { - if (this.props.showAddQuestionSidebar) { - this.props.closeSidebar(); - } else { - this.props.openAddQuestionSidebar(); - } - }; - onCancel = () => { this.props.setSharing(false); }; @@ -220,7 +210,6 @@ class Dashboard extends Component { parameters, parameterValues, isNavbarOpen, - showAddQuestionSidebar, editingParameter, setParameterValue, setParameterIndex, @@ -282,9 +271,6 @@ class Dashboard extends Component { addParameter={addParameter} parametersWidget={parametersWidget} onSharingClick={this.onSharingClick} - onToggleAddQuestionSidebar={this.onToggleAddQuestionSidebar} - showAddQuestionSidebar={showAddQuestionSidebar} - isDataApp={dashboard.is_app_page} /> {shouldRenderParametersWidgetInEditMode && ( @@ -336,7 +322,6 @@ class Dashboard extends Component { <DashboardSidebars {...this.props} onCancel={this.onCancel} - showAddQuestionSidebar={showAddQuestionSidebar} setDashboardAttribute={this.setDashboardAttribute} /> </DashboardBody> diff --git a/frontend/src/metabase/dashboard/components/DashboardSidebars.jsx b/frontend/src/metabase/dashboard/components/DashboardSidebars.jsx index c89b1dd62cf522e45206f60bcbb24fbf21a9c9ba..19e6f1cc541a57542dd267f92007171c3cb90eac 100644 --- a/frontend/src/metabase/dashboard/components/DashboardSidebars.jsx +++ b/frontend/src/metabase/dashboard/components/DashboardSidebars.jsx @@ -10,6 +10,7 @@ import * as MetabaseAnalytics from "metabase/lib/analytics"; import ClickBehaviorSidebar from "./ClickBehaviorSidebar"; import DashboardInfoSidebar from "./DashboardInfoSidebar"; import { AddCardSidebar } from "./add-card-sidebar/AddCardSidebar"; +import { AddActionSidebar } from "./AddActionSidebar"; DashboardSidebars.propTypes = { dashboard: PropTypes.object, @@ -19,7 +20,6 @@ DashboardSidebars.propTypes = { addCardToDashboard: PropTypes.func.isRequired, editingParameter: PropTypes.object, isEditingParameter: PropTypes.bool.isRequired, - showAddQuestionSidebar: PropTypes.bool.isRequired, clickBehaviorSidebarDashcard: PropTypes.object, // only defined when click-behavior sidebar is open onReplaceAllDashCardVisualizationSettings: PropTypes.func.isRequired, onUpdateDashCardVisualizationSettings: PropTypes.func.isRequired, @@ -51,20 +51,15 @@ export function DashboardSidebars({ removeParameter, addCardToDashboard, editingParameter, - isEditingParameter, - showAddQuestionSidebar, clickBehaviorSidebarDashcard, onReplaceAllDashCardVisualizationSettings, onUpdateDashCardVisualizationSettings, onUpdateDashCardColumnSettings, - setEditingParameter, setParameter, setParameterName, setParameterDefaultValue, dashcardData, setParameterFilteringParameters, - isSharing, - isEditing, isFullscreen, onCancel, params, @@ -96,6 +91,16 @@ export function DashboardSidebars({ onSelect={handleAddCard} /> ); + case SIDEBAR_NAME.addActionForm: + case SIDEBAR_NAME.addActionButton: + return ( + <AddActionSidebar + dashboard={dashboard} + displayType={ + sidebar.name === SIDEBAR_NAME.addActionForm ? "form" : "button" + } + /> + ); case SIDEBAR_NAME.clickBehavior: return ( <ClickBehaviorSidebar diff --git a/frontend/src/metabase/dashboard/constants.js b/frontend/src/metabase/dashboard/constants.js index 6703c46daf49e1641a93501e93e722cd4b5ce969..e3f05ec65070f50603ef33b491bd81ac13d6bf60 100644 --- a/frontend/src/metabase/dashboard/constants.js +++ b/frontend/src/metabase/dashboard/constants.js @@ -1,5 +1,7 @@ export const SIDEBAR_NAME = { addQuestion: "addQuestion", + addActionButton: "addActionButton", + addActionForm: "addActionForm", clickBehavior: "clickBehavior", editParameter: "editParameter", sharing: "sharing", diff --git a/frontend/src/metabase/dashboard/containers/DashboardApp.jsx b/frontend/src/metabase/dashboard/containers/DashboardApp.jsx index c9f012b7e89614c066e46725fde2b09d03c9c9bb..130d57e99d46522ac3c1ea8e4f74dc5aa0a4f372 100644 --- a/frontend/src/metabase/dashboard/containers/DashboardApp.jsx +++ b/frontend/src/metabase/dashboard/containers/DashboardApp.jsx @@ -51,7 +51,6 @@ import { getClickBehaviorSidebarDashcard, getIsAddParameterPopoverOpen, getSidebar, - getShowAddQuestionSidebar, getFavicon, getDocumentTitle, getIsRunning, @@ -95,7 +94,6 @@ const mapStateToProps = (state, props) => { clickBehaviorSidebarDashcard: getClickBehaviorSidebarDashcard(state), isAddParameterPopoverOpen: getIsAddParameterPopoverOpen(state), sidebar: getSidebar(state), - showAddQuestionSidebar: getShowAddQuestionSidebar(state), pageFavicon: getFavicon(state), documentTitle: getDocumentTitle(state), isRunning: getIsRunning(state), diff --git a/frontend/src/metabase/dashboard/containers/DashboardHeader.jsx b/frontend/src/metabase/dashboard/containers/DashboardHeader.jsx index 5c7337d69a175320ae68405c89c24cffb0c4d8d0..c1a727e68be253ec7749f93f6c90a2c592885529 100644 --- a/frontend/src/metabase/dashboard/containers/DashboardHeader.jsx +++ b/frontend/src/metabase/dashboard/containers/DashboardHeader.jsx @@ -25,6 +25,7 @@ import { getIsBookmarked, getIsShowDashboardInfoSidebar, } from "metabase/dashboard/selectors"; +import { toggleSidebar } from "../actions"; import Header from "../components/DashboardHeader"; import { SIDEBAR_NAME } from "../constants"; @@ -50,6 +51,7 @@ const mapDispatchToProps = { deleteBookmark: ({ id }) => Bookmark.actions.delete({ id, type: "dashboard" }), onChangeLocation: push, + toggleSidebar, }; class DashboardHeader extends Component { @@ -92,6 +94,8 @@ class DashboardHeader extends Component { onChangeLocation: PropTypes.func.isRequired, + toggleSidebar: PropTypes.func.isRequired, + sidebar: PropTypes.string.isRequired, setSidebar: PropTypes.func.isRequired, closeSidebar: PropTypes.func.isRequired, }; @@ -189,12 +193,12 @@ class DashboardHeader extends Component { isFullscreen, isEditable, location, - onToggleAddQuestionSidebar, - showAddQuestionSidebar, onFullscreenChange, createBookmark, deleteBookmark, + sidebar, setSidebar, + toggleSidebar, isShowingDashboardInfoSidebar, closeSidebar, } = this.props; @@ -210,16 +214,18 @@ class DashboardHeader extends Component { } if (isEditing) { - const addQuestionButtonHint = showAddQuestionSidebar - ? t`Close sidebar` - : t`Add questions`; + const activeSidebarName = sidebar.name; + const addQuestionButtonHint = + activeSidebarName === SIDEBAR_NAME.addQuestion + ? t`Close sidebar` + : t`Add questions`; buttons.push( <Tooltip tooltip={addQuestionButtonHint}> <DashboardHeaderButton icon="add" - isActive={showAddQuestionSidebar} - onClick={onToggleAddQuestionSidebar} + isActive={activeSidebarName === SIDEBAR_NAME.addQuestion} + onClick={() => toggleSidebar(SIDEBAR_NAME.addQuestion)} data-metabase-event="Dashboard;Add Card Sidebar" /> </Tooltip>, @@ -241,23 +247,6 @@ class DashboardHeader extends Component { </Tooltip>, ); - if (isAdmin && dashboard.is_app_page) { - buttons.push( - <Tooltip key="add-action" tooltip={t`Add action`}> - <a - data-metabase-event="Dashboard;Add Action" - key="add-action" - className="text-brand-hover cursor-pointer" - onClick={() => this.onAddAction()} - > - <DashboardHeaderButton> - <Icon name="play" size={18} /> - </DashboardHeaderButton> - </a> - </Tooltip>, - ); - } - const { isAddParameterPopoverOpen, showAddParameterPopover, @@ -293,6 +282,32 @@ class DashboardHeader extends Component { </span>, ); + if (isAdmin && dashboard.is_app_page) { + buttons.push( + <> + <DashboardHeaderActionDivider /> + <Tooltip key="add-action-form" tooltip={t`Add action form`}> + <DashboardHeaderButton + isActive={activeSidebarName === SIDEBAR_NAME.addActionForm} + onClick={() => toggleSidebar(SIDEBAR_NAME.addActionForm)} + data-metabase-event="Dashboard;Add Card Sidebar" + > + <Icon name="list" size={18} /> + </DashboardHeaderButton> + </Tooltip> + <Tooltip key="add-action-button" tooltip={t`Add action button`}> + <DashboardHeaderButton + isActive={activeSidebarName === SIDEBAR_NAME.addActionButton} + onClick={() => toggleSidebar(SIDEBAR_NAME.addActionButton)} + data-metabase-event="Dashboard;Add Card Sidebar" + > + <Icon name="click" size={18} /> + </DashboardHeaderButton> + </Tooltip> + </>, + ); + } + extraButtons.push({ title: t`Revision history`, icon: "history", diff --git a/frontend/src/metabase/entities/actions/actions.ts b/frontend/src/metabase/entities/actions/actions.ts index 584f31136be987ca5cd411cff59f1abb41a538eb..6eef3a6165fc371a2da78e801b242e28f0275d5d 100644 --- a/frontend/src/metabase/entities/actions/actions.ts +++ b/frontend/src/metabase/entities/actions/actions.ts @@ -1,10 +1,6 @@ import { createEntity } from "metabase/lib/entities"; -import type { - ActionFormSettings, - ModelAction, - WritebackAction, -} from "metabase-types/api"; +import type { ActionFormSettings, WritebackAction } from "metabase-types/api"; import type { Dispatch } from "metabase-types/store"; import { ActionsApi, CardApi, ModelActionsApi } from "metabase/services"; @@ -122,9 +118,9 @@ const Actions = createEntity({ list: async (params: any) => { const actions = await ActionsApi.list(params); - return actions.map((action: ModelAction | WritebackAction) => ({ + return actions.map((action: WritebackAction) => ({ ...action, - id: action.id ?? `implicit-${action.slug}`, + id: action.id ?? `implicit-${action.slug}-${action.model_id}`, name: action.name ?? action.slug, })); }, diff --git a/frontend/src/metabase/lib/click-behavior.js b/frontend/src/metabase/lib/click-behavior.js index ec5dabb0ddc5ce3077e279a70e11b7c33305732f..b53ca8252a2ea344190154d00304713b315746b8 100644 --- a/frontend/src/metabase/lib/click-behavior.js +++ b/frontend/src/metabase/lib/click-behavior.js @@ -274,7 +274,7 @@ export function hasActionsMenu(dashcard) { } export function isTableDisplay(dashcard) { - return dashcard.card.display === "table"; + return dashcard?.card?.display === "table"; } export function formatSourceForTarget( diff --git a/frontend/src/metabase/models/components/ModelDetailPage/ModelActionDetails/ModelActionDetails.tsx b/frontend/src/metabase/models/components/ModelDetailPage/ModelActionDetails/ModelActionDetails.tsx index 3a2b6661441074ef37cb22098b75f6aba2bf19bb..30381f9b4f9a0fe771e63a1034cffef362b2e392 100644 --- a/frontend/src/metabase/models/components/ModelDetailPage/ModelActionDetails/ModelActionDetails.tsx +++ b/frontend/src/metabase/models/components/ModelDetailPage/ModelActionDetails/ModelActionDetails.tsx @@ -46,11 +46,7 @@ function ModelActionDetails({ <Button onClick={handleCreateImplicitActions} icon="add"> {t`Enable implicit actions`} </Button> - <Button - as={Link} - to="/action/create" - icon="add" - >{t`Create a new action`}</Button> + <AddActionButton modelId={modelId} /> </EmptyStateContainer> ); } @@ -64,6 +60,7 @@ function ModelActionDetails({ {t`Enable implicit actions`} </Button> )} + <AddActionButton modelId={modelId} /> <ul> {actions.map(action => ( <li key={action.id}> @@ -83,6 +80,14 @@ function ModelActionDetails({ ); } +const AddActionButton = ({ modelId }: { modelId: number }) => ( + <Button + as={Link} + to={`/action/create?model-id=${modelId}`} + icon="add" + >{t`Create a new action`}</Button> +); + export default Actions.loadList( { query: (state: State, props: { modelId?: number | null }) => ({ diff --git a/frontend/src/metabase/models/components/ModelDetailPage/ModelDetailPage.styled.tsx b/frontend/src/metabase/models/components/ModelDetailPage/ModelDetailPage.styled.tsx index 6cea9850586be0025f37a5da99703f2bc6159512..997affd9bd499f01c6e196fe89502f9b9ac5d453 100644 --- a/frontend/src/metabase/models/components/ModelDetailPage/ModelDetailPage.styled.tsx +++ b/frontend/src/metabase/models/components/ModelDetailPage/ModelDetailPage.styled.tsx @@ -60,7 +60,7 @@ export const EmptyStateContainer = styled.div` align-items: center; gap: 1rem; - margin-top: 3rem; + margin: 3rem 0; `; export const EmptyStateTitle = styled.span` diff --git a/frontend/src/metabase/modes/components/drill/ActionClickDrill/ActionClickDrill.unit.spec.ts b/frontend/src/metabase/modes/components/drill/ActionClickDrill/ActionClickDrill.unit.spec.ts index ff3d5d346d4334e449b153914e801eea2435fb14..ce184d58d62626eecf7c76bf94d89cda81f81768 100644 --- a/frontend/src/metabase/modes/components/drill/ActionClickDrill/ActionClickDrill.unit.spec.ts +++ b/frontend/src/metabase/modes/components/drill/ActionClickDrill/ActionClickDrill.unit.spec.ts @@ -188,7 +188,6 @@ describe("ActionClickDrill", () => { const action = createMockQueryAction(); const dashcard = createMockDashboardActionButton({ action, - action_id: action.id, }); const dashboard = createMockDashboard({ ordered_cards: [dashcard as unknown as DashboardOrderedCard], @@ -269,7 +268,6 @@ describe("ActionClickDrill", () => { it("does nothing for buttons without linked action", () => { const dashcard = createMockDashboardActionButton({ - action_id: null, parameter_mappings: [PARAMETER_MAPPING], }); const dashboard = createMockDashboard({ diff --git a/frontend/src/metabase/writeback/components/ActionCreator/ActionCreator.tsx b/frontend/src/metabase/writeback/components/ActionCreator/ActionCreator.tsx index b2763d9aca8aa0c1560427d1cafe7485ee9432a5..5b56e747252ab691b7e2bb2bbf5e58d104c7a010 100644 --- a/frontend/src/metabase/writeback/components/ActionCreator/ActionCreator.tsx +++ b/frontend/src/metabase/writeback/components/ActionCreator/ActionCreator.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from "react"; +import React, { useState, useMemo, useEffect } from "react"; import { t } from "ttag"; import _ from "underscore"; import { connect } from "react-redux"; @@ -7,6 +7,7 @@ import { push } from "react-router-redux"; import Actions from "metabase/entities/actions"; import { getMetadata } from "metabase/selectors/metadata"; import { createQuestionFromAction } from "metabase/writeback/selectors"; + import type { WritebackQueryAction, ActionFormSettings, @@ -66,9 +67,16 @@ function ActionCreatorComponent({ useEffect(() => { setQuestion(passedQuestion ?? newQuestion(metadata)); + // we do not want to update this any time the props or metadata change, only if action id changes }, [actionId]); // eslint-disable-line react-hooks/exhaustive-deps + const defaultModelId: number | undefined = useMemo(() => { + const params = new URLSearchParams(window.location.search); + const modelId = params.get("model-id"); + return modelId ? Number(modelId) : undefined; + }, []); + if (!question || !metadata) { return null; } @@ -115,7 +123,7 @@ function ActionCreatorComponent({ id: (question.card() as SavedCard).id, name: question.displayName(), description: question.description(), - collection_id: question.collectionId(), + model_id: defaultModelId, formSettings, question, }} diff --git a/frontend/src/metabase/writeback/components/ActionCreator/FormCreator/utils.ts b/frontend/src/metabase/writeback/components/ActionCreator/FormCreator/utils.ts index 4a7b5ef31ed38c4ff67da170368c297c8928cbb8..4063313700cc393f8b40f9374336b9dd1b3936f1 100644 --- a/frontend/src/metabase/writeback/components/ActionCreator/FormCreator/utils.ts +++ b/frontend/src/metabase/writeback/components/ActionCreator/FormCreator/utils.ts @@ -16,7 +16,7 @@ export const getDefaultFormSettings = ( overrides: Partial<ActionFormSettings> = {}, ): ActionFormSettings => ({ name: "", - type: "modal", + type: "button", description: "", fields: {}, confirmMessage: "", diff --git a/frontend/src/metabase/writeback/components/ActionViz/Action.tsx b/frontend/src/metabase/writeback/components/ActionViz/Action.tsx index fd6c39c6f28e4d54f8b297915443237d71782eb9..b5970746efc387fc4f2837249db8abe548090301 100644 --- a/frontend/src/metabase/writeback/components/ActionViz/Action.tsx +++ b/frontend/src/metabase/writeback/components/ActionViz/Action.tsx @@ -45,7 +45,7 @@ function ActionComponent({ const dashcardSettings = dashcard.visualization_settings; const actionSettings = dashcard.action?.visualization_settings; const actionDisplayType = - dashcardSettings?.actionDisplayType ?? actionSettings?.type ?? "modal"; + dashcardSettings?.actionDisplayType ?? actionSettings?.type ?? "button"; const dashcardParamValues = useMemo( () => getDashcardParamValues(dashcard, parameterValues), @@ -63,7 +63,7 @@ function ActionComponent({ }, [dashcard, dashcardParamValues]); const shouldDisplayButton = - actionDisplayType !== "inline" || !missingParameters.length; + actionDisplayType !== "form" || !missingParameters.length; const onSubmit = useCallback( (parameterMap: ParametersForActionExecution) => diff --git a/frontend/src/metabase/writeback/components/ActionViz/ActionViz.tsx b/frontend/src/metabase/writeback/components/ActionViz/ActionViz.tsx index 605813cbe4435c2127290c606298387081e58ef1..60072ed4819ed22a345f3f8d616dd60317f5a452 100644 --- a/frontend/src/metabase/writeback/components/ActionViz/ActionViz.tsx +++ b/frontend/src/metabase/writeback/components/ActionViz/ActionViz.tsx @@ -29,8 +29,8 @@ export default Object.assign(Action, { widget: "radio", props: { options: [ - { name: t`Inline`, value: "inline" }, - { name: t`Modal`, value: "modal" }, + { name: t`Form`, value: "form" }, + { name: t`Button`, value: "button" }, ], }, }, diff --git a/frontend/src/metabase/writeback/utils.ts b/frontend/src/metabase/writeback/utils.ts index 41acafd2886c7094c517443a57c9d93348fd434c..af3e61520f9b3e8f9412539802f1921e2599bb6e 100644 --- a/frontend/src/metabase/writeback/utils.ts +++ b/frontend/src/metabase/writeback/utils.ts @@ -115,7 +115,7 @@ export function isImplicitActionButton( const isAction = isActionDashCard(dashCard); return ( isAction && - dashCard.action_id == null && + dashCard.action?.type === "implicit" && isValidImplicitActionClickBehavior( dashCard.visualization_settings?.click_behavior, ) diff --git a/frontend/src/metabase/writeback/utils.unit.spec.ts b/frontend/src/metabase/writeback/utils.unit.spec.ts index a839db3031f829fa32c9d94b9268411c71fe228d..f04564d8928f7bd13dcfd6e15e3be5598fc673ff 100644 --- a/frontend/src/metabase/writeback/utils.unit.spec.ts +++ b/frontend/src/metabase/writeback/utils.unit.spec.ts @@ -1,6 +1,7 @@ import { createMockDashboardActionButton, createMockQueryAction, + createMockImplictQueryAction, } from "metabase-types/api/mocks"; import type { ActionDashboardCard, @@ -9,7 +10,6 @@ import type { import { isMappedExplicitActionButton, isImplicitActionButton } from "./utils"; const PLAIN_BUTTON = createMockDashboardActionButton({ - action_id: null, action: undefined, visualization_settings: { click_behavior: undefined }, }); @@ -17,7 +17,6 @@ const PLAIN_BUTTON = createMockDashboardActionButton({ const QUERY_ACTION = createMockQueryAction(); const EXPLICIT_ACTION = createMockDashboardActionButton({ - action_id: QUERY_ACTION.id, action: QUERY_ACTION, visualization_settings: { click_behavior: undefined, @@ -33,43 +32,27 @@ const PARAMETER_MAPPINGS: ActionParametersMapping[] = [ ]; const IMPLICIT_INSERT_ACTION = createMockDashboardActionButton({ - action_id: null, - action: undefined, + action: createMockImplictQueryAction({ slug: "insert" }), visualization_settings: { - click_behavior: { - type: "action", - actionType: "insert", - tableId: 5, - }, + action_slug: "insert", }, }); const IMPLICIT_UPDATE_ACTION = createMockDashboardActionButton({ - action_id: null, - action: undefined, + action: createMockImplictQueryAction({ slug: "update" }), visualization_settings: { - click_behavior: { - type: "action", - actionType: "update", - objectDetailDashCardId: 5, - }, + action_slug: "update", }, }); const IMPLICIT_DELETE_ACTION = createMockDashboardActionButton({ - action_id: null, - action: undefined, + action: createMockImplictQueryAction({ slug: "delete" }), visualization_settings: { - click_behavior: { - type: "action", - actionType: "delete", - objectDetailDashCardId: 5, - }, + action_slug: "delete", }, }); const NAVIGATION_ACTION_BUTTON = createMockDashboardActionButton({ - action_id: null, action: undefined, visualization_settings: { click_behavior: { @@ -151,7 +134,6 @@ describe("isImplicitActionButton", () => { it("returns false for implicit action with incomplete shape", () => { const insertActionWithoutTableId = createMockDashboardActionButton({ - action_id: null, action: undefined, visualization_settings: { click_behavior: { @@ -166,7 +148,6 @@ describe("isImplicitActionButton", () => { it("returns false for implicit action with unrecognized `actionType`", () => { const unrecognizedAction = createMockDashboardActionButton({ - action_id: null, action: undefined, visualization_settings: { click_behavior: { @@ -182,8 +163,8 @@ describe("isImplicitActionButton", () => { }); IMPLICIT_ACTIONS.forEach(({ action, type }) => { - it(`returns true for implicit ${type} action`, () => { - expect(isImplicitActionButton(action)).toBe(true); + it(`returns false for implicit ${type} action`, () => { + expect(isImplicitActionButton(action)).toBe(false); }); }); });