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