Skip to content
Snippets Groups Projects
Unverified Commit f1eb2772 authored by Case Nelson's avatar Case Nelson Committed by GitHub
Browse files

[Apps] Actions on models (#25767)


* [Apps] Model Actions

Adding migration and endpoints for model_action.

* model action execution

Changing the execution endpoints again, still working on implicit action
execution tests. Need to also test the parameters fetch.

* Re-add execution route for dashcard action_id until Front End catches up

* Add name to model_action GET

* Hydrate model-action on dashcards

* Rename and combine hydration of model_action to action

* Implicit action execution support. Use slug columns as parameter ids

* Go through model-action when using GET /action

* FE integration fixes

* Move action execution to use new parameter shape

Now, parameters should be a map of parameter-id to value
Parameter-id should be mapped on the FE. So the action parameter-id not the unmapped
dashboard parameter-id.

* Bring ModelAction into copy code

* Generate target of implicit actions to match http actions

* Update action test to place action on model

* Add unique constraint to model_action for (card_id, slug)

* Add missing require

* model_action.slug needs to be varchar for mysql constraint

* Fix test from unique constraint

* Consistent ordering for tests

* Addressing Code Reviews and Scaffold changes

* Add type: implicit to implicit model actions

* Update src/metabase/api/model_action.clj

Co-authored-by: default avatarmetamben <103100869+metamben@users.noreply.github.com>

* Allow changing card_id with PUT to dashcard for model-actions

* Update tests after merge

* Fix implicit delete and add better test coverage

* Addressing review changes

Add PUT test
Add schema for dashboard-id and dashcard-id params
Fix select ordering in test

* Model Actions: Frontend (#25646)

* save actions in models

* list model actions on detail page (#25648)

* Link Dashcards to model-based actions (#25770)

* Link dashcards to model-based actions

* address review comments

* Data apps model actions implicit creator (#25807)

* Link dashcards to model-based actions

* address review comments

* Link dashcards to model-based actions

* Allow implicit actions to be used like explicit ones

* address review comments

Co-authored-by: default avatarAnton Kulyk <kuliks.anton@gmail.com>

Co-authored-by: default avatarmetamben <103100869+metamben@users.noreply.github.com>
Co-authored-by: default avatarRyan Laurie <30528226+iethree@users.noreply.github.com>
Co-authored-by: default avatarAnton Kulyk <kuliks.anton@gmail.com>
Co-authored-by: default avatarKyle Doherty <5248953+kdoh@users.noreply.github.com>
parent ae496969
No related branches found
No related tags found
No related merge requests found
Showing
with 460 additions and 98 deletions
......@@ -47,6 +47,7 @@ export interface ActionDashboardCard
extends Omit<BaseDashboardOrderedCard, "parameter_mappings"> {
action_id: number | null;
action?: WritebackAction;
card_id?: number; // model card id for the associated action
parameter_mappings?: ActionParametersMapping[] | null;
visualization_settings: {
......
......@@ -27,3 +27,5 @@ export type ParameterType =
| DateParameterType;
export type ParameterId = string;
export type ActionParameterValue = string | number;
......@@ -5,10 +5,12 @@ export interface WritebackParameter extends Parameter {
target: ParameterTarget;
}
export type WritebackActionType = "http" | "query";
export type WritebackActionType = "http" | "query" | "implicit";
export interface WritebackActionBase {
id: number;
model_id?: number;
slug?: string;
name: string;
description: string | null;
parameters: WritebackParameter[];
......@@ -23,7 +25,7 @@ export type QueryActionCard = Card & {
};
export interface QueryAction {
type: "query";
type: "query" | "implicit";
card: QueryActionCard;
card_id: number;
}
......@@ -53,22 +55,10 @@ export type WritebackAction = WritebackActionBase & (QueryAction | HttpAction);
export type ParameterMappings = Record<ParameterId, ParameterTarget>;
type ParameterForActionExecutionBase = {
type: string;
value: string | number;
export type ParametersForActionExecution = {
[id: ParameterId]: string | number;
};
export type ParameterMappedForActionExecution =
ParameterForActionExecutionBase & {
id: ParameterId;
target: ParameterTarget;
};
export type ArbitraryParameterForActionExecution =
ParameterForActionExecutionBase & {
target: ParameterTarget;
};
export interface ActionFormSubmitResult {
success: boolean;
message?: string;
......@@ -76,5 +66,17 @@ export interface ActionFormSubmitResult {
}
export type OnSubmitActionForm = (
parameters: ArbitraryParameterForActionExecution[],
parameters: ParametersForActionExecution,
) => Promise<ActionFormSubmitResult>;
export interface ModelAction {
id: number;
action_id?: number; // empty for implicit actions
name?: string; // empty for implicit actions
card_id: number; // the card id of the model
entity_id: string;
requires_pk: boolean;
slug: string;
parameter_mappings?: ParameterMappings;
visualization_settings?: ActionFormSettings;
}
......@@ -20,6 +20,7 @@ import FormCollectionWidget from "./widgets/FormCollectionWidget";
import FormSnippetCollectionWidget from "./widgets/FormSnippetCollectionWidget";
import FormHiddenWidget from "./widgets/FormHiddenWidget";
import FormTextFileWidget from "./widgets/FormTextFileWidget";
import FormModelWidget from "./widgets/FormModelWidget";
const WIDGETS = {
info: FormInfoWidget,
......@@ -39,6 +40,7 @@ const WIDGETS = {
snippetCollection: FormSnippetCollectionWidget,
hidden: FormHiddenWidget,
textFile: FormTextFileWidget,
model: FormModelWidget,
};
export function getWidgetComponent(formField) {
......
import React from "react";
import ModelPicker from "metabase/containers/ModelPicker";
import ItemSelect from "metabase/containers/ItemSelect";
import Question from "metabase/entities/questions";
import type { Card } from "metabase-types/api";
import type { FormField } from "metabase-types/forms";
const ModelSelect = ItemSelect(
ModelPicker,
({ id }: { id: Card["id"] }) => <Question.Name id={id} />,
"dataset",
);
function FormModelWidget({ field }: { field: FormField<string, Card["id"]> }) {
return <ModelSelect {...field} />;
}
export default FormModelWidget;
import _ from "underscore";
import styled from "@emotion/styled";
import { color, lighten } from "metabase/lib/colors";
import { space } from "metabase/styled-components/theme";
import Icon from "metabase/components/Icon";
interface ActionOptionProps {
isSelected?: boolean;
hasDescription?: boolean;
}
export const ActionOptionListItem = styled.div<ActionOptionProps>`
color: ${props =>
props.isSelected ? color("text-white") : color("text-normal")};
background-color: ${props =>
props.isSelected ? color("brand") : color("white")};
cursor: pointer;
display: flex;
align-items: ${props => (props.hasDescription ? "flex-start" : "center")};
gap: ${space(1)};
border: 1px solid ${color("border")};
border-radius: ${space(1)};
padding: ${space(2)};
margin: ${space(1)} ${space(0)};
&:hover {
background-color: ${lighten("brand", 0.1)};
color: ${color("text-white")};
}
`;
export const ActionOptionTitle = styled.div`
font-size: 0.875rem;
font-weight: bold;
`;
export const ActionOptionDescription = styled.div`
font-size: 0.875rem;
margin-top: ${space(1)};
`;
import React from "react";
import { SidebarItem } from "../SidebarItem";
import Icon from "metabase/components/Icon";
import {
ActionSidebarItem,
ActionSidebarItemIcon,
ActionDescription,
} from "./ActionOptions.styled";
ActionOptionListItem,
ActionOptionTitle,
ActionOptionDescription,
} from "./ActionOptionItem.styled";
interface ActionOptionProps {
name: string;
......@@ -14,25 +14,25 @@ interface ActionOptionProps {
onClick: () => void;
}
function ActionOptionItem({
export default function ActionOptionItem({
name,
description,
isSelected,
onClick,
}: ActionOptionProps) {
return (
<ActionSidebarItem
<ActionOptionListItem
onClick={onClick}
isSelected={isSelected}
hasDescription={!!description}
>
<ActionSidebarItemIcon name="insight" isSelected={isSelected} />
<Icon name="insight" size={22} />
<div>
<SidebarItem.Name>{name}</SidebarItem.Name>
{description && <ActionDescription>{description}</ActionDescription>}
<ActionOptionTitle>{name}</ActionOptionTitle>
{!!description && (
<ActionOptionDescription>{description}</ActionOptionDescription>
)}
</div>
</ActionSidebarItem>
</ActionOptionListItem>
);
}
export default ActionOptionItem;
import _ from "underscore";
import styled from "@emotion/styled";
import { color } from "metabase/lib/colors";
import { SidebarItem } from "metabase/dashboard/components/ClickBehaviorSidebar/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;
`;
import React, { useState } from "react";
import { t } from "ttag";
import Button from "metabase/core/components/Button";
import EmptyState from "metabase/components/EmptyState";
import Actions from "metabase/entities/actions";
import type { WritebackAction } from "metabase-types/api";
import type { State } from "metabase-types/store";
import ModelPicker from "../ModelPicker";
import ActionOptionItem from "./ActionOptionItem";
export default function ActionPicker({
value,
onChange,
}: {
value: WritebackAction | undefined;
onChange: (value: 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}
/>
</>
)}
</div>
);
}
function ModelActionPicker({
value,
onChange,
actions,
}: {
value: WritebackAction | undefined;
onChange: (newValue: WritebackAction) => void;
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>
);
}
const ConnectedModelActionPicker = Actions.loadList({
query: (state: State, props: { modelId?: number | null }) => ({
"model-id": props?.modelId,
}),
})(ModelActionPicker);
export { default } from "./ActionPicker";
......@@ -10,6 +10,14 @@ import SelectButton from "metabase/core/components/SelectButton";
const MIN_POPOVER_WIDTH = 300;
const typeNameMap = {
card: () => t`question`,
dataset: () => t`model`,
table: () => t`table`,
dashboard: () => t`dashboard`,
page: () => t`page`,
};
export default (PickerComponent, NameComponent, type) =>
class ItemSelect extends React.Component {
state = {
......@@ -28,7 +36,7 @@ export default (PickerComponent, NameComponent, type) =>
};
static defaultProps = {
placeholder: t`Select a ${type}`,
placeholder: t`Select a ${typeNameMap[type]?.() ?? type}`,
inheritWidth: true,
};
......
import React from "react";
import PropTypes from "prop-types";
import ItemPicker from "./ItemPicker";
const ModelPicker = ({ value, onChange, ...props }) => (
<ItemPicker
{...props}
value={value === undefined ? undefined : { model: "page", id: value }}
onChange={page => onChange(page ? page.id : undefined)}
models={["dataset"]}
/>
);
ModelPicker.propTypes = {
value: PropTypes.number,
onChange: PropTypes.func.isRequired,
};
export default ModelPicker;
......@@ -113,8 +113,8 @@ export const saveDashboardAndCards = createThunkAction(
const cards = updatedDashcards.map(
({
id,
action_id,
card_id,
action,
row,
col,
size_x,
......@@ -124,8 +124,8 @@ export const saveDashboardAndCards = createThunkAction(
visualization_settings,
}) => ({
id,
action_id,
card_id,
action,
row,
col,
size_x,
......@@ -142,6 +142,7 @@ export const saveDashboardAndCards = createThunkAction(
}) &&
// filter out mappings for deleted series
(!card_id ||
action ||
card_id === mapping.card_id ||
_.findWhere(series, { id: mapping.card_id })),
),
......
......@@ -22,9 +22,10 @@ import type {
Dashboard,
DashboardOrderedCard,
ActionDashboardCard,
ParameterMappedForActionExecution,
ArbitraryParameterForActionExecution,
ParametersForActionExecution,
ActionFormSubmitResult,
WritebackAction,
ActionParametersMapping,
} from "metabase-types/api";
import type { Dispatch } from "metabase-types/store";
......@@ -44,9 +45,16 @@ export const closeActionParametersModal = createAction(
CLOSE_ACTION_PARAMETERS_MODAL,
);
interface DashboardAttributes {
card_id?: number | null;
action?: WritebackAction | null;
parameter_mappings?: ActionParametersMapping[] | null;
visualization_settings?: ActionDashboardCard["visualization_settings"];
}
export function updateButtonActionMapping(
dashCardId: number,
attributes: { action_id?: number | null; parameter_mappings?: any },
attributes: DashboardAttributes,
) {
return (dispatch: Dispatch) => {
dispatch(
......@@ -234,8 +242,7 @@ export const deleteManyRowsFromDataApp = (
export type ExecuteRowActionPayload = {
dashboard: Dashboard;
dashcard: ActionDashboardCard;
parameters: ParameterMappedForActionExecution[];
extra_parameters: ArbitraryParameterForActionExecution[];
parameters: ParametersForActionExecution;
dispatch: Dispatch;
shouldToast?: boolean;
};
......@@ -244,7 +251,6 @@ export const executeRowAction = async ({
dashboard,
dashcard,
parameters,
extra_parameters,
dispatch,
shouldToast = true,
}: ExecuteRowActionPayload): Promise<ActionFormSubmitResult> => {
......@@ -253,16 +259,21 @@ export const executeRowAction = async ({
const result = await ActionsApi.execute({
dashboardId: dashboard.id,
dashcardId: dashcard.id,
modelId: dashcard.card_id,
slug: dashcard.action?.slug,
parameters,
extra_parameters,
});
if (result["rows-affected"] > 0) {
dispatch(reloadDashboardCards());
if (result["rows-affected"] > 0 || result["rows-updated"]?.[0] > 0) {
message = t`Successfully executed the action`;
} else if (result["created-row"]) {
message = t`Successfully saved`;
} else if (result["rows-deleted"]?.[0] > 0) {
message = t`Successfully deleted`;
} else {
message = t`Success! The action returned: ${JSON.stringify(result)}`;
}
dispatch(reloadDashboardCards());
if (shouldToast) {
dispatch(
addUndo({
......
......@@ -32,3 +32,10 @@ export const ActionDescription = styled.span<{ isSelected?: boolean }>`
export const ClickMappingsContainer = styled.div`
margin-top: 1rem;
`;
export const ActionPickerWrapper = styled.div`
display: flex;
flex-direction: column;
max-height: calc(100vh - 30rem);
overflow-y: auto;
`;
......@@ -2,11 +2,11 @@ import React, { useCallback } from "react";
import { t } from "ttag";
import { connect } from "react-redux";
import Actions from "metabase/entities/actions";
import { updateButtonActionMapping } from "metabase/dashboard/actions";
import { updateSettings } from "metabase/visualizations/lib/settings";
import ActionPicker from "metabase/containers/ActionPicker";
import type {
ActionDashboardCard,
ActionParametersMapping,
......@@ -18,8 +18,12 @@ import type { UiParameter } from "metabase/parameters/types";
import { Heading, SidebarContent } from "../ClickBehaviorSidebar.styled";
import ActionClickMappings from "./ActionClickMappings";
import ActionOptionItem from "./ActionOptionItem";
import { ClickMappingsContainer } from "./ActionOptions.styled";
import {
ClickMappingsContainer,
ActionPickerWrapper,
} from "./ActionOptions.styled";
import { ensureParamsHaveNames } from "./utils";
interface ActionOptionsOwnProps {
dashcard: ActionDashboardCard;
......@@ -30,10 +34,10 @@ interface ActionOptionsDispatchProps {
onUpdateButtonActionMapping: (
dashCardId: number,
settings: {
action_id?: number | null;
action?: WritebackAction;
visualization_settings?: ActionDashboardCard["visualization_settings"];
card_id?: number | null;
action?: WritebackAction | null;
parameter_mappings?: ActionParametersMapping[] | null;
visualization_settings?: ActionDashboardCard["visualization_settings"];
},
) => void;
}
......@@ -45,24 +49,22 @@ const mapDispatchToProps = {
};
function ActionOptions({
actions,
dashcard,
parameters,
onUpdateButtonActionMapping,
}: ActionOptionsProps & { actions: WritebackAction[] }) {
const connectedActionId = dashcard.action_id;
const selectedAction = actions.find(
action => action.id === connectedActionId,
);
}: ActionOptionsProps) {
const selectedAction = dashcard.action;
const handleActionSelected = useCallback(
(action: WritebackAction) => {
onUpdateButtonActionMapping(dashcard.id, {
action_id: action.id,
card_id: action.model_id,
action,
visualization_settings: updateSettings(
{ "button.label": action.name },
{
"button.label": action.name,
action_slug: action.slug, // :-( so hacky
},
dashcard.visualization_settings,
),
// Clean mappings from previous action
......@@ -83,27 +85,23 @@ function ActionOptions({
);
return (
<>
{actions.map(action => (
<ActionOptionItem
key={action.id}
name={action.name}
description={action.description}
isSelected={action.id === connectedActionId}
onClick={() => handleActionSelected(action)}
/>
))}
{selectedAction && (
<ActionPickerWrapper>
<ActionPicker value={selectedAction} onChange={handleActionSelected} />
{!!selectedAction && (
<ClickMappingsContainer>
<ActionClickMappings
action={selectedAction}
action={{
...selectedAction,
parameters: ensureParamsHaveNames(selectedAction.parameters),
}}
dashcard={dashcard}
parameters={parameters}
onChange={handleParameterMappingChange}
/>
</ClickMappingsContainer>
)}
</>
</ActionPickerWrapper>
);
}
......@@ -111,11 +109,11 @@ function ActionOptionsContainer(props: ActionOptionsProps) {
return (
<SidebarContent>
<Heading className="text-medium">{t`Pick an action`}</Heading>
<Actions.ListLoader loadingAndErrorWrapper={false}>
{({ actions = [] }: { actions: WritebackAction[] }) => (
<ActionOptions {...props} actions={actions} />
)}
</Actions.ListLoader>
<ActionOptions
dashcard={props.dashcard}
parameters={props.parameters}
onUpdateButtonActionMapping={props.onUpdateButtonActionMapping}
/>
</SidebarContent>
);
}
......
......@@ -5,6 +5,7 @@ import type {
ActionParametersMapping,
ClickBehaviorParameterMapping,
WritebackAction,
WritebackParameter,
} from "metabase-types/api";
import type { UiParameter } from "metabase/parameters/types";
......@@ -71,3 +72,12 @@ export function turnClickBehaviorParameterMappingsIntoDashCardMappings(
return parameter_mappings;
}
export function ensureParamsHaveNames(
parameters: WritebackParameter[],
): WritebackParameter[] {
return parameters.map(parameter => ({
...parameter,
name: parameter.name ?? parameter.id,
}));
}
import { createEntity } from "metabase/lib/entities";
import type { ActionFormSettings } from "metabase-types/api";
import type {
ActionFormSettings,
ModelAction,
WritebackAction,
} from "metabase-types/api";
import type { Dispatch } from "metabase-types/store";
import { CardApi } from "metabase/services";
import { ActionsApi, CardApi, ModelActionsApi } from "metabase/services";
import {
removeOrphanSettings,
......@@ -11,11 +16,12 @@ import {
setTemplateTagTypesFromFieldSettings,
} from "metabase/entities/actions/utils";
import type Question from "metabase-lib/lib/Question";
import { saveForm } from "./forms";
import { saveForm, updateForm } from "./forms";
type ActionParams = {
name: string;
description?: string;
model_id?: number;
collection_id?: number;
question: Question;
formSettings: ActionFormSettings;
......@@ -56,16 +62,79 @@ const getAPIFn =
const createAction = getAPIFn(CardApi.create);
const updateAction = getAPIFn(CardApi.update);
const associateAction = ({
model_id,
action_id,
}: {
model_id: number;
action_id: number;
}) =>
ModelActionsApi.connectActionToModel({
card_id: model_id,
action_id: action_id,
slug: `action_${action_id}`,
requires_pk: false,
});
const defaultImplicitActionCreateOptions = {
insert: true,
update: true,
delete: true,
};
const enableImplicitActionsForModel =
async (modelId: number, options = defaultImplicitActionCreateOptions) =>
async (dispatch: Dispatch) => {
const methodsToCreate = Object.entries(options)
.filter(([, shouldCreate]) => !!shouldCreate)
.map(([method]) => method);
const apiCalls = methodsToCreate.map(method =>
ModelActionsApi.createImplicitAction({
card_id: modelId,
slug: method,
requires_pk: method !== "insert",
}),
);
await Promise.all(apiCalls);
dispatch({ type: Actions.actionTypes.INVALIDATE_LISTS_ACTION });
};
const Actions = createEntity({
name: "actions",
nameOne: "action",
path: "/api/action",
api: {
create: createAction,
create: async ({ model_id, ...params }: ActionParams) => {
const card = await createAction(params);
if (card?.action_id && model_id) {
const association = await associateAction({
model_id,
action_id: card.action_id,
});
return { ...card, association };
}
return card;
},
update: updateAction,
list: async (params: any) => {
const actions = await ActionsApi.list(params);
return actions.map((action: ModelAction | WritebackAction) => ({
...action,
id: action.id ?? `implicit-${action.slug}`,
name: action.name ?? action.slug,
}));
},
},
actions: {
enableImplicitActionsForModel,
},
forms: {
saveForm,
updateForm,
},
});
......
import { t } from "ttag";
const getFormFields = (formAction: "create" | "update") => [
{
name: "name",
title: t`Name`,
placeholder: t`My new fantastic action`,
autoFocus: true,
validate: (name: string) =>
(!name && t`Name is required`) ||
(name && name.length > 100 && t`Name must be 100 characters or less`),
},
{
name: "description",
title: t`Description`,
type: "text",
placeholder: t`It's optional but oh, so helpful`,
normalize: (description: string) => description || null, // expected to be nil or non-empty string
},
{
name: "model_id",
title: t`Model it's saved in`,
type: formAction === "create" ? "model" : "hidden",
},
{
name: "question",
type: "hidden",
},
{
name: "formSettings",
type: "hidden",
},
];
export const saveForm = {
fields: [
{
name: "name",
title: t`Name`,
placeholder: t`My new fantastic action`,
autoFocus: true,
validate: (name: string) =>
(!name && t`Name is required`) ||
(name && name.length > 100 && t`Name must be 100 characters or less`),
},
{
name: "description",
title: t`Description`,
type: "text",
placeholder: t`It's optional but oh, so helpful`,
normalize: (description: string) => description || null, // expected to be nil or non-empty string
},
{
name: "collection_id",
title: t`Collection it's saved in`,
type: "collection",
},
{
name: "question",
type: "hidden",
},
{
name: "formSettings",
type: "hidden",
},
],
fields: getFormFields("create"),
};
export const updateForm = {
fields: getFormFields("update"),
};
......@@ -4,6 +4,7 @@ import type {
FieldType,
InputType,
ParameterType,
ModelAction,
} from "metabase-types/api";
import { getDefaultFieldSettings } from "metabase/writeback/components/ActionCreator/FormCreator";
......
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