diff --git a/enterprise/frontend/src/metabase-enterprise/snippets/components/SnippetCollectionForm.tsx b/enterprise/frontend/src/metabase-enterprise/snippets/components/SnippetCollectionForm.tsx new file mode 100644 index 0000000000000000000000000000000000000000..b05555391cf9c22bc204ec98b4222d07f63046d5 --- /dev/null +++ b/enterprise/frontend/src/metabase-enterprise/snippets/components/SnippetCollectionForm.tsx @@ -0,0 +1,171 @@ +import React, { useCallback, useMemo } from "react"; +import { t } from "ttag"; +import _ from "underscore"; +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 { color } from "metabase/lib/colors"; + +import SnippetCollections from "metabase/entities/snippet-collections"; +import { DEFAULT_COLLECTION_COLOR_ALIAS } from "metabase/entities/collections"; + +import FormCollectionPicker from "metabase/collections/containers/FormCollectionPicker"; + +import type { Collection, CollectionId } from "metabase-types/api"; +import type { State } from "metabase-types/store"; + +const SNIPPET_COLLECTION_SCHEMA = Yup.object({ + name: Yup.string() + .required(Errors.required) + .max(100, Errors.maxLength) + .default(""), + description: Yup.string().nullable().max(255, Errors.maxLength).default(null), + color: Yup.string() + .nullable() + .default(() => color(DEFAULT_COLLECTION_COLOR_ALIAS)), + parent_id: Yup.number().nullable().default(null), +}); + +type SnippetCollectionFormValues = Pick< + Collection, + "name" | "description" | "color" | "parent_id" +>; + +type UpdateSnippetCollectionFormValues = Partial<SnippetCollectionFormValues> & + Pick<Collection, "id">; + +export interface SnippetCollectionFormOwnProps { + collection: Partial<Collection>; + onSave?: (collection: Collection) => void; + onCancel?: () => void; +} + +interface SnippetCollectionLoaderProps { + snippetCollection?: Collection; +} + +interface SnippetCollectionDispatchProps { + handleCreateSnippetCollection: ( + values: SnippetCollectionFormValues, + ) => Promise<Collection>; + handleUpdateSnippetCollection: ( + values: UpdateSnippetCollectionFormValues, + ) => Promise<Collection>; +} + +type Props = SnippetCollectionFormOwnProps & + SnippetCollectionLoaderProps & + SnippetCollectionDispatchProps; + +const mapDispatchToProps = { + handleCreateSnippetCollection: SnippetCollections.actions.create, + handleUpdateSnippetCollection: SnippetCollections.actions.update, +}; + +function SnippetCollectionForm({ + collection: passedCollection, + snippetCollection, + onSave, + onCancel, + handleCreateSnippetCollection, + handleUpdateSnippetCollection, +}: Props) { + const collection = snippetCollection || passedCollection; + const isEditing = collection.id != null; + + const initialValues = useMemo( + () => + collection + ? SNIPPET_COLLECTION_SCHEMA.cast(collection, { stripUnknown: true }) + : SNIPPET_COLLECTION_SCHEMA.getDefault(), + [collection], + ); + + const handleCreate = useCallback( + async (values: SnippetCollectionFormValues) => { + const action = await handleCreateSnippetCollection(values); + return SnippetCollections.HACK_getObjectFromAction(action); + }, + [handleCreateSnippetCollection], + ); + + const handleUpdate = useCallback( + async (values: UpdateSnippetCollectionFormValues) => { + const action = await handleUpdateSnippetCollection(values); + return SnippetCollections.HACK_getObjectFromAction(action); + }, + [handleUpdateSnippetCollection], + ); + + const handleSubmit = useCallback( + async values => { + const nextCollection = isEditing + ? await handleUpdate({ id: collection.id as CollectionId, ...values }) + : await handleCreate(values); + onSave?.(nextCollection); + }, + [collection.id, isEditing, handleCreate, handleUpdate, onSave], + ); + + return ( + <FormProvider + initialValues={initialValues} + validationSchema={SNIPPET_COLLECTION_SCHEMA} + enableReinitialize + onSubmit={handleSubmit} + > + {({ dirty }) => ( + <Form> + <FormInput + name="name" + title={t`Give your folder a name`} + placeholder={t`Something short but sweet`} + autoFocus + /> + <FormTextArea + name="description" + title={t`Add a description`} + placeholder={t`It's optional but oh, so helpful`} + nullable + /> + <FormCollectionPicker + name="parent_id" + title={t`Folder this should be in`} + type="snippet-collections" + /> + <FormFooter> + <FormErrorMessage inline /> + {!!onCancel && ( + <Button type="button" onClick={onCancel}>{t`Cancel`}</Button> + )} + <FormSubmitButton + title={isEditing ? t`Update` : t`Create`} + disabled={!dirty} + primary + /> + </FormFooter> + </Form> + )} + </FormProvider> + ); +} + +function getCollectionId(state: State, props: SnippetCollectionFormOwnProps) { + return props.collection?.id; +} + +export default _.compose( + SnippetCollections.load({ id: getCollectionId }), + connect(null, mapDispatchToProps), +)(SnippetCollectionForm); diff --git a/enterprise/frontend/src/metabase-enterprise/snippets/components/SnippetCollectionFormModal.tsx b/enterprise/frontend/src/metabase-enterprise/snippets/components/SnippetCollectionFormModal.tsx new file mode 100644 index 0000000000000000000000000000000000000000..547170bf69a1a34248f188af00cabcbdacb32dbe --- /dev/null +++ b/enterprise/frontend/src/metabase-enterprise/snippets/components/SnippetCollectionFormModal.tsx @@ -0,0 +1,50 @@ +import React, { useCallback } from "react"; +import { t } from "ttag"; + +import ModalContent from "metabase/components/ModalContent"; + +import type { Collection } from "metabase-types/api"; + +import SnippetCollectionForm, { + SnippetCollectionFormOwnProps, +} from "./SnippetCollectionForm"; + +interface SnippetCollectionFormModalOwnProps + extends Omit<SnippetCollectionFormOwnProps, "onCancel"> { + onClose?: () => void; +} + +type SnippetCollectionFormModalProps = SnippetCollectionFormModalOwnProps; + +function SnippetFormModal({ + collection, + onSave, + onClose, + ...props +}: SnippetCollectionFormModalProps) { + const isEditing = collection.id != null; + const title = isEditing + ? t`Editing ${collection.name}` + : t`Create your new folder`; + + const handleSave = useCallback( + (snippetCollection: Collection) => { + onSave?.(snippetCollection); + onClose?.(); + }, + [onSave, onClose], + ); + + return ( + <ModalContent title={title} onClose={onClose}> + <SnippetCollectionForm + {...props} + collection={collection} + onSave={handleSave} + onCancel={onClose} + /> + </ModalContent> + ); +} + +export default SnippetFormModal; diff --git a/enterprise/frontend/src/metabase-enterprise/snippets/components/SnippetCollectionFormModal.unit.spec.tsx b/enterprise/frontend/src/metabase-enterprise/snippets/components/SnippetCollectionFormModal.unit.spec.tsx new file mode 100644 index 0000000000000000000000000000000000000000..40eb253672670137cba96350f33aeeab039a1b4d --- /dev/null +++ b/enterprise/frontend/src/metabase-enterprise/snippets/components/SnippetCollectionFormModal.unit.spec.tsx @@ -0,0 +1,206 @@ +import React from "react"; +import userEvent from "@testing-library/user-event"; +import xhrMock from "xhr-mock"; + +import { + act, + renderWithProviders, + screen, + waitForElementToBeRemoved, +} from "__support__/ui"; + +import type { Collection } from "metabase-types/api"; +import { createMockCollection } from "metabase-types/api/mocks"; + +import SnippetCollectionFormModal from "./SnippetCollectionFormModal"; + +const TOP_SNIPPETS_FOLDER = { + id: "root", + name: "Top folder", + can_write: true, +}; + +type SetupOpts = { + folder?: Partial<Collection>; + onClose?: null | (() => void); +}; + +async function setup({ folder = {}, onClose = jest.fn() }: SetupOpts = {}) { + xhrMock.get("/api/collection/root?namespace=snippets", { + body: JSON.stringify(TOP_SNIPPETS_FOLDER), + }); + + xhrMock.get("/api/collection?namespace=snippets", { + body: JSON.stringify([TOP_SNIPPETS_FOLDER]), + }); + + xhrMock.post("/api/collection", (req, res) => + res.status(200).body(createMockCollection(req.body())), + ); + + if (folder.id) { + xhrMock.get(`/api/collection/${folder.id}?namespace=snippets`, (req, res) => + res.status(200).body(folder), + ); + + xhrMock.put(`/api/collection/${folder.id}`, (req, res) => + res.status(200).body(createMockCollection(req.body())), + ); + } + + renderWithProviders( + <SnippetCollectionFormModal + collection={folder} + onClose={onClose || undefined} + />, + ); + + if (folder.id) { + await waitForElementToBeRemoved(() => screen.getByText(/Loading/i)); + } + + return { onClose }; +} + +function setupEditing({ + folder = createMockCollection(), + ...opts +}: SetupOpts = {}) { + return setup({ folder, ...opts }); +} + +const LABEL = { + NAME: /Give your folder a name/i, + DESCRIPTION: /Add a description/i, + FOLDER: /Folder this should be in/i, +}; + +describe("SnippetCollectionFormModal", () => { + beforeEach(() => { + xhrMock.setup(); + }); + + afterEach(() => { + xhrMock.teardown(); + }); + + describe("new folder", () => { + it("displays correct blank state", async () => { + await setup(); + + expect(screen.getByLabelText(LABEL.NAME)).toBeInTheDocument(); + expect(screen.getByLabelText(LABEL.NAME)).toHaveValue(""); + + expect(screen.getByLabelText(LABEL.DESCRIPTION)).toBeInTheDocument(); + expect(screen.getByLabelText(LABEL.DESCRIPTION)).toHaveValue(""); + + expect(screen.getByText(LABEL.FOLDER)).toBeInTheDocument(); + expect(screen.getByText(TOP_SNIPPETS_FOLDER.name)).toBeInTheDocument(); + + expect( + screen.getByRole("button", { name: "Cancel" }), + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Create" }), + ).toBeInTheDocument(); + }); + + it("shows expected title", async () => { + await setup(); + expect(screen.getByText(/Create your new folder/i)).toBeInTheDocument(); + }); + + it("can't submit if name is empty", async () => { + await setup(); + expect(screen.getByRole("button", { name: "Create" })).toBeDisabled(); + }); + + it("can submit when name is filled in", async () => { + await setup(); + + await act(async () => { + await userEvent.type(screen.getByLabelText(LABEL.NAME), "My folder"); + }); + + expect(screen.getByRole("button", { name: "Create" })).not.toBeDisabled(); + }); + + it("doesn't show cancel button if onClose props is not set", async () => { + await setup({ onClose: null }); + expect( + screen.queryByRole("button", { name: "Cancel" }), + ).not.toBeInTheDocument(); + }); + + it("calls onClose when cancel button is clicked", async () => { + const { onClose } = await setup(); + userEvent.click(screen.getByRole("button", { name: "Cancel" })); + expect(onClose).toHaveBeenCalledTimes(1); + }); + }); + + describe("editing folder", () => { + it("shows correct initial state", async () => { + const folder = createMockCollection({ description: "has description" }); + await setupEditing({ folder }); + + screen.debug(); + + expect(screen.getByLabelText(LABEL.NAME)).toBeInTheDocument(); + expect(screen.getByLabelText(LABEL.NAME)).toHaveValue(folder.name); + + expect(screen.getByLabelText(LABEL.DESCRIPTION)).toBeInTheDocument(); + expect(screen.getByLabelText(LABEL.DESCRIPTION)).toHaveValue( + folder.description, + ); + + expect(screen.getByText(LABEL.FOLDER)).toBeInTheDocument(); + expect(screen.getByText(TOP_SNIPPETS_FOLDER.name)).toBeInTheDocument(); + + expect( + screen.getByRole("button", { name: "Cancel" }), + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Update" }), + ).toBeInTheDocument(); + }); + + it("shows expected title", async () => { + const folder = createMockCollection(); + await setupEditing({ folder }); + expect(screen.getByText(`Editing ${folder.name}`)).toBeInTheDocument(); + }); + + it("can't submit until changes are made", async () => { + await setupEditing(); + expect(screen.getByRole("button", { name: "Update" })).toBeDisabled(); + }); + + it("can't submit if name is empty", async () => { + await setupEditing(); + await act(async () => { + await userEvent.clear(screen.getByLabelText(LABEL.NAME)); + }); + expect(screen.getByRole("button", { name: "Update" })).toBeDisabled(); + }); + + it("can submit when have changes", async () => { + await setupEditing(); + userEvent.type(screen.getByLabelText(LABEL.NAME), "My folder"); + expect(screen.getByRole("button", { name: "Update" })).not.toBeDisabled(); + }); + + it("doesn't show cancel button if onClose props is not set", async () => { + await setupEditing({ onClose: null }); + expect( + screen.queryByRole("button", { name: "Cancel" }), + ).not.toBeInTheDocument(); + }); + + it("calls onClose when cancel button is clicked", async () => { + const { onClose } = await setupEditing(); + userEvent.click(screen.getByRole("button", { name: "Cancel" })); + expect(onClose).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/enterprise/frontend/src/metabase-enterprise/snippets/components/SnippetCollectionModal.jsx b/enterprise/frontend/src/metabase-enterprise/snippets/components/SnippetCollectionModal.jsx deleted file mode 100644 index 56f67892eacc38eb373b68b701a383129ec67cf7..0000000000000000000000000000000000000000 --- a/enterprise/frontend/src/metabase-enterprise/snippets/components/SnippetCollectionModal.jsx +++ /dev/null @@ -1,37 +0,0 @@ -/* eslint-disable react/prop-types */ -import React from "react"; -import { t } from "ttag"; - -import Modal from "metabase/components/Modal"; - -import SnippetCollections from "metabase/entities/snippet-collections"; - -class SnippetCollectionModal extends React.Component { - render() { - const { - snippetCollection, - collection: passedCollection, - onClose, - onSaved, - } = this.props; - const collection = snippetCollection || passedCollection; - return ( - <Modal onClose={onClose}> - <SnippetCollections.ModalForm - title={ - collection.id == null - ? t`Create your new folder` - : t`Editing ${collection.name}` - } - snippetCollection={collection} - onClose={onClose} - onSaved={onSaved} - /> - </Modal> - ); - } -} - -export default SnippetCollections.load({ - id: (state, props) => props.collection.id, -})(SnippetCollectionModal); diff --git a/enterprise/frontend/src/metabase-enterprise/snippets/index.js b/enterprise/frontend/src/metabase-enterprise/snippets/index.js index ec09fca5febf5e4f2a0d6d85601231a8838452a1..8661a3a4149f9f9a421bba086a1017636dea13f8 100644 --- a/enterprise/frontend/src/metabase-enterprise/snippets/index.js +++ b/enterprise/frontend/src/metabase-enterprise/snippets/index.js @@ -8,12 +8,13 @@ import { PLUGIN_SNIPPET_SIDEBAR_HEADER_BUTTONS, } from "metabase/plugins"; +import Modal from "metabase/components/Modal"; import MetabaseSettings from "metabase/lib/settings"; import CollectionPermissionsModal from "metabase/admin/permissions/components/CollectionPermissionsModal/CollectionPermissionsModal"; -import Modal from "metabase/components/Modal"; +import { canonicalCollectionId } from "metabase/collections/utils"; import CollectionRow from "./components/CollectionRow"; -import SnippetCollectionModal from "./components/SnippetCollectionModal"; +import SnippetCollectionFormModal from "./components/SnippetCollectionFormModal"; import CollectionOptionsButton from "./components/CollectionOptionsButton"; if (MetabaseSettings.enhancementsEnabled()) { @@ -23,7 +24,9 @@ if (MetabaseSettings.enhancementsEnabled()) { onClick: () => snippetSidebar.setState({ modalSnippetCollection: { - parent_id: snippetSidebar.props.snippetCollection.id, + parent_id: canonicalCollectionId( + snippetSidebar.props.snippetCollection.id, + ), }, }), })); @@ -32,15 +35,21 @@ if (MetabaseSettings.enhancementsEnabled()) { PLUGIN_SNIPPET_SIDEBAR_MODALS.push( snippetSidebar => snippetSidebar.state.modalSnippetCollection && ( - <SnippetCollectionModal - collection={snippetSidebar.state.modalSnippetCollection} + <Modal onClose={() => snippetSidebar.setState({ modalSnippetCollection: null }) } - onSaved={() => { - snippetSidebar.setState({ modalSnippetCollection: null }); - }} - /> + > + <SnippetCollectionFormModal + collection={snippetSidebar.state.modalSnippetCollection} + onClose={() => + snippetSidebar.setState({ modalSnippetCollection: null }) + } + onSaved={() => { + snippetSidebar.setState({ modalSnippetCollection: null }); + }} + /> + </Modal> ), snippetSidebar => snippetSidebar.state.permissionsModalCollectionId != null && ( diff --git a/frontend/src/metabase-types/api/collection.ts b/frontend/src/metabase-types/api/collection.ts index 97e8e647df7c979ea8699b7f7aedf9eeab403209..9af5aa86c890867c84318f1e5bd05518e70f7407 100644 --- a/frontend/src/metabase-types/api/collection.ts +++ b/frontend/src/metabase-types/api/collection.ts @@ -19,6 +19,7 @@ export interface Collection { name: string; description: string | null; can_write: boolean; + color?: string; archived: boolean; children?: Collection[]; authority_level?: "official" | null; diff --git a/frontend/src/metabase/entities/snippet-collections.js b/frontend/src/metabase/entities/snippet-collections.js index 2fdd01ebd0f7ceb5cb31cbc87204d726d2eb2c3b..b6acb049577044aedceab982a59702406a553ad9 100644 --- a/frontend/src/metabase/entities/snippet-collections.js +++ b/frontend/src/metabase/entities/snippet-collections.js @@ -2,28 +2,29 @@ import _ from "underscore"; import { t } from "ttag"; import { createSelector } from "reselect"; -import { color } from "metabase/lib/colors"; import { createEntity, undo } from "metabase/lib/entities"; import { SnippetCollectionSchema } from "metabase/schema"; + import NormalCollections, { getExpandedCollectionsById, } from "metabase/entities/collections"; + import { canonicalCollectionId } from "metabase/collections/utils"; const SnippetCollections = createEntity({ name: "snippetCollections", schema: SnippetCollectionSchema, + displayNameOne: t`snippet collection`, + displayNameMany: t`snippet collections`, + api: _.mapObject( NormalCollections.api, - f => - (first, ...rest) => - f({ ...first, namespace: "snippets" }, ...rest), + request => + (opts, ...rest) => + request({ ...opts, namespace: "snippets" }, ...rest), ), - displayNameOne: t`snippet collection`, - displayNameMany: t`snippet collections`, - objectActions: { setArchived: ({ id }, archived, opts) => SnippetCollections.actions.update( @@ -39,24 +40,14 @@ const SnippetCollections = createEntity({ undo(opts, "folder", "moved"), ), - // NOTE: DELETE not currently implemented - delete: null, + delete: null, // not implemented }, selectors: { getExpandedCollectionsById: createSelector( - [ - state => state.entities.snippetCollections, - state => { - const { list } = state.entities.snippetCollections_list[null] || {}; - return list || []; - }, - ], - (collections, collectionsIds) => - getExpandedCollectionsById( - collectionsIds.map(id => collections[id]), - null, - ), + state => state.entities.snippetCollections || {}, + collections => + getExpandedCollectionsById(Object.values(collections), null), ), }, @@ -66,44 +57,11 @@ const SnippetCollections = createEntity({ }), objectSelectors: { - getIcon: collection => ({ name: "folder" }), - }, - - form: { - fields: [ - { - name: "name", - title: t`Give your folder a name`, - placeholder: t`Something short but sweet`, - validate: name => - (!name && t`Name is required`) || - (name && name.length > 100 && t`Name must be 100 characters or less`), - }, - { - name: "description", - title: t`Add a description`, - type: "text", - placeholder: t`It's optional but oh, so helpful`, - normalize: description => description || null, // expected to be nil or non-empty string - }, - { - name: "color", - title: t`Color`, - type: "hidden", - initial: () => color("brand"), - validate: color => !color && t`Color is required`, - }, - { - name: "parent_id", - title: t`Folder this should be in`, - type: "snippetCollection", - normalize: canonicalCollectionId, - }, - ], + getIcon: () => ({ name: "folder" }), }, - getAnalyticsMetadata([object], { action }, getState) { - return undefined; // TODO: is there anything informative to track here? + getAnalyticsMetadata() { + return undefined; // not tracking }, });