From 79f5e9375483359ac3900a9ed0075500b239f943 Mon Sep 17 00:00:00 2001
From: Anton Kulyk <kuliks.anton@gmail.com>
Date: Mon, 29 Aug 2022 14:46:38 +0100
Subject: [PATCH] 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`
---
 .../containers/FormikForm/FormikForm.tsx      | 27 ++++++--
 .../FormikForm/FormikFormViewAdapter.tsx      | 12 +---
 frontend/src/metabase/containers/formUtils.js | 10 +++
 .../components/EditableText/EditableText.tsx  | 11 ++++
 .../metabase/entities/data-apps/data-apps.ts  | 24 +++++--
 .../src/metabase/entities/data-apps/forms.ts  | 19 +++++-
 .../MainNavbar/DataAppNavbarContainer.tsx     | 63 +++++++++++++++----
 .../MainNavbar/DataAppNavbarView.tsx          | 14 ++++-
 frontend/src/metabase/services.js             |  2 +-
 .../CreateDataAppModal/CreateDataAppModal.tsx |  2 +-
 10 files changed, 149 insertions(+), 35 deletions(-)

diff --git a/frontend/src/metabase/containers/FormikForm/FormikForm.tsx b/frontend/src/metabase/containers/FormikForm/FormikForm.tsx
index 6de3c66b64c..5e8ef961ab9 100644
--- a/frontend/src/metabase/containers/FormikForm/FormikForm.tsx
+++ b/frontend/src/metabase/containers/FormikForm/FormikForm.tsx
@@ -1,7 +1,7 @@
 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]);
 
diff --git a/frontend/src/metabase/containers/FormikForm/FormikFormViewAdapter.tsx b/frontend/src/metabase/containers/FormikForm/FormikFormViewAdapter.tsx
index 3b187a928fe..1b7af57a409 100644
--- a/frontend/src/metabase/containers/FormikForm/FormikFormViewAdapter.tsx
+++ b/frontend/src/metabase/containers/FormikForm/FormikFormViewAdapter.tsx
@@ -1,6 +1,5 @@
 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,
     );
diff --git a/frontend/src/metabase/containers/formUtils.js b/frontend/src/metabase/containers/formUtils.js
index c3f96bc93c9..fe94b57d303 100644
--- a/frontend/src/metabase/containers/formUtils.js
+++ b/frontend/src/metabase/containers/formUtils.js
@@ -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];
+}
diff --git a/frontend/src/metabase/core/components/EditableText/EditableText.tsx b/frontend/src/metabase/core/components/EditableText/EditableText.tsx
index bb59cf66c84..3673abc4f3c 100644
--- a/frontend/src/metabase/core/components/EditableText/EditableText.tsx
+++ b/frontend/src/metabase/core/components/EditableText/EditableText.tsx
@@ -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 => {
diff --git a/frontend/src/metabase/entities/data-apps/data-apps.ts b/frontend/src/metabase/entities/data-apps/data-apps.ts
index b12333e22b1..a7ebd209249 100644
--- a/frontend/src/metabase/entities/data-apps/data-apps.ts
+++ b/frontend/src/metabase/entities/data-apps/data-apps.ts
@@ -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,
     },
   },
 });
diff --git a/frontend/src/metabase/entities/data-apps/forms.ts b/frontend/src/metabase/entities/data-apps/forms.ts
index c7cd5738b60..fa931ecff50 100644
--- a/frontend/src/metabase/entities/data-apps/forms.ts
+++ b/frontend/src/metabase/entities/data-apps/forms.ts
@@ -1,5 +1,22 @@
 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",
+    },
+  ];
+}
diff --git a/frontend/src/metabase/nav/containers/MainNavbar/DataAppNavbarContainer.tsx b/frontend/src/metabase/nav/containers/MainNavbar/DataAppNavbarContainer.tsx
index 7e38b9db4be..484497b4a40 100644
--- a/frontend/src/metabase/nav/containers/MainNavbar/DataAppNavbarContainer.tsx
+++ b/frontend/src/metabase/nav/containers/MainNavbar/DataAppNavbarContainer.tsx
@@ -1,4 +1,7 @@
-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>}
+    </>
   );
 }
 
diff --git a/frontend/src/metabase/nav/containers/MainNavbar/DataAppNavbarView.tsx b/frontend/src/metabase/nav/containers/MainNavbar/DataAppNavbarView.tsx
index fe2637d94fa..f47c3a6c064 100644
--- a/frontend/src/metabase/nav/containers/MainNavbar/DataAppNavbarView.tsx
+++ b/frontend/src/metabase/nav/containers/MainNavbar/DataAppNavbarView.tsx
@@ -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
diff --git a/frontend/src/metabase/services.js b/frontend/src/metabase/services.js
index 02e5564e60a..e24743cd747 100644
--- a/frontend/src/metabase/services.js
+++ b/frontend/src/metabase/services.js
@@ -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/";
diff --git a/frontend/src/metabase/writeback/containers/CreateDataAppModal/CreateDataAppModal.tsx b/frontend/src/metabase/writeback/containers/CreateDataAppModal/CreateDataAppModal.tsx
index 7dcdae8918b..7450e18cb23 100644
--- a/frontend/src/metabase/writeback/containers/CreateDataAppModal/CreateDataAppModal.tsx
+++ b/frontend/src/metabase/writeback/containers/CreateDataAppModal/CreateDataAppModal.tsx
@@ -35,7 +35,7 @@ function CreateDataAppModal({ onClose, onChangeLocation }: Props) {
 
   return (
     <DataApps.ModalForm
-      form={DataApps.forms.details}
+      form={DataApps.forms.create}
       onSaved={handleSave}
       onClose={onClose}
     />
-- 
GitLab