From 8f86eb0060a20c08c0977142d8e146ee72281846 Mon Sep 17 00:00:00 2001
From: Anton Kulyk <kuliks.anton@gmail.com>
Date: Fri, 17 Feb 2023 13:07:10 +0000
Subject: [PATCH] Handle basic/implicit actions in the action editor (#28327)

---
 frontend/src/metabase-types/api/actions.ts    |   4 +-
 .../src/metabase-types/api/mocks/actions.ts   |   4 +-
 .../components/ActionForm/ActionForm.tsx      |   5 +-
 .../actions/components/ActionViz/Action.tsx   |   6 +-
 .../actions/components/ActionViz/utils.ts     |  14 -
 .../ImplicitActionIcon.styled.tsx}            |   5 -
 .../ImplicitActionIcon/ImplicitActionIcon.tsx |  20 ++
 .../components/ImplicitActionIcon/index.ts    |   1 +
 .../ActionContext/ActionContext.ts            |  38 ++
 .../ActionContext/ActionContextProvider.tsx   |  33 ++
 .../ImplicitActionContextProvider.styled.tsx  |  14 +
 .../ImplicitActionContextProvider.tsx         |  89 +++++
 .../ImplicitActionContextProvider/index.ts    |   2 +
 .../QueryActionContextProvider.tsx            | 146 ++++++++
 .../ActionCreator/ActionContext/index.ts      |   2 +
 .../ActionCreator/ActionContext/types.ts      |  16 +
 .../ActionCreator/ActionContext/utils.ts      |  22 ++
 .../ActionCreator/ActionCreator.tsx           | 179 +++++-----
 .../ActionCreator/ActionCreator.unit.spec.tsx | 333 ------------------
 .../ActionCreator/ActionCreatorHeader.tsx     |   4 +-
 .../ActionCreator/ActionCreatorView.tsx       |  55 ++-
 .../ActionCreator/FormCreator/utils.ts        |   7 +-
 .../ActionCreator/InlineActionSettings.tsx    |  11 +-
 .../tests/ActionCreator-Common.unit.spec.tsx  |  76 ++++
 ...ActionCreator-ImplicitActions.unit.spec.ts |  80 +++++
 .../ActionCreator-QueryActions.unit.spec.ts   | 123 +++++++
 .../tests/ActionCreator-Sharing.unit.spec.tsx | 146 ++++++++
 .../containers/ActionCreator/tests/common.tsx |  83 +++++
 .../{ => tests}/utils.unit.spec.ts            |   2 +-
 .../actions/containers/ActionCreator/types.ts |   5 +
 .../actions/containers/ActionCreator/utils.ts |  11 +-
 frontend/src/metabase/actions/utils.ts        |  29 +-
 .../src/metabase/entities/actions/actions.ts  |  31 +-
 .../ModelActionListItem.tsx                   |  11 +-
 .../ModelActionDetails/StackedInsightIcon.tsx |  14 -
 .../containers/PublicAction/PublicAction.tsx  |  14 +-
 .../scenarios/models/model-actions.cy.spec.js | 118 ++++---
 37 files changed, 1184 insertions(+), 569 deletions(-)
 rename frontend/src/metabase/{models/components/ModelDetailPage/ModelActionDetails/StackedInsightIcon.styled.tsx => actions/components/ImplicitActionIcon/ImplicitActionIcon.styled.tsx} (56%)
 create mode 100644 frontend/src/metabase/actions/components/ImplicitActionIcon/ImplicitActionIcon.tsx
 create mode 100644 frontend/src/metabase/actions/components/ImplicitActionIcon/index.ts
 create mode 100644 frontend/src/metabase/actions/containers/ActionCreator/ActionContext/ActionContext.ts
 create mode 100644 frontend/src/metabase/actions/containers/ActionCreator/ActionContext/ActionContextProvider.tsx
 create mode 100644 frontend/src/metabase/actions/containers/ActionCreator/ActionContext/ImplicitActionContextProvider/ImplicitActionContextProvider.styled.tsx
 create mode 100644 frontend/src/metabase/actions/containers/ActionCreator/ActionContext/ImplicitActionContextProvider/ImplicitActionContextProvider.tsx
 create mode 100644 frontend/src/metabase/actions/containers/ActionCreator/ActionContext/ImplicitActionContextProvider/index.ts
 create mode 100644 frontend/src/metabase/actions/containers/ActionCreator/ActionContext/QueryActionContextProvider.tsx
 create mode 100644 frontend/src/metabase/actions/containers/ActionCreator/ActionContext/index.ts
 create mode 100644 frontend/src/metabase/actions/containers/ActionCreator/ActionContext/types.ts
 create mode 100644 frontend/src/metabase/actions/containers/ActionCreator/ActionContext/utils.ts
 delete mode 100644 frontend/src/metabase/actions/containers/ActionCreator/ActionCreator.unit.spec.tsx
 create mode 100644 frontend/src/metabase/actions/containers/ActionCreator/tests/ActionCreator-Common.unit.spec.tsx
 create mode 100644 frontend/src/metabase/actions/containers/ActionCreator/tests/ActionCreator-ImplicitActions.unit.spec.ts
 create mode 100644 frontend/src/metabase/actions/containers/ActionCreator/tests/ActionCreator-QueryActions.unit.spec.ts
 create mode 100644 frontend/src/metabase/actions/containers/ActionCreator/tests/ActionCreator-Sharing.unit.spec.tsx
 create mode 100644 frontend/src/metabase/actions/containers/ActionCreator/tests/common.tsx
 rename frontend/src/metabase/actions/containers/ActionCreator/{ => tests}/utils.unit.spec.ts (99%)
 delete mode 100644 frontend/src/metabase/models/components/ModelDetailPage/ModelActionDetails/StackedInsightIcon.tsx

