Skip to content
Snippets Groups Projects
Unverified Commit 91208f50 authored by Ryan Laurie's avatar Ryan Laurie Committed by GitHub
Browse files

New Action Dashcard Modal + Sidebar (#27935)

* make actionPicker models list collapsible

* use CollapseSection

* WIP action settings modal

* design updates

* new action sidebar/modal design

* cleanup and rename things to make more sense

* add sidebar tests

* remove unnecessary api requests

* cleanup

* unit test action dashcard settings

* design updates

* fewer loading spinners

* update tests

* update change type

* fix tests

* update tests
parent 96908e62
No related branches found
No related tags found
No related merge requests found
Showing
with 709 additions and 77 deletions
......@@ -4,6 +4,7 @@ import type {
ParameterTarget,
ParameterId,
} from "metabase-types/types/Parameter";
import { ActionDashboardCard } from "./actions";
import type { Card, CardId } from "./card";
import type { Dataset } from "./dataset";
......@@ -16,7 +17,7 @@ export interface Dashboard {
name: string;
description: string | null;
model?: string;
ordered_cards: DashboardOrderedCard[];
ordered_cards: (DashboardOrderedCard | ActionDashboardCard)[];
parameters?: Parameter[] | null;
can_write: boolean;
cache_ttl: number | null;
......
import { Dashboard } from "metabase-types/api";
import {
Dashboard,
DashboardOrderedCard,
ActionDashboardCard,
} from "metabase-types/api";
import { createMockCard } from "./card";
export const createMockDashboard = (opts?: Partial<Dashboard>): Dashboard => ({
id: 1,
......@@ -17,3 +22,33 @@ export const createMockDashboard = (opts?: Partial<Dashboard>): Dashboard => ({
},
...opts,
});
export const createMockDashboardOrderedCard = (
opts?: Partial<DashboardOrderedCard>,
): DashboardOrderedCard => ({
id: 1,
dashboard_id: 1,
size_x: 1,
size_y: 1,
visualization_settings: {},
justAdded: false,
card_id: 1,
card: createMockCard(),
parameter_mappings: [],
...opts,
});
export const createMockActionDashboardCard = (
opts?: Partial<ActionDashboardCard>,
): ActionDashboardCard => ({
...createMockDashboardOrderedCard(),
action: undefined,
card: createMockCard(),
visualization_settings: {
"button.label": "Please click me",
"button.variant": "primary",
actionDisplayType: "button",
virtual_card: createMockCard({ display: "action" }),
},
...opts,
});
......@@ -11,11 +11,12 @@ import type {
Dashboard,
} from "metabase-types/api";
import type { VisualizationProps } from "metabase-types/types/Visualization";
import type { Dispatch } from "metabase-types/store";
import type { Dispatch, State } from "metabase-types/store";
import type { ParameterValueOrArray } from "metabase-types/types/Parameter";
import { generateFieldSettingsFromParameters } from "metabase/actions/utils";
import { getEditingDashcardId } from "metabase/dashboard/selectors";
import {
getDashcardParamValues,
getNotProvidedActionParameters,
......@@ -23,14 +24,14 @@ import {
setNumericValues,
} from "./utils";
import ActionVizForm from "./ActionVizForm";
import { ActionParameterOptions } from "./ActionOptions";
import { StyledButton } from "./ActionButton.styled";
import ActionButtonView from "./ActionButtonView";
export interface ActionProps extends VisualizationProps {
dashcard: ActionDashboardCard;
dashboard: Dashboard;
dispatch: Dispatch;
parameterValues: { [id: string]: ParameterValueOrArray };
isEditingDashcard: boolean;
}
export function ActionComponent({
......@@ -38,9 +39,9 @@ export function ActionComponent({
dashboard,
dispatch,
isSettings,
isEditing,
settings,
parameterValues,
isEditingDashcard,
}: ActionProps) {
const actionSettings = dashcard.action?.visualization_settings;
const actionDisplayType =
......@@ -92,38 +93,44 @@ export function ActionComponent({
[dashboard, dashcard, dashcardParamValues, dispatch, shouldDisplayButton],
);
const showParameterMapper = isEditing && !isSettings;
return (
<>
{showParameterMapper && (
<ActionParameterOptions dashcard={dashcard} dashboard={dashboard} />
)}
<ActionVizForm
onSubmit={onSubmit}
dashcard={dashcard}
dashboard={dashboard}
settings={settings}
isSettings={isSettings}
missingParameters={missingParameters}
dashcardParamValues={dashcardParamValues}
action={dashcard.action as WritebackQueryAction}
shouldDisplayButton={shouldDisplayButton}
/>
</>
<ActionVizForm
onSubmit={onSubmit}
dashcard={dashcard}
dashboard={dashboard}
settings={settings}
isSettings={isSettings}
missingParameters={missingParameters}
dashcardParamValues={dashcardParamValues}
action={dashcard.action as WritebackQueryAction}
shouldDisplayButton={shouldDisplayButton}
isEditingDashcard={isEditingDashcard}
/>
);
}
const ConnectedActionComponent = connect()(ActionComponent);
export default function Action(props: ActionProps) {
function mapStateToProps(state: State, props: ActionProps) {
return {
isEditingDashcard: getEditingDashcardId(state) === props.dashcard.id,
};
}
export function ActionFn(props: ActionProps) {
if (!props.dashcard?.action) {
return (
<StyledButton>
<strong>{t`Assign an action`}</strong>
</StyledButton>
<ActionButtonView
disabled
icon="bolt"
tooltip={t`No action assigned`}
settings={props.settings}
focus={props.isEditingDashcard}
/>
);
}
return <ConnectedActionComponent {...props} />;
}
export default connect(mapStateToProps)(ActionFn);
......@@ -4,7 +4,7 @@ import nock from "nock";
import userEvent from "@testing-library/user-event";
import { waitFor } from "@testing-library/react";
import { render, screen } from "__support__/ui";
import { renderWithProviders, screen } from "__support__/ui";
import {
createMockActionParameter,
......@@ -59,11 +59,13 @@ const defaultProps = {
} as unknown as ActionProps;
async function setup(options?: Partial<ActionProps>) {
return render(<ActionComponent {...defaultProps} {...options} />);
return renderWithProviders(
<ActionComponent {...defaultProps} {...options} />,
);
}
async function setupActionWrapper(options?: Partial<ActionProps>) {
return render(<Action {...defaultProps} {...options} />);
return renderWithProviders(<Action {...defaultProps} {...options} />);
}
function setupExecutionEndpoint(expectedBody: any) {
......@@ -88,7 +90,7 @@ describe("Actions > ActionViz > ActionComponent", () => {
action: undefined,
},
});
expect(screen.getByText("Assign an action")).toBeInTheDocument();
expect(screen.getByLabelText("bolt icon")).toBeInTheDocument();
});
it("should render a button with default text", async () => {
......
import styled from "@emotion/styled";
import { color } from "metabase/lib/colors";
import Button from "metabase/core/components/Button";
export const StyledButton = styled(Button)<{ isFullHeight?: boolean }>`
export const StyledButton = styled(Button)<{
isFullHeight?: boolean;
focus?: boolean;
}>`
height: ${({ isFullHeight }) => (isFullHeight ? "100%" : "auto")};
${({ focus }) =>
focus
? `
border: 2px solid ${color("focus")};
`
: ""}
`;
export const StyledButtonContent = styled.div`
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
`;
StyledButton.defaultProps = {
......
......@@ -3,17 +3,27 @@ import { t } from "ttag";
import type { VisualizationProps } from "metabase-types/types/Visualization";
import { StyledButton } from "./ActionButton.styled";
import Icon from "metabase/components/Icon";
import Ellipsified from "metabase/core/components/Ellipsified";
import { StyledButton, StyledButtonContent } from "./ActionButton.styled";
interface ActionButtonViewProps extends Pick<VisualizationProps, "settings"> {
disabled?: boolean;
icon?: string;
tooltip?: string;
isFullHeight?: boolean;
onClick: () => void;
onClick?: () => void;
focus?: boolean;
}
function ActionButtonView({
settings,
disabled,
icon,
tooltip,
isFullHeight,
onClick,
focus,
}: ActionButtonViewProps) {
const label = settings["button.label"];
const variant = settings["button.variant"] ?? "primary";
......@@ -25,11 +35,16 @@ function ActionButtonView({
return (
<StyledButton
disabled={!!disabled}
onClick={onClick}
isFullHeight={isFullHeight}
focus={focus}
{...variantProps}
>
{label ?? t`Click me`}
<StyledButtonContent>
{icon && <Icon name={icon} tooltip={tooltip} />}
<Ellipsified>{label ?? t`Click me`}</Ellipsified>
</StyledButtonContent>
</StyledButton>
);
}
......
import styled from "@emotion/styled";
import { space } from "metabase/styled-components/theme";
import { color } from "metabase/lib/colors";
export const ActionSettingsWrapper = styled.div`
display: flex;
height: 80vh;
overflow: hidden;
min-width: 50rem;
`;
export const ActionSettingsHeader = styled.h2`
font-size: 1.25rem;
padding-bottom: ${space(1)};
padding-left: ${space(3)};
`;
// make strolling nicer by fading out the top and bottom of the column
const fade = (side: "top" | "bottom") => `
content : "";
position : absolute;
z-index : 1;
pointer-events : none;
background-image : linear-gradient( to ${side},
rgba(255,255,255, 0),
rgba(255,255,255, 1) 90%);
height : 2rem;
`;
export const ActionSettingsLeft = styled.div`
padding-left: ${space(3)};
padding-top: ${space(3)};
padding-bottom: ${space(3)};
width: 20rem;
overflow-y: auto;
&:before {
${fade("top")}
top: 0;
left: 0;
width: 19rem;
}
&:after {
${fade("bottom")}
bottom: 0;
left: 0;
width: 19rem;
}
`;
export const ActionSettingsRight = styled.div`
display: flex;
flex: 1;
flex-direction: column;
padding-top: ${space(3)};
border-left: 1px solid ${color("border")};
`;
export const ParameterMapperContainer = styled.div`
display: flex;
flex-direction: column;
overflow-y: auto;
flex: 1;
padding-top: ${space(1)};
padding-bottom: ${space(3)};
padding-left: ${space(3)};
padding-right: ${space(3)};
`;
export const ModalActions = styled.div`
display: flex;
flex: 0 0 auto;
justify-content: flex-end;
gap: 1rem;
padding: 1rem;
border-top: 1px solid ${color("border")};
`;
import React from "react";
import { connect } from "react-redux";
import { t } from "ttag";
import type {
ActionDashboardCard,
Dashboard,
WritebackAction,
} from "metabase-types/api";
import Button from "metabase/core/components/Button";
import { ConnectedActionPicker } from "metabase/actions/containers/ActionPicker/ActionPicker";
import { setActionForDashcard } from "metabase/dashboard/actions";
import EmptyState from "metabase/components/EmptyState";
import { ConnectedActionParameterMappingForm } from "./ActionParameterMapper";
import {
ActionSettingsWrapper,
ParameterMapperContainer,
ActionSettingsHeader,
ActionSettingsLeft,
ActionSettingsRight,
ModalActions,
} from "./ActionDashcardSettings.styled";
const mapDispatchToProps = {
setActionForDashcard,
};
interface Props {
dashboard: Dashboard;
dashcard: ActionDashboardCard;
onClose: () => void;
setActionForDashcard: (
dashcard: ActionDashboardCard,
action: WritebackAction,
) => void;
}
export function ActionDashcardSettings({
dashboard,
dashcard,
onClose,
setActionForDashcard,
}: Props) {
const action = dashcard.action;
const setAction = (newAction: WritebackAction) => {
setActionForDashcard(dashcard, newAction);
};
return (
<ActionSettingsWrapper>
<ActionSettingsLeft>
<h4 className="pb2">{t`Action Library`}</h4>
<ConnectedActionPicker currentAction={action} onClick={setAction} />
</ActionSettingsLeft>
<ActionSettingsRight>
{action ? (
<>
<ActionSettingsHeader>{action.name}</ActionSettingsHeader>
<ParameterMapperContainer>
<ConnectedActionParameterMappingForm
dashcard={dashcard}
dashboard={dashboard}
action={dashcard.action}
/>
</ParameterMapperContainer>
</>
) : (
<ParameterMapperContainer>
<EmptyActionState />
</ParameterMapperContainer>
)}
<ModalActions>
<Button primary onClick={onClose}>
{t`Done`}
</Button>
</ModalActions>
</ActionSettingsRight>
</ActionSettingsWrapper>
);
}
const EmptyActionState = () => (
<EmptyState className="p3" message={t`Select an action to get started`} />
);
export const ConnectedActionDashcardSettings = connect(
null,
mapDispatchToProps,
)(ActionDashcardSettings);
import React from "react";
import userEvent from "@testing-library/user-event";
import nock from "nock";
import { renderWithProviders, screen, waitFor } from "__support__/ui";
import {
setupActionEndpoints,
setupActionsEndpoints,
setupCardsEndpoints,
setupSearchEndpoints,
} from "__support__/server-mocks";
import {
createMockDashboard,
createMockActionDashboardCard,
createMockDashboardOrderedCard,
createMockQueryAction,
createMockCard,
createMockParameter,
createMockActionParameter,
} from "metabase-types/api/mocks";
import { ConnectedActionDashcardSettings } from "./ActionDashcardSettings";
const dashboardParameter = createMockParameter({
id: "dash-param-id",
name: "Dashboard Parameter",
slug: "dashboard-parameter",
});
const actionParameter1 = createMockActionParameter({
id: "action-param-id-1",
name: "Action Parameter 1",
slug: "action-parameter-1",
target: ["variable", ["template-tag", "action-parameter-1"]],
});
const actionParameter2 = createMockActionParameter({
id: "action-param-id-2",
name: "Action Parameter 2",
slug: "action-parameter-2",
target: ["variable", ["template-tag", "action-parameter-2"]],
});
const models = [
createMockCard({ id: 1, name: "Model Uno", dataset: true }),
createMockCard({ id: 2, name: "Model Deux", dataset: true }),
];
const actions1 = [
createMockQueryAction({ id: 1, name: "Action Uno", model_id: models[0].id }),
createMockQueryAction({ id: 2, name: "Action Dos", model_id: models[0].id }),
createMockQueryAction({ id: 3, name: "Action Tres", model_id: models[0].id }),
];
const actions2 = [
createMockQueryAction({ id: 11, name: "Action Un", model_id: models[1].id }),
createMockQueryAction({
id: 12,
name: "Action Deux",
model_id: models[1].id,
}),
createMockQueryAction({
id: 13,
name: "Action Trois",
model_id: models[1].id,
parameters: [actionParameter1, actionParameter2],
}),
];
const dashcard = createMockDashboardOrderedCard();
const actionDashcard = createMockActionDashboardCard({ id: 2 });
const actionDashcardWithAction = createMockActionDashboardCard({
id: 3,
action: actions2[2],
});
const dashboard = createMockDashboard({
ordered_cards: [dashcard, actionDashcard, actionDashcardWithAction],
parameters: [dashboardParameter],
});
const setup = (
options?: Partial<
React.ComponentProps<typeof ConnectedActionDashcardSettings>
>,
) => {
const closeSpy = jest.fn();
const scope = nock(location.origin);
setupSearchEndpoints(scope, models);
setupCardsEndpoints(scope, models);
setupActionsEndpoints(scope, models[0].id, actions1);
setupActionsEndpoints(scope, models[1].id, actions2);
[...actions1, ...actions2].forEach(action => {
setupActionEndpoints(scope, action);
});
renderWithProviders(
<ConnectedActionDashcardSettings
onClose={closeSpy}
dashboard={dashboard}
dashcard={actionDashcard}
{...options}
/>,
);
return { closeSpy };
};
describe("ActionViz > ActionDashcardSettings", () => {
it("shows the action dashcard settings component", () => {
setup();
expect(screen.getByText("Action Library")).toBeInTheDocument();
expect(screen.getByText(/Select an action/i)).toBeInTheDocument();
});
it("loads the model list", async () => {
setup();
expect(screen.getByText("Action Library")).toBeInTheDocument();
await screen.findByText("Model Uno");
await screen.findByText("Model Deux");
});
it("shows actions within their respective models", async () => {
setup();
const modelExpander = await screen.findByText("Model Uno");
expect(screen.queryByText("Action Uno")).not.toBeInTheDocument();
userEvent.click(modelExpander);
await screen.findByText("Action Uno");
expect(screen.getByText("Action Uno")).toBeInTheDocument();
expect(screen.getByText("Action Dos")).toBeInTheDocument();
});
it("shows the action assigned to a dashcard", async () => {
setup({
dashcard: actionDashcardWithAction,
});
// action name should be visible in library and parameter mapper
await waitFor(() =>
expect(screen.getAllByText("Action Trois")).toHaveLength(2),
);
});
it("shows parameters for an action", async () => {
setup({
dashcard: actionDashcardWithAction,
});
expect(screen.getByText("Action Parameter 1")).toBeInTheDocument();
expect(screen.getByText("Action Parameter 2")).toBeInTheDocument();
});
it("can close the modal with the done button", () => {
const { closeSpy } = setup();
userEvent.click(screen.getByRole("button", { name: "Done" }));
expect(closeSpy).toHaveBeenCalledTimes(1);
});
});
......@@ -3,41 +3,9 @@ import styled from "@emotion/styled";
import { space } from "metabase/styled-components/theme";
import { color } from "metabase/lib/colors";
const TRANSITION_TIME = "200ms";
export const ActionParameterTriggerContainer = styled.button`
position: absolute;
top: -10px;
right: -10px;
background: ${color("white")};
color: ${color("brand")};
border: 2px solid ${color("brand")};
border-radius: 50%;
width: 22px;
height: 22px;
z-index: 3;
pointer-events: all;
cursor: pointer;
transition: background ${TRANSITION_TIME} ease-in-out;
transition: color ${TRANSITION_TIME} ease-in-out;
&:hover {
color: ${color("white")};
background: ${color("brand")};
}
`;
export const ParameterMapperTitleContainer = styled.div`
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: ${space(1)};
`;
export const ParameterMapperContainer = styled.div`
padding: 1.5rem;
min-width: 15rem;
max-width: 25rem;
`;
export const ParameterFormSection = styled.div`
......
import React, { useCallback, useMemo } from "react";
import React, { useCallback } from "react";
import { t } from "ttag";
import { connect } from "react-redux";
import _ from "underscore";
import { useToggle } from "metabase/hooks/use-toggle";
import Icon from "metabase/components/Icon";
import Select from "metabase/core/components/Select";
import Button from "metabase/core/components/Button";
import TippyPopoverWithTrigger from "metabase/components/PopoverWithTrigger/TippyPopoverWithTrigger";
import ActionCreator from "metabase/actions/containers/ActionCreator";
import Actions from "metabase/entities/actions";
import Questions from "metabase/entities/questions";
import { setParameterMapping } from "metabase/dashboard/actions";
import type { SelectChangeEvent } from "metabase/core/components/Select";
import type {
ActionDashboardCard,
......@@ -24,20 +15,18 @@ import type {
Dashboard,
} from "metabase-types/api";
import type { State } from "metabase-types/store";
import type {
ParameterTarget,
ParameterId,
} from "metabase-types/types/Parameter";
import EmptyState from "metabase/components/EmptyState";
import type Question from "metabase-lib/Question";
import {
ActionParameterTriggerContainer,
ParameterMapperContainer,
ParameterFormSection,
ParameterFormLabel,
ParameterMapperTitleContainer,
} from "./ActionOptions.styled";
} from "./ActionParameterMapper.styled";
interface ActionParameterMapperProps {
dashcard: ActionDashboardCard;
......@@ -46,16 +35,10 @@ interface ActionParameterMapperProps {
action?: WritebackAction;
}
type NewParameterChangeEvent = {
target: {
value: string;
};
};
type ParameterMappingFn = (
parameterId: ParameterId,
dashcardId: number,
cardId: number,
cardId: number | undefined,
target: ParameterTarget,
) => void;
......@@ -67,84 +50,6 @@ const mapDispatchToProps = {
setParameterMapping,
};
export const ActionParameterOptions = ({
dashcard,
dashboard,
action,
}: ActionParameterMapperProps) => {
const [
isActionCreatorOpen,
{ toggle: toggleIsActionCreatorVisible, turnOff: hideActionCreator },
] = useToggle();
const actionParameters = dashcard?.action?.parameters ?? [];
const dashboardParameters = dashboard.parameters ?? [];
const canEditAction = dashcard.action?.type !== "implicit";
const hasParameters = actionParameters.length && dashboardParameters.length;
if (!hasParameters && !canEditAction) {
return null;
}
if (!hasParameters) {
return (
<>
<ActionParameterTriggerContainer onClick={toggleIsActionCreatorVisible}>
<Icon name="pencil" size={11} tooltip={t`Edit action`} />
</ActionParameterTriggerContainer>
{isActionCreatorOpen && (
<ActionCreator
modelId={dashcard.card?.id}
databaseId={dashcard.card?.database_id}
actionId={dashcard?.action?.id}
onClose={hideActionCreator}
/>
)}
</>
);
}
return (
<>
{isActionCreatorOpen && (
<ActionCreator
modelId={dashcard.card?.id}
databaseId={dashcard.card?.database_id}
actionId={dashcard?.action?.id}
onClose={hideActionCreator}
/>
)}
<TippyPopoverWithTrigger
isInitiallyVisible={dashcard.justAdded}
placement="right"
renderTrigger={({ onClick, visible }) => (
<ActionParameterTriggerContainer onClick={onClick}>
<Icon
name="bolt"
size={14}
tooltip={t`Assign action parameter values`}
/>
</ActionParameterTriggerContainer>
)}
popoverContent={({ closePopover }) => (
<ConnectedActionParameterMappingForm
dashcard={dashcard}
dashboard={dashboard}
showEditModal={
canEditAction
? () => {
toggleIsActionCreatorVisible();
closePopover();
}
: undefined
}
/>
)}
/>
</>
);
};
const getTargetKey = (param: WritebackParameter | ActionParametersMapping) =>
JSON.stringify(param.target);
......@@ -153,18 +58,11 @@ export const ActionParameterMappingForm = ({
dashboard,
action: passedAction,
setParameterMapping,
showEditModal,
}: ActionParameterMapperProps &
DispatchProps & { showEditModal?: () => void }) => {
}: ActionParameterMapperProps & DispatchProps) => {
const action = passedAction ?? dashcard?.action;
const actionParameters = action?.parameters ?? [];
const dashboardParameters = dashboard.parameters ?? [];
const actionName = useMemo(
() => action?.name ?? action?.id ?? t`Action`,
[action],
);
const currentMappings = Object.fromEntries(
dashcard.parameter_mappings?.map(mapping => [
getTargetKey(mapping),
......@@ -174,32 +72,18 @@ export const ActionParameterMappingForm = ({
const handleParameterChange = useCallback(
(dashboardParameterId, target) => {
if (dashcard.card?.id) {
setParameterMapping(
dashboardParameterId,
dashcard.id,
dashcard.card.id,
target,
);
}
setParameterMapping(
dashboardParameterId,
dashcard.id,
undefined, // this is irrelevant for action parameters
target,
);
},
[dashcard, setParameterMapping],
);
return (
<ParameterMapperContainer>
<ParameterMapperTitleContainer>
<h4>{t`Connect ${actionName}`}</h4>
{showEditModal && (
<Button
onlyIcon
icon="pencil"
iconSize={14}
iconColor="text-medium"
onClick={showEditModal}
/>
)}
</ParameterMapperTitleContainer>
{actionParameters.map((actionParam: WritebackParameter) => (
<ParameterFormSection key={actionParam.id}>
<ParameterFormLabel>
......@@ -207,7 +91,7 @@ export const ActionParameterMappingForm = ({
</ParameterFormLabel>
<Select
value={currentMappings[getTargetKey(actionParam)] ?? null}
onChange={(e: NewParameterChangeEvent) =>
onChange={(e: SelectChangeEvent<string>) =>
handleParameterChange(e?.target?.value, actionParam.target)
}
options={[
......@@ -221,19 +105,14 @@ export const ActionParameterMappingForm = ({
/>
</ParameterFormSection>
))}
{actionParameters.length === 0 && (
<EmptyState message={t`This action has no parameters to map`} />
)}
</ParameterMapperContainer>
);
};
const ConnectedActionParameterMappingForm = _.compose(
Actions.load({
id: (state: State, props: ActionParameterMapperProps) =>
props.dashcard.action?.id,
}),
Questions.load({
id: (state: State, props: ActionParameterMapperProps) =>
props?.dashcard.card?.id,
entityAlias: "model",
}),
connect(null, mapDispatchToProps),
export const ConnectedActionParameterMappingForm = connect(
null,
mapDispatchToProps,
)(ActionParameterMappingForm);
......@@ -14,6 +14,7 @@ export default Object.assign(Action, {
supportsSeries: false,
hidden: true,
supportPreviewing: false,
disableSettingsConfig: true,
minSize: { width: 1, height: 1 },
......
......@@ -29,6 +29,7 @@ interface ActionFormProps {
dashcardParamValues: ParametersForActionExecution;
action: WritebackQueryAction;
shouldDisplayButton: boolean;
isEditingDashcard: boolean;
}
function ActionVizForm({
......@@ -41,6 +42,7 @@ function ActionVizForm({
dashcardParamValues,
action,
shouldDisplayButton,
isEditingDashcard,
}: ActionFormProps) {
const [showModal, setShowModal] = useState(false);
const title = getFormTitle(action);
......@@ -72,6 +74,7 @@ function ActionVizForm({
onClick={onClick}
settings={settings}
isFullHeight={!isSettings}
focus={isEditingDashcard}
/>
{showModal && (
<ActionParametersInputModal
......
export { default } from "./ActionViz";
export { ActionDashcardSettings } from "./ActionDashcardSettings";
import styled from "@emotion/styled";
import { color } from "metabase/lib/colors";
import { color, alpha } from "metabase/lib/colors";
import { space } from "metabase/styled-components/theme";
import UnstyledEmptyState from "metabase/components/EmptyState";
......@@ -11,10 +11,27 @@ export const ModelCollapseSection = styled(CollapseSection)`
margin-bottom: ${space(1)};
`;
export const ActionItem = styled.li`
export const ActionsList = styled.ul`
list-style: none;
padding: 0.5rem 1rem;
`;
export const ActionItem = styled.li<{ isSelected?: boolean }>`
display: flex;
font-weight: bold;
color: ${color("brand")};
justify-content: space-between;
margin: 1rem 1.5rem;
padding: 0.5rem 0.75rem;
margin-bottom: 1px;
border-radius: ${space(0)};
cursor: pointer;
${({ isSelected }) =>
isSelected ? `background-color: ${alpha("brand", 0.2)};` : ""}
&:hover {
background-color: ${alpha("brand", 0.35)};
}
`;
export const EmptyState = styled(UnstyledEmptyState)`
......@@ -31,3 +48,7 @@ export const EditButton = styled(Button)`
color: ${color("text-light")};
padding: 0 0.5rem;
`;
export const NewActionButton = styled(Button)`
margin: 0.25rem 0.75rem;
`;
import React, { useState } from "react";
import { t } from "ttag";
import _ from "underscore";
import { useToggle } from "metabase/hooks/use-toggle";
import Actions from "metabase/entities/actions";
import Questions from "metabase/entities/questions";
import Search from "metabase/entities/search";
import type { Card, WritebackAction } from "metabase-types/api";
import type { State } from "metabase-types/store";
import Button from "metabase/core/components/Button";
import { isImplicitAction } from "metabase/actions/utils";
import ActionCreator from "metabase/actions/containers/ActionCreator";
import {
ActionsList,
ActionItem,
EditButton,
EmptyState,
ModelCollapseSection,
EmptyModelStateContainer,
NewActionButton,
} from "./ActionPicker.styled";
export default function ActionPicker({
modelIds,
models,
onClick,
currentAction,
}: {
modelIds: number[];
models: Card[];
onClick: (action: WritebackAction) => void;
currentAction?: WritebackAction;
}) {
return (
<div className="scroll-y">
{modelIds.map(modelId => (
{models.map(model => (
<ConnectedModelActionPicker
key={modelId}
modelId={modelId}
key={model.id}
model={model}
onClick={onClick}
currentAction={currentAction}
/>
))}
{!modelIds.length && (
{!models.length && (
<EmptyState
message={t`No models found`}
action={t`Create new model`}
......@@ -54,10 +56,12 @@ function ModelActionPicker({
onClick,
model,
actions,
currentAction,
}: {
onClick: (newValue: WritebackAction) => void;
model: Card;
actions: WritebackAction[];
currentAction?: WritebackAction;
}) {
const [editingActionId, setEditingActionId] = useState<number | undefined>(
undefined,
......@@ -73,16 +77,25 @@ function ModelActionPicker({
setEditingActionId(undefined);
};
const hasCurrentAction = currentAction?.model_id === model.id;
return (
<>
<ModelCollapseSection header={<h4>{model.name}</h4>}>
<ModelCollapseSection
header={<h4>{model.name}</h4>}
initialState={hasCurrentAction ? "expanded" : "collapsed"}
>
{actions?.length ? (
<ul>
<ActionsList>
{actions?.map(action => (
<ActionItem key={action.id}>
<Button onlyText onClick={() => onClick(action)}>
<span>{action.name}</span>
</Button>
<ActionItem
key={action.id}
role="button"
isSelected={currentAction?.id === action.id}
aria-selected={currentAction?.id === action.id}
onClick={() => onClick(action)}
>
<span>{action.name}</span>
{!isImplicitAction(action) && (
<EditButton
icon="pencil"
......@@ -95,18 +108,16 @@ function ModelActionPicker({
)}
</ActionItem>
))}
<ActionItem>
<Button onlyText onClick={toggleIsActionCreatorVisible}>
{t`Create new action`}
</Button>
</ActionItem>
</ul>
<NewActionButton onlyText onClick={toggleIsActionCreatorVisible}>
{t`Create new action`}
</NewActionButton>
</ActionsList>
) : (
<EmptyModelStateContainer>
<div>{t`There are no actions for this model`}</div>
<Button onClick={toggleIsActionCreatorVisible} borderless>
<NewActionButton onlyText onClick={toggleIsActionCreatorVisible}>
{t`Create new action`}
</Button>
</NewActionButton>
</EmptyModelStateContainer>
)}
</ModelCollapseSection>
......@@ -122,14 +133,17 @@ function ModelActionPicker({
);
}
const ConnectedModelActionPicker = _.compose(
Questions.load({
id: (state: State, props: { modelId?: number | null }) => props?.modelId,
entityAlias: "model",
const ConnectedModelActionPicker = Actions.loadList({
query: (state: State, props: { model: { id: number | null } }) => ({
"model-id": props?.model?.id,
}),
Actions.loadList({
query: (state: State, props: { modelId?: number | null }) => ({
"model-id": props?.modelId,
}),
loadingAndErrorWrapper: false,
})(ModelActionPicker);
export const ConnectedActionPicker = Search.loadList({
query: () => ({
models: ["dataset"],
}),
)(ModelActionPicker);
loadingAndErrorWrapper: true,
listName: "models",
})(ActionPicker);
......@@ -251,7 +251,9 @@ export default class Popover extends Component {
return (
<OnClickOutsideWrapper
handleDismissal={this.handleDismissal}
ignoreElement={this.props.ignoreTrigger && this._getTargetElement()}
ignoreElement={
this.props.ignoreTrigger ? this._getTargetElement() : undefined
}
>
{content}
</OnClickOutsideWrapper>
......
......@@ -8,6 +8,7 @@ import {
import { addUndo } from "metabase/redux/undo";
import { ActionsApi, PublicApi } from "metabase/services";
import { SIDEBAR_NAME } from "metabase/dashboard/constants";
import type {
ActionDashboardCard,
......@@ -24,6 +25,7 @@ import type { Dispatch } from "metabase-types/store";
import { getDashboardType } from "../utils";
import { setDashCardAttributes } from "./core";
import { reloadDashboardCards } from "./data-fetching";
import { setSidebar, closeSidebar } from "./ui";
interface DashboardAttributes {
card_id?: CardId | null;
......@@ -140,3 +142,19 @@ export const executeRowAction = async ({
return { success: false, error: message, message };
}
};
export const setEditingDashcardId =
(dashcardId: number | null) => (dispatch: Dispatch) => {
if (dashcardId != null) {
dispatch(
setSidebar({
name: SIDEBAR_NAME.action,
props: {
dashcardId,
},
}),
);
} else {
dispatch(closeSidebar());
}
};
import _ from "underscore";
import { t } from "ttag";
import { createAction } from "metabase/lib/redux";
import { measureText } from "metabase/lib/measure-text";
......@@ -137,10 +137,10 @@ export const addActionToDashboard =
archived: false,
};
const buttonLabel = action.name ?? action.id;
const buttonLabel = action.name ?? action.id ?? t`Click Me`;
const dashcardOverrides = {
action,
action: action.id ? action : null,
action_id: action.id,
card_id: action.model_id,
card: virtualActionsCard,
......
......@@ -103,7 +103,7 @@ export const setParameterMapping = createThunkAction(
let parameter_mappings = dashcard.parameter_mappings || [];
// allow mapping the same parameeter to multiple action targets
// allow mapping the same parameter to multiple action targets
if (!isAction) {
parameter_mappings = parameter_mappings.filter(
m => m.card_id !== card_id || m.parameter_id !== parameter_id,
......@@ -133,6 +133,23 @@ export const setParameterMapping = createThunkAction(
},
);
export const SET_ACTION_FOR_DASHCARD =
"metabase/dashboard/SET_ACTION_FOR_DASHCARD";
export const setActionForDashcard = createThunkAction(
SET_PARAMETER_MAPPING,
(dashcard, newAction) => dispatch => {
dispatch(
setDashCardAttributes({
id: dashcard.id,
attributes: {
action_id: newAction.id,
action: newAction,
},
}),
);
},
);
export const SET_PARAMETER_NAME = "metabase/dashboard/SET_PARAMETER_NAME";
export const setParameterName = createThunkAction(
SET_PARAMETER_NAME,
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment