From 2e8e313aff9a6863621b8444fc3ddd2254fa13d9 Mon Sep 17 00:00:00 2001
From: Anton Kulyk <kuliks.anton@gmail.com>
Date: Mon, 16 Jan 2023 19:56:52 +0000
Subject: [PATCH] Cherry-pick action creator component (#27673)

* Cherry-pick `ModelPicker`

* Cherry-pick actions editor and form components

* Migrate from entity forms

* Remove `ModelPicker`

* Temporarily add "New > Action" flow

* Disable save button if query is empty

* Fix utilities moved

* Fix type errors

* Address comments

* Simplify `ActionForm` tests

* Break down `ActionCreator` props

* Add basic `ActionCreator` tests

* update action creator header gap styling

* Extract `convertActionToQuestionCard`

* Fix `CreateActionForm` ignores action name

* Fix `FormModelPicker` crash

* Address comments

* Remove "New > Action" flow

Co-authored-by: Ryan Laurie <iethree@gmail.com>
---
 frontend/src/metabase-types/api/actions.ts    |  14 -
 .../ActionForm/ActionForm.styled.tsx          |  43 +
 .../components/ActionForm/ActionForm.tsx      | 197 +++++
 .../ActionForm/ActionForm.unit.spec.tsx       | 745 ++++++++++++++++++
 .../ActionForm/ActionFormFieldWidget.tsx      |  46 ++
 .../actions/components/ActionForm/index.ts    |   2 +
 .../actions/components/ActionForm/utils.ts    | 145 ++++
 .../ActionCreator/ActionCreator.styled.tsx    |  46 ++
 .../ActionCreator/ActionCreator.tsx           | 230 ++++++
 .../ActionCreator/ActionCreator.unit.spec.tsx | 127 +++
 .../ActionCreatorHeader.styled.tsx            |  56 ++
 .../ActionCreator/ActionCreatorHeader.tsx     |  40 +
 .../FormCreator/EmptyFormPlaceholder.tsx      |  33 +
 .../FormCreator/FieldSettingsButtons.tsx      |  42 +
 .../FieldSettingsPopover.styled.tsx           |  36 +
 .../FormCreator/FieldSettingsPopover.tsx      | 232 ++++++
 .../FieldSettingsPopover.unit.spec.tsx        |  94 +++
 .../FormCreator/FormCreator.styled.tsx        |  64 ++
 .../ActionCreator/FormCreator/FormCreator.tsx |  68 ++
 .../FormCreator/OptionEditor.styled.tsx       |  28 +
 .../FormCreator/OptionEditor.tsx              |  57 ++
 .../ActionCreator/FormCreator/constants.ts    |  90 +++
 .../ActionCreator/FormCreator/index.ts        |   2 +
 .../ActionCreator/FormCreator/utils.ts        | 157 ++++
 .../FormCreator/utils.unit.spec.ts            | 317 ++++++++
 .../InlineDataReference.styled.tsx            |  30 +
 .../ActionCreator/InlineDataReference.tsx     |  50 ++
 .../ActionCreator/QueryActionEditor.tsx       |  45 ++
 .../containers/ActionCreator/index.tsx        |   1 +
 .../actions/containers/ActionCreator/utils.ts |  41 +
 .../CreateActionForm/CreateActionForm.tsx     | 131 +++
 .../containers/CreateActionForm/index.ts      |   1 +
 frontend/src/metabase/actions/selectors.ts    |  21 +
 frontend/src/metabase/actions/types.ts        |  25 +
 frontend/src/metabase/actions/utils.ts        |  70 +-
 .../src/metabase/actions/utils.unit.spec.ts   |  45 ++
 .../components/NewItemMenu/NewItemMenu.tsx    |   2 +-
 .../core/components/FormRadio/FormRadio.tsx   |   2 +-
 .../src/metabase/entities/actions/actions.ts  |   7 +-
 .../src/metabase/entities/actions/forms.ts    |  41 -
 .../FormModelPicker.styled.tsx                |  11 +
 .../FormModelPicker/FormModelPicker.tsx       |  97 +++
 .../containers/FormModelPicker/index.ts       |   1 +
 .../DataSelectorDatabasePicker.tsx            |  16 +-
 .../components/NativeQueryEditor/utils.ts     |   2 +-
 .../SidebarContent/SidebarContent.tsx         |   4 +-
 .../SidebarHeader/SidebarHeader.tsx           |   2 +-
 47 files changed, 3486 insertions(+), 70 deletions(-)
 create mode 100644 frontend/src/metabase/actions/components/ActionForm/ActionForm.styled.tsx
 create mode 100644 frontend/src/metabase/actions/components/ActionForm/ActionForm.tsx
 create mode 100644 frontend/src/metabase/actions/components/ActionForm/ActionForm.unit.spec.tsx
 create mode 100644 frontend/src/metabase/actions/components/ActionForm/ActionFormFieldWidget.tsx
 create mode 100644 frontend/src/metabase/actions/components/ActionForm/index.ts
 create mode 100644 frontend/src/metabase/actions/components/ActionForm/utils.ts
 create mode 100644 frontend/src/metabase/actions/containers/ActionCreator/ActionCreator.styled.tsx
 create mode 100644 frontend/src/metabase/actions/containers/ActionCreator/ActionCreator.tsx
 create mode 100644 frontend/src/metabase/actions/containers/ActionCreator/ActionCreator.unit.spec.tsx
 create mode 100644 frontend/src/metabase/actions/containers/ActionCreator/ActionCreatorHeader.styled.tsx
 create mode 100644 frontend/src/metabase/actions/containers/ActionCreator/ActionCreatorHeader.tsx
 create mode 100644 frontend/src/metabase/actions/containers/ActionCreator/FormCreator/EmptyFormPlaceholder.tsx
 create mode 100644 frontend/src/metabase/actions/containers/ActionCreator/FormCreator/FieldSettingsButtons.tsx
 create mode 100644 frontend/src/metabase/actions/containers/ActionCreator/FormCreator/FieldSettingsPopover.styled.tsx
 create mode 100644 frontend/src/metabase/actions/containers/ActionCreator/FormCreator/FieldSettingsPopover.tsx
 create mode 100644 frontend/src/metabase/actions/containers/ActionCreator/FormCreator/FieldSettingsPopover.unit.spec.tsx
 create mode 100644 frontend/src/metabase/actions/containers/ActionCreator/FormCreator/FormCreator.styled.tsx
 create mode 100644 frontend/src/metabase/actions/containers/ActionCreator/FormCreator/FormCreator.tsx
 create mode 100644 frontend/src/metabase/actions/containers/ActionCreator/FormCreator/OptionEditor.styled.tsx
 create mode 100644 frontend/src/metabase/actions/containers/ActionCreator/FormCreator/OptionEditor.tsx
 create mode 100644 frontend/src/metabase/actions/containers/ActionCreator/FormCreator/constants.ts
 create mode 100644 frontend/src/metabase/actions/containers/ActionCreator/FormCreator/index.ts
 create mode 100644 frontend/src/metabase/actions/containers/ActionCreator/FormCreator/utils.ts
 create mode 100644 frontend/src/metabase/actions/containers/ActionCreator/FormCreator/utils.unit.spec.ts
 create mode 100644 frontend/src/metabase/actions/containers/ActionCreator/InlineDataReference.styled.tsx
 create mode 100644 frontend/src/metabase/actions/containers/ActionCreator/InlineDataReference.tsx
 create mode 100644 frontend/src/metabase/actions/containers/ActionCreator/QueryActionEditor.tsx
 create mode 100644 frontend/src/metabase/actions/containers/ActionCreator/index.tsx
 create mode 100644 frontend/src/metabase/actions/containers/ActionCreator/utils.ts
 create mode 100644 frontend/src/metabase/actions/containers/CreateActionForm/CreateActionForm.tsx
 create mode 100644 frontend/src/metabase/actions/containers/CreateActionForm/index.ts
 create mode 100644 frontend/src/metabase/actions/selectors.ts
 create mode 100644 frontend/src/metabase/actions/types.ts
 create mode 100644 frontend/src/metabase/actions/utils.unit.spec.ts
 delete mode 100644 frontend/src/metabase/entities/actions/forms.ts
 create mode 100644 frontend/src/metabase/models/containers/FormModelPicker/FormModelPicker.styled.tsx
 create mode 100644 frontend/src/metabase/models/containers/FormModelPicker/FormModelPicker.tsx
 create mode 100644 frontend/src/metabase/models/containers/FormModelPicker/index.ts

diff --git a/frontend/src/metabase-types/api/actions.ts b/frontend/src/metabase-types/api/actions.ts
index bdf3fc13f56..486d0cf4616 100644
--- a/frontend/src/metabase-types/api/actions.ts
+++ b/frontend/src/metabase-types/api/actions.ts
@@ -147,17 +147,3 @@ export type ActionFormOption = {
   name: string | number;
   value: string | number;
 };
-
-export type ActionFormFieldProps = {
-  name: string;
-  title: string;
-  description?: string;
-  placeholder?: string;
-  type: InputComponentType;
-  optional?: boolean;
-  options?: ActionFormOption[];
-};
-
-export type ActionFormProps = {
-  fields: ActionFormFieldProps[];
-};
diff --git a/frontend/src/metabase/actions/components/ActionForm/ActionForm.styled.tsx b/frontend/src/metabase/actions/components/ActionForm/ActionForm.styled.tsx
new file mode 100644
index 00000000000..14b8a155d29
--- /dev/null
+++ b/frontend/src/metabase/actions/components/ActionForm/ActionForm.styled.tsx
@@ -0,0 +1,43 @@
+import styled from "@emotion/styled";
+import { space } from "metabase/styled-components/theme";
+import { color } from "metabase/lib/colors";
+
+export const ActionFormButtonContainer = styled.div`
+  display: flex;
+  justify-content: flex-end;
+  gap: 0.5rem;
+`;
+
+interface FormFieldContainerProps {
+  isSettings?: boolean;
+}
+
+export const FormFieldContainer = styled.div<FormFieldContainerProps>`
+  ${({ isSettings }) =>
+    isSettings &&
+    `
+    position: relative;
+    display: flex;
+    align-items: center;
+    border-radius: ${space(1)};
+    padding: ${space(1)};
+    margin-bottom: ${space(1)};
+    background-color: ${color("bg-white")};
+    border: 1px solid ${color("border")};
+    overflow: hidden;
+  `}
+`;
+
+export const SettingsContainer = styled.div`
+  display: flex;
+  flex-direction: column;
+  justify-content: space-between;
+  color: ${color("text-medium")};
+  margin-right: ${space(1)};
+`;
+
+export const InputContainer = styled.div`
+  flex-grow: 1;
+  flex-basis: 1;
+  flex-shrink: 0;
+`;
diff --git a/frontend/src/metabase/actions/components/ActionForm/ActionForm.tsx b/frontend/src/metabase/actions/components/ActionForm/ActionForm.tsx
new file mode 100644
index 00000000000..6e07e2ef2f3
--- /dev/null
+++ b/frontend/src/metabase/actions/components/ActionForm/ActionForm.tsx
@@ -0,0 +1,197 @@
+import React, { useMemo } from "react";
+import { t } from "ttag";
+import _ from "underscore";
+import { DragDropContext, Droppable, Draggable } from "react-beautiful-dnd";
+
+import type {
+  DraggableProvided,
+  OnDragEndResponder,
+  DroppableProvided,
+} from "react-beautiful-dnd";
+import type { FormikHelpers } from "formik";
+
+import Button from "metabase/core/components/Button";
+import Form from "metabase/core/components/Form";
+import FormProvider from "metabase/core/components/FormProvider";
+import FormSubmitButton from "metabase/core/components/FormSubmitButton";
+import FormErrorMessage from "metabase/core/components/FormErrorMessage";
+import Icon from "metabase/components/Icon";
+
+import type {
+  ActionFormInitialValues,
+  ActionFormSettings,
+  FieldSettings,
+  WritebackParameter,
+  Parameter,
+  ParametersForActionExecution,
+} from "metabase-types/api";
+
+import { reorderFields } from "metabase/actions/containers/ActionCreator/FormCreator";
+import { FieldSettingsButtons } from "../../containers/ActionCreator/FormCreator/FieldSettingsButtons";
+import { FormFieldWidget } from "./ActionFormFieldWidget";
+import {
+  ActionFormButtonContainer,
+  FormFieldContainer,
+  SettingsContainer,
+  InputContainer,
+} from "./ActionForm.styled";
+
+import { getForm, getFormValidationSchema } from "./utils";
+
+export interface ActionFormComponentProps {
+  parameters: WritebackParameter[] | Parameter[];
+  initialValues?: ActionFormInitialValues;
+  onClose?: () => void;
+  onSubmit?: (
+    params: ParametersForActionExecution,
+    actions: FormikHelpers<ParametersForActionExecution>,
+  ) => void;
+  submitTitle?: string;
+  submitButtonColor?: string;
+  formSettings?: ActionFormSettings;
+  setFormSettings?: (formSettings: ActionFormSettings) => void;
+}
+
+export const ActionForm = ({
+  parameters,
+  initialValues = {},
+  onClose,
+  onSubmit,
+  submitTitle,
+  submitButtonColor = "primary",
+  formSettings,
+  setFormSettings,
+}: ActionFormComponentProps): JSX.Element => {
+  // allow us to change the color of the submit button
+  const submitButtonVariant = { [submitButtonColor]: true };
+
+  const isSettings = !!(formSettings && setFormSettings);
+
+  const form = useMemo(
+    () => getForm(parameters, formSettings?.fields),
+    [parameters, formSettings?.fields],
+  );
+
+  const formValidationSchema = useMemo(
+    () => getFormValidationSchema(parameters, formSettings?.fields),
+    [parameters, formSettings?.fields],
+  );
+
+  const handleDragEnd: OnDragEndResponder = ({ source, destination }) => {
+    if (!isSettings) {
+      return;
+    }
+
+    const oldOrder = source.index;
+    const newOrder = destination?.index ?? source.index;
+
+    const reorderedFields = reorderFields(
+      formSettings.fields,
+      oldOrder,
+      newOrder,
+    );
+    setFormSettings({
+      ...formSettings,
+      fields: reorderedFields,
+    });
+  };
+
+  const handleChangeFieldSettings = (newFieldSettings: FieldSettings) => {
+    if (!isSettings || !newFieldSettings?.id) {
+      return;
+    }
+
+    setFormSettings({
+      ...formSettings,
+      fields: {
+        ...formSettings.fields,
+        [newFieldSettings.id]: newFieldSettings,
+      },
+    });
+  };
+
+  const handleSubmit = (
+    values: ParametersForActionExecution,
+    actions: FormikHelpers<ParametersForActionExecution>,
+  ) => onSubmit?.(formValidationSchema.cast(values), actions);
+
+  if (isSettings) {
+    return (
+      <FormProvider
+        initialValues={initialValues}
+        validationSchema={formValidationSchema}
+        onSubmit={handleSubmit}
+      >
+        <Form role="form" data-testid="action-form-editor">
+          <DragDropContext onDragEnd={handleDragEnd}>
+            <Droppable droppableId="action-form-droppable">
+              {(provided: DroppableProvided) => (
+                <div {...provided.droppableProps} ref={provided.innerRef}>
+                  {form.fields.map((field, index) => (
+                    <Draggable
+                      key={`draggable-${field.name}`}
+                      draggableId={field.name}
+                      index={index}
+                    >
+                      {(provided: DraggableProvided) => (
+                        <FormFieldContainer
+                          ref={provided.innerRef}
+                          {...provided.draggableProps}
+                          {...provided.dragHandleProps}
+                          isSettings={isSettings}
+                        >
+                          <SettingsContainer>
+                            <Icon name="grabber2" size={14} />
+                          </SettingsContainer>
+
+                          <InputContainer>
+                            <FormFieldWidget
+                              key={field.name}
+                              formField={field}
+                            />
+                          </InputContainer>
+                          <FieldSettingsButtons
+                            fieldSettings={formSettings.fields[field.name]}
+                            onChange={handleChangeFieldSettings}
+                          />
+                        </FormFieldContainer>
+                      )}
+                    </Draggable>
+                  ))}
+                </div>
+              )}
+            </Droppable>
+          </DragDropContext>
+        </Form>
+      </FormProvider>
+    );
+  }
+
+  return (
+    <FormProvider
+      initialValues={initialValues}
+      validationSchema={formValidationSchema}
+      onSubmit={handleSubmit}
+      enableReinitialize
+    >
+      {({ dirty }) => (
+        <Form disabled={!dirty} role="form" data-testid="action-form">
+          {form.fields.map(field => (
+            <FormFieldWidget key={field.name} formField={field} />
+          ))}
+
+          <ActionFormButtonContainer>
+            {onClose && <Button onClick={onClose}>{t`Cancel`}</Button>}
+            <FormSubmitButton
+              disabled={!dirty}
+              title={submitTitle ?? t`Save`}
+              {...submitButtonVariant}
+            />
+          </ActionFormButtonContainer>
+
+          <FormErrorMessage />
+        </Form>
+      )}
+    </FormProvider>
+  );
+};
diff --git a/frontend/src/metabase/actions/components/ActionForm/ActionForm.unit.spec.tsx b/frontend/src/metabase/actions/components/ActionForm/ActionForm.unit.spec.tsx
new file mode 100644
index 00000000000..47d735b6b5f
--- /dev/null
+++ b/frontend/src/metabase/actions/components/ActionForm/ActionForm.unit.spec.tsx
@@ -0,0 +1,745 @@
+import React from "react";
+import _ from "underscore";
+import { render, screen, fireEvent, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+
+import type {
+  ActionFormSettings,
+  FieldSettings,
+  ParametersForActionExecution,
+  WritebackParameter,
+} from "metabase-types/api";
+import { createMockActionParameter } from "metabase-types/api/mocks";
+
+import { ActionForm } from "./ActionForm";
+
+const makeFieldSettings = (
+  overrides: Partial<FieldSettings> = {},
+): FieldSettings => ({
+  id: "abc-123",
+  name: "form field name",
+  title: "form field name",
+  order: 1,
+  fieldType: "string",
+  inputType: "string",
+  required: false,
+  hidden: false,
+  ...overrides,
+});
+
+const makeParameter = ({
+  id = "abc-123",
+  ...params
+}: Partial<WritebackParameter> = {}): WritebackParameter => {
+  return createMockActionParameter({
+    id,
+    target: ["variable", ["template-tag", id]],
+    type: "type/Text",
+    required: false,
+    ...params,
+  });
+};
+
+type SetupOpts = {
+  initialValues?: ParametersForActionExecution;
+  parameters: WritebackParameter[];
+  formSettings: ActionFormSettings;
+  isSettings?: boolean;
+};
+
+const setup = ({
+  initialValues,
+  parameters,
+  formSettings,
+  isSettings = false,
+}: SetupOpts) => {
+  const setFormSettings = jest.fn();
+  const onSubmit = jest.fn();
+
+  render(
+    <ActionForm
+      initialValues={initialValues}
+      parameters={parameters}
+      formSettings={formSettings}
+      setFormSettings={isSettings ? setFormSettings : undefined}
+      onSubmit={onSubmit}
+    />,
+  );
+
+  return { setFormSettings, onSubmit };
+};
+
+function setupSettings(opts: Omit<SetupOpts, "isSettings">) {
+  return setup({ ...opts, isSettings: true });
+}
+
+describe("Actions > ActionForm", () => {
+  describe("Form Display", () => {
+    it("displays a form with am input label", () => {
+      setup({
+        parameters: [makeParameter()],
+        formSettings: {
+          type: "form",
+          fields: {
+            "abc-123": makeFieldSettings({ inputType: "string" }),
+          },
+        },
+      });
+
+      expect(screen.getByRole("form")).toBeInTheDocument();
+      expect(screen.getByTestId("action-form")).toBeInTheDocument();
+      expect(screen.getByLabelText(/form field name/i)).toBeInTheDocument();
+    });
+
+    it("displays a text input", () => {
+      setup({
+        parameters: [makeParameter()],
+        formSettings: {
+          type: "form",
+          fields: {
+            "abc-123": makeFieldSettings({ inputType: "string" }),
+          },
+        },
+      });
+
+      expect(
+        screen.getByLabelText(/form field name/i, { selector: "input" }),
+      ).toHaveAttribute("type", "text");
+    });
+
+    it("displays a numeric input", () => {
+      setup({
+        parameters: [makeParameter()],
+        formSettings: {
+          type: "form",
+          fields: {
+            "abc-123": makeFieldSettings({ inputType: "number" }),
+          },
+        },
+      });
+
+      expect(
+        screen.getByLabelText(/form field name/i, { selector: "input" }),
+      ).toHaveAttribute("type", "number");
+    });
+
+    it("displays a textarea input", () => {
+      setup({
+        parameters: [makeParameter()],
+        formSettings: {
+          type: "form",
+          fields: {
+            "abc-123": makeFieldSettings({ inputType: "text" }),
+          },
+        },
+      });
+
+      expect(
+        screen.getByLabelText(/form field name/i, { selector: "textarea" }),
+      ).toBeInTheDocument();
+    });
+
+    it("displays a boolean input", () => {
+      setup({
+        parameters: [makeParameter()],
+        formSettings: {
+          type: "form",
+          fields: {
+            "abc-123": makeFieldSettings({ inputType: "boolean" }),
+          },
+        },
+      });
+
+      expect(screen.getByRole("switch")).toBeInTheDocument();
+    });
+
+    it("displays a date input", () => {
+      setup({
+        parameters: [makeParameter()],
+        formSettings: {
+          type: "form",
+          fields: {
+            "abc-123": makeFieldSettings({ inputType: "date" }),
+          },
+        },
+      });
+
+      expect(
+        screen.getByLabelText(/form field name/i, { selector: "input" }),
+      ).toHaveAttribute("type", "date");
+    });
+
+    it("displays a time input", () => {
+      setup({
+        parameters: [makeParameter()],
+        formSettings: {
+          type: "form",
+          fields: {
+            "abc-123": makeFieldSettings({ inputType: "time" }),
+          },
+        },
+      });
+
+      expect(
+        screen.getByLabelText(/form field name/i, { selector: "input" }),
+      ).toHaveAttribute("type", "time");
+    });
+
+    it("displays a datetime input", () => {
+      setup({
+        parameters: [makeParameter()],
+        formSettings: {
+          type: "form",
+          fields: {
+            "abc-123": makeFieldSettings({ inputType: "datetime" }),
+          },
+        },
+      });
+
+      expect(
+        screen.getByLabelText(/form field name/i, { selector: "input" }),
+      ).toHaveAttribute("type", "datetime-local");
+    });
+
+    it("displays a radio input", () => {
+      setup({
+        parameters: [makeParameter()],
+        formSettings: {
+          type: "form",
+          fields: {
+            "abc-123": makeFieldSettings({ inputType: "radio" }),
+          },
+        },
+      });
+
+      expect(screen.getByRole("radiogroup")).toBeInTheDocument();
+    });
+
+    it("displays a select input", () => {
+      setup({
+        parameters: [makeParameter()],
+        formSettings: {
+          type: "form",
+          fields: {
+            "abc-123": makeFieldSettings({ inputType: "select" }),
+          },
+        },
+      });
+
+      expect(screen.getByTestId("select-button")).toBeInTheDocument();
+    });
+
+    it("can submit form field values", async () => {
+      const { onSubmit } = setup({
+        parameters: [
+          makeParameter({ id: "abc-123" }),
+          makeParameter({ id: "def-456" }),
+        ],
+        formSettings: {
+          type: "form",
+          fields: {
+            "abc-123": makeFieldSettings({
+              inputType: "string",
+              id: "abc-123",
+              title: "text input",
+            }),
+            "def-456": makeFieldSettings({
+              inputType: "number",
+              id: "def-456",
+              title: "number input",
+            }),
+          },
+        },
+      });
+
+      userEvent.type(screen.getByLabelText(/text input/i), "Murloc");
+      userEvent.type(screen.getByLabelText(/number input/i), "12345");
+      userEvent.click(screen.getByRole("button", { name: "Save" }));
+
+      await waitFor(() => {
+        expect(onSubmit).toHaveBeenCalledWith(
+          {
+            "abc-123": "Murloc",
+            "def-456": 12345,
+          },
+          expect.any(Object),
+        );
+      });
+    });
+  });
+
+  describe("Form Validation", () => {
+    it("allows form submission when required fields are provided", async () => {
+      const { onSubmit } = setup({
+        parameters: [
+          makeParameter({ id: "abc-123" }),
+          makeParameter({ id: "def-456" }),
+        ],
+        formSettings: {
+          type: "form",
+          fields: {
+            "abc-123": makeFieldSettings({
+              inputType: "string",
+              id: "abc-123",
+              title: "foo input",
+              required: true,
+            }),
+            "def-456": makeFieldSettings({
+              inputType: "string",
+              id: "def-456",
+              title: "bar input",
+              required: false,
+            }),
+          },
+        },
+      });
+
+      userEvent.type(await screen.findByLabelText(/foo input/i), "baz");
+      userEvent.type(await screen.findByLabelText(/bar input/i), "baz");
+      userEvent.click(screen.getByRole("button", { name: "Save" }));
+
+      await waitFor(() => expect(onSubmit).toHaveBeenCalled());
+      expect(
+        screen.queryByText(/this field is required/i),
+      ).not.toBeInTheDocument();
+    });
+
+    it("disables form submission when required fields are not provided", async () => {
+      const { onSubmit } = setup({
+        parameters: [
+          makeParameter({ id: "abc-123" }),
+          makeParameter({ id: "def-456" }),
+        ],
+        formSettings: {
+          type: "form",
+          fields: {
+            "abc-123": makeFieldSettings({
+              inputType: "string",
+              id: "abc-123",
+              title: "foo input",
+              required: true,
+            }),
+            "def-456": makeFieldSettings({
+              inputType: "string",
+              id: "def-456",
+              title: "bar input",
+              required: false,
+            }),
+          },
+        },
+      });
+
+      userEvent.click(await screen.findByLabelText(/foo input/i)); // leave empty
+      userEvent.type(await screen.findByLabelText(/bar input/i), "baz");
+      await waitFor(() =>
+        expect(screen.getByRole("button", { name: "Save" })).toBeDisabled(),
+      );
+
+      userEvent.click(screen.getByRole("button", { name: "Save" }));
+
+      expect(
+        await screen.findByText(/this field is required/i),
+      ).toBeInTheDocument();
+      expect(onSubmit).not.toHaveBeenCalled();
+    });
+
+    it("disables form submission when no fields are changed", async () => {
+      const { onSubmit } = setup({
+        parameters: [
+          makeParameter({ id: "abc-123" }),
+          makeParameter({ id: "def-456" }),
+        ],
+        formSettings: {
+          type: "form",
+          fields: {
+            "abc-123": makeFieldSettings({
+              inputType: "string",
+              id: "abc-123",
+              title: "foo input",
+              required: false,
+            }),
+            "def-456": makeFieldSettings({
+              inputType: "string",
+              id: "def-456",
+              title: "bar input",
+              required: false,
+            }),
+          },
+        },
+      });
+
+      expect(screen.getByRole("button", { name: "Save" })).toBeDisabled();
+
+      userEvent.click(screen.getByRole("button", { name: "Save" }));
+
+      await waitFor(() => expect(onSubmit).not.toHaveBeenCalled());
+    });
+
+    it("cannot type a string in a numeric field", async () => {
+      setup({
+        parameters: [makeParameter()],
+        formSettings: {
+          type: "form",
+          fields: {
+            "abc-123": makeFieldSettings({ inputType: "number" }),
+          },
+        },
+      });
+
+      const input = await screen.findByLabelText(/form field name/i);
+      userEvent.type(input, "baz");
+
+      await waitFor(() => expect(input).not.toHaveValue());
+    });
+
+    it("allows submission of a null non-required boolean field", async () => {
+      const { onSubmit } = setup({
+        parameters: [
+          makeParameter({ id: "abc-123" }),
+          makeParameter({ id: "def-456" }),
+        ],
+        formSettings: {
+          type: "form",
+          fields: {
+            "abc-123": makeFieldSettings({
+              inputType: "string",
+              id: "abc-123",
+              title: "foo input",
+              required: true,
+            }),
+            "def-456": makeFieldSettings({
+              inputType: "boolean",
+              id: "def-456",
+              title: "bar input",
+              required: false,
+            }),
+          },
+        },
+      });
+
+      userEvent.type(await screen.findByLabelText(/foo input/i), "baz");
+      userEvent.type(await screen.findByLabelText(/bar input/i), "baz");
+      userEvent.click(screen.getByRole("button", { name: "Save" }));
+
+      await waitFor(() => expect(onSubmit).toHaveBeenCalled());
+      expect(
+        screen.queryByText(/this field is required/i),
+      ).not.toBeInTheDocument();
+    });
+
+    it("sets a default value for an empty field", async () => {
+      const { onSubmit } = setup({
+        parameters: [
+          makeParameter({ id: "abc-123" }),
+          makeParameter({ id: "def-456" }),
+        ],
+        formSettings: {
+          type: "form",
+          fields: {
+            "abc-123": makeFieldSettings({
+              inputType: "string",
+              id: "abc-123",
+              title: "foo input",
+              required: true,
+            }),
+            "def-456": makeFieldSettings({
+              inputType: "boolean",
+              id: "def-456",
+              title: "bar input",
+              required: false,
+            }),
+          },
+        },
+      });
+
+      userEvent.type(await screen.findByLabelText(/foo input/i), "baz");
+      userEvent.type(await screen.findByLabelText(/bar input/i), "baz");
+      userEvent.click(screen.getByRole("button", { name: "Save" }));
+
+      await waitFor(() => expect(onSubmit).toHaveBeenCalled());
+      expect(
+        screen.queryByText(/this field is required/i),
+      ).not.toBeInTheDocument();
+    });
+
+    it("sets types on form submissions correctly", async () => {
+      const { onSubmit } = setup({
+        parameters: [
+          makeParameter({ id: "abc-123" }),
+          makeParameter({ id: "def-456" }),
+          makeParameter({ id: "ghi-789" }),
+        ],
+        formSettings: {
+          type: "form",
+          fields: {
+            "abc-123": makeFieldSettings({
+              inputType: "string",
+              id: "abc-123",
+              title: "foo input",
+              required: false,
+            }),
+            "def-456": makeFieldSettings({
+              inputType: "boolean",
+              id: "def-456",
+              title: "bar input",
+              required: false,
+            }),
+            "ghi-789": makeFieldSettings({
+              inputType: "number",
+              id: "ghi-789",
+              title: "baz input",
+              required: false,
+            }),
+          },
+        },
+      });
+
+      userEvent.type(await screen.findByLabelText(/foo input/i), "1");
+      userEvent.type(await screen.findByLabelText(/bar input/i), "1");
+      userEvent.type(await screen.findByLabelText(/baz input/i), "1");
+      userEvent.click(screen.getByRole("button", { name: "Save" }));
+
+      await waitFor(() => {
+        expect(onSubmit).toHaveBeenCalledWith(
+          {
+            "abc-123": "1",
+            "def-456": true,
+            "ghi-789": 1,
+          },
+          expect.anything(),
+        );
+      });
+    });
+  });
+
+  // this may not be the final desired behavior, but it's what we have for now
+  describe("Null Handling", () => {
+    const inputTypes = ["string", "number", "text", "date", "datetime", "time"];
+    inputTypes.forEach(inputType => {
+      it(`casts empty optional ${inputType} field to null`, async () => {
+        const { onSubmit } = setup({
+          initialValues: { "abc-123": 1 },
+          parameters: [makeParameter({ id: "abc-123" })],
+          formSettings: {
+            type: "form",
+            fields: {
+              "abc-123": makeFieldSettings({
+                inputType: inputType as FieldSettings["inputType"],
+                id: "abc-123",
+                title: "input",
+                required: false,
+              }),
+            },
+          },
+        });
+
+        // userEvent.clear doesn't work for date or time inputs 🤷
+        fireEvent.change(screen.getByLabelText(/input/i), {
+          target: { value: "" },
+        });
+        userEvent.click(screen.getByRole("button", { name: "Save" }));
+
+        await waitFor(() => {
+          expect(onSubmit).toHaveBeenCalledWith(
+            {
+              "abc-123": null,
+            },
+            expect.any(Object),
+          );
+        });
+      });
+    });
+
+    // bug repro: https://github.com/metabase/metabase/issues/27377
+    // eslint-disable-next-line jest/no-disabled-tests
+    it.skip("casts empty optional category fields to null", async () => {
+      const { onSubmit } = setup({
+        initialValues: { "abc-123": "aaa" },
+        parameters: [makeParameter({ id: "abc-123" })],
+        formSettings: {
+          type: "form",
+          fields: {
+            "abc-123": makeFieldSettings({
+              inputType: "category",
+              id: "abc-123",
+              title: "input",
+              required: false,
+            }),
+          },
+        },
+      });
+
+      userEvent.clear(screen.getByLabelText(/input/i));
+      userEvent.click(screen.getByRole("button", { name: "Save" }));
+
+      await waitFor(() => {
+        expect(onSubmit).toHaveBeenCalledWith(
+          {
+            "abc-123": null,
+          },
+          expect.any(Object),
+        );
+      });
+    });
+  });
+
+  describe("Form Creation", () => {
+    it("renders the form editor", () => {
+      setupSettings({
+        parameters: [makeParameter()],
+        formSettings: {
+          type: "form",
+          fields: {
+            "abc-123": makeFieldSettings({ inputType: "string" }),
+          },
+        },
+      });
+
+      expect(screen.getByTestId("action-form-editor")).toBeInTheDocument();
+      expect(screen.getByRole("textbox")).toBeInTheDocument();
+    });
+
+    it("can change a string field to a numeric field", async () => {
+      const formSettings: ActionFormSettings = {
+        type: "form",
+        fields: {
+          "abc-123": makeFieldSettings({ inputType: "string" }),
+        },
+      };
+      const { setFormSettings } = setupSettings({
+        parameters: [makeParameter()],
+        formSettings,
+      });
+
+      // click the settings cog then the number input type
+      userEvent.click(await screen.findByLabelText("gear icon"));
+      userEvent.click(await screen.findByText("number"));
+
+      await waitFor(() => {
+        expect(setFormSettings).toHaveBeenCalledWith({
+          ...formSettings,
+          fields: {
+            "abc-123": makeFieldSettings({
+              fieldType: "number",
+              inputType: "number",
+            }),
+          },
+        });
+      });
+    });
+
+    it("can change a string field to a text(area) field", async () => {
+      const formSettings: ActionFormSettings = {
+        type: "form",
+        fields: {
+          "abc-123": makeFieldSettings({ inputType: "string" }),
+        },
+      };
+
+      const { setFormSettings } = setupSettings({
+        parameters: [makeParameter()],
+        formSettings,
+      });
+
+      // click the settings cog then the number input type
+      userEvent.click(await screen.findByLabelText("gear icon"));
+      userEvent.click(await screen.findByText("long text"));
+
+      await waitFor(() => {
+        expect(setFormSettings).toHaveBeenCalledWith({
+          ...formSettings,
+          fields: {
+            "abc-123": makeFieldSettings({
+              fieldType: "string",
+              inputType: "text",
+            }),
+          },
+        });
+      });
+    });
+
+    it("can change a numeric field to a date field", async () => {
+      const formSettings: ActionFormSettings = {
+        type: "form",
+        fields: {
+          "abc-123": makeFieldSettings({ inputType: "number" }),
+        },
+      };
+
+      const { setFormSettings } = setupSettings({
+        parameters: [makeParameter()],
+        formSettings,
+      });
+
+      userEvent.click(await screen.findByLabelText("gear icon"));
+      userEvent.click(await screen.findByText("date"));
+
+      await waitFor(() => {
+        expect(setFormSettings).toHaveBeenCalledWith({
+          ...formSettings,
+          fields: {
+            "abc-123": makeFieldSettings({
+              fieldType: "date",
+              inputType: "date",
+            }),
+          },
+        });
+      });
+    });
+
+    it("can change a date field to a select field", async () => {
+      const formSettings: ActionFormSettings = {
+        type: "form",
+        fields: {
+          "abc-123": makeFieldSettings({ inputType: "date" }),
+        },
+      };
+      const { setFormSettings } = setupSettings({
+        parameters: [makeParameter()],
+        formSettings,
+      });
+
+      userEvent.click(await screen.findByLabelText("gear icon"));
+      userEvent.click(await screen.findByText("category"));
+
+      await waitFor(() => {
+        expect(setFormSettings).toHaveBeenCalledWith({
+          ...formSettings,
+          fields: {
+            "abc-123": makeFieldSettings({
+              fieldType: "category",
+              inputType: "select",
+            }),
+          },
+        });
+      });
+    });
+    it("can toggle required state", async () => {
+      const formSettings: ActionFormSettings = {
+        type: "form",
+        fields: {
+          "abc-123": makeFieldSettings({ inputType: "string" }),
+        },
+      };
+      const { setFormSettings } = setupSettings({
+        parameters: [makeParameter()],
+        formSettings,
+      });
+
+      userEvent.click(await screen.findByLabelText("gear icon"));
+      userEvent.click(await screen.findByRole("switch"));
+
+      await waitFor(() => {
+        expect(setFormSettings).toHaveBeenCalledWith({
+          ...formSettings,
+          fields: {
+            "abc-123": makeFieldSettings({
+              required: true,
+              inputType: "string",
+            }),
+          },
+        });
+      });
+    });
+  });
+});
diff --git a/frontend/src/metabase/actions/components/ActionForm/ActionFormFieldWidget.tsx b/frontend/src/metabase/actions/components/ActionForm/ActionFormFieldWidget.tsx
new file mode 100644
index 00000000000..d070674a467
--- /dev/null
+++ b/frontend/src/metabase/actions/components/ActionForm/ActionFormFieldWidget.tsx
@@ -0,0 +1,46 @@
+import React, { forwardRef } from "react";
+import _ from "underscore";
+
+import FormInputWidget from "metabase/core/components/FormInput";
+import FormTextAreaWidget from "metabase/core/components/FormTextArea";
+import FormRadioWidget, {
+  FormRadioProps,
+} from "metabase/core/components/FormRadio";
+import FormSelectWidget from "metabase/core/components/FormSelect";
+import FormNumericInputWidget from "metabase/core/components/FormNumericInput";
+import FormBooleanWidget from "metabase/core/components/FormToggle";
+import CategoryFieldPicker from "metabase/components/FormCategoryInput";
+
+import type { InputComponentType } from "metabase-types/api";
+import type { ActionFormFieldProps } from "metabase/actions/types";
+
+const VerticalRadio = (props: FormRadioProps) => (
+  <FormRadioWidget {...props} vertical />
+);
+
+const WIDGETS: Record<InputComponentType, React.FunctionComponent<any>> = {
+  text: FormInputWidget,
+  date: FormInputWidget,
+  time: FormInputWidget,
+  "datetime-local": FormInputWidget,
+  textarea: FormTextAreaWidget,
+  number: FormNumericInputWidget,
+  boolean: FormBooleanWidget,
+  radio: VerticalRadio,
+  select: FormSelectWidget,
+  category: CategoryFieldPicker,
+};
+
+interface FormWidgetProps {
+  formField: ActionFormFieldProps;
+}
+
+export const FormFieldWidget = forwardRef(function FormFieldWidget(
+  { formField }: FormWidgetProps,
+  ref: React.Ref<any>,
+) {
+  const Widget =
+    (formField.type ? WIDGETS[formField.type] : FormInputWidget) ??
+    FormInputWidget;
+  return <Widget {...formField} nullable={formField.optional} ref={ref} />;
+});
diff --git a/frontend/src/metabase/actions/components/ActionForm/index.ts b/frontend/src/metabase/actions/components/ActionForm/index.ts
new file mode 100644
index 00000000000..e1f10285c7c
--- /dev/null
+++ b/frontend/src/metabase/actions/components/ActionForm/index.ts
@@ -0,0 +1,2 @@
+export * from "./ActionForm";
+export * from "./ActionFormFieldWidget";
diff --git a/frontend/src/metabase/actions/components/ActionForm/utils.ts b/frontend/src/metabase/actions/components/ActionForm/utils.ts
new file mode 100644
index 00000000000..9f4ccef144e
--- /dev/null
+++ b/frontend/src/metabase/actions/components/ActionForm/utils.ts
@@ -0,0 +1,145 @@
+import { t } from "ttag";
+import _ from "underscore";
+import * as Yup from "yup";
+
+import type {
+  ActionFormSettings,
+  ActionFormOption,
+  FieldSettingsMap,
+  InputSettingType,
+  InputComponentType,
+  Parameter,
+  WritebackParameter,
+} from "metabase-types/api";
+import type {
+  ActionFormProps,
+  ActionFormFieldProps,
+  FieldSettings,
+} from "metabase/actions/types";
+
+import { sortActionParams, isEditableField } from "metabase/actions/utils";
+import { isEmpty } from "metabase/lib/validate";
+
+const getOptionsFromArray = (
+  options: (number | string)[],
+): ActionFormOption[] => options.map(o => ({ name: o, value: o }));
+
+const getSampleOptions = () => [
+  { name: t`Option One`, value: 1 },
+  { name: t`Option Two`, value: 2 },
+  { name: t`Option Three`, value: 3 },
+];
+
+const inputTypeHasOptions = (fieldSettings: FieldSettings) =>
+  ["select", "radio"].includes(fieldSettings.inputType);
+
+type FieldPropTypeMap = Record<InputSettingType, InputComponentType>;
+
+const fieldPropsTypeMap: FieldPropTypeMap = {
+  string: "text",
+  text: "textarea",
+  date: "date",
+  datetime: "datetime-local",
+  time: "time",
+  number: "number",
+  boolean: "boolean",
+  category: "category",
+  select: "select",
+  radio: "radio",
+};
+
+export const getFormField = (
+  parameter: Parameter,
+  fieldSettings: FieldSettings,
+) => {
+  if (
+    fieldSettings.field &&
+    !isEditableField(fieldSettings.field, parameter as Parameter)
+  ) {
+    return undefined;
+  }
+
+  const fieldProps: ActionFormFieldProps = {
+    name: parameter.id,
+    type: fieldPropsTypeMap[fieldSettings?.inputType] ?? "text",
+    title:
+      fieldSettings.title ||
+      fieldSettings.name ||
+      parameter["display-name"] ||
+      parameter.name ||
+      parameter.id,
+    description: fieldSettings.description ?? "",
+    placeholder: fieldSettings?.placeholder,
+    optional: !fieldSettings.required,
+    field: fieldSettings.field,
+  };
+
+  if (inputTypeHasOptions(fieldSettings)) {
+    fieldProps.options = fieldSettings.valueOptions?.length
+      ? getOptionsFromArray(fieldSettings.valueOptions)
+      : getSampleOptions();
+  }
+
+  return fieldProps;
+};
+
+export const getForm = (
+  parameters: WritebackParameter[] | Parameter[],
+  fieldSettings: Record<string, FieldSettings> = {},
+): ActionFormProps => {
+  const sortedParams = parameters.sort(
+    sortActionParams({ fields: fieldSettings } as ActionFormSettings),
+  );
+  return {
+    fields: sortedParams
+      ?.map(param => getFormField(param, fieldSettings[param.id] ?? {}))
+      .filter(Boolean) as ActionFormFieldProps[],
+  };
+};
+
+const getFieldValidationType = (fieldSettings: FieldSettings) => {
+  switch (fieldSettings.inputType) {
+    case "number":
+      return Yup.number();
+    case "boolean":
+      return Yup.boolean();
+    case "date":
+    case "datetime":
+    case "time":
+      // for dates, cast empty strings to null
+      return Yup.string().transform((value, originalValue) =>
+        originalValue?.length ? value : null,
+      );
+    default:
+      return Yup.string();
+  }
+};
+
+export const getFormValidationSchema = (
+  parameters: WritebackParameter[] | Parameter[],
+  fieldSettings: FieldSettingsMap = {},
+) => {
+  const requiredMessage = t`This field is required`;
+
+  const schema = Object.values(fieldSettings)
+    .filter(fieldSetting =>
+      // only validate fields that are present in the form
+      parameters.find(parameter => parameter.id === fieldSetting.id),
+    )
+    .map(fieldSetting => {
+      let yupType: Yup.AnySchema = getFieldValidationType(fieldSetting);
+
+      if (fieldSetting.required) {
+        yupType = yupType.required(requiredMessage);
+      } else {
+        yupType = yupType.nullable();
+      }
+
+      if (!isEmpty(fieldSetting.defaultValue)) {
+        yupType = yupType.default(fieldSetting.defaultValue);
+      }
+
+      return [fieldSetting.id, yupType];
+    });
+  return Yup.object(Object.fromEntries(schema));
+};
diff --git a/frontend/src/metabase/actions/containers/ActionCreator/ActionCreator.styled.tsx b/frontend/src/metabase/actions/containers/ActionCreator/ActionCreator.styled.tsx
new file mode 100644
index 00000000000..efe3eed4a51
--- /dev/null
+++ b/frontend/src/metabase/actions/containers/ActionCreator/ActionCreator.styled.tsx
@@ -0,0 +1,46 @@
+import styled from "@emotion/styled";
+import { color } from "metabase/lib/colors";
+import { space } from "metabase/styled-components/theme";
+
+export const ActionCreatorBodyContainer = styled.div`
+  display: grid;
+  grid-template-columns: 4fr 3fr;
+  border-top: 1px solid ${color("border")};
+  .react-resizable-handle {
+    display: none;
+  }
+  flex: 1;
+  overflow-y: auto;
+`;
+
+export const EditorContainer = styled.div`
+  flex: 1 1 0;
+  overflow-y: auto;
+  background-color: ${color("bg-light")};
+
+  .ace_editor {
+    margin-left: ${space(2)};
+  }
+`;
+
+export const ModalActions = styled.div`
+  display: flex;
+  flex: 0 0 auto;
+  justify-content: space-between;
+  gap: 1rem;
+  padding: 1rem;
+  border-top: 1px solid ${color("border")};
+`;
+
+export const ModalRoot = styled.div`
+  display: flex;
+  flex-direction: column;
+  height: 90vh;
+`;
+
+export const ModalLeft = styled.div`
+  position: relative;
+  display: flex;
+  flex-direction: column;
+  border-right: 1px solid ${color("border")};
+`;
diff --git a/frontend/src/metabase/actions/containers/ActionCreator/ActionCreator.tsx b/frontend/src/metabase/actions/containers/ActionCreator/ActionCreator.tsx
new file mode 100644
index 00000000000..6a340c9d7bd
--- /dev/null
+++ b/frontend/src/metabase/actions/containers/ActionCreator/ActionCreator.tsx
@@ -0,0 +1,230 @@
+import React, { useState, useMemo, useEffect } from "react";
+import { t } from "ttag";
+import _ from "underscore";
+import { connect } from "react-redux";
+import { push } from "react-router-redux";
+
+import Button from "metabase/core/components/Button";
+import Modal from "metabase/components/Modal";
+
+import { useToggle } from "metabase/hooks/use-toggle";
+import Actions, { ActionParams } from "metabase/entities/actions";
+import Database from "metabase/entities/databases";
+import { getMetadata } from "metabase/selectors/metadata";
+
+import { createQuestionFromAction } from "metabase/actions/selectors";
+
+import type {
+  WritebackQueryAction,
+  ActionFormSettings,
+} from "metabase-types/api";
+import type { State } from "metabase-types/store";
+import type { SavedCard } from "metabase-types/types/Card";
+
+import type NativeQuery from "metabase-lib/queries/NativeQuery";
+import type Metadata from "metabase-lib/metadata/Metadata";
+import type Question from "metabase-lib/Question";
+
+import CreateActionForm from "../CreateActionForm";
+import ActionCreatorHeader from "./ActionCreatorHeader";
+import QueryActionEditor from "./QueryActionEditor";
+import FormCreator from "./FormCreator";
+import {
+  DataReferenceTriggerButton,
+  DataReferenceInline,
+} from "./InlineDataReference";
+
+import {
+  ActionCreatorBodyContainer,
+  EditorContainer,
+  ModalRoot,
+  ModalActions,
+  ModalLeft,
+} from "./ActionCreator.styled";
+
+import { newQuestion, convertActionToQuestionCard } from "./utils";
+
+const mapStateToProps = (
+  state: State,
+  { action }: { action: WritebackQueryAction },
+) => ({
+  metadata: getMetadata(state),
+  question: action ? createQuestionFromAction(state, action) : undefined,
+  actionId: action ? action.id : undefined,
+});
+
+const mapDispatchToProps = {
+  push,
+  update: Actions.actions.update,
+};
+
+const EXAMPLE_QUERY =
+  "UPDATE products\nSET rating = {{ my_new_value }}\nWHERE id = {{ my_primary_key }}";
+
+interface OwnProps {
+  modelId?: number;
+  databaseId?: number;
+  onClose?: () => void;
+}
+
+interface StateProps {
+  actionId?: number;
+  question?: Question;
+  metadata: Metadata;
+}
+
+interface DispatchProps {
+  push: (url: string) => void;
+  update: (action: ActionParams) => void;
+}
+
+type ActionCreatorProps = OwnProps & StateProps & DispatchProps;
+
+function ActionCreatorComponent({
+  metadata,
+  question: passedQuestion,
+  actionId,
+  modelId,
+  databaseId,
+  update,
+  onClose,
+}: ActionCreatorProps) {
+  const [question, setQuestion] = useState(
+    passedQuestion ?? newQuestion(metadata, databaseId),
+  );
+  const [formSettings, setFormSettings] = useState<
+    ActionFormSettings | undefined
+  >(undefined);
+  const [showSaveModal, setShowSaveModal] = useState(false);
+
+  const [isDataRefOpen, { toggle: toggleDataRef, turnOff: closeDataRef }] =
+    useToggle(false);
+
+  useEffect(() => {
+    setQuestion(passedQuestion ?? newQuestion(metadata, databaseId));
+
+    // we do not want to update this any time the props or metadata change, only if action id changes
+  }, [actionId]); // eslint-disable-line react-hooks/exhaustive-deps
+
+  const defaultModelId: number | undefined = useMemo(() => {
+    if (modelId) {
+      return modelId;
+    }
+    const params = new URLSearchParams(window.location.search);
+    const modelQueryParam = params.get("model-id");
+    return modelId ? Number(modelQueryParam) : undefined;
+  }, [modelId]);
+
+  if (!question || !metadata) {
+    return null;
+  }
+
+  const query = question.query() as NativeQuery;
+
+  const isNew = !actionId && !(question.card() as SavedCard).id;
+
+  const handleSaveClick = () => {
+    if (isNew) {
+      setShowSaveModal(true);
+    } else {
+      update({
+        id: question.id(),
+        name: question.displayName() ?? "",
+        description: question.description() ?? null,
+        model_id: defaultModelId as number,
+        formSettings: formSettings as ActionFormSettings,
+        question,
+      });
+      onClose?.();
+    }
+  };
+
+  const handleOnSave = (action: WritebackQueryAction) => {
+    const actionCard = convertActionToQuestionCard(action);
+    setQuestion(question.setCard(actionCard));
+    setTimeout(() => setShowSaveModal(false), 1000);
+    onClose?.();
+  };
+
+  const handleClose = () => setShowSaveModal(false);
+
+  const handleExampleClick = () => {
+    setQuestion(
+      question.setQuery(query.setQueryText(query.queryText() + EXAMPLE_QUERY)),
+    );
+  };
+
+  return (
+    <>
+      <Modal wide onClose={onClose}>
+        <ModalRoot>
+          <ActionCreatorBodyContainer>
+            <ModalLeft>
+              <DataReferenceTriggerButton onClick={toggleDataRef} />
+              <ActionCreatorHeader
+                type="query"
+                name={question.displayName() ?? t`New Action`}
+                onChangeName={newName =>
+                  setQuestion(q => q.setDisplayName(newName))
+                }
+              />
+              <EditorContainer>
+                <QueryActionEditor
+                  question={question}
+                  setQuestion={setQuestion}
+                />
+              </EditorContainer>
+              <ModalActions>
+                <Button onClick={onClose} borderless>
+                  {t`Cancel`}
+                </Button>
+                <Button
+                  primary
+                  disabled={query.isEmpty()}
+                  onClick={handleSaveClick}
+                >
+                  {isNew ? t`Save` : t`Update`}
+                </Button>
+              </ModalActions>
+            </ModalLeft>
+
+            <DataReferenceInline
+              isOpen={isDataRefOpen}
+              onClose={closeDataRef}
+            />
+
+            {!isDataRefOpen && (
+              <FormCreator
+                params={question?.parameters() ?? []}
+                formSettings={
+                  question?.card()?.visualization_settings as ActionFormSettings
+                }
+                onChange={setFormSettings}
+                onExampleClick={handleExampleClick}
+              />
+            )}
+          </ActionCreatorBodyContainer>
+        </ModalRoot>
+      </Modal>
+      {showSaveModal && (
+        <Modal title={t`New Action`} onClose={handleClose}>
+          <CreateActionForm
+            question={question}
+            formSettings={formSettings as ActionFormSettings}
+            modelId={defaultModelId}
+            onCreate={handleOnSave}
+            onCancel={handleClose}
+          />
+        </Modal>
+      )}
+    </>
+  );
+}
+
+export default _.compose(
+  Actions.load({
+    id: (state: State, props: { actionId?: number }) => props.actionId,
+  }),
+  Database.loadList(),
+  connect(mapStateToProps, mapDispatchToProps),
+)(ActionCreatorComponent);
diff --git a/frontend/src/metabase/actions/containers/ActionCreator/ActionCreator.unit.spec.tsx b/frontend/src/metabase/actions/containers/ActionCreator/ActionCreator.unit.spec.tsx
new file mode 100644
index 00000000000..e97167beea0
--- /dev/null
+++ b/frontend/src/metabase/actions/containers/ActionCreator/ActionCreator.unit.spec.tsx
@@ -0,0 +1,127 @@
+import React from "react";
+import nock from "nock";
+
+import {
+  renderWithProviders,
+  screen,
+  waitForElementToBeRemoved,
+} from "__support__/ui";
+import { setupDatabasesEndpoints } from "__support__/server-mocks";
+import { SAMPLE_DATABASE } from "__support__/sample_database_fixture";
+
+import {
+  createMockActionParameter,
+  createMockQueryAction,
+} from "metabase-types/api/mocks";
+import type { WritebackQueryAction } from "metabase-types/api";
+import type Database from "metabase-lib/metadata/Database";
+import type Table from "metabase-lib/metadata/Table";
+
+import ActionCreator from "./ActionCreator";
+
+// eslint-disable-next-line react/display-name
+jest.mock("metabase/query_builder/components/NativeQueryEditor", () => () => (
+  <span data-testid="native-query-editor">Mock Native Query Editor</span>
+));
+
+function getDatabaseObject(database: Database) {
+  return {
+    ...database.getPlainObject(),
+    tables: database.tables.map(getTableObject),
+  };
+}
+
+function getTableObject(table: Table) {
+  return {
+    ...table.getPlainObject(),
+    schema: table.schema_name,
+  };
+}
+
+type SetupOpts = {
+  action?: WritebackQueryAction;
+};
+
+async function setup({ action }: SetupOpts = {}) {
+  const scope = nock(location.origin);
+
+  setupDatabasesEndpoints(scope, [getDatabaseObject(SAMPLE_DATABASE)]);
+
+  if (action) {
+    scope.get(`/api/action/${action.id}`).reply(200, action);
+  }
+
+  renderWithProviders(<ActionCreator actionId={action?.id} />, {
+    withSampleDatabase: true,
+  });
+
+  await waitForElementToBeRemoved(() =>
+    screen.queryByTestId("loading-spinner"),
+  );
+}
+
+async function setupEditing({
+  action = createMockQueryAction(),
+  ...opts
+} = {}) {
+  await setup({ action, ...opts });
+  return { action };
+}
+
+describe("ActionCreator", () => {
+  afterEach(() => {
+    nock.cleanAll();
+  });
+
+  describe("new action", () => {
+    it("renders correctly", async () => {
+      await setup();
+
+      expect(screen.getByText(/New action/i)).toBeInTheDocument();
+      expect(screen.getByText(SAMPLE_DATABASE.name)).toBeInTheDocument();
+      expect(screen.getByTestId("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();
+    });
+  });
+
+  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.getByText(SAMPLE_DATABASE.name)).toBeInTheDocument();
+      expect(screen.getByTestId("native-query-editor")).toBeInTheDocument();
+      expect(
+        screen.getByRole("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();
+    });
+  });
+});
diff --git a/frontend/src/metabase/actions/containers/ActionCreator/ActionCreatorHeader.styled.tsx b/frontend/src/metabase/actions/containers/ActionCreator/ActionCreatorHeader.styled.tsx
new file mode 100644
index 00000000000..95f65091611
--- /dev/null
+++ b/frontend/src/metabase/actions/containers/ActionCreator/ActionCreatorHeader.styled.tsx
@@ -0,0 +1,56 @@
+import styled from "@emotion/styled";
+import { color } from "metabase/lib/colors";
+import { space } from "metabase/styled-components/theme";
+
+import Select from "metabase/core/components/Select";
+import SelectButton from "metabase/core/components/SelectButton";
+import EditableTextBase from "metabase/core/components/EditableText";
+
+export const Container = styled.div`
+  display: flex;
+  flex: 0 0 auto;
+  align-items: center;
+  justify-content: space-between;
+  width: 100%;
+  background-color: ${color("white")};
+  border-bottom: 1px solid ${color("border")};
+  padding: ${space(2)} ${space(3)};
+`;
+
+export const LeftHeader = styled.div`
+  display: flex;
+  align-items: center;
+  color: ${color("text-medium")};
+  gap: ${space(2)};
+`;
+
+export const EditableText = styled(EditableTextBase)`
+  font-weight: bold;
+  font-size: 1.3em;
+  color: ${color("text-medium")};
+`;
+
+export const Option = styled.div`
+  color: ${color("text-medium")};
+  ${disabled => disabled && `color: ${color("text-medium")}`};
+`;
+
+export const CompactSelect = styled(Select)`
+  ${SelectButton.Root} {
+    border: none;
+    border-radius: 6px;
+    min-width: 80px;
+    color: ${color("text-medium")};
+  }
+  ${SelectButton.Content} {
+    margin-right: 6px;
+  }
+  ${SelectButton.Icon} {
+    margin-left: 0;
+  }
+  &:hover {
+    ${SelectButton.Root} {
+      background-color: ${color("bg-light")};
+    }
+  }
+`;
diff --git a/frontend/src/metabase/actions/containers/ActionCreator/ActionCreatorHeader.tsx b/frontend/src/metabase/actions/containers/ActionCreator/ActionCreatorHeader.tsx
new file mode 100644
index 00000000000..a5e4a5a576c
--- /dev/null
+++ b/frontend/src/metabase/actions/containers/ActionCreator/ActionCreatorHeader.tsx
@@ -0,0 +1,40 @@
+import React from "react";
+import { t } from "ttag";
+
+import type { WritebackActionType } from "metabase-types/api";
+
+import {
+  Container,
+  LeftHeader,
+  EditableText,
+  CompactSelect,
+} from "./ActionCreatorHeader.styled";
+
+type Props = {
+  name: string;
+  type: WritebackActionType;
+  onChangeName: (name: string) => void;
+  onChangeType?: (type: WritebackActionType) => void;
+};
+
+const OPTS = [{ value: "query", name: t`Query`, disabled: true }];
+
+const ActionCreatorHeader = ({
+  name = t`New Action`,
+  type,
+  onChangeName,
+  onChangeType,
+}: Props) => {
+  return (
+    <Container>
+      <LeftHeader>
+        <EditableText initialValue={name} onChange={onChangeName} />
+        {!!onChangeType && (
+          <CompactSelect options={OPTS} value={type} onChange={onChangeType} />
+        )}
+      </LeftHeader>
+    </Container>
+  );
+};
+
+export default ActionCreatorHeader;
diff --git a/frontend/src/metabase/actions/containers/ActionCreator/FormCreator/EmptyFormPlaceholder.tsx b/frontend/src/metabase/actions/containers/ActionCreator/FormCreator/EmptyFormPlaceholder.tsx
new file mode 100644
index 00000000000..f4656b509fe
--- /dev/null
+++ b/frontend/src/metabase/actions/containers/ActionCreator/FormCreator/EmptyFormPlaceholder.tsx
@@ -0,0 +1,33 @@
+import React from "react";
+import { t } from "ttag";
+
+import Icon from "metabase/components/Icon";
+
+import {
+  EmptyFormPlaceholderWrapper,
+  ExplainerText,
+  ExampleButton,
+  IconContainer,
+  TopRightIcon,
+} from "./FormCreator.styled";
+
+export const EmptyFormPlaceholder = ({
+  onExampleClick,
+}: {
+  onExampleClick: () => void;
+}) => (
+  <EmptyFormPlaceholderWrapper>
+    <IconContainer>
+      <Icon name="sql" size={62} />
+      <TopRightIcon name="insight" size={24} />
+    </IconContainer>
+    <h3>{t`Build custom forms and business logic.`}</h3>
+    <ExplainerText>
+      {t`Actions let you write parameterized SQL that can then be attached to buttons, clicks, or even added on the page as form elements.`}
+    </ExplainerText>
+    <ExplainerText>
+      {t`Use actions to update your data based on user input or values on the page.`}
+    </ExplainerText>
+    <ExampleButton onClick={onExampleClick}>{t`See an example`}</ExampleButton>
+  </EmptyFormPlaceholderWrapper>
+);
diff --git a/frontend/src/metabase/actions/containers/ActionCreator/FormCreator/FieldSettingsButtons.tsx b/frontend/src/metabase/actions/containers/ActionCreator/FormCreator/FieldSettingsButtons.tsx
new file mode 100644
index 00000000000..609debea090
--- /dev/null
+++ b/frontend/src/metabase/actions/containers/ActionCreator/FormCreator/FieldSettingsButtons.tsx
@@ -0,0 +1,42 @@
+import React from "react";
+
+import type { FieldSettings } from "metabase-types/api";
+
+import { OptionPopover } from "./OptionEditor";
+import { FieldSettingsPopover } from "./FieldSettingsPopover";
+
+import { FieldSettingsButtonsContainer } from "./FormCreator.styled";
+
+export function FieldSettingsButtons({
+  fieldSettings,
+  onChange,
+}: {
+  fieldSettings: FieldSettings;
+  onChange: (fieldSettings: FieldSettings) => void;
+}) {
+  if (!fieldSettings) {
+    return null;
+  }
+
+  const updateOptions = (newOptions: (string | number)[]) => {
+    onChange({
+      ...fieldSettings,
+      valueOptions: newOptions,
+    });
+  };
+
+  const hasOptions =
+    fieldSettings.inputType === "select" || fieldSettings.inputType === "radio";
+
+  return (
+    <FieldSettingsButtonsContainer>
+      {hasOptions && (
+        <OptionPopover
+          options={fieldSettings.valueOptions ?? []}
+          onChange={updateOptions}
+        />
+      )}
+      <FieldSettingsPopover fieldSettings={fieldSettings} onChange={onChange} />
+    </FieldSettingsButtonsContainer>
+  );
+}
diff --git a/frontend/src/metabase/actions/containers/ActionCreator/FormCreator/FieldSettingsPopover.styled.tsx b/frontend/src/metabase/actions/containers/ActionCreator/FormCreator/FieldSettingsPopover.styled.tsx
new file mode 100644
index 00000000000..0420f21a199
--- /dev/null
+++ b/frontend/src/metabase/actions/containers/ActionCreator/FormCreator/FieldSettingsPopover.styled.tsx
@@ -0,0 +1,36 @@
+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";
+
+export const SettingsPopoverBody = styled.div`
+  padding: ${space(3)};
+`;
+
+export const SectionLabel = styled.div`
+  color: ${color("text-medium")};
+  font-weight: bold;
+  padding-left: ${space(0)};
+  margin-bottom: ${space(1)};
+`;
+
+export const Divider = styled.div`
+  border-bottom: 1px solid ${color("border")};
+  margin: ${space(2)} 0;
+`;
+
+export const ToggleContainer = styled.div`
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding-left: ${space(0)};
+  margin-bottom: ${space(1)};
+`;
+
+export const SettingsTriggerIcon = styled(Icon)`
+  color: ${color("brand")};
+  &:hover {
+    color: ${lighten("brand", 0.1)};
+  }
+`;
diff --git a/frontend/src/metabase/actions/containers/ActionCreator/FormCreator/FieldSettingsPopover.tsx b/frontend/src/metabase/actions/containers/ActionCreator/FormCreator/FieldSettingsPopover.tsx
new file mode 100644
index 00000000000..48740fcfef3
--- /dev/null
+++ b/frontend/src/metabase/actions/containers/ActionCreator/FormCreator/FieldSettingsPopover.tsx
@@ -0,0 +1,232 @@
+import React, { useMemo } from "react";
+import { t } from "ttag";
+
+import type {
+  FieldSettings,
+  FieldType,
+  InputSettingType,
+} from "metabase-types/api";
+
+import Input from "metabase/core/components/Input";
+import Radio from "metabase/core/components/Radio";
+import Toggle from "metabase/core/components/Toggle";
+import TippyPopoverWithTrigger from "metabase/components/PopoverWithTrigger/TippyPopoverWithTrigger";
+
+import { getFieldTypes, getInputTypes } from "./constants";
+import {
+  SettingsTriggerIcon,
+  ToggleContainer,
+  SettingsPopoverBody,
+  SectionLabel,
+  Divider,
+} from "./FieldSettingsPopover.styled";
+
+export function FieldSettingsPopover({
+  fieldSettings,
+  onChange,
+}: {
+  fieldSettings: FieldSettings;
+  onChange: (fieldSettings: FieldSettings) => void;
+}) {
+  return (
+    <TippyPopoverWithTrigger
+      placement="bottom-end"
+      triggerContent={
+        <SettingsTriggerIcon
+          name="gear"
+          size={14}
+          tooltip={t`Change field settings`}
+        />
+      }
+      maxWidth={400}
+      popoverContent={() => (
+        <FormCreatorPopoverBody
+          fieldSettings={fieldSettings}
+          onChange={onChange}
+        />
+      )}
+    />
+  );
+}
+
+export function FormCreatorPopoverBody({
+  fieldSettings,
+  onChange,
+}: {
+  fieldSettings: FieldSettings;
+  onChange: (fieldSettings: FieldSettings) => void;
+}) {
+  const inputTypes = useMemo(getInputTypes, []);
+
+  const handleUpdateFieldType = (newFieldType: FieldType) =>
+    onChange({
+      ...fieldSettings,
+      fieldType: newFieldType,
+      inputType: inputTypes[newFieldType][0].value,
+    });
+
+  const handleUpdateInputType = (newInputType: InputSettingType) =>
+    onChange({
+      ...fieldSettings,
+      inputType: newInputType,
+    });
+
+  const handleUpdatePlaceholder = (newPlaceholder: string) =>
+    onChange({
+      ...fieldSettings,
+      placeholder: newPlaceholder,
+    });
+
+  const handleUpdateRequired = ({
+    required,
+    defaultValue,
+  }: {
+    required: boolean;
+    defaultValue?: string | number;
+  }) =>
+    onChange({
+      ...fieldSettings,
+      required,
+      defaultValue:
+        fieldSettings.fieldType === "number"
+          ? Number(defaultValue)
+          : defaultValue,
+    });
+
+  const hasPlaceholder =
+    fieldSettings.fieldType !== "date" && fieldSettings.inputType !== "radio";
+
+  return (
+    <SettingsPopoverBody data-testid="field-settings-popover">
+      <FieldTypeSelect
+        value={fieldSettings.fieldType}
+        onChange={handleUpdateFieldType}
+      />
+      <Divider />
+      <InputTypeSelect
+        value={fieldSettings.inputType}
+        fieldType={fieldSettings.fieldType}
+        onChange={handleUpdateInputType}
+      />
+      <Divider />
+      {hasPlaceholder && (
+        <PlaceholderInput
+          value={fieldSettings.placeholder ?? ""}
+          onChange={handleUpdatePlaceholder}
+        />
+      )}
+      <Divider />
+      <RequiredInput
+        value={fieldSettings.required}
+        defaultValue={fieldSettings.defaultValue}
+        onChange={handleUpdateRequired}
+      />
+    </SettingsPopoverBody>
+  );
+}
+
+function FieldTypeSelect({
+  value,
+  onChange,
+}: {
+  value: FieldType;
+  onChange: (newFieldType: FieldType) => void;
+}) {
+  const fieldTypes = useMemo(getFieldTypes, []);
+
+  return (
+    <div>
+      <SectionLabel>{t`Field type`}</SectionLabel>
+      <Radio
+        variant="bubble"
+        value={value}
+        options={fieldTypes}
+        onChange={onChange}
+      />
+    </div>
+  );
+}
+
+function InputTypeSelect({
+  fieldType,
+  value,
+  onChange,
+}: {
+  value: InputSettingType;
+  fieldType: FieldType;
+  onChange: (newInputType: InputSettingType) => void;
+}) {
+  const inputTypes = useMemo(getInputTypes, []);
+
+  return (
+    <Radio
+      vertical
+      value={value}
+      options={inputTypes[fieldType ?? "string"]}
+      onChange={onChange}
+    />
+  );
+}
+
+function PlaceholderInput({
+  value,
+  onChange,
+}: {
+  value: string;
+  onChange: (newPlaceholder: string) => void;
+}) {
+  const inputTypes = useMemo(getInputTypes, []);
+
+  return (
+    <div>
+      <SectionLabel>{t`Placeholder text`}</SectionLabel>
+      <Input
+        fullWidth
+        value={value}
+        onChange={e => onChange(e.target.value)}
+        data-testid="placeholder-input"
+      />
+    </div>
+  );
+}
+
+function RequiredInput({
+  value,
+  defaultValue,
+  onChange,
+}: {
+  value: boolean;
+  defaultValue?: string | number;
+  onChange: ({
+    required,
+    defaultValue,
+  }: {
+    required: boolean;
+    defaultValue?: string | number;
+  }) => void;
+}) {
+  return (
+    <div>
+      <ToggleContainer>
+        <strong>{t`Required`}</strong>
+        <Toggle
+          value={!!value}
+          onChange={required => onChange({ required, defaultValue })}
+        />
+      </ToggleContainer>
+      {!value && (
+        <>
+          <SectionLabel>{t`Default Value`}</SectionLabel>
+          <Input
+            fullWidth
+            value={defaultValue ?? ""}
+            onChange={e =>
+              onChange({ required: false, defaultValue: e.target.value })
+            }
+            data-testid="placeholder-input"
+          />
+        </>
+      )}
+    </div>
+  );
+}
diff --git a/frontend/src/metabase/actions/containers/ActionCreator/FormCreator/FieldSettingsPopover.unit.spec.tsx b/frontend/src/metabase/actions/containers/ActionCreator/FormCreator/FieldSettingsPopover.unit.spec.tsx
new file mode 100644
index 00000000000..20df24c9fe9
--- /dev/null
+++ b/frontend/src/metabase/actions/containers/ActionCreator/FormCreator/FieldSettingsPopover.unit.spec.tsx
@@ -0,0 +1,94 @@
+import React from "react";
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+
+import { getDefaultFieldSettings } from "../../../utils";
+import { FieldSettingsPopover } from "./FieldSettingsPopover";
+
+describe("actions > FormCreator > FieldSettingsPopover", () => {
+  it("should show the popover", async () => {
+    const changeSpy = jest.fn();
+    const settings = getDefaultFieldSettings();
+
+    render(
+      <FieldSettingsPopover fieldSettings={settings} onChange={changeSpy} />,
+    );
+
+    await userEvent.click(screen.getByLabelText("gear icon"));
+
+    expect(
+      await screen.findByTestId("field-settings-popover"),
+    ).toBeInTheDocument();
+  });
+
+  it("should fire onChange handler clicking a different field type", async () => {
+    const changeSpy = jest.fn();
+    const settings = getDefaultFieldSettings();
+
+    render(
+      <FieldSettingsPopover fieldSettings={settings} onChange={changeSpy} />,
+    );
+
+    await userEvent.click(screen.getByLabelText("gear icon"));
+
+    expect(
+      await screen.findByTestId("field-settings-popover"),
+    ).toBeInTheDocument();
+
+    await userEvent.click(screen.getByText("date"));
+
+    expect(changeSpy).toHaveBeenCalledTimes(1);
+
+    expect(changeSpy).toHaveBeenCalledWith({
+      ...settings,
+      fieldType: "date",
+      inputType: "date", // should set default input type for new field type
+    });
+  });
+
+  it("should fire onChange handler clicking a different input type", async () => {
+    const changeSpy = jest.fn();
+    const settings = getDefaultFieldSettings();
+
+    render(
+      <FieldSettingsPopover fieldSettings={settings} onChange={changeSpy} />,
+    );
+
+    await userEvent.click(screen.getByLabelText("gear icon"));
+
+    expect(
+      await screen.findByTestId("field-settings-popover"),
+    ).toBeInTheDocument();
+
+    await userEvent.click(screen.getByText("dropdown"));
+
+    expect(changeSpy).toHaveBeenCalledTimes(1);
+
+    expect(changeSpy).toHaveBeenCalledWith({
+      ...settings,
+      inputType: "select",
+    });
+  });
+
+  it("should fire onChange handler editing placeholder", async () => {
+    const changeSpy = jest.fn();
+    const settings = getDefaultFieldSettings();
+
+    render(
+      <FieldSettingsPopover fieldSettings={settings} onChange={changeSpy} />,
+    );
+
+    await userEvent.click(screen.getByLabelText("gear icon"));
+
+    expect(
+      await screen.findByTestId("field-settings-popover"),
+    ).toBeInTheDocument();
+
+    await userEvent.type(screen.getByTestId("placeholder-input"), "$");
+
+    expect(changeSpy).toHaveBeenLastCalledWith({
+      ...settings,
+      placeholder: "$",
+    });
+  });
+});
diff --git a/frontend/src/metabase/actions/containers/ActionCreator/FormCreator/FormCreator.styled.tsx b/frontend/src/metabase/actions/containers/ActionCreator/FormCreator/FormCreator.styled.tsx
new file mode 100644
index 00000000000..836c9ae87fb
--- /dev/null
+++ b/frontend/src/metabase/actions/containers/ActionCreator/FormCreator/FormCreator.styled.tsx
@@ -0,0 +1,64 @@
+import styled from "@emotion/styled";
+import InputBase from "metabase/core/components/Input";
+import Icon from "metabase/components/Icon";
+
+import { color, lighten } from "metabase/lib/colors";
+import { space } from "metabase/styled-components/theme";
+
+export const FormCreatorWrapper = styled.div`
+  flex: 1 1 0;
+  transition: flex 500ms ease-in-out;
+  padding: ${space(3)};
+  background-color: ${color("white")};
+  overflow-y: auto;
+`;
+
+export const FieldSettingsButtonsContainer = styled.div`
+  position: absolute;
+  bottom: 0;
+  right: 0;
+  padding: ${space(0)};
+  display: flex;
+  gap: ${space(1)};
+  align-items: center;
+  justify-content: flex-end;
+`;
+
+export const EmptyFormPlaceholderWrapper = styled.div`
+  display: flex;
+  flex-direction: column;
+  justify-content: center;
+  align-items: center;
+  height: 100%;
+  text-align: center;
+  padding: 5rem;
+`;
+
+export const ExplainerText = styled.p`
+  font-weight: 400;
+  color: ${color("text-medium")};
+  margin: ${space(2)} auto;
+`;
+
+export const ExampleButton = styled.button`
+  font-weight: bold;
+  cursor: pointer;
+  margin: ${space(2)};
+  color: ${color("brand")};
+  :hover {
+    color: ${lighten("brand", 0.1)};
+  }
+`;
+
+export const IconContainer = styled.div`
+  display: inline-block;
+  padding: 1.25rem;
+  position: relative;
+  color: ${color("brand")};
+`;
+
+export const TopRightIcon = styled(Icon)`
+  position: absolute;
+  top: 0;
+  right: 0;
+`;
diff --git a/frontend/src/metabase/actions/containers/ActionCreator/FormCreator/FormCreator.tsx b/frontend/src/metabase/actions/containers/ActionCreator/FormCreator/FormCreator.tsx
new file mode 100644
index 00000000000..a26ff27308c
--- /dev/null
+++ b/frontend/src/metabase/actions/containers/ActionCreator/FormCreator/FormCreator.tsx
@@ -0,0 +1,68 @@
+import React, { useState, useEffect, useMemo } from "react";
+import _ from "underscore";
+
+import { ActionForm } from "metabase/actions/components/ActionForm";
+
+import { addMissingSettings } from "metabase/entities/actions/utils";
+
+import type { ActionFormSettings, Parameter } from "metabase-types/api";
+
+import { getDefaultFormSettings, sortActionParams } from "../../../utils";
+import { hasNewParams } from "./utils";
+
+import { EmptyFormPlaceholder } from "./EmptyFormPlaceholder";
+import { FormCreatorWrapper } from "./FormCreator.styled";
+
+function FormCreator({
+  params,
+  formSettings: passedFormSettings,
+  onChange,
+  onExampleClick,
+}: {
+  params: Parameter[];
+  formSettings?: ActionFormSettings;
+  onChange: (formSettings: ActionFormSettings) => void;
+  onExampleClick: () => void;
+}) {
+  const [formSettings, setFormSettings] = useState<ActionFormSettings>(
+    passedFormSettings?.fields ? passedFormSettings : getDefaultFormSettings(),
+  );
+
+  useEffect(() => {
+    onChange(formSettings);
+  }, [formSettings, onChange]);
+
+  useEffect(() => {
+    // add default settings for new parameters
+    if (formSettings && params && hasNewParams(params, formSettings)) {
+      setFormSettings(addMissingSettings(formSettings, params));
+    }
+  }, [params, formSettings]);
+
+  const sortedParams = useMemo(
+    () => params.sort(sortActionParams(formSettings)),
+    [params, formSettings],
+  );
+
+  if (!sortedParams.length) {
+    return (
+      <FormCreatorWrapper>
+        <EmptyFormPlaceholder onExampleClick={onExampleClick} />
+      </FormCreatorWrapper>
+    );
+  }
+
+  return (
+    <FormCreatorWrapper>
+      <ActionForm
+        parameters={sortedParams}
+        onClose={_.noop}
+        onSubmit={_.noop}
+        formSettings={formSettings}
+        setFormSettings={setFormSettings}
+      />
+    </FormCreatorWrapper>
+  );
+}
+
+export default FormCreator;
diff --git a/frontend/src/metabase/actions/containers/ActionCreator/FormCreator/OptionEditor.styled.tsx b/frontend/src/metabase/actions/containers/ActionCreator/FormCreator/OptionEditor.styled.tsx
new file mode 100644
index 00000000000..9b82aa960b9
--- /dev/null
+++ b/frontend/src/metabase/actions/containers/ActionCreator/FormCreator/OptionEditor.styled.tsx
@@ -0,0 +1,28 @@
+import styled from "@emotion/styled";
+
+import { color } from "metabase/lib/colors";
+import { space } from "metabase/styled-components/theme";
+
+export const OptionEditorContainer = styled.div`
+  display: flex;
+  flex-direction: column;
+  padding: ${space(2)};
+`;
+
+export const AddMorePrompt = styled.div`
+  text-align: center;
+  font-size: 0.875rem;
+  margin: ${space(1)} 0;
+  height: 1.25rem;
+  color: ${color("text-light")};
+  transition: opacity 0.2s ease-in-out;
+`;
+
+export const TextArea = styled.textarea`
+  resize: none;
+  border: none;
+  outline: 1px solid ${color("border")};
+  width: 20rem;
+  border-radius: ${space(1)};
+  padding: ${space(1)};
+`;
diff --git a/frontend/src/metabase/actions/containers/ActionCreator/FormCreator/OptionEditor.tsx b/frontend/src/metabase/actions/containers/ActionCreator/FormCreator/OptionEditor.tsx
new file mode 100644
index 00000000000..af1768c314e
--- /dev/null
+++ b/frontend/src/metabase/actions/containers/ActionCreator/FormCreator/OptionEditor.tsx
@@ -0,0 +1,57 @@
+import React, { useState } from "react";
+import { t } from "ttag";
+
+import TippyPopoverWithTrigger from "metabase/components/PopoverWithTrigger/TippyPopoverWithTrigger";
+import Button from "metabase/core/components/Button";
+import Icon from "metabase/components/Icon";
+
+import {
+  OptionEditorContainer,
+  AddMorePrompt,
+  TextArea,
+} from "./OptionEditor.styled";
+
+type ValueOptions = (string | number)[];
+
+const optionsToText = (options: ValueOptions) => options.join("\n");
+const textToOptions = (text: string): ValueOptions =>
+  text.split("\n").map(option => option.trim());
+
+export const OptionPopover = ({
+  options,
+  onChange,
+}: {
+  options: ValueOptions;
+  onChange: (options: ValueOptions) => void;
+}) => {
+  const [text, setText] = useState(optionsToText(options));
+  const save = (closePopover: () => void) => {
+    onChange(textToOptions(text));
+    closePopover();
+  };
+
+  return (
+    <TippyPopoverWithTrigger
+      placement="bottom-end"
+      triggerContent={
+        <Icon name="list" size={14} tooltip={t`Change options`} />
+      }
+      maxWidth={400}
+      popoverContent={({ closePopover }) => (
+        <OptionEditorContainer>
+          <TextArea
+            value={text}
+            onChange={e => setText(e.target.value)}
+            placeholder={t`Enter one option per line`}
+          />
+          <AddMorePrompt style={{ opacity: text.length ? 1 : 0 }}>
+            {t`Press enter to add another option`}
+          </AddMorePrompt>
+          <Button onClick={() => save(closePopover)} small>
+            {t`Save`}
+          </Button>
+        </OptionEditorContainer>
+      )}
+    />
+  );
+};
diff --git a/frontend/src/metabase/actions/containers/ActionCreator/FormCreator/constants.ts b/frontend/src/metabase/actions/containers/ActionCreator/FormCreator/constants.ts
new file mode 100644
index 00000000000..6307436e361
--- /dev/null
+++ b/frontend/src/metabase/actions/containers/ActionCreator/FormCreator/constants.ts
@@ -0,0 +1,90 @@
+import { t } from "ttag";
+import type { FieldType, InputSettingType } from "metabase-types/api";
+
+interface FieldOptionType {
+  value: FieldType;
+  name: string;
+}
+
+export const getFieldTypes = (): FieldOptionType[] => [
+  {
+    value: "string",
+    name: t`text`,
+  },
+  {
+    value: "number",
+    name: t`number`,
+  },
+  {
+    value: "date",
+    name: t`date`,
+  },
+  {
+    value: "category",
+    name: t`category`,
+  },
+];
+
+interface InputOptionType {
+  value: InputSettingType;
+  name: string;
+}
+
+interface InputOptionsMap {
+  string: InputOptionType[];
+  number: InputOptionType[];
+  date: InputOptionType[];
+  category: InputOptionType[];
+}
+
+const getTextInputs = (): InputOptionType[] => [
+  {
+    value: "string",
+    name: t`text`,
+  },
+  {
+    value: "text",
+    name: t`long text`,
+  },
+];
+
+const getSelectInputs = (): InputOptionType[] => [
+  {
+    value: "select",
+    name: t`dropdown`,
+  },
+  {
+    value: "radio",
+    name: t`inline select`,
+  },
+];
+
+export const getInputTypes = (): InputOptionsMap => ({
+  string: [...getTextInputs(), ...getSelectInputs()],
+  number: [
+    {
+      value: "number",
+      name: t`number`,
+    },
+    ...getSelectInputs(),
+  ],
+  date: [
+    {
+      value: "date",
+      name: t`date`,
+    },
+    {
+      value: "datetime",
+      name: t`date + time`,
+    },
+    // {
+    //   value: "monthyear",
+    //   name: t`month + year`,
+    // },
+    // {
+    //   value: "quarteryear",
+    //   name: t`quarter + year`,
+    // },
+  ],
+  category: [...getSelectInputs()],
+});
diff --git a/frontend/src/metabase/actions/containers/ActionCreator/FormCreator/index.ts b/frontend/src/metabase/actions/containers/ActionCreator/FormCreator/index.ts
new file mode 100644
index 00000000000..6f9610d8cf1
--- /dev/null
+++ b/frontend/src/metabase/actions/containers/ActionCreator/FormCreator/index.ts
@@ -0,0 +1,2 @@
+export { default } from "./FormCreator";
+export * from "./utils";
diff --git a/frontend/src/metabase/actions/containers/ActionCreator/FormCreator/utils.ts b/frontend/src/metabase/actions/containers/ActionCreator/FormCreator/utils.ts
new file mode 100644
index 00000000000..808c04006db
--- /dev/null
+++ b/frontend/src/metabase/actions/containers/ActionCreator/FormCreator/utils.ts
@@ -0,0 +1,157 @@
+import { t } from "ttag";
+import _ from "underscore";
+
+import { moveElement } from "metabase/core/utils/arrays";
+import { slugify } from "metabase/lib/formatting";
+
+import type {
+  ActionFormSettings,
+  WritebackAction,
+  FieldSettings,
+  FieldSettingsMap,
+  ParameterId,
+  Parameter,
+} from "metabase-types/api";
+
+import Field from "metabase-lib/metadata/Field";
+import { TYPE } from "metabase-lib/types/constants";
+
+import { getDefaultFieldSettings } from "../../../utils";
+
+export const getFormTitle = (action: WritebackAction): string => {
+  return action.visualization_settings?.name || action.name || t`Action form`;
+};
+
+export const getSubmitButtonColor = (action: WritebackAction): string => {
+  if (action.type === "implicit" && action.kind === "row/delete") {
+    return "danger";
+  }
+  return action.visualization_settings?.submitButtonColor ?? "primary";
+};
+
+export const getSubmitButtonLabel = (action: WritebackAction): string => {
+  if (action.visualization_settings?.submitButtonLabel) {
+    return action.visualization_settings.submitButtonLabel;
+  }
+
+  if (action.type === "implicit") {
+    if (action.kind === "row/delete") {
+      return t`Delete`;
+    }
+
+    if (action.kind === "row/update") {
+      return t`Update`;
+    }
+  }
+
+  return t`Save`;
+};
+
+export const generateFieldSettingsFromParameters = (
+  params: Parameter[],
+  fields?: Field[],
+) => {
+  const fieldSettings: Record<ParameterId, FieldSettings> = {};
+
+  const fieldMetadataMap = Object.fromEntries(
+    fields?.map(f => [slugify(f.name), f]) ?? [],
+  );
+
+  params.forEach((param, index) => {
+    const field = fieldMetadataMap[param.id]
+      ? new Field(fieldMetadataMap[param.id])
+      : undefined;
+
+    const name = param["display-name"] ?? param.name ?? param.id;
+    const displayName = field?.displayName?.() ?? name;
+
+    fieldSettings[param.id] = getDefaultFieldSettings({
+      id: param.id,
+      name,
+      title: displayName,
+      placeholder: displayName,
+      required: !!param?.required,
+      order: index,
+      description: field?.description ?? "",
+      fieldType: getFieldType(param),
+      inputType: getInputType(param, field),
+      field: field ?? undefined,
+    });
+  });
+  return fieldSettings;
+};
+
+const getFieldType = (param: Parameter): "number" | "string" => {
+  return isNumericParameter(param) ? "number" : "string";
+};
+
+const isNumericParameter = (param: Parameter): boolean =>
+  /integer|float/gi.test(param.type);
+
+export const getInputType = (param: Parameter, field?: Field) => {
+  if (!field) {
+    return isNumericParameter(param) ? "number" : "string";
+  }
+
+  if (field.isFK()) {
+    return field.isNumeric() ? "number" : "string";
+  }
+  if (field.isNumeric()) {
+    return "number";
+  }
+  if (field.isBoolean()) {
+    return "boolean";
+  }
+  if (field.isTime()) {
+    return "time";
+  }
+  if (field.isDate()) {
+    return field.isDateWithoutTime() ? "date" : "datetime";
+  }
+  if (
+    field.semantic_type === TYPE.Description ||
+    field.semantic_type === TYPE.Comment ||
+    field.base_type === TYPE.Structured
+  ) {
+    return "text";
+  }
+  if (
+    field.semantic_type === TYPE.Title ||
+    field.semantic_type === TYPE.Email
+  ) {
+    return "string";
+  }
+  if (field.isCategory() && field.semantic_type !== TYPE.Name) {
+    return "category";
+  }
+  return "string";
+};
+
+export const reorderFields = (
+  fields: FieldSettingsMap,
+  oldIndex: number,
+  newIndex: number,
+) => {
+  // we have to jump through some hoops here because fields settings are an unordered map
+  // with order properties
+  const fieldsWithIds = _.mapObject(fields, (field, key) => ({
+    ...field,
+    id: key,
+  }));
+  const orderedFields = _.sortBy(Object.values(fieldsWithIds), "order");
+  const reorderedFields = moveElement(orderedFields, oldIndex, newIndex);
+
+  const fieldsWithUpdatedOrderProperty = reorderedFields.map(
+    (field, index) => ({
+      ...field,
+      order: index,
+    }),
+  );
+
+  return _.indexBy(fieldsWithUpdatedOrderProperty, "id");
+};
+
+export const hasNewParams = (
+  params: Parameter[],
+  formSettings: ActionFormSettings,
+) => !!params.find(param => !formSettings.fields[param.id]);
diff --git a/frontend/src/metabase/actions/containers/ActionCreator/FormCreator/utils.unit.spec.ts b/frontend/src/metabase/actions/containers/ActionCreator/FormCreator/utils.unit.spec.ts
new file mode 100644
index 00000000000..1559e41b97b
--- /dev/null
+++ b/frontend/src/metabase/actions/containers/ActionCreator/FormCreator/utils.unit.spec.ts
@@ -0,0 +1,317 @@
+import type { FieldSettingsMap } from "metabase-types/api";
+import Field from "metabase-lib/metadata/Field";
+
+import {
+  getDefaultFieldSettings,
+  getDefaultFormSettings,
+} from "../../../utils";
+import {
+  reorderFields,
+  hasNewParams,
+  generateFieldSettingsFromParameters,
+  getInputType,
+} from "./utils";
+
+const createField = (options?: any) => {
+  return new Field({
+    name: "test_field",
+    display_name: "Test Field",
+    base_type: "type/Text",
+    semantic_type: "type/Text",
+    ...options,
+  });
+};
+
+const createParameter = (options?: any) => {
+  return {
+    id: "test_parameter",
+    name: "Test Parameter",
+    type: "type/Text",
+    ...options,
+  };
+};
+
+const getFirstEntry = (obj: any): any => {
+  return Object.entries(obj)[0];
+};
+
+describe("actions > ActionCreator > FormCreator > utils", () => {
+  describe("generateFieldSettingsFromParameters", () => {
+    it("should generate settings for a string field", () => {
+      const fields = [createField({ name: "test-field" })];
+      const params = [createParameter({ id: "test-field" })];
+      const [id, settings] = getFirstEntry(
+        generateFieldSettingsFromParameters(params, fields),
+      );
+
+      expect(settings.fieldType).toBe("string");
+      expect(settings.inputType).toBe("string");
+    });
+
+    it("should generate settings for an Integer field", () => {
+      const fields = [
+        createField({
+          name: "test-field",
+          base_type: "type/Integer",
+          semantic_type: "type/Integer",
+        }),
+      ];
+      const params = [
+        createParameter({ id: "test-field", type: "type/Integer" }),
+      ];
+      const [id, settings] = getFirstEntry(
+        generateFieldSettingsFromParameters(params, fields),
+      );
+
+      expect(settings.fieldType).toBe("number");
+      expect(settings.inputType).toBe("number");
+    });
+
+    it("should generate settings for a float field", () => {
+      const fields = [
+        createField({
+          name: "test-field",
+          base_type: "type/Float",
+          semantic_type: "type/Float",
+        }),
+      ];
+      const params = [
+        createParameter({ id: "test-field", type: "type/Float" }),
+      ];
+      const [id, settings] = getFirstEntry(
+        generateFieldSettingsFromParameters(params, fields),
+      );
+
+      expect(settings.fieldType).toBe("number");
+      expect(settings.inputType).toBe("number");
+    });
+
+    it("should generate settings for a category field", () => {
+      const fields = [
+        createField({ name: "test-field", semantic_type: "type/Category" }),
+      ];
+      const params = [createParameter({ id: "test-field", type: "type/Text" })];
+      const [id, settings] = getFirstEntry(
+        generateFieldSettingsFromParameters(params, fields),
+      );
+
+      expect(settings.fieldType).toBe("string");
+      expect(settings.inputType).toBe("category");
+    });
+
+    it("should set the parameter id as the object key", () => {
+      const fields = [createField({ name: "test-field" })];
+      const params = [createParameter({ id: "test-field" })];
+      const [id, settings] = getFirstEntry(
+        generateFieldSettingsFromParameters(params, fields),
+      );
+
+      expect(id).toEqual("test-field");
+    });
+
+    it("should get display name from field metadata", () => {
+      const fields = [createField({ name: "test-field" })];
+      const params = [createParameter({ id: "test-field" })];
+      const [id, settings] = getFirstEntry(
+        generateFieldSettingsFromParameters(params, fields),
+      );
+
+      expect(settings.placeholder).toBe("Test Field");
+      expect(settings.title).toBe("Test Field");
+    });
+
+    it("matches field names to parameter ids case-insensitively", () => {
+      const fields = [createField({ name: "TEST-field" })];
+      const params = [createParameter({ id: "test-field" })];
+      const [id, settings] = getFirstEntry(
+        generateFieldSettingsFromParameters(params, fields),
+      );
+
+      expect(id).toEqual("test-field");
+      expect(settings.placeholder).toBe("Test Field");
+      expect(settings.title).toBe("Test Field");
+      expect(settings.name).toBe("Test Parameter");
+    });
+
+    it("sets settings from parameter if there is no corresponding field", () => {
+      const fields = [createField({ name: "xyz", description: "foo bar baz" })];
+      const params = [createParameter({ id: "test-field", name: null })];
+      const [id, settings] = getFirstEntry(
+        generateFieldSettingsFromParameters(params, fields),
+      );
+
+      expect(settings.placeholder).toBe("test-field");
+      expect(settings.title).toBe("test-field");
+      expect(settings.name).toBe("test-field");
+    });
+
+    it("sets required prop to true", () => {
+      const fields = [createField({ name: "test-field" })];
+      const params = [createParameter({ id: "test-field", required: true })];
+      const [id, settings] = getFirstEntry(
+        generateFieldSettingsFromParameters(params, fields),
+      );
+
+      expect(settings.required).toBe(true);
+    });
+
+    it("sets required prop to false", () => {
+      const fields = [createField({ name: "test-field" })];
+      const params = [createParameter({ id: "test-field", required: false })];
+      const [id, settings] = getFirstEntry(
+        generateFieldSettingsFromParameters(params, fields),
+      );
+
+      expect(settings.required).toBe(false);
+    });
+
+    it("sets description text", () => {
+      const fields = [
+        createField({ name: "test-field", description: "foo bar baz" }),
+      ];
+      const params = [createParameter({ id: "test-field" })];
+      const [id, settings] = getFirstEntry(
+        generateFieldSettingsFromParameters(params, fields),
+      );
+
+      expect(settings.description).toBe("foo bar baz");
+    });
+  });
+
+  describe("getInputType", () => {
+    it('should return "number" for numeric parameters', () => {
+      const intParam = createParameter({ type: "type/Integer" });
+      expect(getInputType(intParam)).toEqual("number");
+
+      const floatParam = createParameter({ type: "type/Float" });
+      expect(getInputType(floatParam)).toEqual("number");
+    });
+
+    it('should return "string" for non-numeric parameters', () => {
+      const textParam = createParameter({ type: "type/Text" });
+      expect(getInputType(textParam)).toEqual("string");
+
+      const turtleParam = createParameter({ type: "type/Turtle" });
+      expect(getInputType(turtleParam)).toEqual("string");
+    });
+
+    it('should return "number" for numeric foreign keys', () => {
+      const field = createField({
+        semantic_type: "type/FK",
+        base_type: "type/Integer",
+      });
+      expect(getInputType(createParameter(), field)).toEqual("number");
+    });
+
+    it('should return "string" for string foreign keys', () => {
+      const field = createField({
+        semantic_type: "type/FK",
+        base_type: "type/Text",
+      });
+      expect(getInputType(createParameter(), field)).toEqual("string");
+    });
+
+    it('should return "number" for floating point numbers', () => {
+      const field = createField({
+        base_type: "type/Float",
+      });
+      expect(getInputType(createParameter(), field)).toEqual("number");
+    });
+
+    it('should return "boolean" for booleans', () => {
+      const field = createField({
+        base_type: "type/Boolean",
+      });
+      expect(getInputType(createParameter(), field)).toEqual("boolean");
+    });
+
+    it('should return "date" for dates', () => {
+      const dateTypes = ["type/Date"];
+      const param = createParameter();
+
+      dateTypes.forEach(type => {
+        const field = createField({ base_type: type });
+        expect(getInputType(param, field)).toEqual("date");
+      });
+    });
+
+    it('should return "datetime" for datetimes', () => {
+      const dateTypes = ["type/DateTime", "type/DateTimeWithLocalTZ"];
+      const param = createParameter();
+
+      dateTypes.forEach(type => {
+        const field = createField({ base_type: type });
+        expect(getInputType(param, field)).toEqual("datetime");
+      });
+    });
+
+    it('should return "time" for times', () => {
+      const dateTypes = ["type/Time", "type/TimeWithLocalTZ"];
+      const param = createParameter();
+
+      dateTypes.forEach(type => {
+        const field = createField({ base_type: type });
+        expect(getInputType(param, field)).toEqual("time");
+      });
+    });
+
+    it('should return "category" for categories', () => {
+      const field = createField({
+        semantic_type: "type/Category",
+      });
+      expect(getInputType(createParameter(), field)).toEqual("category");
+    });
+
+    it('should return "text" for description', () => {
+      const field = createField({
+        semantic_type: "type/Description",
+      });
+      expect(getInputType(createParameter(), field)).toEqual("text");
+    });
+  });
+
+  describe("reorderFields", () => {
+    it("should reorder fields", () => {
+      const fields = {
+        a: getDefaultFieldSettings({ order: 0 }),
+        b: getDefaultFieldSettings({ order: 1 }),
+        c: getDefaultFieldSettings({ order: 2 }),
+      } as FieldSettingsMap;
+      // move b to index 0
+      const reorderedFields = reorderFields(fields, 1, 0);
+      expect(reorderedFields.a.order).toEqual(1);
+      expect(reorderedFields.b.order).toEqual(0);
+      expect(reorderedFields.c.order).toEqual(2);
+    });
+  });
+
+  describe("hasNewParams", () => {
+    const formSettings = getDefaultFormSettings({
+      fields: {
+        a: getDefaultFieldSettings({ order: 0 }),
+        b: getDefaultFieldSettings({ order: 1 }),
+        c: getDefaultFieldSettings({ order: 2 }),
+      },
+    });
+
+    it("should return true if there are new params", () => {
+      const params = [
+        createParameter({ id: "a" }),
+        createParameter({ id: "b" }),
+        createParameter({ id: "new" }),
+      ];
+
+      expect(hasNewParams(params, formSettings)).toBe(true);
+    });
+
+    it("should return false if there are no new params", () => {
+      const params = [
+        createParameter({ id: "a" }),
+        createParameter({ id: "b" }),
+        createParameter({ id: "c" }),
+      ];
+
+      expect(hasNewParams(params, formSettings)).toBe(false);
+    });
+  });
+});
diff --git a/frontend/src/metabase/actions/containers/ActionCreator/InlineDataReference.styled.tsx b/frontend/src/metabase/actions/containers/ActionCreator/InlineDataReference.styled.tsx
new file mode 100644
index 00000000000..4e7a966c7d2
--- /dev/null
+++ b/frontend/src/metabase/actions/containers/ActionCreator/InlineDataReference.styled.tsx
@@ -0,0 +1,30 @@
+import styled from "@emotion/styled";
+import { color } from "metabase/lib/colors";
+
+import Button from "metabase/core/components/Button";
+import SidebarContent from "metabase/query_builder/components/SidebarContent";
+
+export const DataReferenceContainer = styled.div<{ isOpen: boolean }>`
+  display: ${props => (props.isOpen ? "block" : "none")};
+  overflow: hidden;
+  position: relative;
+  height: 100%;
+  background-color: ${color("white")};
+  border-left: 1px solid ${color("border")};
+  border-right: 1px solid ${color("border")};
+
+  ${SidebarContent.Header.Root} {
+    position: sticky;
+    top: 0;
+    padding: 1.5rem 1.5rem 0.5rem 1.5rem;
+    margin: 0;
+    background-color: ${color("white")};
+  }
+`;
+
+export const TriggerButton = styled(Button)`
+  position: absolute;
+  top: 76px;
+  right: 10px;
+  z-index: 10;
+`;
diff --git a/frontend/src/metabase/actions/containers/ActionCreator/InlineDataReference.tsx b/frontend/src/metabase/actions/containers/ActionCreator/InlineDataReference.tsx
new file mode 100644
index 00000000000..a2d94139c0f
--- /dev/null
+++ b/frontend/src/metabase/actions/containers/ActionCreator/InlineDataReference.tsx
@@ -0,0 +1,50 @@
+import React, { useState } from "react";
+import { t } from "ttag";
+
+import Tooltip from "metabase/core/components/Tooltip";
+
+import DataReference from "metabase/query_builder/components/dataref/DataReference";
+
+import {
+  DataReferenceContainer,
+  TriggerButton,
+} from "./InlineDataReference.styled";
+
+export const DataReferenceInline = ({
+  onClose,
+  isOpen,
+}: {
+  onClose: () => void;
+  isOpen: boolean;
+}) => {
+  const [dataRefStack, setDataRefStack] = useState<any[]>([]);
+
+  const pushRefStack = (ref: any) => {
+    setDataRefStack([...dataRefStack, ref]);
+  };
+
+  const popRefStack = () => {
+    setDataRefStack(dataRefStack.slice(0, -1));
+  };
+
+  return (
+    <DataReferenceContainer isOpen={isOpen}>
+      <DataReference
+        dataReferenceStack={dataRefStack}
+        popDataReferenceStack={popRefStack}
+        pushDataReferenceStack={pushRefStack}
+        onClose={onClose}
+      />
+    </DataReferenceContainer>
+  );
+};
+
+export const DataReferenceTriggerButton = ({
+  onClick,
+}: {
+  onClick: () => void;
+}) => (
+  <Tooltip tooltip={t`Data Reference`}>
+    <TriggerButton onlyIcon onClick={onClick} icon="reference" iconSize={16} />
+  </Tooltip>
+);
diff --git a/frontend/src/metabase/actions/containers/ActionCreator/QueryActionEditor.tsx b/frontend/src/metabase/actions/containers/ActionCreator/QueryActionEditor.tsx
new file mode 100644
index 00000000000..0fe2c526ac5
--- /dev/null
+++ b/frontend/src/metabase/actions/containers/ActionCreator/QueryActionEditor.tsx
@@ -0,0 +1,45 @@
+import React, { useCallback } from "react";
+
+import NativeQueryEditor from "metabase/query_builder/components/NativeQueryEditor";
+
+import type NativeQuery from "metabase-lib/queries/NativeQuery";
+import type Question from "metabase-lib/Question";
+
+import { getTemplateTagParameters } from "metabase-lib/parameters/utils/template-tags";
+
+function QueryActionEditor({
+  question,
+  setQuestion,
+}: {
+  question: Question;
+  setQuestion: (q: Question) => void;
+}) {
+  const handleChange = useCallback(
+    (newQuery: NativeQuery) => {
+      const newParams = getTemplateTagParameters(
+        newQuery.templateTagsWithoutSnippets(),
+      );
+      setQuestion(question.setQuery(newQuery).setParameters(newParams));
+    },
+    [question, setQuestion],
+  );
+
+  return (
+    <>
+      <NativeQueryEditor
+        query={question.query()}
+        viewHeight="full"
+        setDatasetQuery={handleChange}
+        enableRun={false}
+        hasEditingSidebar={false}
+        isNativeEditorOpen
+        hasParametersList={false}
+        resizable={false}
+        readOnly={false}
+        requireWriteback
+      />
+    </>
+  );
+}
+
+export default QueryActionEditor;
diff --git a/frontend/src/metabase/actions/containers/ActionCreator/index.tsx b/frontend/src/metabase/actions/containers/ActionCreator/index.tsx
new file mode 100644
index 00000000000..480dee181a9
--- /dev/null
+++ b/frontend/src/metabase/actions/containers/ActionCreator/index.tsx
@@ -0,0 +1 @@
+export { default } from "./ActionCreator";
diff --git a/frontend/src/metabase/actions/containers/ActionCreator/utils.ts b/frontend/src/metabase/actions/containers/ActionCreator/utils.ts
new file mode 100644
index 00000000000..ee99bf1b7f1
--- /dev/null
+++ b/frontend/src/metabase/actions/containers/ActionCreator/utils.ts
@@ -0,0 +1,41 @@
+import type {
+  WritebackQueryAction,
+  VisualizationSettings,
+} from "metabase-types/api";
+import type {
+  Card as LegacyCard,
+  NativeDatasetQuery,
+} from "metabase-types/types/Card";
+import type Metadata from "metabase-lib/metadata/Metadata";
+import Question from "metabase-lib/Question";
+
+// ActionCreator uses the NativeQueryEditor, which expects a Question object
+// This utilities help us to work with the WritebackQueryAction as with a Question
+
+export const newQuestion = (metadata: Metadata, databaseId?: number) => {
+  return new Question(
+    {
+      dataset_query: {
+        type: "native",
+        database: databaseId ?? null,
+        native: {
+          query: "",
+        },
+      },
+    },
+    metadata,
+  );
+};
+
+export const convertActionToQuestionCard = (
+  action: WritebackQueryAction,
+): LegacyCard<NativeDatasetQuery> => {
+  return {
+    name: action.name,
+    description: action.description,
+    dataset_query: action.dataset_query as NativeDatasetQuery,
+    display: "action",
+    visualization_settings:
+      action.visualization_settings as VisualizationSettings,
+  };
+};
diff --git a/frontend/src/metabase/actions/containers/CreateActionForm/CreateActionForm.tsx b/frontend/src/metabase/actions/containers/CreateActionForm/CreateActionForm.tsx
new file mode 100644
index 00000000000..90737e25e48
--- /dev/null
+++ b/frontend/src/metabase/actions/containers/CreateActionForm/CreateActionForm.tsx
@@ -0,0 +1,131 @@
+import React, { useCallback, useMemo } from "react";
+import { t } from "ttag";
+import * as Yup from "yup";
+import { connect } from "react-redux";
+
+import Button from "metabase/core/components/Button";
+import Form from "metabase/core/components/Form";
+import FormFooter from "metabase/core/components/FormFooter";
+import FormProvider from "metabase/core/components/FormProvider";
+import FormInput from "metabase/core/components/FormInput";
+import FormTextArea from "metabase/core/components/FormTextArea";
+import FormSubmitButton from "metabase/core/components/FormSubmitButton";
+import FormErrorMessage from "metabase/core/components/FormErrorMessage";
+
+import * as Errors from "metabase/core/utils/errors";
+
+import Actions, { CreateQueryActionOptions } from "metabase/entities/actions";
+
+import FormModelPicker from "metabase/models/containers/FormModelPicker";
+
+import type {
+  ActionFormSettings,
+  CardId,
+  WritebackQueryAction,
+} from "metabase-types/api";
+import type { State } from "metabase-types/store";
+import type Question from "metabase-lib/Question";
+
+const ACTION_SCHEMA = Yup.object({
+  name: Yup.string()
+    .required(Errors.required)
+    .max(100, Errors.maxLength)
+    .default(""),
+  description: Yup.string().nullable().max(255, Errors.maxLength).default(null),
+  model_id: Yup.number().required(Errors.required),
+});
+
+type FormValues = Pick<
+  CreateQueryActionOptions,
+  "name" | "description" | "model_id"
+>;
+
+interface OwnProps {
+  question: Question;
+  formSettings: ActionFormSettings;
+  modelId?: CardId;
+  onCreate?: (values: WritebackQueryAction) => void;
+  onCancel?: () => void;
+}
+
+interface DispatchProps {
+  handleCreateAction: (
+    action: CreateQueryActionOptions,
+  ) => Promise<WritebackQueryAction>;
+}
+
+type Props = OwnProps & DispatchProps;
+
+const mapDispatchToProps = {
+  handleCreateAction: Actions.actions.create,
+};
+
+function CreateActionForm({
+  question,
+  formSettings,
+  modelId,
+  handleCreateAction,
+  onCreate,
+  onCancel,
+}: Props) {
+  const initialValues = useMemo(
+    () => ({
+      ...ACTION_SCHEMA.getDefault(),
+      name: question.displayName(),
+      description: question.description(),
+      model_id: modelId,
+    }),
+    [question, modelId],
+  );
+
+  const handleCreate = useCallback(
+    async (values: FormValues) => {
+      const reduxAction = await handleCreateAction({
+        ...values,
+        question,
+        formSettings,
+      });
+      const action = Actions.HACK_getObjectFromAction(reduxAction);
+      onCreate?.(action);
+    },
+    [question, formSettings, handleCreateAction, onCreate],
+  );
+
+  return (
+    <FormProvider
+      initialValues={initialValues as FormValues}
+      validationSchema={ACTION_SCHEMA}
+      onSubmit={handleCreate}
+    >
+      {({ dirty }) => (
+        <Form disabled={!dirty}>
+          <FormInput
+            name="name"
+            title={t`Name`}
+            placeholder={t`My new fantastic action`}
+            autoFocus
+          />
+          <FormTextArea
+            name="description"
+            title={t`Description`}
+            placeholder={t`It's optional but oh, so helpful`}
+            nullable
+          />
+          <FormModelPicker name="model_id" title={t`Model it's saved in`} />
+          <FormFooter>
+            <FormErrorMessage inline />
+            {!!onCancel && (
+              <Button type="button" onClick={onCancel}>{t`Cancel`}</Button>
+            )}
+            <FormSubmitButton title={t`Create`} disabled={!dirty} primary />
+          </FormFooter>
+        </Form>
+      )}
+    </FormProvider>
+  );
+}
+
+export default connect<unknown, DispatchProps, OwnProps, State>(
+  null,
+  mapDispatchToProps,
+)(CreateActionForm);
diff --git a/frontend/src/metabase/actions/containers/CreateActionForm/index.ts b/frontend/src/metabase/actions/containers/CreateActionForm/index.ts
new file mode 100644
index 00000000000..c344120d4a3
--- /dev/null
+++ b/frontend/src/metabase/actions/containers/CreateActionForm/index.ts
@@ -0,0 +1 @@
+export { default } from "./CreateActionForm";
diff --git a/frontend/src/metabase/actions/selectors.ts b/frontend/src/metabase/actions/selectors.ts
new file mode 100644
index 00000000000..a0dd8c86c56
--- /dev/null
+++ b/frontend/src/metabase/actions/selectors.ts
@@ -0,0 +1,21 @@
+import { getMetadata } from "metabase/selectors/metadata";
+
+import type { WritebackActionBase, QueryAction } from "metabase-types/api";
+import type { State } from "metabase-types/store";
+import Question from "metabase-lib/Question";
+
+export function createQuestionFromAction(
+  state: State,
+  action: WritebackActionBase & QueryAction,
+) {
+  return new Question(
+    {
+      id: action.id,
+      name: action.name,
+      description: action.description,
+      dataset_query: action.dataset_query,
+      visualization_settings: action.visualization_settings,
+    },
+    getMetadata(state),
+  ).setParameters(action.parameters);
+}
diff --git a/frontend/src/metabase/actions/types.ts b/frontend/src/metabase/actions/types.ts
new file mode 100644
index 00000000000..9539199bacc
--- /dev/null
+++ b/frontend/src/metabase/actions/types.ts
@@ -0,0 +1,25 @@
+import type {
+  ActionFormOption,
+  FieldSettings as BaseFieldSettings,
+  InputComponentType,
+} from "metabase-types/api";
+import type Field from "metabase-lib/metadata/Field";
+
+export type FieldSettings = BaseFieldSettings & {
+  field?: Field;
+};
+
+export type ActionFormFieldProps = {
+  name: string;
+  title: string;
+  description?: string;
+  placeholder?: string;
+  type: InputComponentType;
+  optional?: boolean;
+  options?: ActionFormOption[];
+  field?: Field;
+};
+
+export type ActionFormProps = {
+  fields: ActionFormFieldProps[];
+};
diff --git a/frontend/src/metabase/actions/utils.ts b/frontend/src/metabase/actions/utils.ts
index 1ddf786c0f0..d6271597417 100644
--- a/frontend/src/metabase/actions/utils.ts
+++ b/frontend/src/metabase/actions/utils.ts
@@ -1,15 +1,83 @@
 import type {
   ActionFormSettings,
   Database,
-  FieldSettings,
+  Parameter,
+  WritebackAction,
 } from "metabase-types/api";
 
+import { TYPE } from "metabase-lib/types/constants";
+import type Field from "metabase-lib/metadata/Field";
+
+import type { FieldSettings } from "./types";
+
 export const checkDatabaseSupportsActions = (database: Database) =>
   database.features.includes("actions");
 
 export const checkDatabaseActionsEnabled = (database: Database) =>
   !!database.settings?.["database-enable-actions"];
 
+const AUTOMATIC_DATE_TIME_FIELDS = [
+  TYPE.CreationDate,
+  TYPE.CreationTemporal,
+  TYPE.CreationTime,
+  TYPE.CreationTimestamp,
+
+  TYPE.DeletionDate,
+  TYPE.DeletionTemporal,
+  TYPE.DeletionTime,
+  TYPE.DeletionTimestamp,
+
+  TYPE.UpdatedDate,
+  TYPE.UpdatedTemporal,
+  TYPE.UpdatedTime,
+  TYPE.UpdatedTimestamp,
+];
+
+const isAutomaticDateTimeField = (field: Field) => {
+  return AUTOMATIC_DATE_TIME_FIELDS.includes(field.semantic_type);
+};
+
+export const isEditableField = (field: Field, parameter: Parameter) => {
+  const isRealField = typeof field.id === "number";
+  if (!isRealField) {
+    // Filters out custom, aggregated columns, etc.
+    return false;
+  }
+
+  if (field.isPK()) {
+    // Most of the time PKs are auto-generated,
+    // but there are rare cases when they're not
+    // In this case they're marked as `required`
+    return parameter.required;
+  }
+
+  if (isAutomaticDateTimeField(field)) {
+    return parameter.required;
+  }
+
+  return true;
+};
+
+export const hasImplicitActions = (actions: WritebackAction[]): boolean =>
+  actions.some(isImplicitAction);
+
+export const isImplicitAction = (action: WritebackAction): boolean =>
+  action.type === "implicit";
+
+export const shouldPrefetchValues = (action: WritebackAction) => {
+  // in the future there should be a setting to configure this
+  // for custom actions
+  return action.type === "implicit" && action.kind === "row/update";
+};
+
+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;
+
+    return aOrder - bOrder;
+  };
+
 export const getDefaultFormSettings = (
   overrides: Partial<ActionFormSettings> = {},
 ): ActionFormSettings => ({
diff --git a/frontend/src/metabase/actions/utils.unit.spec.ts b/frontend/src/metabase/actions/utils.unit.spec.ts
new file mode 100644
index 00000000000..3e4b741b0df
--- /dev/null
+++ b/frontend/src/metabase/actions/utils.unit.spec.ts
@@ -0,0 +1,45 @@
+import {
+  getDefaultFieldSettings,
+  getDefaultFormSettings,
+  sortActionParams,
+} from "./utils";
+
+const createParameter = (options?: any) => {
+  return {
+    id: "test_parameter",
+    name: "Test Parameter",
+    type: "type/Text",
+    ...options,
+  };
+};
+
+describe("sortActionParams", () => {
+  const formSettings = getDefaultFormSettings({
+    fields: {
+      a: getDefaultFieldSettings({ order: 0 }),
+      b: getDefaultFieldSettings({ order: 1 }),
+      c: getDefaultFieldSettings({ order: 2 }),
+    },
+  });
+
+  it("should return a sorting function", () => {
+    const sortFn = sortActionParams(formSettings);
+    expect(typeof sortFn).toBe("function");
+  });
+
+  it("should sort params by the settings-defined field order", () => {
+    const sortFn = sortActionParams(formSettings);
+
+    const params = [
+      createParameter({ id: "c" }),
+      createParameter({ id: "a" }),
+      createParameter({ id: "b" }),
+    ];
+
+    const sortedParams = params.sort(sortFn);
+
+    expect(sortedParams[0].id).toEqual("a");
+    expect(sortedParams[1].id).toEqual("b");
+    expect(sortedParams[2].id).toEqual("c");
+  });
+});
diff --git a/frontend/src/metabase/components/NewItemMenu/NewItemMenu.tsx b/frontend/src/metabase/components/NewItemMenu/NewItemMenu.tsx
index c79b940c4e3..3bcef9728d0 100644
--- a/frontend/src/metabase/components/NewItemMenu/NewItemMenu.tsx
+++ b/frontend/src/metabase/components/NewItemMenu/NewItemMenu.tsx
@@ -11,7 +11,7 @@ import CreateDashboardModal from "metabase/dashboard/containers/CreateDashboardM
 
 import type { CollectionId } from "metabase-types/api";
 
-type ModalType = "new-app" | "new-dashboard" | "new-collection";
+type ModalType = "new-dashboard" | "new-collection";
 
 export interface NewItemMenuProps {
   className?: string;
diff --git a/frontend/src/metabase/core/components/FormRadio/FormRadio.tsx b/frontend/src/metabase/core/components/FormRadio/FormRadio.tsx
index 85753b1e75d..548be6843b7 100644
--- a/frontend/src/metabase/core/components/FormRadio/FormRadio.tsx
+++ b/frontend/src/metabase/core/components/FormRadio/FormRadio.tsx
@@ -4,7 +4,7 @@ import Radio, { RadioOption, RadioProps } from "metabase/core/components/Radio";
 import FormField from "metabase/core/components/FormField";
 
 export interface FormRadioProps<
-  TValue extends Key,
+  TValue extends Key = string,
   TOption = RadioOption<TValue>,
 > extends Omit<
     RadioProps<TValue, TOption>,
diff --git a/frontend/src/metabase/entities/actions/actions.ts b/frontend/src/metabase/entities/actions/actions.ts
index 041bbfa548d..b55a7dd7694 100644
--- a/frontend/src/metabase/entities/actions/actions.ts
+++ b/frontend/src/metabase/entities/actions/actions.ts
@@ -18,7 +18,6 @@ import {
   setTemplateTagTypesFromFieldSettings,
 } from "metabase/entities/actions/utils";
 import type Question from "metabase-lib/Question";
-import { saveForm, updateForm } from "./forms";
 
 export type ActionParams = {
   id?: WritebackAction["id"];
@@ -35,7 +34,7 @@ interface BaseCreateActionParams {
   model_id: WritebackActionBase["model_id"];
   name: WritebackActionBase["name"];
   description: WritebackActionBase["description"];
-  parameters: WritebackActionBase["parameters"];
+  parameters?: WritebackActionBase["parameters"];
 }
 
 interface UpdateActionParams {
@@ -202,10 +201,6 @@ const Actions = createEntity({
   actions: {
     enableImplicitActionsForModel,
   },
-  forms: {
-    saveForm,
-    updateForm,
-  },
 });
 
 export default Actions;
diff --git a/frontend/src/metabase/entities/actions/forms.ts b/frontend/src/metabase/entities/actions/forms.ts
deleted file mode 100644
index bab7fceb86d..00000000000
--- a/frontend/src/metabase/entities/actions/forms.ts
+++ /dev/null
@@ -1,41 +0,0 @@
-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: getFormFields("create"),
-};
-
-export const updateForm = {
-  fields: getFormFields("update"),
-};
diff --git a/frontend/src/metabase/models/containers/FormModelPicker/FormModelPicker.styled.tsx b/frontend/src/metabase/models/containers/FormModelPicker/FormModelPicker.styled.tsx
new file mode 100644
index 00000000000..ad80cf0d02f
--- /dev/null
+++ b/frontend/src/metabase/models/containers/FormModelPicker/FormModelPicker.styled.tsx
@@ -0,0 +1,11 @@
+import styled from "@emotion/styled";
+
+import ItemPicker from "metabase/containers/ItemPicker";
+
+export const MIN_POPOVER_WIDTH = 300;
+
+export const PopoverItemPicker = styled(ItemPicker)<{ width: number }>`
+  width: ${({ width = MIN_POPOVER_WIDTH }) => width}px;
+  padding: 1rem;
+  overflow: auto;
+`;
diff --git a/frontend/src/metabase/models/containers/FormModelPicker/FormModelPicker.tsx b/frontend/src/metabase/models/containers/FormModelPicker/FormModelPicker.tsx
new file mode 100644
index 00000000000..b57d59c892c
--- /dev/null
+++ b/frontend/src/metabase/models/containers/FormModelPicker/FormModelPicker.tsx
@@ -0,0 +1,97 @@
+import React, {
+  useCallback,
+  useEffect,
+  useState,
+  useRef,
+  HTMLAttributes,
+} from "react";
+import { t } from "ttag";
+import { useField } from "formik";
+
+import { useUniqueId } from "metabase/hooks/use-unique-id";
+
+import FormField from "metabase/core/components/FormField";
+import SelectButton from "metabase/core/components/SelectButton";
+import TippyPopoverWithTrigger from "metabase/components/PopoverWithTrigger/TippyPopoverWithTrigger";
+
+import Models from "metabase/entities/questions";
+
+import type { CardId } from "metabase-types/api";
+
+import { PopoverItemPicker, MIN_POPOVER_WIDTH } from "./FormModelPicker.styled";
+
+export interface FormModelPickerProps extends HTMLAttributes<HTMLDivElement> {
+  name: string;
+  title?: string;
+  placeholder?: string;
+}
+
+const ITEM_PICKER_MODELS = ["dataset"];
+
+function FormModelPicker({
+  className,
+  style,
+  name,
+  title,
+  placeholder = t`Select a model`,
+}: FormModelPickerProps) {
+  const id = useUniqueId();
+  const [{ value }, { error, touched }, { setValue }] = useField(name);
+  const formFieldRef = useRef<HTMLDivElement>(null);
+  const [width, setWidth] = useState(MIN_POPOVER_WIDTH);
+
+  useEffect(() => {
+    const { width: formFieldWidth } =
+      formFieldRef.current?.getBoundingClientRect() || {};
+    if (formFieldWidth) {
+      setWidth(formFieldWidth);
+    }
+  }, []);
+
+  const renderTrigger = useCallback(
+    ({ onClick: handleShowPopover }) => (
+      <FormField
+        className={className}
+        style={style}
+        title={title}
+        htmlFor={id}
+        error={touched ? error : undefined}
+        ref={formFieldRef}
+      >
+        <SelectButton onClick={handleShowPopover}>
+          {typeof value === "number" ? <Models.Name id={value} /> : placeholder}
+        </SelectButton>
+      </FormField>
+    ),
+    [id, value, title, placeholder, error, touched, className, style],
+  );
+
+  const renderContent = useCallback(
+    ({ closePopover }) => {
+      return (
+        <PopoverItemPicker
+          value={{ id: value, model: "dataset" }}
+          models={ITEM_PICKER_MODELS}
+          onChange={({ id }: { id: CardId }) => {
+            setValue(id);
+            closePopover();
+          }}
+          showSearch
+          width={width}
+        />
+      );
+    },
+    [value, width, setValue],
+  );
+
+  return (
+    <TippyPopoverWithTrigger
+      placement="bottom-start"
+      renderTrigger={renderTrigger}
+      popoverContent={renderContent}
+      maxWidth={width}
+    />
+  );
+}
+
+export default FormModelPicker;
diff --git a/frontend/src/metabase/models/containers/FormModelPicker/index.ts b/frontend/src/metabase/models/containers/FormModelPicker/index.ts
new file mode 100644
index 00000000000..af969907f71
--- /dev/null
+++ b/frontend/src/metabase/models/containers/FormModelPicker/index.ts
@@ -0,0 +1 @@
+export { default } from "./FormModelPicker";
diff --git a/frontend/src/metabase/query_builder/components/DataSelector/DataSelectorDatabasePicker/DataSelectorDatabasePicker.tsx b/frontend/src/metabase/query_builder/components/DataSelector/DataSelectorDatabasePicker/DataSelectorDatabasePicker.tsx
index 833e09aa317..7280d7109f0 100644
--- a/frontend/src/metabase/query_builder/components/DataSelector/DataSelectorDatabasePicker/DataSelectorDatabasePicker.tsx
+++ b/frontend/src/metabase/query_builder/components/DataSelector/DataSelectorDatabasePicker/DataSelectorDatabasePicker.tsx
@@ -2,12 +2,14 @@ import React from "react";
 
 import Icon from "metabase/components/Icon";
 import AccordionList from "metabase/core/components/AccordionList";
-import type { Database } from "metabase-types/api/database";
-import DataSelectorLoading from "../DataSelectorLoading";
 
-import { RawDataBackButton } from "../DataSelector.styled";
+import { checkDatabaseActionsEnabled } from "metabase/actions/utils";
+
+import type { Database } from "metabase-types/api/database";
 
 import type { Schema } from "../types";
+import DataSelectorLoading from "../DataSelectorLoading";
+import { RawDataBackButton } from "../DataSelector.styled";
 
 type DataSelectorDatabasePickerProps = {
   databases: Database[];
@@ -16,6 +18,7 @@ type DataSelectorDatabasePickerProps = {
   hasInitialFocus?: boolean;
   hasNextStep?: boolean;
   isLoading?: boolean;
+  requireWriteback?: boolean;
   selectedDatabase?: Database;
   selectedSchema?: Schema;
   onBack?: () => void;
@@ -27,6 +30,7 @@ type Item = {
   database: Database;
   index: number;
   name: string;
+  writebackEnabled?: boolean;
 };
 
 type Section = {
@@ -40,6 +44,7 @@ const DataSelectorDatabasePicker = ({
   hasNextStep,
   onBack,
   hasInitialFocus,
+  requireWriteback = false,
 }: DataSelectorDatabasePickerProps) => {
   if (databases.length === 0) {
     return <DataSelectorLoading />;
@@ -77,6 +82,11 @@ const DataSelectorDatabasePicker = ({
       sections={sections}
       onChange={(item: Item) => onChangeDatabase(item.database)}
       onChangeSection={handleChangeSection}
+      itemIsClickable={
+        requireWriteback
+          ? (item: Item) => checkDatabaseActionsEnabled(item.database)
+          : undefined
+      }
       itemIsSelected={(item: Item) =>
         selectedDatabase && item.database.id === selectedDatabase.id
       }
diff --git a/frontend/src/metabase/query_builder/components/NativeQueryEditor/utils.ts b/frontend/src/metabase/query_builder/components/NativeQueryEditor/utils.ts
index f313ea0ef4d..4d85d2e5082 100644
--- a/frontend/src/metabase/query_builder/components/NativeQueryEditor/utils.ts
+++ b/frontend/src/metabase/query_builder/components/NativeQueryEditor/utils.ts
@@ -15,7 +15,7 @@ const FRACTION_OF_TOTAL_VIEW_HEIGHT = 0.4;
 
 // the query editor needs a fixed pixel height for now
 // until we extract the resizable component
-const FULL_HEIGHT = 500;
+const FULL_HEIGHT = 400;
 
 // This determines the max height that the editor *automatically* takes.
 // - On load, long queries will be capped at this length
diff --git a/frontend/src/metabase/query_builder/components/SidebarContent/SidebarContent.tsx b/frontend/src/metabase/query_builder/components/SidebarContent/SidebarContent.tsx
index 7e944e77fb6..542bd7c9750 100644
--- a/frontend/src/metabase/query_builder/components/SidebarContent/SidebarContent.tsx
+++ b/frontend/src/metabase/query_builder/components/SidebarContent/SidebarContent.tsx
@@ -55,4 +55,6 @@ function SidebarContent({
   );
 }
 
-export default SidebarContent;
+export default Object.assign(SidebarContent, {
+  Header: SidebarHeader,
+});
diff --git a/frontend/src/metabase/query_builder/components/SidebarHeader/SidebarHeader.tsx b/frontend/src/metabase/query_builder/components/SidebarHeader/SidebarHeader.tsx
index 107885b6be8..bc5e80b0a6c 100644
--- a/frontend/src/metabase/query_builder/components/SidebarHeader/SidebarHeader.tsx
+++ b/frontend/src/metabase/query_builder/components/SidebarHeader/SidebarHeader.tsx
@@ -61,4 +61,4 @@ function SidebarHeader({ className, title, icon, onBack, onClose }: Props) {
   );
 }
 
-export default SidebarHeader;
+export default Object.assign(SidebarHeader, { Root: HeaderRoot });
-- 
GitLab