diff --git a/frontend/src/metabase-types/api/actions.ts b/frontend/src/metabase-types/api/actions.ts
index 2368f9b8e0f..7d3c95ca79c 100644
--- a/frontend/src/metabase-types/api/actions.ts
+++ b/frontend/src/metabase-types/api/actions.ts
@@ -150,9 +150,9 @@ export interface FieldSettings {
 export type FieldSettingsMap = Record<ParameterId, FieldSettings>;
 export interface ActionFormSettings {
   name?: string;
-  type: ActionDisplayType;
+  type?: ActionDisplayType;
   description?: string;
-  fields: FieldSettingsMap;
+  fields?: FieldSettingsMap;
   submitButtonLabel?: string;
   submitButtonColor?: string;
   confirmMessage?: string;
diff --git a/frontend/src/metabase-types/api/mocks/actions.ts b/frontend/src/metabase-types/api/mocks/actions.ts
index e91cb6cbc2c..d9c9adbbccc 100644
--- a/frontend/src/metabase-types/api/mocks/actions.ts
+++ b/frontend/src/metabase-types/api/mocks/actions.ts
@@ -49,10 +49,10 @@ export const createMockQueryAction = ({
 export const createMockImplicitQueryAction = ({
   creator = createMockUserInfo(),
   ...opts
-}: Partial<WritebackImplicitQueryAction>): WritebackImplicitQueryAction => ({
+}: Partial<WritebackImplicitQueryAction> = {}): WritebackImplicitQueryAction => ({
   id: 1,
   kind: "row/create",
-  name: "",
+  name: "Implicit Query Action",
   description: "",
   model_id: 1,
   parameters: [],
diff --git a/frontend/src/metabase/actions/components/ActionForm/ActionForm.tsx b/frontend/src/metabase/actions/components/ActionForm/ActionForm.tsx
index 61ed2493155..c205f8dc18b 100644
--- a/frontend/src/metabase/actions/components/ActionForm/ActionForm.tsx
+++ b/frontend/src/metabase/actions/components/ActionForm/ActionForm.tsx
@@ -87,7 +87,7 @@ export const ActionForm = ({
     const newOrder = destination?.index ?? source.index;
 
     const reorderedFields = reorderFields(
-      formSettings.fields,
+      formSettings.fields ?? {},
       oldOrder,
       newOrder,
     );
@@ -117,6 +117,7 @@ export const ActionForm = ({
   ) => onSubmit?.(formValidationSchema.cast(values), actions);
 
   if (isSettings) {
+    const fieldSettings = formSettings.fields || {};
     return (
       <FormProvider
         initialValues={initialValues}
@@ -155,7 +156,7 @@ export const ActionForm = ({
                           </InputContainer>
                           {isEditable && (
                             <FieldSettingsButtons
-                              fieldSettings={formSettings.fields[field.name]}
+                              fieldSettings={fieldSettings[field.name]}
                               onChange={handleChangeFieldSettings}
                             />
                           )}
diff --git a/frontend/src/metabase/actions/components/ActionViz/Action.tsx b/frontend/src/metabase/actions/components/ActionViz/Action.tsx
index ae90d5bfdf9..cffd6d9847a 100644
--- a/frontend/src/metabase/actions/components/ActionViz/Action.tsx
+++ b/frontend/src/metabase/actions/components/ActionViz/Action.tsx
@@ -14,14 +14,16 @@ import type { VisualizationProps } from "metabase-types/types/Visualization";
 import type { Dispatch, State } from "metabase-types/store";
 import type { ParameterValueOrArray } from "metabase-types/types/Parameter";
 
-import { generateFieldSettingsFromParameters } from "metabase/actions/utils";
+import {
+  generateFieldSettingsFromParameters,
+  setNumericValues,
+} from "metabase/actions/utils";
 
 import { getEditingDashcardId } from "metabase/dashboard/selectors";
 import {
   getDashcardParamValues,
   getNotProvidedActionParameters,
   shouldShowConfirmation,
-  setNumericValues,
 } from "./utils";
 import ActionVizForm from "./ActionVizForm";
 import ActionButtonView from "./ActionButtonView";
diff --git a/frontend/src/metabase/actions/components/ActionViz/utils.ts b/frontend/src/metabase/actions/components/ActionViz/utils.ts
index ee0dec8b4bc..cdd240a754d 100644
--- a/frontend/src/metabase/actions/components/ActionViz/utils.ts
+++ b/frontend/src/metabase/actions/components/ActionViz/utils.ts
@@ -11,7 +11,6 @@ import type {
   ParametersForActionExecution,
   WritebackAction,
   WritebackParameter,
-  FieldSettingsMap,
 } from "metabase-types/api";
 import type { ParameterValueOrArray } from "metabase-types/types/Parameter";
 
@@ -45,19 +44,6 @@ function prepareParameter(
   return [actionParameter.id, formatParameterValue(parameterValue)];
 }
 
-export function setNumericValues(
-  params: ParametersForActionExecution,
-  fieldSettings: FieldSettingsMap,
-) {
-  Object.entries(params).forEach(([key, value]) => {
-    if (fieldSettings[key]?.fieldType === "number" && !isEmpty(value)) {
-      params[key] = Number(value) ?? null;
-    }
-  });
-
-  return params;
-}
-
 export function getDashcardParamValues(
   dashcard: ActionDashboardCard,
   parameterValues: { [id: string]: ParameterValueOrArray },
diff --git a/frontend/src/metabase/models/components/ModelDetailPage/ModelActionDetails/StackedInsightIcon.styled.tsx b/frontend/src/metabase/actions/components/ImplicitActionIcon/ImplicitActionIcon.styled.tsx
similarity index 56%
rename from frontend/src/metabase/models/components/ModelDetailPage/ModelActionDetails/StackedInsightIcon.styled.tsx
rename to frontend/src/metabase/actions/components/ImplicitActionIcon/ImplicitActionIcon.styled.tsx
index 23d78940f9c..092569b6946 100644
--- a/frontend/src/metabase/models/components/ModelDetailPage/ModelActionDetails/StackedInsightIcon.styled.tsx
+++ b/frontend/src/metabase/actions/components/ImplicitActionIcon/ImplicitActionIcon.styled.tsx
@@ -1,12 +1,7 @@
 import styled from "@emotion/styled";
-import Icon from "metabase/components/Icon";
 
 export const Root = styled.div`
   display: flex;
   flex-direction: column;
   justify-content: center;
 `;
-
-export const IconSmall = styled(Icon)`
-  margin-left: 24px;
-`;
diff --git a/frontend/src/metabase/actions/components/ImplicitActionIcon/ImplicitActionIcon.tsx b/frontend/src/metabase/actions/components/ImplicitActionIcon/ImplicitActionIcon.tsx
new file mode 100644
index 00000000000..7b2a918f794
--- /dev/null
+++ b/frontend/src/metabase/actions/components/ImplicitActionIcon/ImplicitActionIcon.tsx
@@ -0,0 +1,20 @@
+import React from "react";
+import Icon from "metabase/components/Icon";
+import { Root } from "./ImplicitActionIcon.styled";
+
+interface ImplicitActionIconProps {
+  size?: number;
+}
+
+function ImplicitActionIcon({ size = 14 }: ImplicitActionIconProps) {
+  const sizeSmall = size * 0.375;
+  const marginLeft = size * 0.75;
+  return (
+    <Root>
+      <Icon name="insight" size={sizeSmall} style={{ marginLeft }} />
+      <Icon name="insight" size={size} />
+    </Root>
+  );
+}
+
+export default ImplicitActionIcon;
diff --git a/frontend/src/metabase/actions/components/ImplicitActionIcon/index.ts b/frontend/src/metabase/actions/components/ImplicitActionIcon/index.ts
new file mode 100644
index 00000000000..72fbdf98c6c
--- /dev/null
+++ b/frontend/src/metabase/actions/components/ImplicitActionIcon/index.ts
@@ -0,0 +1 @@
+export { default } from "./ImplicitActionIcon";
diff --git a/frontend/src/metabase/actions/containers/ActionCreator/ActionContext/ActionContext.ts b/frontend/src/metabase/actions/containers/ActionCreator/ActionContext/ActionContext.ts
new file mode 100644
index 00000000000..69cafad621b
--- /dev/null
+++ b/frontend/src/metabase/actions/containers/ActionCreator/ActionContext/ActionContext.ts
@@ -0,0 +1,38 @@
+import { createContext, useContext } from "react";
+import _ from "underscore";
+
+import type { ActionFormSettings, WritebackAction } from "metabase-types/api";
+
+import { getDefaultFormSettings } from "../../../utils";
+import type { ActionCreatorUIProps } from "../types";
+import type { EditableActionParams, EditorBodyProps } from "./types";
+import { createEmptyWritebackAction } from "./utils";
+
+export type ActionContextType = {
+  action: Partial<WritebackAction>;
+  formSettings: ActionFormSettings;
+  canSave: boolean;
+  isNew: boolean;
+  ui: ActionCreatorUIProps;
+  handleActionChange: (action: EditableActionParams) => void;
+  handleFormSettingsChange: (formSettings: ActionFormSettings) => void;
+  handleSetupExample: () => void;
+  renderEditorBody: (props: EditorBodyProps) => React.ReactNode;
+};
+
+export const ActionContext = createContext<ActionContextType>({
+  action: createEmptyWritebackAction(),
+  formSettings: getDefaultFormSettings(),
+  canSave: false,
+  isNew: true,
+  ui: {
+    canRename: true,
+    canChangeFieldSettings: true,
+  },
+  handleActionChange: _.noop,
+  handleFormSettingsChange: _.noop,
+  handleSetupExample: _.noop,
+  renderEditorBody: () => null,
+});
+
+export const useActionContext = () => useContext(ActionContext);
diff --git a/frontend/src/metabase/actions/containers/ActionCreator/ActionContext/ActionContextProvider.tsx b/frontend/src/metabase/actions/containers/ActionCreator/ActionContext/ActionContextProvider.tsx
new file mode 100644
index 00000000000..2a08c8a26d7
--- /dev/null
+++ b/frontend/src/metabase/actions/containers/ActionCreator/ActionContext/ActionContextProvider.tsx
@@ -0,0 +1,33 @@
+import React from "react";
+import type { WritebackAction } from "metabase-types/api";
+
+import ImplicitActionContextProvider, {
+  ImplicitActionContextProviderProps,
+} from "./ImplicitActionContextProvider";
+import QueryActionContextProvider, {
+  QueryActionContextProviderProps,
+} from "./QueryActionContextProvider";
+
+type Props = Omit<ImplicitActionContextProviderProps, "initialAction"> &
+  Omit<QueryActionContextProviderProps, "initialAction"> & {
+    initialAction?: WritebackAction;
+  };
+
+function ActionContextProvider({ initialAction, ...props }: Props) {
+  if (initialAction?.type === "query") {
+    return (
+      <QueryActionContextProvider {...props} initialAction={initialAction} />
+    );
+  }
+
+  if (initialAction?.type === "implicit") {
+    return (
+      <ImplicitActionContextProvider {...props} initialAction={initialAction} />
+    );
+  }
+
+  // Fallback to "new query action" mode when the action type is not supported
+  return <QueryActionContextProvider {...props} />;
+}
+
+export default ActionContextProvider;
diff --git a/frontend/src/metabase/actions/containers/ActionCreator/ActionContext/ImplicitActionContextProvider/ImplicitActionContextProvider.styled.tsx b/frontend/src/metabase/actions/containers/ActionCreator/ActionContext/ImplicitActionContextProvider/ImplicitActionContextProvider.styled.tsx
new file mode 100644
index 00000000000..eec2f897ad2
--- /dev/null
+++ b/frontend/src/metabase/actions/containers/ActionCreator/ActionContext/ImplicitActionContextProvider/ImplicitActionContextProvider.styled.tsx
@@ -0,0 +1,14 @@
+import styled from "@emotion/styled";
+
+export const EditorBodyRoot = styled.div`
+  display: flex;
+  flex-direction: column;
+  justify-content: center;
+  align-items: center;
+  width: 100%;
+  height: 100%;
+`;
+
+export const EditorTitle = styled.h3`
+  margin-top: 1rem;
+`;
diff --git a/frontend/src/metabase/actions/containers/ActionCreator/ActionContext/ImplicitActionContextProvider/ImplicitActionContextProvider.tsx b/frontend/src/metabase/actions/containers/ActionCreator/ActionContext/ImplicitActionContextProvider/ImplicitActionContextProvider.tsx
new file mode 100644
index 00000000000..d69e4379a6f
--- /dev/null
+++ b/frontend/src/metabase/actions/containers/ActionCreator/ActionContext/ImplicitActionContextProvider/ImplicitActionContextProvider.tsx
@@ -0,0 +1,89 @@
+import React, { useCallback, useMemo, useState } from "react";
+import { t } from "ttag";
+import _ from "underscore";
+
+import ImplicitActionIcon from "metabase/actions/components/ImplicitActionIcon";
+
+import type {
+  ActionFormSettings,
+  WritebackImplicitQueryAction,
+} from "metabase-types/api";
+
+import { getDefaultFormSettings } from "../../../../utils";
+import type { ActionContextProviderProps } from "../types";
+import { ActionContext } from "../ActionContext";
+import {
+  EditorBodyRoot,
+  EditorTitle,
+} from "./ImplicitActionContextProvider.styled";
+
+export type ImplicitActionContextProviderProps = Omit<
+  ActionContextProviderProps,
+  "initialAction"
+> & {
+  initialAction: WritebackImplicitQueryAction;
+};
+
+function EditorBody() {
+  return (
+    <EditorBodyRoot>
+      <ImplicitActionIcon size={64} />
+      <EditorTitle>{t`Auto tracking schema`}</EditorTitle>
+    </EditorBodyRoot>
+  );
+}
+
+function cleanFormSettings(formSettings?: ActionFormSettings) {
+  const formSettingsWithDefaults = getDefaultFormSettings(formSettings);
+
+  // For implicit actions fields are generated dynamically according to the current schema
+  // For now, we don't let to customize the form to avoid dealing with fields getting out of sync
+  return _.omit(formSettingsWithDefaults, "fields");
+}
+
+function ImplicitActionContextProvider({
+  initialAction,
+  children,
+}: ImplicitActionContextProviderProps) {
+  const [formSettings, setFormSettings] = useState(
+    getDefaultFormSettings(initialAction.visualization_settings),
+  );
+
+  const handleFormSettingsChange = useCallback(
+    (nextFormSettings: ActionFormSettings) => {
+      setFormSettings(cleanFormSettings(nextFormSettings));
+    },
+    [],
+  );
+
+  const canSave = useMemo(() => {
+    return !_.isEqual(
+      cleanFormSettings(formSettings),
+      cleanFormSettings(initialAction?.visualization_settings),
+    );
+  }, [formSettings, initialAction?.visualization_settings]);
+
+  const value = useMemo(
+    () => ({
+      action: initialAction,
+      formSettings,
+      isNew: false,
+      canSave,
+      ui: {
+        canRename: false,
+        canChangeFieldSettings: false,
+      },
+      handleFormSettingsChange,
+      handleActionChange: _.noop,
+      handleSetupExample: _.noop,
+      renderEditorBody: EditorBody,
+    }),
+    [initialAction, formSettings, canSave, handleFormSettingsChange],
+  );
+
+  return (
+    <ActionContext.Provider value={value}>{children}</ActionContext.Provider>
+  );
+}
+
+export default ImplicitActionContextProvider;
diff --git a/frontend/src/metabase/actions/containers/ActionCreator/ActionContext/ImplicitActionContextProvider/index.ts b/frontend/src/metabase/actions/containers/ActionCreator/ActionContext/ImplicitActionContextProvider/index.ts
new file mode 100644
index 00000000000..17793daf46a
--- /dev/null
+++ b/frontend/src/metabase/actions/containers/ActionCreator/ActionContext/ImplicitActionContextProvider/index.ts
@@ -0,0 +1,2 @@
+export { default } from "./ImplicitActionContextProvider";
+export * from "./ImplicitActionContextProvider";
diff --git a/frontend/src/metabase/actions/containers/ActionCreator/ActionContext/QueryActionContextProvider.tsx b/frontend/src/metabase/actions/containers/ActionCreator/ActionContext/QueryActionContextProvider.tsx
new file mode 100644
index 00000000000..9137788cc21
--- /dev/null
+++ b/frontend/src/metabase/actions/containers/ActionCreator/ActionContext/QueryActionContextProvider.tsx
@@ -0,0 +1,146 @@
+import React, { useCallback, useEffect, useMemo, useState } from "react";
+
+import { CreateQueryActionParams } from "metabase/entities/actions";
+import QueryActionEditor from "metabase/actions/containers/ActionCreator/QueryActionEditor";
+
+import type { DatabaseId, WritebackQueryAction } from "metabase-types/api";
+import type Metadata from "metabase-lib/metadata/Metadata";
+import type NativeQuery from "metabase-lib/queries/NativeQuery";
+
+import { getTemplateTagParametersFromCard } from "metabase-lib/parameters/utils/template-tags";
+
+import { getDefaultFormSettings } from "../../../utils";
+import {
+  newQuestion,
+  convertActionToQuestion,
+  convertQuestionToAction,
+} from "../utils";
+
+import { ActionContext } from "./ActionContext";
+import type { ActionContextProviderProps, EditorBodyProps } from "./types";
+
+export interface QueryActionContextProviderProps
+  extends ActionContextProviderProps<WritebackQueryAction> {
+  metadata: Metadata;
+  databaseId?: DatabaseId;
+}
+
+const EXAMPLE_QUERY =
+  "UPDATE products\nSET rating = {{ my_new_value }}\nWHERE id = {{ my_primary_key }}";
+
+function resolveQuestion(
+  action: WritebackQueryAction | undefined,
+  { metadata, databaseId }: { metadata: Metadata; databaseId?: DatabaseId },
+) {
+  return action
+    ? convertActionToQuestion(action, metadata)
+    : newQuestion(metadata, databaseId);
+}
+
+function QueryActionContextProvider({
+  initialAction,
+  metadata,
+  databaseId,
+  children,
+}: QueryActionContextProviderProps) {
+  const [question, setQuestion] = useState(
+    resolveQuestion(initialAction, { metadata, databaseId }),
+  );
+
+  const query = useMemo(() => question.query() as NativeQuery, [question]);
+
+  const [formSettings, setFormSettings] = useState(
+    getDefaultFormSettings(initialAction?.visualization_settings),
+  );
+
+  const action = useMemo(() => {
+    const action = convertQuestionToAction(question, formSettings);
+    return {
+      ...initialAction,
+      ...action,
+      type: "query" as const,
+    };
+  }, [initialAction, question, formSettings]);
+
+  const isNew = !initialAction && !question.isSaved();
+  const canSave = !query.isEmpty();
+
+  useEffect(() => {
+    setQuestion(resolveQuestion(initialAction, { metadata, databaseId }));
+    // we do not want to update this any time
+    // the props or metadata change, only if action id changes
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [initialAction?.id]);
+
+  const handleActionChange = useCallback(
+    (values: Partial<CreateQueryActionParams>) => {
+      let nextQuestion = question.clone();
+
+      if (values.name) {
+        nextQuestion = nextQuestion.setDisplayName(values.name);
+      }
+
+      if (values.description) {
+        nextQuestion = nextQuestion.setDescription(values.description);
+      }
+
+      setQuestion(nextQuestion);
+    },
+    [question],
+  );
+
+  const handleSetupExample = useCallback(() => {
+    const nextQuery = query.setQueryText(query.queryText() + EXAMPLE_QUERY);
+    setQuestion(question.setQuery(nextQuery));
+  }, [question, query]);
+
+  const handleQueryChange = useCallback((nextQuery: NativeQuery) => {
+    const nextQuestion = nextQuery.question();
+    const parameters = getTemplateTagParametersFromCard(nextQuestion.card());
+    setQuestion(nextQuestion.setParameters(parameters));
+  }, []);
+
+  const renderEditorBody = useCallback(
+    ({ isEditable }: EditorBodyProps) => (
+      <QueryActionEditor
+        query={query}
+        isEditable={isEditable}
+        onChangeQuestionQuery={handleQueryChange}
+      />
+    ),
+    [query, handleQueryChange],
+  );
+
+  const value = useMemo(
+    () => ({
+      action,
+      formSettings,
+      isNew,
+      canSave,
+      ui: {
+        canRename: true,
+        canChangeFieldSettings: true,
+      },
+      handleActionChange,
+      handleFormSettingsChange: setFormSettings,
+      handleSetupExample,
+      renderEditorBody,
+    }),
+    [
+      action,
+      formSettings,
+      isNew,
+      canSave,
+      handleActionChange,
+      setFormSettings,
+      handleSetupExample,
+      renderEditorBody,
+    ],
+  );
+
+  return (
+    <ActionContext.Provider value={value}>{children}</ActionContext.Provider>
+  );
+}
+
+export default QueryActionContextProvider;
diff --git a/frontend/src/metabase/actions/containers/ActionCreator/ActionContext/index.ts b/frontend/src/metabase/actions/containers/ActionCreator/ActionContext/index.ts
new file mode 100644
index 00000000000..b96f0089700
--- /dev/null
+++ b/frontend/src/metabase/actions/containers/ActionCreator/ActionContext/index.ts
@@ -0,0 +1,2 @@
+export { default } from "./ActionContextProvider";
+export { useActionContext } from "./ActionContext";
diff --git a/frontend/src/metabase/actions/containers/ActionCreator/ActionContext/types.ts b/frontend/src/metabase/actions/containers/ActionCreator/ActionContext/types.ts
new file mode 100644
index 00000000000..b89cd2988a2
--- /dev/null
+++ b/frontend/src/metabase/actions/containers/ActionCreator/ActionContext/types.ts
@@ -0,0 +1,16 @@
+import type { ReactNode } from "react";
+import type { WritebackAction } from "metabase-types/api";
+
+export type EditableActionParams = Pick<
+  Partial<WritebackAction>,
+  "name" | "description"
+>;
+
+export type EditorBodyProps = {
+  isEditable: boolean;
+};
+
+export interface ActionContextProviderProps<T = WritebackAction> {
+  initialAction?: T;
+  children: ReactNode;
+}
diff --git a/frontend/src/metabase/actions/containers/ActionCreator/ActionContext/utils.ts b/frontend/src/metabase/actions/containers/ActionCreator/ActionContext/utils.ts
new file mode 100644
index 00000000000..7ad43d3b579
--- /dev/null
+++ b/frontend/src/metabase/actions/containers/ActionCreator/ActionContext/utils.ts
@@ -0,0 +1,22 @@
+import type { WritebackQueryAction } from "metabase-types/api";
+import { getDefaultFormSettings } from "./../../../utils";
+
+export function createEmptyWritebackAction(): Partial<WritebackQueryAction> {
+  return {
+    name: "",
+    description: null,
+    type: "query",
+    public_uuid: null,
+    parameters: [],
+    dataset_query: {
+      type: "native",
+      // @ts-expect-error — this is a valid unsaved query state
+      // We could allow nulls in the query type, but that'd require a lot of changes
+      database: null,
+      native: {
+        query: "",
+      },
+    },
+    visualization_settings: getDefaultFormSettings(),
+  };
+}
diff --git a/frontend/src/metabase/actions/containers/ActionCreator/ActionCreator.tsx b/frontend/src/metabase/actions/containers/ActionCreator/ActionCreator.tsx
index 04963afb9f5..29d1758c04d 100644
--- a/frontend/src/metabase/actions/containers/ActionCreator/ActionCreator.tsx
+++ b/frontend/src/metabase/actions/containers/ActionCreator/ActionCreator.tsx
@@ -1,4 +1,4 @@
-import React, { useState, useEffect, useCallback } from "react";
+import React, { useState } from "react";
 import { t } from "ttag";
 import _ from "underscore";
 import { connect } from "react-redux";
@@ -6,34 +6,26 @@ import { connect } from "react-redux";
 import Modal from "metabase/components/Modal";
 
 import Actions, {
-  CreateQueryActionParams,
-  UpdateQueryActionParams,
+  CreateActionParams,
+  UpdateActionParams,
 } from "metabase/entities/actions";
 import Database from "metabase/entities/databases";
 import Questions from "metabase/entities/questions";
 import { getMetadata } from "metabase/selectors/metadata";
 
 import type {
-  ActionFormSettings,
   Card,
-  DatabaseId,
   WritebackActionId,
+  WritebackAction,
   WritebackQueryAction,
 } from "metabase-types/api";
 import type { State } from "metabase-types/store";
 
 import Question from "metabase-lib/Question";
-import NativeQuery from "metabase-lib/queries/NativeQuery";
-import Metadata from "metabase-lib/metadata/Metadata";
+import type Metadata from "metabase-lib/metadata/Metadata";
 
-import { getTemplateTagParametersFromCard } from "metabase-lib/parameters/utils/template-tags";
-
-import { getDefaultFormSettings } from "../../utils";
-import {
-  newQuestion,
-  convertActionToQuestion,
-  convertQuestionToAction,
-} from "./utils";
+import { isSavedAction } from "../../utils";
+import ActionContext, { useActionContext } from "./ActionContext";
 import ActionCreatorView from "./ActionCreatorView";
 import CreateActionForm, {
   FormValues as CreateActionFormValues,
@@ -47,7 +39,7 @@ interface OwnProps {
 }
 
 interface ActionLoaderProps {
-  action?: WritebackQueryAction;
+  initialAction?: WritebackAction;
 }
 
 interface ModelLoaderProps {
@@ -60,8 +52,8 @@ interface StateProps {
 }
 
 interface DispatchProps {
-  onCreateAction: (params: CreateQueryActionParams) => void;
-  onUpdateAction: (params: UpdateQueryActionParams) => void;
+  onCreateAction: (params: CreateActionParams) => void;
+  onUpdateAction: (params: UpdateActionParams) => void;
 }
 
 export type ActionCreatorProps = OwnProps;
@@ -82,119 +74,90 @@ const mapDispatchToProps = {
   onUpdateAction: Actions.actions.update,
 };
 
-const EXAMPLE_QUERY =
-  "UPDATE products\nSET rating = {{ my_new_value }}\nWHERE id = {{ my_primary_key }}";
-
-function resolveQuestion(
-  action: WritebackQueryAction | undefined,
-  { metadata, databaseId }: { metadata: Metadata; databaseId?: DatabaseId },
-) {
-  return action
-    ? convertActionToQuestion(action, metadata)
-    : newQuestion(metadata, databaseId);
-}
-
 function ActionCreator({
-  action,
   model,
-  databaseId,
-  metadata,
   onCreateAction,
   onUpdateAction,
   onClose,
 }: Props) {
-  const [question, setQuestion] = useState(
-    resolveQuestion(action, { metadata, databaseId }),
-  );
-
-  const [formSettings, setFormSettings] = useState<ActionFormSettings>(
-    getDefaultFormSettings(action?.visualization_settings),
-  );
+  const {
+    action,
+    formSettings,
+    isNew,
+    canSave,
+    ui: UIProps,
+    handleActionChange,
+    handleFormSettingsChange,
+    handleSetupExample,
+    renderEditorBody,
+  } = useActionContext();
 
   const [showSaveModal, setShowSaveModal] = useState(false);
 
-  const query = question.query() as NativeQuery;
-  const isNew = !action && !question.isSaved();
   const isEditable = model.canWriteActions();
 
-  useEffect(() => {
-    setQuestion(resolveQuestion(action, { metadata, databaseId }));
-
-    // we do not want to update this any time the props or metadata change, only if action id changes
-  }, [action?.id]); // eslint-disable-line react-hooks/exhaustive-deps
-
-  const handleChangeQuestionQuery = useCallback(
-    (newQuery: NativeQuery) => {
-      const newQuestion = newQuery.question();
-      const newParams = getTemplateTagParametersFromCard(newQuestion.card());
-      setQuestion(newQuestion.setQuery(newQuery).setParameters(newParams));
-    },
-    [setQuestion],
-  );
-
-  const handleClickSave = () => {
-    if (isNew) {
-      setShowSaveModal(true);
-    } else {
-      const action = convertQuestionToAction(question, formSettings);
-      onUpdateAction({ ...action, model_id: model.id() });
-      onClose?.();
+  const handleCreate = async (values: CreateActionFormValues) => {
+    if (action.type !== "query") {
+      return; // only query action creation is supported now
     }
-  };
 
-  const handleCreate = async (values: CreateActionFormValues) => {
-    const action = convertQuestionToAction(question, formSettings);
     await onCreateAction({
       ...action,
       ...values,
-      type: "query",
-    });
+      visualization_settings: formSettings,
+    } as WritebackQueryAction);
 
-    const nextQuestion = question
-      .setDisplayName(values.name)
-      .setDescription(values.description);
-    setQuestion(nextQuestion);
+    // Sync the editor state with data from save modal form
+    handleActionChange(values);
 
     setShowSaveModal(false);
     onClose?.();
   };
 
-  const handleCloseNewActionModal = () => setShowSaveModal(false);
+  const handleUpdate = () => {
+    if (isSavedAction(action)) {
+      onUpdateAction({
+        ...action,
+        model_id: model.id(),
+        visualization_settings: formSettings,
+      });
+    }
+  };
 
-  const handleClickExample = () => {
-    setQuestion(
-      question.setQuery(query.setQueryText(query.queryText() + EXAMPLE_QUERY)),
-    );
+  const handleClickSave = () => {
+    if (isNew) {
+      setShowSaveModal(true);
+    } else {
+      handleUpdate();
+      onClose?.();
+    }
   };
 
-  if (!question || !metadata) {
-    return null;
-  }
+  const handleCloseNewActionModal = () => setShowSaveModal(false);
 
   return (
     <>
       <ActionCreatorView
-        isNew={isNew}
-        isEditable={isEditable}
-        canSave={query.isEmpty()}
+        {...UIProps}
         action={action}
-        question={question}
         formSettings={formSettings}
-        onChangeQuestionQuery={handleChangeQuestionQuery}
-        onChangeName={newName =>
-          setQuestion(question => question.setDisplayName(newName))
-        }
-        onCloseModal={onClose}
-        onChangeFormSettings={setFormSettings}
+        canSave={canSave}
+        isNew={isNew}
+        isEditable={isEditable}
+        onChangeAction={handleActionChange}
+        onChangeFormSettings={handleFormSettingsChange}
         onClickSave={handleClickSave}
-        onClickExample={handleClickExample}
-      />
+        onClickExample={handleSetupExample}
+        onCloseModal={onClose}
+      >
+        {renderEditorBody({ isEditable })}
+      </ActionCreatorView>
       {showSaveModal && (
         <Modal title={t`New Action`} onClose={handleCloseNewActionModal}>
           <CreateActionForm
             initialValues={{
-              name: question.displayName() ?? "",
-              description: question.description(),
+              name: action.name,
+              description: action.description,
               model_id: model.id(),
             }}
             onCreate={handleCreate}
@@ -206,9 +169,33 @@ function ActionCreator({
   );
 }
 
+function ActionCreatorWithContext({
+  initialAction,
+  metadata,
+  databaseId,
+  ...props
+}: Props) {
+  return (
+    <ActionContext
+      initialAction={initialAction}
+      databaseId={databaseId}
+      metadata={metadata}
+    >
+      <ActionCreator
+        {...props}
+        initialAction={initialAction}
+        databaseId={databaseId}
+        metadata={metadata}
+      />
+    </ActionContext>
+  );
+}
+
 export default _.compose(
   Actions.load({
     id: (state: State, props: OwnProps) => props.actionId,
+    loadingAndErrorWrapper: false,
+    entityAlias: "initialAction",
   }),
   Questions.load({
     id: (state: State, props: OwnProps) => props.modelId,
@@ -216,4 +203,4 @@ export default _.compose(
   }),
   Database.loadList(),
   connect(mapStateToProps, mapDispatchToProps),
-)(ActionCreator);
+)(ActionCreatorWithContext);
diff --git a/frontend/src/metabase/actions/containers/ActionCreator/ActionCreator.unit.spec.tsx b/frontend/src/metabase/actions/containers/ActionCreator/ActionCreator.unit.spec.tsx
deleted file mode 100644
index b3dc8337acc..00000000000
--- a/frontend/src/metabase/actions/containers/ActionCreator/ActionCreator.unit.spec.tsx
+++ /dev/null
@@ -1,333 +0,0 @@
-import React from "react";
-import nock from "nock";
-import userEvent from "@testing-library/user-event";
-
-import {
-  renderWithProviders,
-  screen,
-  waitFor,
-  waitForElementToBeRemoved,
-  getIcon,
-  queryIcon,
-} from "__support__/ui";
-import {
-  setupCardsEndpoints,
-  setupDatabasesEndpoints,
-} from "__support__/server-mocks";
-
-import { WritebackQueryAction } from "metabase-types/api";
-import {
-  createMockActionParameter,
-  createMockCard,
-  createMockDatabase,
-  createMockQueryAction,
-  createMockUser,
-} from "metabase-types/api/mocks";
-import {
-  createMockSettingsState,
-  createMockState,
-} from "metabase-types/store/mocks";
-
-import ActionCreator from "./ActionCreator";
-
-type SetupOpts = {
-  action?: WritebackQueryAction;
-  canWrite?: boolean;
-  hasActionsEnabled?: boolean;
-  isAdmin?: boolean;
-  isPublicSharingEnabled?: boolean;
-};
-
-async function setup({
-  action,
-  canWrite = true,
-  hasActionsEnabled = true,
-  isAdmin = false,
-  isPublicSharingEnabled = false,
-}: SetupOpts = {}) {
-  const scope = nock(location.origin);
-  const model = createMockCard({
-    dataset: true,
-    can_write: canWrite,
-  });
-  const database = createMockDatabase({
-    settings: { "database-enable-actions": hasActionsEnabled },
-  });
-
-  setupDatabasesEndpoints(scope, [database]);
-  setupCardsEndpoints(scope, [model]);
-
-  if (action) {
-    scope.get(`/api/action/${action.id}`).reply(200, action);
-    scope.delete(`/api/action/${action.id}/public_link`).reply(204);
-    scope
-      .post(`/api/action/${action.id}/public_link`)
-      .reply(200, { uuid: "mock-uuid" });
-  }
-
-  renderWithProviders(
-    <ActionCreator actionId={action?.id} modelId={model.id} />,
-    {
-      storeInitialState: createMockState({
-        currentUser: createMockUser({
-          is_superuser: isAdmin,
-        }),
-        settings: createMockSettingsState({
-          "enable-public-sharing": isPublicSharingEnabled,
-          "site-url": SITE_URL,
-        }),
-      }),
-    },
-  );
-
-  await waitForElementToBeRemoved(() =>
-    screen.queryByTestId("loading-spinner"),
-  );
-}
-
-async function setupEditing({
-  action = createMockQueryAction(),
-  ...opts
-}: SetupOpts = {}) {
-  await setup({ action, ...opts });
-  return { action };
-}
-
-const SITE_URL = "http://localhost:3000";
-
-describe("ActionCreator", () => {
-  afterEach(() => {
-    nock.cleanAll();
-  });
-
-  describe("new action", () => {
-    it("renders correctly", async () => {
-      await setup();
-
-      expect(screen.getByText(/New action/i)).toBeInTheDocument();
-      expect(
-        screen.getByTestId("mock-native-query-editor"),
-      ).toBeInTheDocument();
-      expect(screen.getByRole("button", { name: "Save" })).toBeInTheDocument();
-      expect(
-        screen.queryByRole("button", { name: "Update" }),
-      ).not.toBeInTheDocument();
-      expect(
-        screen.getByRole("button", { name: "Cancel" }),
-      ).toBeInTheDocument();
-    });
-
-    it("should disable submit by default", async () => {
-      await setup();
-      expect(screen.getByRole("button", { name: "Save" })).toBeDisabled();
-      expect(screen.getByRole("button", { name: "Cancel" })).toBeEnabled();
-    });
-
-    it("should show clickable data reference icon", async () => {
-      await setup();
-      getIcon("reference", "button").click();
-
-      expect(screen.getByText("Data Reference")).toBeInTheDocument();
-      expect(screen.getByText("Database")).toBeInTheDocument();
-    });
-
-    it("should show action settings button", async () => {
-      await setup({ isAdmin: true, isPublicSharingEnabled: true });
-      expect(
-        screen.getByRole("button", { name: "Action settings" }),
-      ).toBeInTheDocument();
-    });
-  });
-
-  describe("editing action", () => {
-    it("renders correctly", async () => {
-      const { action } = await setupEditing();
-
-      expect(screen.getByText(action.name)).toBeInTheDocument();
-      expect(screen.queryByText(/New action/i)).not.toBeInTheDocument();
-      expect(
-        screen.getByTestId("mock-native-query-editor"),
-      ).toBeInTheDocument();
-      expect(
-        await screen.findByRole("button", { name: "Update" }),
-      ).toBeInTheDocument();
-      expect(
-        screen.queryByRole("button", { name: "Create" }),
-      ).not.toBeInTheDocument();
-      expect(
-        screen.getByRole("button", { name: "Cancel" }),
-      ).toBeInTheDocument();
-    });
-
-    it("renders parameters", async () => {
-      const action = createMockQueryAction({
-        parameters: [createMockActionParameter({ name: "FooBar" })],
-      });
-      await setupEditing({ action });
-
-      expect(screen.getByText("FooBar")).toBeInTheDocument();
-    });
-
-    it("blocks editing if the user doesn't have write permissions for the collection", async () => {
-      const action = createMockQueryAction({
-        parameters: [createMockActionParameter({ name: "FooBar" })],
-      });
-      await setupEditing({ action, canWrite: false });
-
-      expect(screen.getByDisplayValue(action.name)).toBeDisabled();
-      expect(queryIcon("grabber2")).not.toBeInTheDocument();
-      expect(screen.queryByLabelText("Field settings")).not.toBeInTheDocument();
-      expect(
-        screen.queryByRole("button", { name: "Update" }),
-      ).not.toBeInTheDocument();
-
-      screen.getByLabelText("Action settings").click();
-
-      expect(screen.getByLabelText("Success message")).toBeDisabled();
-    });
-
-    it("blocks editing if actions are disabled for the database", async () => {
-      const action = createMockQueryAction({
-        parameters: [createMockActionParameter({ name: "FooBar" })],
-      });
-      await setupEditing({ action, hasActionsEnabled: false });
-
-      expect(screen.getByDisplayValue(action.name)).toBeDisabled();
-      expect(queryIcon("grabber2")).not.toBeInTheDocument();
-      expect(screen.queryByLabelText("Field settings")).not.toBeInTheDocument();
-      expect(
-        screen.queryByRole("button", { name: "Update" }),
-      ).not.toBeInTheDocument();
-
-      screen.getByLabelText("Action settings").click();
-
-      expect(screen.getByLabelText("Success message")).toBeDisabled();
-    });
-
-    describe("admin users and has public sharing enabled", () => {
-      const mockUuid = "mock-uuid";
-
-      it("should show action settings button", async () => {
-        await setupEditing({
-          isAdmin: true,
-          isPublicSharingEnabled: true,
-        });
-
-        expect(
-          screen.getByRole("button", { name: "Action settings" }),
-        ).toBeInTheDocument();
-      });
-
-      it("should be able to enable action public sharing", async () => {
-        await setupEditing({
-          isAdmin: true,
-          isPublicSharingEnabled: true,
-        });
-
-        screen.getByRole("button", { name: "Action settings" }).click();
-
-        expect(screen.getByText("Action settings")).toBeInTheDocument();
-        const makePublicToggle = screen.getByRole("switch", {
-          name: "Make public",
-        });
-        expect(makePublicToggle).not.toBeChecked();
-        expect(
-          screen.queryByRole("textbox", { name: "Public action link URL" }),
-        ).not.toBeInTheDocument();
-
-        screen.getByRole("switch", { name: "Make public" }).click();
-
-        await waitFor(() => {
-          expect(makePublicToggle).toBeChecked();
-        });
-
-        const expectedPublicLinkUrl = `${SITE_URL}/public/action/${mockUuid}`;
-        expect(
-          screen.getByRole("textbox", { name: "Public action link URL" }),
-        ).toHaveValue(expectedPublicLinkUrl);
-      });
-
-      it("should be able to disable action public sharing", async () => {
-        await setupEditing({
-          action: createMockQueryAction({ public_uuid: mockUuid }),
-          isAdmin: true,
-          isPublicSharingEnabled: true,
-        });
-        screen.getByRole("button", { name: "Action settings" }).click();
-
-        expect(screen.getByText("Action settings")).toBeInTheDocument();
-        const makePublicToggle = screen.getByRole("switch", {
-          name: "Make public",
-        });
-        expect(makePublicToggle).toBeChecked();
-        const expectedPublicLinkUrl = `${SITE_URL}/public/action/${mockUuid}`;
-        expect(
-          screen.getByRole("textbox", { name: "Public action link URL" }),
-        ).toHaveValue(expectedPublicLinkUrl);
-
-        makePublicToggle.click();
-        expect(
-          screen.getByRole("heading", { name: "Disable this public link?" }),
-        ).toBeInTheDocument();
-        screen.getByRole("button", { name: "Yes" }).click();
-
-        await waitFor(() => {
-          expect(makePublicToggle).not.toBeChecked();
-        });
-
-        expect(
-          screen.queryByRole("textbox", { name: "Public action link URL" }),
-        ).not.toBeInTheDocument();
-      });
-
-      it("should be able to set success message", async () => {
-        await setupEditing();
-
-        userEvent.click(
-          screen.getByRole("button", { name: "Action settings" }),
-        );
-
-        userEvent.type(
-          screen.getByRole("textbox", { name: "Success message" }),
-          `Thanks!`,
-        );
-        expect(
-          screen.getByRole("textbox", { name: "Success message" }),
-        ).toHaveValue("Thanks!");
-      });
-    });
-
-    describe("no permission to see public sharing", () => {
-      it("should not show sharing settings when user is admin but public sharing is disabled", async () => {
-        await setupEditing({
-          isAdmin: true,
-          isPublicSharingEnabled: false,
-        });
-
-        userEvent.click(
-          screen.getByRole("button", { name: "Action settings" }),
-        );
-        expect(
-          screen.queryByRole("switch", {
-            name: "Make public",
-          }),
-        ).not.toBeInTheDocument();
-      });
-
-      it("should not show sharing settings when user is not admin but public sharing is enabled", async () => {
-        await setupEditing({
-          isPublicSharingEnabled: true,
-        });
-
-        userEvent.click(
-          screen.getByRole("button", { name: "Action settings" }),
-        );
-        expect(
-          screen.queryByRole("switch", {
-            name: "Make public",
-          }),
-        ).not.toBeInTheDocument();
-      });
-    });
-  });
-});
diff --git a/frontend/src/metabase/actions/containers/ActionCreator/ActionCreatorHeader.tsx b/frontend/src/metabase/actions/containers/ActionCreator/ActionCreatorHeader.tsx
index 2267996cbb9..66901755570 100644
--- a/frontend/src/metabase/actions/containers/ActionCreator/ActionCreatorHeader.tsx
+++ b/frontend/src/metabase/actions/containers/ActionCreator/ActionCreatorHeader.tsx
@@ -15,6 +15,7 @@ type Props = {
   name: string;
   type: WritebackActionType;
   isEditable: boolean;
+  canRename: boolean;
   onChangeName: (name: string) => void;
   onChangeType?: (type: WritebackActionType) => void;
   actionButtons: React.ReactElement[];
@@ -25,6 +26,7 @@ const OPTS = [{ value: "query", name: t`Query`, disabled: true }];
 const ActionCreatorHeader = ({
   name = t`New Action`,
   isEditable,
+  canRename,
   type,
   onChangeName,
   onChangeType,
@@ -36,7 +38,7 @@ const ActionCreatorHeader = ({
         <EditableText
           initialValue={name}
           onChange={onChangeName}
-          isDisabled={!isEditable}
+          isDisabled={!isEditable || !canRename}
         />
         {!!onChangeType && (
           <CompactSelect options={OPTS} value={type} onChange={onChangeType} />
diff --git a/frontend/src/metabase/actions/containers/ActionCreator/ActionCreatorView.tsx b/frontend/src/metabase/actions/containers/ActionCreator/ActionCreatorView.tsx
index b8f6fdb9344..c87c759782f 100644
--- a/frontend/src/metabase/actions/containers/ActionCreator/ActionCreatorView.tsx
+++ b/frontend/src/metabase/actions/containers/ActionCreator/ActionCreatorView.tsx
@@ -3,7 +3,6 @@ import { t } from "ttag";
 
 import Button from "metabase/core/components/Button";
 import ActionCreatorHeader from "metabase/actions/containers/ActionCreator/ActionCreatorHeader";
-import QueryActionEditor from "metabase/actions/containers/ActionCreator/QueryActionEditor";
 import FormCreator from "metabase/actions/containers/ActionCreator/FormCreator";
 import {
   DataReferenceTriggerButton,
@@ -19,46 +18,45 @@ import {
 
 import { isNotNull } from "metabase/core/utils/types";
 import type { ActionFormSettings, WritebackAction } from "metabase-types/api";
-import type NativeQuery from "metabase-lib/queries/NativeQuery";
-import type Question from "metabase-lib/Question";
 
-import type { SideView } from "./types";
+import type { ActionCreatorUIProps, SideView } from "./types";
 import InlineActionSettings, {
   ActionSettingsTriggerButton,
 } from "./InlineActionSettings";
 
-interface ActionCreatorProps {
-  isNew: boolean;
+interface ActionCreatorProps extends ActionCreatorUIProps {
+  action: Partial<WritebackAction>;
+  formSettings: ActionFormSettings;
+
   canSave: boolean;
+  isNew: boolean;
   isEditable: boolean;
 
-  action?: WritebackAction;
-  question: Question;
-  formSettings: ActionFormSettings;
+  children: React.ReactNode;
 
-  onChangeQuestionQuery: (query: NativeQuery) => void;
-  onChangeName: (name: string) => void;
-  onCloseModal?: () => void;
+  onChangeAction: (action: Partial<WritebackAction>) => void;
   onChangeFormSettings: (formSettings: ActionFormSettings) => void;
   onClickSave: () => void;
   onClickExample: () => void;
+  onCloseModal?: () => void;
 }
 
 const DEFAULT_SIDE_VIEW: SideView = "actionForm";
 
 export default function ActionCreatorView({
-  isNew,
-  canSave,
-  isEditable,
   action,
-  question,
   formSettings,
-  onChangeQuestionQuery,
-  onChangeName,
-  onCloseModal,
+  canSave,
+  isNew,
+  isEditable,
+  canRename,
+  canChangeFieldSettings,
+  children,
+  onChangeAction,
   onChangeFormSettings,
   onClickSave,
   onClickExample,
+  onCloseModal,
 }: ActionCreatorProps) {
   const [activeSideView, setActiveSideView] =
     useState<SideView>(DEFAULT_SIDE_VIEW);
@@ -93,9 +91,10 @@ export default function ActionCreatorView({
         <ModalLeft>
           <ActionCreatorHeader
             type="query"
-            name={question.displayName() ?? t`New Action`}
+            name={action.name ?? t`New Action`}
+            canRename={canRename}
             isEditable={isEditable}
-            onChangeName={onChangeName}
+            onChangeName={name => onChangeAction({ name })}
             actionButtons={[
               <DataReferenceTriggerButton
                 key="dataReference"
@@ -107,19 +106,13 @@ export default function ActionCreatorView({
               />,
             ].filter(isNotNull)}
           />
-          <EditorContainer>
-            <QueryActionEditor
-              query={question.query() as NativeQuery}
-              isEditable={isEditable}
-              onChangeQuestionQuery={onChangeQuestionQuery}
-            />
-          </EditorContainer>
+          <EditorContainer>{children}</EditorContainer>
           <ModalActions>
             <Button onClick={onCloseModal} borderless>
               {t`Cancel`}
             </Button>
             {isEditable && (
-              <Button primary disabled={canSave} onClick={onClickSave}>
+              <Button primary disabled={!canSave} onClick={onClickSave}>
                 {isNew ? t`Save` : t`Update`}
               </Button>
             )}
@@ -127,9 +120,9 @@ export default function ActionCreatorView({
         </ModalLeft>
         {activeSideView === "actionForm" ? (
           <FormCreator
-            params={question.parameters() ?? []}
+            params={action.parameters ?? []}
             formSettings={formSettings}
-            isEditable={isEditable}
+            isEditable={isEditable && canChangeFieldSettings}
             onChange={onChangeFormSettings}
             onExampleClick={onClickExample}
           />
diff --git a/frontend/src/metabase/actions/containers/ActionCreator/FormCreator/utils.ts b/frontend/src/metabase/actions/containers/ActionCreator/FormCreator/utils.ts
index 0aaec9bacc0..d7a2b2d0ce7 100644
--- a/frontend/src/metabase/actions/containers/ActionCreator/FormCreator/utils.ts
+++ b/frontend/src/metabase/actions/containers/ActionCreator/FormCreator/utils.ts
@@ -60,6 +60,9 @@ export const reorderFields = (
 };
 
 export const hasNewParams = (
-  params: Parameter[],
+  parameters: Parameter[],
   formSettings: ActionFormSettings,
-) => !!params.find(param => !formSettings.fields[param.id]);
+) => {
+  const fieldIds = Object.keys(formSettings.fields || {});
+  return parameters.some(parameter => !fieldIds.includes(parameter.id));
+};
diff --git a/frontend/src/metabase/actions/containers/ActionCreator/InlineActionSettings.tsx b/frontend/src/metabase/actions/containers/ActionCreator/InlineActionSettings.tsx
index e7f00340f91..650d049899f 100644
--- a/frontend/src/metabase/actions/containers/ActionCreator/InlineActionSettings.tsx
+++ b/frontend/src/metabase/actions/containers/ActionCreator/InlineActionSettings.tsx
@@ -27,6 +27,7 @@ import Modal from "metabase/components/Modal";
 import { useToggle } from "metabase/hooks/use-toggle";
 import CopyWidget from "metabase/components/CopyWidget";
 
+import { isSavedAction } from "../../utils";
 import {
   ActionSettingsContainer,
   ActionSettingsContent,
@@ -34,7 +35,7 @@ import {
 } from "./InlineActionSettings.styled";
 
 interface OwnProps {
-  action?: WritebackAction;
+  action?: Partial<WritebackAction>;
   formSettings: ActionFormSettings;
   isEditable: boolean;
   onChangeFormSettings: (formSettings: ActionFormSettings) => void;
@@ -99,14 +100,18 @@ const InlineActionSettings = ({
 
   const handleTogglePublic = (isPublic: boolean) => {
     if (isPublic) {
-      action && onCreatePublicLink({ id: action.id });
+      if (isSavedAction(action)) {
+        onCreatePublicLink({ id: action.id });
+      }
     } else {
       openModal();
     }
   };
 
   const handleDisablePublicLink = () => {
-    action && onDeletePublicLink({ id: action.id });
+    if (isSavedAction(action)) {
+      onDeletePublicLink({ id: action.id });
+    }
   };
 
   const handleSuccessMessageChange = (
diff --git a/frontend/src/metabase/actions/containers/ActionCreator/tests/ActionCreator-Common.unit.spec.tsx b/frontend/src/metabase/actions/containers/ActionCreator/tests/ActionCreator-Common.unit.spec.tsx
new file mode 100644
index 00000000000..cb4a6f94df3
--- /dev/null
+++ b/frontend/src/metabase/actions/containers/ActionCreator/tests/ActionCreator-Common.unit.spec.tsx
@@ -0,0 +1,76 @@
+import nock from "nock";
+import userEvent from "@testing-library/user-event";
+
+import { screen } from "__support__/ui";
+
+import {
+  createMockImplicitQueryAction,
+  createMockQueryAction,
+} from "metabase-types/api/mocks";
+
+import { setup as baseSetup, SetupOpts } from "./common";
+
+async function setup({
+  action = createMockImplicitQueryAction(),
+  ...opts
+}: SetupOpts = {}) {
+  await baseSetup({ action, ...opts });
+  return { action };
+}
+
+describe("ActionCreator > Common", () => {
+  afterEach(() => {
+    nock.cleanAll();
+  });
+
+  describe.each([
+    ["query", createMockQueryAction],
+    ["implicit", createMockImplicitQueryAction],
+  ])(`%s actions`, (_, getAction) => {
+    describe("with write permissions", () => {
+      it("should show action settings button", async () => {
+        await setup({ action: getAction(), canWrite: true });
+        const button = screen.getByRole("button", { name: "Action settings" });
+        expect(button).toBeInTheDocument();
+      });
+
+      it("should be able to set success message", async () => {
+        await setup();
+
+        userEvent.click(
+          screen.getByRole("button", { name: "Action settings" }),
+        );
+
+        userEvent.type(
+          screen.getByRole("textbox", { name: "Success message" }),
+          `Thanks!`,
+        );
+        expect(
+          screen.getByRole("textbox", { name: "Success message" }),
+        ).toHaveValue("Thanks!");
+      });
+    });
+
+    describe("with read-only permissions", () => {
+      it("should show action settings button", async () => {
+        await setup({ action: getAction(), canWrite: false });
+        const button = screen.getByRole("button", { name: "Action settings" });
+        expect(button).toBeInTheDocument();
+      });
+
+      it("should not allow editing success message", async () => {
+        await setup({ canWrite: false });
+
+        userEvent.click(
+          screen.getByRole("button", { name: "Action settings" }),
+        );
+
+        expect(
+          screen.getByRole("textbox", {
+            name: "Success message",
+          }),
+        ).toBeDisabled();
+      });
+    });
+  });
+});
diff --git a/frontend/src/metabase/actions/containers/ActionCreator/tests/ActionCreator-ImplicitActions.unit.spec.ts b/frontend/src/metabase/actions/containers/ActionCreator/tests/ActionCreator-ImplicitActions.unit.spec.ts
new file mode 100644
index 00000000000..a4a01f29051
--- /dev/null
+++ b/frontend/src/metabase/actions/containers/ActionCreator/tests/ActionCreator-ImplicitActions.unit.spec.ts
@@ -0,0 +1,80 @@
+import nock from "nock";
+
+import { screen, queryIcon } from "__support__/ui";
+
+import {
+  createMockActionParameter,
+  createMockImplicitQueryAction,
+} from "metabase-types/api/mocks";
+
+import { setup as baseSetup, SetupOpts } from "./common";
+
+async function setup({
+  action = createMockImplicitQueryAction(),
+  ...opts
+}: SetupOpts = {}) {
+  await baseSetup({ action, ...opts });
+  return { action };
+}
+
+describe("ActionCreator > Implicit Actions", () => {
+  afterEach(() => {
+    nock.cleanAll();
+  });
+
+  it("renders correctly", async () => {
+    const { action } = await setup();
+
+    expect(screen.getByText(action.name)).toBeInTheDocument();
+    expect(screen.getByText("Auto tracking schema")).toBeInTheDocument();
+    expect(screen.getByRole("button", { name: "Cancel" })).toBeInTheDocument();
+
+    expect(screen.queryByText(/New action/i)).not.toBeInTheDocument();
+    expect(
+      screen.queryByTestId("mock-native-query-editor"),
+    ).not.toBeInTheDocument();
+  });
+
+  it("renders parameters", async () => {
+    await setup({
+      action: createMockImplicitQueryAction({
+        parameters: [createMockActionParameter({ name: "FooBar" })],
+      }),
+    });
+
+    expect(screen.getByText("FooBar")).toBeInTheDocument();
+  });
+
+  it("allows only form settings changes", async () => {
+    const { action } = await setup({
+      action: createMockImplicitQueryAction({
+        parameters: [createMockActionParameter({ name: "FooBar" })],
+      }),
+    });
+
+    expect(screen.getByDisplayValue(action.name)).toBeDisabled();
+    expect(screen.queryByLabelText("Field settings")).not.toBeInTheDocument();
+    expect(queryIcon("grabber2")).not.toBeInTheDocument();
+  });
+
+  it("blocks editing if the user doesn't have write permissions for the collection", async () => {
+    const { action } = await setup({
+      action: createMockImplicitQueryAction({
+        parameters: [createMockActionParameter({ name: "FooBar" })],
+      }),
+      canWrite: false,
+    });
+
+    expect(screen.getByDisplayValue(action.name)).toBeDisabled();
+
+    expect(screen.queryByLabelText("Field settings")).not.toBeInTheDocument();
+    expect(queryIcon("grabber2")).not.toBeInTheDocument();
+
+    expect(
+      screen.queryByRole("button", { name: "Update" }),
+    ).not.toBeInTheDocument();
+    expect(
+      screen.queryByRole("button", { name: "Create" }),
+    ).not.toBeInTheDocument();
+  });
+});
diff --git a/frontend/src/metabase/actions/containers/ActionCreator/tests/ActionCreator-QueryActions.unit.spec.ts b/frontend/src/metabase/actions/containers/ActionCreator/tests/ActionCreator-QueryActions.unit.spec.ts
new file mode 100644
index 00000000000..7f8b710f1f1
--- /dev/null
+++ b/frontend/src/metabase/actions/containers/ActionCreator/tests/ActionCreator-QueryActions.unit.spec.ts
@@ -0,0 +1,123 @@
+import nock from "nock";
+
+import { screen, getIcon, queryIcon } from "__support__/ui";
+
+import {
+  createMockActionParameter,
+  createMockQueryAction,
+} from "metabase-types/api/mocks";
+
+import { setup } from "./common";
+
+describe("ActionCreator > Query Actions", () => {
+  afterEach(() => {
+    nock.cleanAll();
+  });
+
+  describe("new action", () => {
+    it("renders correctly", async () => {
+      await setup();
+
+      expect(screen.getByText(/New action/i)).toBeInTheDocument();
+      expect(
+        screen.getByTestId("mock-native-query-editor"),
+      ).toBeInTheDocument();
+      expect(screen.getByRole("button", { name: "Save" })).toBeInTheDocument();
+      expect(
+        screen.queryByRole("button", { name: "Update" }),
+      ).not.toBeInTheDocument();
+      expect(
+        screen.getByRole("button", { name: "Cancel" }),
+      ).toBeInTheDocument();
+    });
+
+    it("should disable submit by default", async () => {
+      await setup();
+      expect(screen.getByRole("button", { name: "Save" })).toBeDisabled();
+      expect(screen.getByRole("button", { name: "Cancel" })).toBeEnabled();
+    });
+
+    it("should show clickable data reference icon", async () => {
+      await setup();
+      getIcon("reference", "button").click();
+
+      expect(screen.getByText("Data Reference")).toBeInTheDocument();
+      expect(screen.getByText("Database")).toBeInTheDocument();
+    });
+
+    it("should show action settings button", async () => {
+      await setup();
+      expect(
+        screen.getByRole("button", { name: "Action settings" }),
+      ).toBeInTheDocument();
+    });
+  });
+
+  describe("editing action", () => {
+    it("renders correctly", async () => {
+      const action = createMockQueryAction();
+      await setup({ action });
+
+      expect(screen.getByText(action.name)).toBeInTheDocument();
+      expect(screen.queryByText(/New action/i)).not.toBeInTheDocument();
+      expect(
+        screen.getByTestId("mock-native-query-editor"),
+      ).toBeInTheDocument();
+      expect(
+        await screen.findByRole("button", { name: "Update" }),
+      ).toBeInTheDocument();
+      expect(
+        screen.queryByRole("button", { name: "Create" }),
+      ).not.toBeInTheDocument();
+      expect(
+        screen.getByRole("button", { name: "Cancel" }),
+      ).toBeInTheDocument();
+    });
+
+    it("renders parameters", async () => {
+      await setup({
+        action: createMockQueryAction({
+          parameters: [createMockActionParameter({ name: "FooBar" })],
+        }),
+      });
+
+      expect(screen.getByText("FooBar")).toBeInTheDocument();
+    });
+
+    it("blocks editing if the user doesn't have write permissions for the collection", async () => {
+      const action = createMockQueryAction({
+        parameters: [createMockActionParameter({ name: "FooBar" })],
+      });
+      await setup({ action, canWrite: false });
+
+      expect(screen.getByDisplayValue(action.name)).toBeDisabled();
+      expect(queryIcon("grabber2")).not.toBeInTheDocument();
+      expect(screen.queryByLabelText("Field settings")).not.toBeInTheDocument();
+      expect(
+        screen.queryByRole("button", { name: "Update" }),
+      ).not.toBeInTheDocument();
+
+      screen.getByLabelText("Action settings").click();
+
+      expect(screen.getByLabelText("Success message")).toBeDisabled();
+    });
+
+    it("blocks editing if actions are disabled for the database", async () => {
+      const action = createMockQueryAction({
+        parameters: [createMockActionParameter({ name: "FooBar" })],
+      });
+      await setup({ action, hasActionsEnabled: false });
+
+      expect(screen.getByDisplayValue(action.name)).toBeDisabled();
+      expect(queryIcon("grabber2")).not.toBeInTheDocument();
+      expect(screen.queryByLabelText("Field settings")).not.toBeInTheDocument();
+      expect(
+        screen.queryByRole("button", { name: "Update" }),
+      ).not.toBeInTheDocument();
+
+      screen.getByLabelText("Action settings").click();
+
+      expect(screen.getByLabelText("Success message")).toBeDisabled();
+    });
+  });
+});
diff --git a/frontend/src/metabase/actions/containers/ActionCreator/tests/ActionCreator-Sharing.unit.spec.tsx b/frontend/src/metabase/actions/containers/ActionCreator/tests/ActionCreator-Sharing.unit.spec.tsx
new file mode 100644
index 00000000000..ef8147f1dc8
--- /dev/null
+++ b/frontend/src/metabase/actions/containers/ActionCreator/tests/ActionCreator-Sharing.unit.spec.tsx
@@ -0,0 +1,146 @@
+import nock from "nock";
+import userEvent from "@testing-library/user-event";
+
+import { screen, waitFor } from "__support__/ui";
+
+import {
+  createMockImplicitQueryAction,
+  createMockQueryAction,
+} from "metabase-types/api/mocks";
+
+import { setup as baseSetup, SITE_URL, SetupOpts } from "./common";
+
+async function setup({
+  action = createMockImplicitQueryAction(),
+  ...opts
+}: SetupOpts = {}) {
+  await baseSetup({ action, ...opts });
+  return { action };
+}
+
+describe("ActionCreator > Sharing", () => {
+  afterEach(() => {
+    nock.cleanAll();
+  });
+
+  describe.each([
+    ["query", createMockQueryAction],
+    ["implicit", createMockImplicitQueryAction],
+  ])(`%s actions`, (_, getAction) => {
+    describe("admin users and has public sharing enabled", () => {
+      const mockUuid = "mock-uuid";
+      const privateAction = getAction();
+      const publicAction = getAction({ public_uuid: mockUuid });
+
+      it("should show action settings button", async () => {
+        await setup({
+          action: privateAction,
+          isAdmin: true,
+          isPublicSharingEnabled: true,
+        });
+
+        expect(
+          screen.getByRole("button", { name: "Action settings" }),
+        ).toBeInTheDocument();
+      });
+
+      it("should be able to enable action public sharing", async () => {
+        await setup({
+          action: privateAction,
+          isAdmin: true,
+          isPublicSharingEnabled: true,
+        });
+
+        screen.getByRole("button", { name: "Action settings" }).click();
+
+        expect(screen.getByText("Action settings")).toBeInTheDocument();
+        const makePublicToggle = screen.getByRole("switch", {
+          name: "Make public",
+        });
+        expect(makePublicToggle).not.toBeChecked();
+        expect(
+          screen.queryByRole("textbox", { name: "Public action link URL" }),
+        ).not.toBeInTheDocument();
+
+        screen.getByRole("switch", { name: "Make public" }).click();
+
+        await waitFor(() => {
+          expect(makePublicToggle).toBeChecked();
+        });
+
+        const expectedPublicLinkUrl = `${SITE_URL}/public/action/${mockUuid}`;
+        expect(
+          screen.getByRole("textbox", { name: "Public action link URL" }),
+        ).toHaveValue(expectedPublicLinkUrl);
+      });
+
+      it("should be able to disable action public sharing", async () => {
+        await setup({
+          action: publicAction,
+          isAdmin: true,
+          isPublicSharingEnabled: true,
+        });
+        screen.getByRole("button", { name: "Action settings" }).click();
+
+        expect(screen.getByText("Action settings")).toBeInTheDocument();
+        const makePublicToggle = screen.getByRole("switch", {
+          name: "Make public",
+        });
+        expect(makePublicToggle).toBeChecked();
+        const expectedPublicLinkUrl = `${SITE_URL}/public/action/${mockUuid}`;
+        expect(
+          screen.getByRole("textbox", { name: "Public action link URL" }),
+        ).toHaveValue(expectedPublicLinkUrl);
+
+        makePublicToggle.click();
+        expect(
+          screen.getByRole("heading", { name: "Disable this public link?" }),
+        ).toBeInTheDocument();
+        screen.getByRole("button", { name: "Yes" }).click();
+
+        await waitFor(() => {
+          expect(makePublicToggle).not.toBeChecked();
+        });
+
+        expect(
+          screen.queryByRole("textbox", { name: "Public action link URL" }),
+        ).not.toBeInTheDocument();
+      });
+    });
+
+    describe("no permission to see public sharing", () => {
+      it("should not show sharing settings when user is admin but public sharing is disabled", async () => {
+        await setup({
+          action: getAction(),
+          isAdmin: true,
+          isPublicSharingEnabled: false,
+        });
+
+        userEvent.click(
+          screen.getByRole("button", { name: "Action settings" }),
+        );
+        expect(
+          screen.queryByRole("switch", {
+            name: "Make public",
+          }),
+        ).not.toBeInTheDocument();
+      });
+
+      it("should not show sharing settings when user is not admin but public sharing is enabled", async () => {
+        await setup({
+          action: getAction(),
+          isPublicSharingEnabled: true,
+        });
+
+        userEvent.click(
+          screen.getByRole("button", { name: "Action settings" }),
+        );
+        expect(
+          screen.queryByRole("switch", {
+            name: "Make public",
+          }),
+        ).not.toBeInTheDocument();
+      });
+    });
+  });
+});
diff --git a/frontend/src/metabase/actions/containers/ActionCreator/tests/common.tsx b/frontend/src/metabase/actions/containers/ActionCreator/tests/common.tsx
new file mode 100644
index 00000000000..623eb1c9457
--- /dev/null
+++ b/frontend/src/metabase/actions/containers/ActionCreator/tests/common.tsx
@@ -0,0 +1,83 @@
+/* istanbul ignore file */
+import React from "react";
+import nock from "nock";
+
+import {
+  renderWithProviders,
+  screen,
+  waitForElementToBeRemoved,
+} from "__support__/ui";
+import {
+  setupCardsEndpoints,
+  setupDatabasesEndpoints,
+} from "__support__/server-mocks";
+
+import type { WritebackAction } from "metabase-types/api";
+import {
+  createMockCard,
+  createMockDatabase,
+  createMockUser,
+} from "metabase-types/api/mocks";
+import {
+  createMockSettingsState,
+  createMockState,
+} from "metabase-types/store/mocks";
+
+import ActionCreator from "../ActionCreator";
+
+export const SITE_URL = "http://localhost:3000";
+
+export type SetupOpts = {
+  action?: WritebackAction;
+  canWrite?: boolean;
+  hasActionsEnabled?: boolean;
+  isAdmin?: boolean;
+  isPublicSharingEnabled?: boolean;
+};
+
+export async function setup({
+  action,
+  canWrite = true,
+  hasActionsEnabled = true,
+  isAdmin = false,
+  isPublicSharingEnabled = false,
+}: SetupOpts = {}) {
+  const scope = nock(location.origin);
+  const model = createMockCard({
+    dataset: true,
+    can_write: canWrite,
+  });
+  const database = createMockDatabase({
+    settings: { "database-enable-actions": hasActionsEnabled },
+  });
+
+  setupDatabasesEndpoints(scope, [database]);
+  setupCardsEndpoints(scope, [model]);
+
+  if (action) {
+    scope.get(`/api/action/${action.id}`).reply(200, action);
+    scope.delete(`/api/action/${action.id}/public_link`).reply(204);
+    scope
+      .post(`/api/action/${action.id}/public_link`)
+      .reply(200, { uuid: "mock-uuid" });
+  }
+
+  renderWithProviders(
+    <ActionCreator actionId={action?.id} modelId={model.id} />,
+    {
+      storeInitialState: createMockState({
+        currentUser: createMockUser({
+          is_superuser: isAdmin,
+        }),
+        settings: createMockSettingsState({
+          "enable-public-sharing": isPublicSharingEnabled,
+          "site-url": SITE_URL,
+        }),
+      }),
+    },
+  );
+
+  await waitForElementToBeRemoved(() =>
+    screen.queryByTestId("loading-spinner"),
+  );
+}
diff --git a/frontend/src/metabase/actions/containers/ActionCreator/utils.unit.spec.ts b/frontend/src/metabase/actions/containers/ActionCreator/tests/utils.unit.spec.ts
similarity index 99%
rename from frontend/src/metabase/actions/containers/ActionCreator/utils.unit.spec.ts
rename to frontend/src/metabase/actions/containers/ActionCreator/tests/utils.unit.spec.ts
index 5bed4757481..dc15d5e835c 100644
--- a/frontend/src/metabase/actions/containers/ActionCreator/utils.unit.spec.ts
+++ b/frontend/src/metabase/actions/containers/ActionCreator/tests/utils.unit.spec.ts
@@ -12,7 +12,7 @@ import {
   removeOrphanSettings,
   setParameterTypesFromFieldSettings,
   setTemplateTagTypesFromFieldSettings,
-} from "./utils";
+} from "../utils";
 
 const createQuestionWithTemplateTags = (tagType: TemplateTagType) =>
   getUnsavedNativeQuestion({
diff --git a/frontend/src/metabase/actions/containers/ActionCreator/types.ts b/frontend/src/metabase/actions/containers/ActionCreator/types.ts
index e9cee97a15b..7e8e32b0f9e 100644
--- a/frontend/src/metabase/actions/containers/ActionCreator/types.ts
+++ b/frontend/src/metabase/actions/containers/ActionCreator/types.ts
@@ -1 +1,6 @@
 export type SideView = "dataReference" | "actionForm" | "actionSettings";
+
+export interface ActionCreatorUIProps {
+  canRename: boolean;
+  canChangeFieldSettings: boolean;
+}
diff --git a/frontend/src/metabase/actions/containers/ActionCreator/utils.ts b/frontend/src/metabase/actions/containers/ActionCreator/utils.ts
index e8094e4907d..14d9575737e 100644
--- a/frontend/src/metabase/actions/containers/ActionCreator/utils.ts
+++ b/frontend/src/metabase/actions/containers/ActionCreator/utils.ts
@@ -4,6 +4,7 @@ import { getDefaultFieldSettings } from "metabase/actions/utils";
 
 import type {
   ActionFormSettings,
+  DatabaseId,
   FieldType,
   InputSettingType,
   NativeDatasetQuery,
@@ -70,7 +71,7 @@ export const setTemplateTagTypesFromFieldSettings = (
   question: Question,
   settings: ActionFormSettings,
 ): Question => {
-  const fields = settings.fields;
+  const fields = settings.fields || {};
   const query = question.query() as NativeQuery;
   let tempQuestion = question.clone();
 
@@ -104,7 +105,7 @@ export const setParameterTypesFromFieldSettings = (
   settings: ActionFormSettings,
   parameters: Parameter[],
 ): Parameter[] => {
-  const fields = settings.fields;
+  const fields = settings.fields || {};
   return parameters.map(parameter => {
     const field = fields[parameter.id];
     return {
@@ -123,7 +124,7 @@ export const removeOrphanSettings = (
   const parameterIds = parameters.map(parameter => parameter.id);
   return {
     ...formSettings,
-    fields: _.pick(formSettings.fields, parameterIds),
+    fields: _.pick(formSettings.fields || {}, parameterIds),
   };
 };
 
@@ -132,7 +133,7 @@ export const addMissingSettings = (
   parameters: Parameter[],
 ): ActionFormSettings => {
   const parameterIds = parameters.map(parameter => parameter.id);
-  const fieldIds = Object.keys(settings.fields);
+  const fieldIds = Object.keys(settings.fields || {});
   const missingIds = _.difference(parameterIds, fieldIds);
 
   if (!missingIds.length) {
@@ -171,7 +172,7 @@ export const convertQuestionToAction = (
     name: question.displayName() as string,
     description: question.description(),
     dataset_query: question.datasetQuery() as NativeDatasetQuery,
-    database_id: question.databaseId(),
+    database_id: question.databaseId() as DatabaseId,
     parameters: parameters as WritebackParameter[],
     visualization_settings,
   };
diff --git a/frontend/src/metabase/actions/utils.ts b/frontend/src/metabase/actions/utils.ts
index 0d05d419874..01dea423672 100644
--- a/frontend/src/metabase/actions/utils.ts
+++ b/frontend/src/metabase/actions/utils.ts
@@ -4,14 +4,18 @@ import type {
   Database,
   Parameter,
   WritebackAction,
+  WritebackActionBase,
   ActionDashboardCard,
   BaseDashboardOrderedCard,
   Card,
   FieldSettings,
+  FieldSettingsMap,
   ParameterId,
+  ParametersForActionExecution,
 } from "metabase-types/api";
 
 import { slugify } from "metabase/lib/formatting";
+import { isEmpty } from "metabase/lib/validate";
 
 import { TYPE } from "metabase-lib/types/constants";
 import Field from "metabase-lib/metadata/Field";
@@ -77,8 +81,10 @@ export const shouldPrefetchValues = (action: WritebackAction) => {
 
 export const sortActionParams =
   (formSettings: ActionFormSettings) => (a: Parameter, b: Parameter) => {
-    const aOrder = formSettings.fields[a.id]?.order ?? 0;
-    const bOrder = formSettings.fields[b.id]?.order ?? 0;
+    const fields = formSettings.fields || {};
+
+    const aOrder = fields[a.id]?.order ?? 0;
+    const bOrder = fields[b.id]?.order ?? 0;
 
     return aOrder - bOrder;
   };
@@ -199,6 +205,12 @@ export const getInputType = (param: Parameter, field?: Field) => {
   return "string";
 };
 
+export function isSavedAction(
+  action?: Partial<WritebackActionBase>,
+): action is WritebackAction {
+  return action != null && action.id != null;
+}
+
 export function isActionDashCard(
   dashCard: BaseDashboardOrderedCard,
 ): dashCard is ActionDashboardCard {
@@ -211,3 +223,16 @@ export const isActionCard = (card: Card) => card?.display === "action";
 export const getFormTitle = (action: WritebackAction): string => {
   return action.visualization_settings?.name || action.name || t`Action form`;
 };
+
+export function setNumericValues(
+  params: ParametersForActionExecution,
+  fieldSettings: FieldSettingsMap,
+) {
+  Object.entries(params).forEach(([key, value]) => {
+    if (fieldSettings[key]?.fieldType === "number" && !isEmpty(value)) {
+      params[key] = Number(value) ?? null;
+    }
+  });
+
+  return params;
+}
diff --git a/frontend/src/metabase/entities/actions/actions.ts b/frontend/src/metabase/entities/actions/actions.ts
index 5031d919e18..457eecb6297 100644
--- a/frontend/src/metabase/entities/actions/actions.ts
+++ b/frontend/src/metabase/entities/actions/actions.ts
@@ -1,5 +1,6 @@
 import { t } from "ttag";
 import { updateIn } from "icepick";
+import _ from "underscore";
 import { createAction } from "redux-actions";
 
 import { createEntity } from "metabase/lib/entities";
@@ -37,6 +38,14 @@ export type UpdateImplicitActionParams = Omit<
 > &
   BaseUpdateActionParams;
 
+export type CreateActionParams =
+  | CreateQueryActionParams
+  | CreateImplicitActionParams;
+
+export type UpdateActionParams =
+  | UpdateQueryActionParams
+  | UpdateImplicitActionParams;
+
 const defaultImplicitActionCreateOptions = {
   insert: true,
   update: true,
@@ -94,14 +103,28 @@ const Actions = createEntity({
   nameOne: "action",
   path: "/api/action",
   api: {
-    create: (params: CreateQueryActionParams | CreateImplicitActionParams) =>
-      ActionsApi.create(params),
-    update: (params: UpdateQueryActionParams | UpdateImplicitActionParams) =>
-      ActionsApi.update(params),
+    create: (params: CreateActionParams) => ActionsApi.create(params),
+    update: (params: UpdateActionParams) => {
+      // Changing action type is not supported
+      const cleanParams = _.omit(params, "type");
+      return ActionsApi.update(cleanParams);
+    },
   },
   actions: {
     enableImplicitActionsForModel,
   },
+  writableProperties: [
+    "name",
+    "description",
+    "type",
+    "model_id",
+    "database_id",
+    "dataset_query",
+    "parameters",
+    "public_uuid",
+    "visualization_settings",
+    "archived",
+  ],
   objectActions: {
     createPublicLink: createAction(
       CREATE_PUBLIC_LINK,
diff --git a/frontend/src/metabase/models/components/ModelDetailPage/ModelActionDetails/ModelActionListItem.tsx b/frontend/src/metabase/models/components/ModelDetailPage/ModelActionDetails/ModelActionListItem.tsx
index 1838d2ba8d3..7e9ac45ffd0 100644
--- a/frontend/src/metabase/models/components/ModelDetailPage/ModelActionDetails/ModelActionListItem.tsx
+++ b/frontend/src/metabase/models/components/ModelDetailPage/ModelActionDetails/ModelActionListItem.tsx
@@ -1,10 +1,11 @@
 import React from "react";
 import { t } from "ttag";
 
+import ImplicitActionIcon from "metabase/actions/components/ImplicitActionIcon";
+
 import type { WritebackAction, WritebackQueryAction } from "metabase-types/api";
 
 import { isNotNull } from "metabase/core/utils/types";
-import StackedInsightIcon from "./StackedInsightIcon";
 import {
   ActionTitle,
   ActionSubtitle,
@@ -29,15 +30,13 @@ function QueryActionCardContent({ action }: { action: WritebackQueryAction }) {
 function ImplicitActionCardContent() {
   return (
     <ImplicitActionCardContentRoot>
-      <StackedInsightIcon />
+      <ImplicitActionIcon size={32} />
       <ImplicitActionMessage>{t`Auto tracking schema`}</ImplicitActionMessage>
     </ImplicitActionCardContentRoot>
   );
 }
 
 function ModelActionListItem({ action, editorUrl, canWrite }: Props) {
-  const hasEditorLink = action.type !== "implicit";
-
   const renderCardContent = () =>
     action.type === "query" ? (
       <QueryActionCardContent action={action} />
@@ -63,9 +62,7 @@ function ModelActionListItem({ action, editorUrl, canWrite }: Props) {
       )}
       <Card>
         {renderCardContent()}
-        {hasEditorLink && (
-          <EditorLink icon={canWrite ? "pencil" : "eye"} to={editorUrl} />
-        )}
+        <EditorLink icon={canWrite ? "pencil" : "eye"} to={editorUrl} />
       </Card>
     </>
   );
diff --git a/frontend/src/metabase/models/components/ModelDetailPage/ModelActionDetails/StackedInsightIcon.tsx b/frontend/src/metabase/models/components/ModelDetailPage/ModelActionDetails/StackedInsightIcon.tsx
deleted file mode 100644
index 44183628ca0..00000000000
--- a/frontend/src/metabase/models/components/ModelDetailPage/ModelActionDetails/StackedInsightIcon.tsx
+++ /dev/null
@@ -1,14 +0,0 @@
-import React from "react";
-import Icon from "metabase/components/Icon";
-import { Root, IconSmall } from "./StackedInsightIcon.styled";
-
-function StackedInsightIcon() {
-  return (
-    <Root>
-      <IconSmall name="insight" size={12} />
-      <Icon name="insight" size={32} />
-    </Root>
-  );
-}
-
-export default StackedInsightIcon;
diff --git a/frontend/src/metabase/public/containers/PublicAction/PublicAction.tsx b/frontend/src/metabase/public/containers/PublicAction/PublicAction.tsx
index b3fa5bb825e..25a7b1f3831 100644
--- a/frontend/src/metabase/public/containers/PublicAction/PublicAction.tsx
+++ b/frontend/src/metabase/public/containers/PublicAction/PublicAction.tsx
@@ -5,7 +5,11 @@ import title from "metabase/hoc/Title";
 import { PublicApi } from "metabase/services";
 
 import { ActionForm } from "metabase/actions/components/ActionForm";
-import { getSuccessMessage } from "metabase/actions/utils";
+import {
+  generateFieldSettingsFromParameters,
+  getSuccessMessage,
+  setNumericValues,
+} from "metabase/actions/utils";
 
 import type {
   ParametersForActionExecution,
@@ -33,13 +37,17 @@ function PublicAction({ action, publicId, onError }: Props) {
   const handleSubmit = useCallback(
     async (values: ParametersForActionExecution) => {
       try {
-        await PublicApi.executeAction({ uuid: publicId, parameters: values });
+        const fieldSettings =
+          action.visualization_settings?.fields ||
+          generateFieldSettingsFromParameters(action.parameters);
+        const parameters = setNumericValues(values, fieldSettings);
+        await PublicApi.executeAction({ uuid: publicId, parameters });
         setSubmitted(true);
       } catch (error) {
         onError(error as AppErrorDescriptor);
       }
     },
-    [publicId, onError],
+    [action, publicId, onError],
   );
 
   useMount(() => {
diff --git a/frontend/test/metabase/scenarios/models/model-actions.cy.spec.js b/frontend/test/metabase/scenarios/models/model-actions.cy.spec.js
index 38742938f8e..615f4364a26 100644
--- a/frontend/test/metabase/scenarios/models/model-actions.cy.spec.js
+++ b/frontend/test/metabase/scenarios/models/model-actions.cy.spec.js
@@ -3,6 +3,7 @@ import {
   modal,
   popover,
   restore,
+  createAction,
 } from "__support__/e2e/helpers";
 
 import { createMockActionParameter } from "metabase-types/api/mocks";
@@ -122,68 +123,75 @@ describe("scenarios > models > actions", () => {
   });
 
   it("should allow to make actions public and execute them", () => {
-    cy.intercept("/api/public/action/*/execute", request => {
-      expect(request.body).to.deep.equal({
-        parameters: { [TEST_PARAMETER.id]: -2 },
-      });
-    });
+    const IMPLICIT_ACTION_NAME = "Update order";
 
     cy.get("@modelId").then(modelId => {
-      cy.request("POST", "/api/action", {
+      createAction({
         ...SAMPLE_QUERY_ACTION,
         model_id: modelId,
       });
-      cy.visit(`/model/${modelId}/detail`);
+      createAction({
+        type: "implicit",
+        kind: "row/update",
+        name: IMPLICIT_ACTION_NAME,
+        model_id: modelId,
+      });
+      cy.visit(`/model/${modelId}/detail/actions`);
       cy.wait("@getModel");
     });
 
-    cy.findByText("Actions").click();
-    openActionEditorFor(SAMPLE_QUERY_ACTION.name);
-
-    cy.findByRole("dialog").within(() => {
-      cy.button("Action settings").click();
-      cy.findByLabelText("Make public").should("not.be.checked").click();
-      cy.findByLabelText("Public action link URL")
-        .invoke("val")
-        .then(url => {
-          cy.wrap(url).as("publicUrl");
-        });
-      cy.button("Update").click();
-      cy.wait("@updateAction");
+    enableSharingFor(SAMPLE_QUERY_ACTION.name, {
+      publicUrlAlias: "queryActionPublicUrl",
+    });
+    enableSharingFor(IMPLICIT_ACTION_NAME, {
+      publicUrlAlias: "implicitActionPublicUrl",
     });
 
-    cy.get("@publicUrl").then(url => {
-      cy.signOut();
+    cy.signOut();
+
+    cy.get("@queryActionPublicUrl").then(url => {
       cy.visit(url);
+      cy.findByLabelText(TEST_PARAMETER.name).type("-2");
+      cy.findByRole("button", { name: "Submit" }).click();
+      cy.findByText(`${SAMPLE_QUERY_ACTION.name} ran successfully`).should(
+        "be.visible",
+      );
+      cy.findByRole("form").should("not.exist");
+      cy.findByRole("button", { name: "Submit" }).should("not.exist");
     });
 
-    cy.findByLabelText(TEST_PARAMETER.name).type("-2");
-    cy.findByRole("button", { name: "Submit" }).click();
-    cy.findByText(`${SAMPLE_QUERY_ACTION.name} ran successfully`).should(
-      "be.visible",
-    );
-    cy.findByRole("form").should("not.exist");
-    cy.findByRole("button", { name: "Submit" }).should("not.exist");
+    cy.get("@implicitActionPublicUrl").then(url => {
+      cy.visit(url);
+
+      // Order 1 has quantity 2 by default, so we're not actually mutating data
+      cy.findByLabelText(/^id/i).type("1");
+      cy.findByLabelText(/quantity/i).type("2");
+
+      cy.findByRole("button", { name: "Submit" }).click();
+      cy.findByText(`${IMPLICIT_ACTION_NAME} ran successfully`).should(
+        "be.visible",
+      );
+      cy.findByRole("form").should("not.exist");
+      cy.findByRole("button", { name: "Submit" }).should("not.exist");
+    });
 
     cy.signInAsAdmin();
     cy.get("@modelId").then(modelId => {
-      cy.visit(`/model/${modelId}/detail`);
+      cy.visit(`/model/${modelId}/detail/actions`);
       cy.wait("@getModel");
     });
 
-    cy.findByText("Actions").click();
-    openActionEditorFor(SAMPLE_QUERY_ACTION.name);
+    disableSharingFor(SAMPLE_QUERY_ACTION.name);
+    disableSharingFor(IMPLICIT_ACTION_NAME);
 
-    cy.findByRole("dialog").within(() => {
-      cy.findByRole("button", { name: "Action settings" }).click();
-      cy.findByLabelText("Make public").should("be.checked").click();
-    });
-    modal().within(() => {
-      cy.findByText("Disable this public link?").should("be.visible");
-      cy.findByRole("button", { name: "Yes" }).click();
+    cy.get("@queryActionPublicUrl").then(url => {
+      cy.visit(url);
+      cy.findByRole("form").should("not.exist");
+      cy.findByRole("button", { name: "Submit" }).should("not.exist");
+      cy.findByText("An error occurred.").should("be.visible");
     });
 
-    cy.get("@publicUrl").then(url => {
+    cy.get("@implicitActionPublicUrl").then(url => {
       cy.visit(url);
       cy.findByRole("form").should("not.exist");
       cy.findByRole("button", { name: "Submit" }).should("not.exist");
@@ -244,3 +252,33 @@ function fieldSettings() {
   cy.findByTestId("action-form-editor").within(() => cy.icon("gear").click());
   return popover();
 }
+
+function enableSharingFor(actionName, { publicUrlAlias }) {
+  openActionEditorFor(actionName);
+
+  cy.findByRole("dialog").within(() => {
+    cy.button("Action settings").click();
+    cy.findByLabelText("Make public").should("not.be.checked").click();
+    cy.findByLabelText("Public action link URL")
+      .invoke("val")
+      .then(url => {
+        cy.wrap(url).as(publicUrlAlias);
+      });
+    cy.button("Cancel").click();
+  });
+}
+
+function disableSharingFor(actionName) {
+  openActionEditorFor(actionName);
+  cy.findByRole("dialog").within(() => {
+    cy.findByRole("button", { name: "Action settings" }).click();
+    cy.findByLabelText("Make public").should("be.checked").click();
+  });
+  modal().within(() => {
+    cy.findByText("Disable this public link?").should("be.visible");
+    cy.findByRole("button", { name: "Yes" }).click();
+  });
+  cy.findByRole("dialog").within(() => {
+    cy.button("Cancel").click();
+  });
+}
-- 
GitLab