diff --git a/e2e/test/scenarios/collections/permissions.cy.spec.js b/e2e/test/scenarios/collections/permissions.cy.spec.js index 761c4b28dea7e42898986ce752535da3745f5e1f..2d41e8d4aa2ccfdc0075e1ba83ed21c7762dd939 100644 --- a/e2e/test/scenarios/collections/permissions.cy.spec.js +++ b/e2e/test/scenarios/collections/permissions.cy.spec.js @@ -326,7 +326,8 @@ describe("collection permissions", () => { cy.visit("/collection/root"); openCollectionItemMenu("Orders in a dashboard"); popover().findByText("Duplicate").click(); - cy.findByTestId("select-button").findByText( + cy.findByTestId("collection-picker-button").should( + "have.text", `${first_name} ${last_name}'s Personal Collection`, ); }); diff --git a/e2e/test/scenarios/dashboard/dashboard-management.cy.spec.js b/e2e/test/scenarios/dashboard/dashboard-management.cy.spec.js index 2e0ceeb185479533e9f27917eb80a6e5e1327e27..0c3587b889208a469fe0209fbda07db714519637 100644 --- a/e2e/test/scenarios/dashboard/dashboard-management.cy.spec.js +++ b/e2e/test/scenarios/dashboard/dashboard-management.cy.spec.js @@ -16,6 +16,7 @@ import { openDashboardMenu, toggleDashboardInfoSidebar, entityPickerModal, + collectionOnTheGoModal, } from "e2e/support/helpers"; const PERMISSIONS = { @@ -188,18 +189,21 @@ describe("managing dashboard from the dashboard's edit menu", () => { cy.findByLabelText("Only duplicate the dashboard").should( "not.be.checked", ); - cy.findByTestId("select-button").click(); + cy.findByTestId("collection-picker-button").click(); }); - popover().findByText("New collection").click(); + entityPickerModal() + .findByText("Create a new collection") + .click(); const NEW_COLLECTION = "Foo Collection"; - cy.findByTestId("new-collection-modal").then(modal => { - cy.findByPlaceholderText("My new fantastic collection").type( + collectionOnTheGoModal().within(() => { + cy.findByPlaceholderText("My new collection").type( NEW_COLLECTION, ); cy.button("Create").click(); - cy.button("Duplicate").click(); - assertOnRequest("copyDashboard"); }); + cy.button("Select").click(); + cy.button("Duplicate").click(); + assertOnRequest("copyDashboard"); cy.url().should("contain", `/dashboard/${newDashboardId}`); @@ -304,7 +308,8 @@ describe("managing dashboard from the dashboard's edit menu", () => { const { first_name, last_name } = USERS[user]; popover().findByText("Duplicate").click(); - cy.findByTestId("select-button").findByText( + cy.findByTestId("collection-picker-button").should( + "have.text", `${first_name} ${last_name}'s Personal Collection`, ); }); diff --git a/e2e/test/scenarios/question/saved.cy.spec.js b/e2e/test/scenarios/question/saved.cy.spec.js index 315ee0366634ddd3fd0f5a22519063372afa7c12..40b90d911a82896dedecba98fbfe0bf94e9e31e2 100644 --- a/e2e/test/scenarios/question/saved.cy.spec.js +++ b/e2e/test/scenarios/question/saved.cy.spec.js @@ -17,6 +17,8 @@ import { queryBuilderHeader, openNotebook, selectFilterOperator, + entityPickerModal, + collectionOnTheGoModal, } from "e2e/support/helpers"; describe("scenarios > question > saved", () => { @@ -149,18 +151,25 @@ describe("scenarios > question > saved", () => { modal().within(() => { cy.findByLabelText("Name").should("have.value", "Orders - Duplicate"); - cy.findByTestId("select-button").click(); + cy.findByTestId("collection-picker-button").click(); }); - popover().findByText("New collection").click(); + + entityPickerModal().findByText("Create a new collection").click(); const NEW_COLLECTION = "Foo"; - cy.findByTestId("new-collection-modal").then(modal => { - cy.findByPlaceholderText("My new fantastic collection").type( - NEW_COLLECTION, - ); + collectionOnTheGoModal().then(() => { + cy.findByPlaceholderText("My new collection").type(NEW_COLLECTION); cy.findByText("Create").click(); + }); + + entityPickerModal().findByText("Select").click(); + + modal().within(() => { cy.findByLabelText("Name").should("have.value", "Orders - Duplicate"); - cy.findByTestId("select-button").should("have.text", NEW_COLLECTION); + cy.findByTestId("collection-picker-button").should( + "have.text", + NEW_COLLECTION, + ); cy.findByText("Duplicate").click(); cy.wait("@cardCreate"); }); diff --git a/frontend/src/metabase/dashboard/components/DashboardCopyModal.jsx b/frontend/src/metabase/dashboard/components/DashboardCopyModal.jsx index 9e4444861f52dfe7298fb896e4bea973b3d54fa3..364296560b791ae43ba947b2563427f0c4e0678c 100644 --- a/frontend/src/metabase/dashboard/components/DashboardCopyModal.jsx +++ b/frontend/src/metabase/dashboard/components/DashboardCopyModal.jsx @@ -68,8 +68,8 @@ const DashboardCopyModal = ({ form={Dashboards.forms.duplicate} title={title} overwriteOnInitialValuesChange - copy={object => - copyDashboard({ id: initialDashboardId }, dissoc(object, "id")) + copy={async object => + await copyDashboard({ id: initialDashboardId }, dissoc(object, "id")) } onClose={onClose} onSaved={dashboard => onReplaceLocation(Urls.dashboard(dashboard))} diff --git a/frontend/src/metabase/dashboard/containers/CopyDashboardForm.tsx b/frontend/src/metabase/dashboard/containers/CopyDashboardForm.tsx new file mode 100644 index 0000000000000000000000000000000000000000..3f9a02a1c67836e95ea7f73fbeba4467417169d8 --- /dev/null +++ b/frontend/src/metabase/dashboard/containers/CopyDashboardForm.tsx @@ -0,0 +1,130 @@ +import { useCallback, useMemo } from "react"; +import { withRouter } from "react-router"; +import { t } from "ttag"; +import _ from "underscore"; +import * as Yup from "yup"; + +import FormCollectionPicker from "metabase/collections/containers/FormCollectionPicker/FormCollectionPicker"; +import type { FilterItemsInPersonalCollection } from "metabase/containers/ItemPicker"; +import Button from "metabase/core/components/Button"; +import FormFooter from "metabase/core/components/FormFooter"; +import Dashboards from "metabase/entities/dashboards"; +import { + FormTextInput, + FormTextarea, + FormSubmitButton, + FormErrorMessage, + FormCheckbox, + Form, + FormProvider, + FormObserver, +} from "metabase/forms"; +import * as Errors from "metabase/lib/errors"; +import type { CollectionId, Dashboard } from "metabase-types/api"; + +import { DashboardCopyModalShallowCheckboxLabel } from "../components/DashboardCopyModal/DashboardCopyModalShallowCheckboxLabel/DashboardCopyModalShallowCheckboxLabel"; + +const DASHBOARD_SCHEMA = Yup.object({ + name: Yup.string() + .required(Errors.required) + .max(100, Errors.maxLength) + .default(""), + description: Yup.string().nullable().max(255, Errors.maxLength).default(null), + collection_id: Yup.number().nullable().default(null), + is_shallow_copy: Yup.boolean().default(false), +}); + +export interface CopyDashboardFormProperties { + name: string; + description: string | null; + collection_id: CollectionId | null; +} + +export interface CopyDashboardFormProps { + onSubmit?: (values: CopyDashboardFormProperties) => Dashboard; + onSaved?: (dashboard?: Dashboard) => void; + onClose?: () => void; + initialValues?: CopyDashboardFormProperties | null; + filterPersonalCollections?: FilterItemsInPersonalCollection; + onValuesChange?: (vals: CopyDashboardFormProperties) => void; +} + +function CopyDashboardForm({ + onSubmit, + onSaved, + onClose, + initialValues, + filterPersonalCollections, + onValuesChange, +}: CopyDashboardFormProps) { + const computedInitialValues = useMemo( + () => ({ + ...DASHBOARD_SCHEMA.getDefault(), + ...initialValues, + }), + [initialValues], + ); + + const handleSubmit = useCallback( + async (values: CopyDashboardFormProperties) => { + const result = await onSubmit?.(values); + const dashboard = Dashboards.HACK_getObjectFromAction(result); + onSaved?.(dashboard); + }, + [onSubmit, onSaved], + ); + + const handleChange = useCallback( + (values: CopyDashboardFormProperties) => { + onValuesChange?.(values); + }, + [onValuesChange], + ); + + return ( + <FormProvider + initialValues={computedInitialValues} + validationSchema={DASHBOARD_SCHEMA} + onSubmit={handleSubmit} + enableReinitialize + > + <Form> + <FormObserver onChange={handleChange} /> + <FormTextInput + name="name" + label={t`Name`} + placeholder={t`What is the name of your dashboard?`} + autoFocus + mb="1.5rem" + /> + <FormTextarea + name="description" + label={t`Description`} + placeholder={t`It's optional but oh, so helpful`} + nullable + mb="1.5rem" + minRows={6} + /> + <FormCollectionPicker + name="collection_id" + title={t`Which collection should this go in?`} + filterPersonalCollections={filterPersonalCollections} + /> + <FormCheckbox + name="is_shallow_copy" + label={<DashboardCopyModalShallowCheckboxLabel />} + /> + <FormFooter> + <FormErrorMessage inline /> + {!!onClose && ( + <Button type="button" onClick={onClose}>{t`Cancel`}</Button> + )} + <FormSubmitButton label={t`Duplicate`} /> + </FormFooter> + </Form> + </FormProvider> + ); +} + +export const CopyDashboardFormConnected = + _.compose(withRouter)(CopyDashboardForm); diff --git a/frontend/src/metabase/entities/containers/EntityCopyModal.tsx b/frontend/src/metabase/entities/containers/EntityCopyModal.tsx index 24b5ae1fcdb2f06539da2e50d3bf06dc3d6c3aa4..75b4833a20db9b3fd1034d84c0929b241c292643 100644 --- a/frontend/src/metabase/entities/containers/EntityCopyModal.tsx +++ b/frontend/src/metabase/entities/containers/EntityCopyModal.tsx @@ -1,11 +1,16 @@ import { dissoc } from "icepick"; import { t } from "ttag"; +import { + getInstanceAnalyticsCustomCollection, + isInstanceAnalyticsCollection, +} from "metabase/collections/utils"; import { useCollectionListQuery } from "metabase/common/hooks"; import ModalContent from "metabase/components/ModalContent"; import { CreateCollectionOnTheGo } from "metabase/containers/CreateCollectionOnTheGo"; import type { FormContainerProps } from "metabase/containers/FormikForm"; -import EntityForm from "metabase/entities/containers/EntityForm"; +import { CopyDashboardFormConnected } from "metabase/dashboard/containers/CopyDashboardForm"; +import { CopyQuestionForm } from "metabase/questions/components/CopyQuestionForm"; import { Flex, Loader } from "metabase/ui"; import type { BaseFieldValues } from "metabase-types/forms"; @@ -28,13 +33,55 @@ const EntityCopyModal = ({ onSaved, ...props }: EntityCopyModalProps & Partial<FormContainerProps<BaseFieldValues>>) => { - const { data: collections } = useCollectionListQuery(); + const { data: collections = [] } = useCollectionListQuery(); + + const resolvedObject = + typeof entityObject?.getPlainObject === "function" + ? entityObject.getPlainObject() + : entityObject; + + if (isInstanceAnalyticsCollection(resolvedObject?.collection)) { + const customCollection = getInstanceAnalyticsCustomCollection(collections); + if (customCollection) { + resolvedObject.collection_id = customCollection.id; + } + } + + const initialValues = { + ...dissoc(resolvedObject, "id"), + name: resolvedObject.name + " - " + t`Duplicate`, + }; + + const renderForm = (props: any) => { + switch (entityType) { + case "dashboards": + return ( + <CopyDashboardFormConnected + onSubmit={copy} + onClose={onClose} + onSaved={onSaved} + collections={collections} + {...props} + /> + ); + case "questions": + return ( + <CopyQuestionForm + onSubmit={copy} + onClose={onClose} + onSaved={onSaved} + collections={collections} + {...props} + /> + ); + } + }; return ( <CreateCollectionOnTheGo> {({ resumedValues }) => ( <ModalContent - title={title || t`Duplicate "${entityObject.name}"`} + title={title || t`Duplicate "${resolvedObject.name}"`} onClose={onClose} > {!collections?.length ? ( @@ -42,20 +89,7 @@ const EntityCopyModal = ({ <Loader /> </Flex> ) : ( - <EntityForm - resumedValues={resumedValues} - entityType={entityType} - entityObject={{ - ...dissoc(entityObject, "id"), - name: entityObject.name + " - " + t`Duplicate`, - }} - onSubmit={copy} - onClose={onClose} - onSaved={onSaved} - submitTitle={t`Duplicate`} - collections={collections} - {...props} - /> + renderForm({ ...props, resumedValues, initialValues }) )} </ModalContent> )} diff --git a/frontend/src/metabase/forms/components/FormObserver/FormObserver.tsx b/frontend/src/metabase/forms/components/FormObserver/FormObserver.tsx new file mode 100644 index 0000000000000000000000000000000000000000..c8d2abaf0ec17d8582f8f022f0eaf7b02f1e1a5f --- /dev/null +++ b/frontend/src/metabase/forms/components/FormObserver/FormObserver.tsx @@ -0,0 +1,21 @@ +import { useFormikContext } from "formik"; +import { useEffect } from "react"; + +interface FormObserverProps<T> { + onChange: (vals: T) => void; +} + +/** This component can be used to effectivy add an onChange handler to a from. + however, this should be used with caution as it is bad practice to duplicate + state. */ +export const FormObserver = <T,>({ onChange }: FormObserverProps<T>) => { + const { values } = useFormikContext<T>(); + + useEffect(() => { + if (values) { + onChange(values); + } + }, [values, onChange]); + + return null; +}; diff --git a/frontend/src/metabase/forms/components/FormObserver/index.ts b/frontend/src/metabase/forms/components/FormObserver/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..16cd020dcec07e8d580f1466766c79d9b2a770fb --- /dev/null +++ b/frontend/src/metabase/forms/components/FormObserver/index.ts @@ -0,0 +1 @@ +export * from "./FormObserver"; diff --git a/frontend/src/metabase/forms/components/FormTextInput/FormTextInput.tsx b/frontend/src/metabase/forms/components/FormTextInput/FormTextInput.tsx index eab58db63ce88bd93ca6c7c7290e26939730e676..aa931dacae2ccabfc6ecc461a0632319b60b8bbd 100644 --- a/frontend/src/metabase/forms/components/FormTextInput/FormTextInput.tsx +++ b/frontend/src/metabase/forms/components/FormTextInput/FormTextInput.tsx @@ -60,6 +60,11 @@ export const FormTextInput = forwardRef(function FormTextInput( onBlur={handleBlur} rightSection={hasCopyButton ? <CopyWidgetButton value={value} /> : null} rightSectionWidth={hasCopyButton ? 40 : undefined} + styles={{ + input: { + fontWeight: "bold", + }, + }} /> ); }); diff --git a/frontend/src/metabase/forms/components/FormTextarea/FormTextarea.tsx b/frontend/src/metabase/forms/components/FormTextarea/FormTextarea.tsx index 3037e7c98b8b8bba1984bdc86ca18ec1263deb16..f571a93a1edf0928eaee62559c60cb77b942d0bc 100644 --- a/frontend/src/metabase/forms/components/FormTextarea/FormTextarea.tsx +++ b/frontend/src/metabase/forms/components/FormTextarea/FormTextarea.tsx @@ -48,6 +48,11 @@ export const FormTextarea = forwardRef(function FormTextarea( error={touched ? error : null} onChange={handleChange} onBlur={handleBlur} + styles={{ + input: { + fontWeight: "bold", + }, + }} /> ); }); diff --git a/frontend/src/metabase/forms/components/index.ts b/frontend/src/metabase/forms/components/index.ts index bd873a241dcc9dced8db0b60df3634c7bb1332f6..d469ab767ee8656d318d08d1d905e4d656ed7ca8 100644 --- a/frontend/src/metabase/forms/components/index.ts +++ b/frontend/src/metabase/forms/components/index.ts @@ -5,6 +5,7 @@ export * from "./FormErrorMessage"; export * from "./FormGroupsWidget"; export * from "./FormGroupWidget"; export * from "./FormNumberInput"; +export * from "./FormObserver"; export * from "./FormProvider"; export * from "./FormRadioGroup"; export * from "./FormSecretKey"; diff --git a/frontend/src/metabase/questions/components/CopyQuestionForm.tsx b/frontend/src/metabase/questions/components/CopyQuestionForm.tsx new file mode 100644 index 0000000000000000000000000000000000000000..d8133a2dce12fec5a0e47eaac4d293cac34eb50b --- /dev/null +++ b/frontend/src/metabase/questions/components/CopyQuestionForm.tsx @@ -0,0 +1,96 @@ +import { useMemo } from "react"; +import { t } from "ttag"; +import * as Yup from "yup"; + +import FormCollectionPicker from "metabase/collections/containers/FormCollectionPicker/FormCollectionPicker"; +import FormFooter from "metabase/core/components/FormFooter"; +import { + Form, + FormTextInput, + FormTextarea, + FormProvider, + FormSubmitButton, + FormErrorMessage, +} from "metabase/forms"; +import * as Errors from "metabase/lib/errors"; +import { Button } from "metabase/ui"; +import type { CollectionId } from "metabase-types/api"; + +const QUESTION_SCHEMA = Yup.object({ + name: Yup.string() + .required(Errors.required) + .max(100, Errors.maxLength) + .default(""), + description: Yup.string().nullable().max(255, Errors.maxLength).default(null), + collection_id: Yup.number().nullable().default(null), +}); + +type CopyQuestionProperties = { + name: string; + description: string | null; + collection_id: CollectionId | null; +}; + +interface CopyQuestionFormProps { + initialValues: Partial<CopyQuestionProperties>; + onCancel: () => void; + onSubmit: (vals: CopyQuestionProperties) => void; + onSaved: () => void; +} + +export const CopyQuestionForm = ({ + initialValues, + onCancel, + onSubmit, + onSaved, +}: CopyQuestionFormProps) => { + const computedInitialValues = useMemo<CopyQuestionProperties>( + () => ({ + ...QUESTION_SCHEMA.getDefault(), + ...initialValues, + }), + [initialValues], + ); + + const handleDuplicate = async (vals: CopyQuestionProperties) => { + await onSubmit(vals); + onSaved?.(); + }; + + return ( + <FormProvider + initialValues={computedInitialValues} + validationSchema={QUESTION_SCHEMA} + onSubmit={handleDuplicate} + > + <Form> + <FormTextInput + name="name" + label={t`Name`} + placeholder={t`What is the name of your dashboard?`} + autoFocus + mb="1.5rem" + /> + <FormTextarea + name="description" + label={t`Description`} + placeholder={t`It's optional but oh, so helpful`} + nullable + mb="1.5rem" + minRows={4} + /> + <FormCollectionPicker + name="collection_id" + title={t`Which collection should this go in?`} + /> + <FormFooter> + <FormErrorMessage inline /> + {!!onCancel && ( + <Button type="button" onClick={onCancel}>{t`Cancel`}</Button> + )} + <FormSubmitButton label={t`Duplicate`} /> + </FormFooter> + </Form> + </FormProvider> + ); +};