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

Data app settings form (#25028)

* Form framework: extract `getMaybeNestedValue` util

* Form framework: handle nested fields in initial values

* Use different forms for creating and updating apps

* Fix data app update endpoint

* Update underlying collection on app update

* Add app settings modal

* Make `EditableText` handle initial value change

* Simplify `getMaybeNestedValue`

* Use `useEffect` instead of `useLayoutEffect`
parent b8a04e8d
No related branches found
No related tags found
No related merge requests found
Showing with 149 additions and 35 deletions
import React, { useCallback, useMemo, useState } from "react";
import { t } from "ttag";
import _ from "underscore";
import { merge } from "icepick";
import { assocIn, getIn, merge } from "icepick";
// eslint-disable-next-line import/named
import { Formik, FormikProps, FormikErrors, FormikHelpers } from "formik";
......@@ -16,7 +16,12 @@ import {
import FormikFormViewAdapter from "./FormikFormViewAdapter";
import useInlineFields from "./useInlineFields";
import { makeFormObject, cleanObject } from "../formUtils";
import {
makeFormObject,
cleanObject,
isNestedFieldName,
getMaybeNestedValue,
} from "../formUtils";
interface FormContainerProps<Values extends BaseFieldValues> {
form?: FormObject<Values>;
......@@ -125,14 +130,28 @@ function Form<Values extends BaseFieldValues>({
const initialValues = useMemo(() => {
const fieldNames = formObject.fieldNames(values);
const [nestedFieldNames, regularFieldNames] = _.partition(
fieldNames,
isNestedFieldName,
);
let filteredInitialValues: FieldValues = {};
const filteredInitialValues: FieldValues = {};
Object.keys(initialValuesProp || {}).forEach(fieldName => {
if (fieldNames.includes(fieldName)) {
if (regularFieldNames.includes(fieldName)) {
filteredInitialValues[fieldName] = initialValuesProp[fieldName];
}
});
nestedFieldNames.forEach(nestedFieldName => {
const fieldValuePath = (nestedFieldName as string).split(".");
filteredInitialValues = assocIn(
filteredInitialValues,
fieldValuePath,
getIn(initialValuesProp, fieldValuePath),
);
});
return merge(formObject.initial(values), filteredInitialValues);
}, [values, initialValuesProp, formObject]);
......
import React, { useEffect, useState } from "react";
import _ from "underscore";
import { getIn } from "icepick";
// eslint-disable-next-line import/named
import { FormikProps } from "formik";
......@@ -11,6 +10,7 @@ import { usePrevious } from "metabase/hooks/use-previous";
import { BaseFieldValues, FormField } from "metabase-types/forms";
import { getMaybeNestedValue } from "../formUtils";
import FormView from "./FormView";
type FormProps<Values extends BaseFieldValues> = Omit<
......@@ -34,14 +34,6 @@ type FormProps<Values extends BaseFieldValues> = Omit<
| "submitFailed"
>;
function getMaybeNestedValue<Value = string>(
obj: Record<string, Value>,
fieldName: string,
): Value {
const isNestedField = fieldName.includes(".");
return isNestedField ? getIn(obj, fieldName.split(".")) : obj[fieldName];
}
interface FormikFormViewAdapterOwnProps<Values> {
onValuesChange: (values: Values) => void;
}
......@@ -88,7 +80,7 @@ function FormikFormViewAdapter<Values extends BaseFieldValues>({
const value = getMaybeNestedValue(values, name);
const initialValue = getMaybeNestedValue(initialValues, name);
const error = getMaybeNestedValue(errors as Record<string, string>, name);
const isTouched = !!getMaybeNestedValue<boolean>(
const isTouched = !!getMaybeNestedValue(
touched as Record<string, boolean>,
name,
);
......
......@@ -100,3 +100,13 @@ export function cleanObject(object) {
});
return result;
}
export function isNestedFieldName(name) {
return name.includes(".");
}
export function getMaybeNestedValue(obj, fieldName) {
return isNestedFieldName(fieldName)
? getIn(obj, fieldName.split("."))
: obj[fieldName];
}
......@@ -5,9 +5,13 @@ import React, {
HTMLAttributes,
Ref,
useCallback,
useEffect,
useState,
useRef,
} from "react";
import { usePrevious } from "metabase/hooks/use-previous";
import { EditableTextArea, EditableTextRoot } from "./EditableText.styled";
export type EditableTextAttributes = Omit<
......@@ -42,6 +46,13 @@ const EditableText = forwardRef(function EditableText(
const [submitValue, setSubmitValue] = useState(initialValue ?? "");
const displayValue = inputValue ? inputValue : placeholder;
const submitOnBlur = useRef(true);
const previousInitialValue = usePrevious(initialValue);
useEffect(() => {
if (initialValue && initialValue !== previousInitialValue) {
setInputValue(initialValue);
}
}, [initialValue, previousInitialValue]);
const handleBlur = useCallback(
e => {
......
......@@ -2,13 +2,13 @@ import { color } from "metabase/lib/colors";
import { createEntity } from "metabase/lib/entities";
import { DataAppSchema } from "metabase/schema";
import { DataAppsApi } from "metabase/services";
import { CollectionsApi, DataAppsApi } from "metabase/services";
import { Collection, DataApp } from "metabase-types/api";
import { DEFAULT_COLLECTION_COLOR_ALIAS } from "../collections/constants";
import { createForm } from "./forms";
import { createNewAppForm, createAppSettingsForm } from "./forms";
import { getDataAppIcon, isDataAppCollection } from "./utils";
type EditableDataAppParams = Pick<
......@@ -20,6 +20,10 @@ type EditableDataAppParams = Pick<
type CreateDataAppParams = Partial<EditableDataAppParams> &
Pick<EditableDataAppParams, "name">;
type UpdateDataAppParams = Pick<DataApp, "id" | "collection_id"> & {
collection: Pick<Collection, "name" | "description">;
};
const DataApps = createEntity({
name: "dataApps",
nameOne: "dataApp",
......@@ -45,6 +49,15 @@ const DataApps = createEntity({
},
});
},
update: async ({
id,
collection,
collection_id,
...rest
}: UpdateDataAppParams) => {
await CollectionsApi.update({ ...collection, id: collection_id });
return DataAppsApi.update({ id, ...rest });
},
},
objectSelectors: {
......@@ -52,8 +65,11 @@ const DataApps = createEntity({
},
forms: {
details: {
fields: createForm,
create: {
fields: createNewAppForm,
},
settings: {
fields: createAppSettingsForm,
},
},
});
......
import { createNameField, createDescriptionField } from "../collections/forms";
export function createForm() {
export function createNewAppForm() {
return [createNameField(), createDescriptionField()];
}
export function createAppSettingsForm() {
return [
{
...createNameField(),
name: "collection.name",
},
{
...createDescriptionField(),
name: "collection.description",
},
{
name: "collection_id",
type: "hidden",
},
];
}
import React, { useMemo } from "react";
import React, { useCallback, useMemo, useState } from "react";
import { t } from "ttag";
import Modal from "metabase/components/Modal";
import * as Urls from "metabase/lib/urls";
......@@ -15,6 +18,8 @@ import DataAppNavbarView from "./DataAppNavbarView";
const FETCHING_SEARCH_MODELS = ["dashboard", "dataset", "card"];
const LIMIT = 100;
type NavbarModal = "MODAL_APP_SETTINGS" | null;
interface Props extends MainNavbarProps {
dataApp: DataApp;
loading: boolean;
......@@ -31,6 +36,8 @@ function DataAppNavbarContainer({
loading: loadingDataApp,
...props
}: Props) {
const [modal, setModal] = useState<NavbarModal>(null);
const collectionContentQuery = useMemo(() => {
if (!dataApp) {
return {};
......@@ -42,22 +49,54 @@ function DataAppNavbarContainer({
};
}, [dataApp]);
const onEditAppSettings = useCallback(() => {
setModal("MODAL_APP_SETTINGS");
}, []);
const closeModal = useCallback(() => setModal(null), []);
const renderModalContent = useCallback(() => {
if (modal === "MODAL_APP_SETTINGS") {
return (
<DataApps.ModalForm
form={DataApps.forms.settings}
title={t`Settings`}
dataApp={dataApp}
onClose={closeModal}
onSaved={closeModal}
submitTitle={t`Save`}
/>
);
}
return null;
}, [dataApp, modal, closeModal]);
if (loadingDataApp) {
return <NavbarLoadingView />;
}
return (
<Search.ListLoader
query={collectionContentQuery}
loadingAndErrorWrapper={false}
>
{({ list = [], loading: loadingAppContent }: SearchRenderProps) => {
if (loadingAppContent) {
return <NavbarLoadingView />;
}
return <DataAppNavbarView {...props} dataApp={dataApp} items={list} />;
}}
</Search.ListLoader>
<>
<Search.ListLoader
query={collectionContentQuery}
loadingAndErrorWrapper={false}
>
{({ list = [], loading: loadingAppContent }: SearchRenderProps) => {
if (loadingAppContent) {
return <NavbarLoadingView />;
}
return (
<DataAppNavbarView
{...props}
dataApp={dataApp}
items={list}
onEditAppSettings={onEditAppSettings}
/>
);
}}
</Search.ListLoader>
{modal && <Modal onClose={closeModal}>{renderModalContent()}</Modal>}
</>
);
}
......
......@@ -26,9 +26,15 @@ interface Props extends MainNavbarProps {
dataApp: DataApp;
items: any[];
selectedItems: SelectedItem[];
onEditAppSettings: () => void;
}
function DataAppNavbarView({ dataApp, items, selectedItems }: Props) {
function DataAppNavbarView({
dataApp,
items,
selectedItems,
onEditAppSettings,
}: Props) {
const appPages = useMemo(
() => items.filter(item => item.model === "dashboard"),
[items],
......@@ -63,7 +69,11 @@ function DataAppNavbarView({ dataApp, items, selectedItems }: Props) {
<DataAppActionButton icon="add" onlyIcon />
</Tooltip>
<Tooltip tooltip={t`Settings`}>
<DataAppActionButton icon="gear" onlyIcon />
<DataAppActionButton
icon="gear"
onClick={onEditAppSettings}
onlyIcon
/>
</Tooltip>
</ButtonGroup>
<ExitDataAppButton
......
......@@ -183,7 +183,7 @@ export const CollectionsApi = {
export const DataAppsApi = {
list: GET("/api/app"),
create: POST("/api/app"),
update: PUT("/api/app"),
update: PUT("/api/app/:id"),
};
const PIVOT_PUBLIC_PREFIX = "/api/public/pivot/";
......
......@@ -35,7 +35,7 @@ function CreateDataAppModal({ onClose, onChangeLocation }: Props) {
return (
<DataApps.ModalForm
form={DataApps.forms.details}
form={DataApps.forms.create}
onSaved={handleSave}
onClose={onClose}
/>
......
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