Skip to content
Snippets Groups Projects
Unverified Commit 2e8e313a authored by Anton Kulyk's avatar Anton Kulyk Committed by GitHub
Browse files

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: default avatarRyan Laurie <iethree@gmail.com>
parent 03cf872f
No related merge requests found
Showing
with 2274 additions and 14 deletions
......@@ -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[];
};
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;
`;
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>
);
};
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} />;
});
export * from "./ActionForm";
export * from "./ActionFormFieldWidget";
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));
};
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")};
`;
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);
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();
});
});
});
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")};
}
}
`;
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;
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>
);
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>
);
}
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)};
}
`;
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>
);
}
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: "$",
});
});
});
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;
`;
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;
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)};
`;
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment