diff --git a/frontend/src/metabase-types/api/index.ts b/frontend/src/metabase-types/api/index.ts index 83c3028cca13dd5a3b2734694c68d6b081561e00..aaec5ccc2a776e196de9d4014fc1c3f7f77f2910 100644 --- a/frontend/src/metabase-types/api/index.ts +++ b/frontend/src/metabase-types/api/index.ts @@ -20,6 +20,7 @@ export * from "./revision"; export * from "./segment"; export * from "./settings"; export * from "./slack"; +export * from "./snippets"; export * from "./table"; export * from "./timeline"; export * from "./user"; diff --git a/frontend/src/metabase-types/api/mocks/index.ts b/frontend/src/metabase-types/api/mocks/index.ts index d9e5f5c4d7bf523386cc72773c03f27ef11e2a31..3b3030f2ad40afb9fbb0f84617239f6bca414623 100644 --- a/frontend/src/metabase-types/api/mocks/index.ts +++ b/frontend/src/metabase-types/api/mocks/index.ts @@ -13,5 +13,6 @@ export * from "./segment"; export * from "./table"; export * from "./timeline"; export * from "./settings"; +export * from "./snippets"; export * from "./user"; export * from "./writeback"; diff --git a/frontend/src/metabase-types/api/mocks/snippets.ts b/frontend/src/metabase-types/api/mocks/snippets.ts new file mode 100644 index 0000000000000000000000000000000000000000..085a7e9aeba962b4d02e5476dfdace34bf8fd0d1 --- /dev/null +++ b/frontend/src/metabase-types/api/mocks/snippets.ts @@ -0,0 +1,21 @@ +import { NativeQuerySnippet } from "metabase-types/api"; +import { createMockUser } from "./user"; + +export const createMockNativeQuerySnippet = ({ + creator = createMockUser(), + creator_id = creator.id, + ...opts +}: Partial<NativeQuerySnippet> = {}): NativeQuerySnippet => ({ + id: 1, + name: "My Snippet", + description: null, + content: "SELECT * FROM my_table", + collection_id: null, + creator, + creator_id, + entity_id: "snippet_entity_id", + archived: false, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + ...opts, +}); diff --git a/frontend/src/metabase-types/api/snippets.ts b/frontend/src/metabase-types/api/snippets.ts new file mode 100644 index 0000000000000000000000000000000000000000..902b0f5baef5da49209c3cdab294dd55cccda16e --- /dev/null +++ b/frontend/src/metabase-types/api/snippets.ts @@ -0,0 +1,18 @@ +import { RegularCollectionId } from "./collection"; +import { User, UserId } from "./user"; + +export type NativeQuerySnippetId = number; + +export interface NativeQuerySnippet { + id: NativeQuerySnippetId; + name: string; + description: string | null; + content: string; + collection_id: RegularCollectionId | null; + creator_id: UserId; + creator: User; + archived: boolean; + entity_id: string; + created_at: string; + updated_at: string; +} diff --git a/frontend/src/metabase-types/store/entities.ts b/frontend/src/metabase-types/store/entities.ts index eed38d36f4064446b43931229e1de7361965a65c..a5ab03a7d5a8e60d3017cda717c96f068a172eee 100644 --- a/frontend/src/metabase-types/store/entities.ts +++ b/frontend/src/metabase-types/store/entities.ts @@ -4,6 +4,8 @@ import { DataApp, DataAppId, Database, + NativeQuerySnippet, + NativeQuerySnippetId, Table, } from "metabase-types/api"; @@ -12,4 +14,5 @@ export interface EntitiesState { dataApps?: Record<DataAppId, DataApp>; databases?: Record<number, Database>; tables?: Record<number | string, Table>; + snippets?: Record<NativeQuerySnippetId, NativeQuerySnippet>; } diff --git a/frontend/src/metabase/collections/containers/FormCollectionPicker/FormCollectionPicker.tsx b/frontend/src/metabase/collections/containers/FormCollectionPicker/FormCollectionPicker.tsx index 3f2cf29e399d0db3cd73e1d78b1fb9c4b8670129..6512044f2990ea32d1c66a7c0d34d4ca01e055ce 100644 --- a/frontend/src/metabase/collections/containers/FormCollectionPicker/FormCollectionPicker.tsx +++ b/frontend/src/metabase/collections/containers/FormCollectionPicker/FormCollectionPicker.tsx @@ -15,6 +15,10 @@ import SelectButton from "metabase/core/components/SelectButton"; import TippyPopoverWithTrigger from "metabase/components/PopoverWithTrigger/TippyPopoverWithTrigger"; import CollectionName from "metabase/containers/CollectionName"; +import SnippetCollectionName from "metabase/containers/SnippetCollectionName"; + +import Collections from "metabase/entities/collections"; +import SnippetCollections from "metabase/entities/snippet-collections"; import { isValidCollectionId } from "metabase/collections/utils"; @@ -30,16 +34,32 @@ export interface FormCollectionPickerProps name: string; title?: string; placeholder?: string; + type?: "collections" | "snippet-collections"; } const ITEM_PICKER_MODELS = ["collection"]; +function ItemName({ + id, + type = "collections", +}: { + id: CollectionId; + type?: "collections" | "snippet-collections"; +}) { + return type === "snippet-collections" ? ( + <SnippetCollectionName id={id} /> + ) : ( + <CollectionName id={id} /> + ); +} + function FormCollectionPicker({ className, style, name, title, placeholder = t`Select a collection`, + type = "collections", }: FormCollectionPickerProps) { const id = useUniqueId(); const [{ value }, { error, touched }, { setValue }] = useField(name); @@ -66,29 +86,38 @@ function FormCollectionPicker({ > <SelectButton onClick={handleShowPopover}> {isValidCollectionId(value) ? ( - <CollectionName id={value} /> + <ItemName id={value} type={type} /> ) : ( placeholder )} </SelectButton> </FormField> ), - [id, value, title, placeholder, error, touched, className, style], + [id, value, type, title, placeholder, error, touched, className, style], ); const renderContent = useCallback( - ({ closePopover }) => ( - <PopoverItemPicker - value={{ id: value, model: "collection" }} - models={ITEM_PICKER_MODELS} - onChange={({ id }: { id: CollectionId }) => { - setValue(id); - closePopover(); - }} - width={width} - /> - ), - [value, width, setValue], + ({ closePopover }) => { + // Search API doesn't support collection namespaces yet + const hasSearch = type === "collections"; + + const entity = type === "collections" ? Collections : SnippetCollections; + + return ( + <PopoverItemPicker + value={{ id: value, model: "collection" }} + models={ITEM_PICKER_MODELS} + entity={entity} + onChange={({ id }: { id: CollectionId }) => { + setValue(id); + closePopover(); + }} + showSearch={hasSearch} + width={width} + /> + ); + }, + [value, type, width, setValue], ); return ( diff --git a/frontend/src/metabase/collections/utils.ts b/frontend/src/metabase/collections/utils.ts index ddff06f13830b145f4d2f2123306ce82144ca5a3..e5d55bc16462d7ac9a1dbffa13e002fa78139164 100644 --- a/frontend/src/metabase/collections/utils.ts +++ b/frontend/src/metabase/collections/utils.ts @@ -54,7 +54,7 @@ export function isPersonalCollectionChild( return Boolean(parentCollection && !!parentCollection.personal_owner_id); } -export function isRootCollection(collection: Collection): boolean { +export function isRootCollection(collection: Pick<Collection, "id">): boolean { return canonicalCollectionId(collection.id) === null; } diff --git a/frontend/src/metabase/containers/SnippetCollectionName.tsx b/frontend/src/metabase/containers/SnippetCollectionName.tsx new file mode 100644 index 0000000000000000000000000000000000000000..51a1af3eaf05ff13ea08e9cfab09d45378a19ea7 --- /dev/null +++ b/frontend/src/metabase/containers/SnippetCollectionName.tsx @@ -0,0 +1,19 @@ +import React from "react"; +import { t } from "ttag"; + +import SnippetCollections from "metabase/entities/snippet-collections"; +import { isRootCollection } from "metabase/collections/utils"; + +import type { CollectionId } from "metabase-types/api"; + +function SnippetCollectionName({ id }: { id: CollectionId }) { + if (isRootCollection({ id })) { + return <span>{t`Top folder`}</span>; + } + if (!Number.isSafeInteger(id)) { + return null; + } + return <SnippetCollections.Name id={id} />; +} + +export default SnippetCollectionName; diff --git a/frontend/src/metabase/core/components/FormTextArea/FormTextArea.tsx b/frontend/src/metabase/core/components/FormTextArea/FormTextArea.tsx index bb1bb4b5bf489af42b73e33cab01eff75ac55657..00ac867cd867295ae2bb384de55769ab83eb7a61 100644 --- a/frontend/src/metabase/core/components/FormTextArea/FormTextArea.tsx +++ b/frontend/src/metabase/core/components/FormTextArea/FormTextArea.tsx @@ -73,4 +73,6 @@ const FormTextArea = forwardRef(function FormTextArea( ); }); -export default FormTextArea; +export default Object.assign(FormTextArea, { + Root: TextArea.Root, +}); diff --git a/frontend/src/metabase/core/components/TextArea/TextArea.tsx b/frontend/src/metabase/core/components/TextArea/TextArea.tsx index 8a9b8116596b7c623d30a665690e521b77bee2fa..56b4e561f104a4195aaceb3c2c59473b04d6020c 100644 --- a/frontend/src/metabase/core/components/TextArea/TextArea.tsx +++ b/frontend/src/metabase/core/components/TextArea/TextArea.tsx @@ -16,4 +16,6 @@ const TextArea = forwardRef(function TextArea( ); }); -export default TextArea; +export default Object.assign(TextArea, { + Root: TextAreaRoot, +}); diff --git a/frontend/src/metabase/entities/snippets.js b/frontend/src/metabase/entities/snippets.js index 4beb0b76dedf4e4b2b7c4d4e9863abdd785ffff8..955c8304c0cbc07a00abb1bd1f84fb9af5c8d344 100644 --- a/frontend/src/metabase/entities/snippets.js +++ b/frontend/src/metabase/entities/snippets.js @@ -1,34 +1,4 @@ -import { t } from "ttag"; - import { createEntity } from "metabase/lib/entities"; -import validate from "metabase/lib/validate"; -import { canonicalCollectionId } from "metabase/collections/utils"; - -const formFields = [ - { - name: "content", - title: t`Enter some SQL here so you can reuse it later`, - placeholder: "AND canceled_at IS null\nAND account_type = 'PAID'", - type: "text", - className: - "Form-input full text-monospace text-normal text-small bg-light text-spaced", - rows: 4, - autoFocus: true, - validate: validate.required().maxLength(10000), - }, - { - name: "name", - title: t`Give your snippet a name`, - placeholder: t`Current Customers`, - validate: validate.required().maxLength(100), - }, - { - name: "description", - title: t`Add a description`, - placeholder: t`It's optional but oh, so helpful`, - validate: validate.maxLength(500), - }, -]; const Snippets = createEntity({ name: "snippets", @@ -38,28 +8,6 @@ const Snippets = createEntity({ getFetched: (state, props) => getFetched(state, props) || getObject(state, props), }), - forms: { - withoutVisibleCollectionPicker: { - fields: [ - ...formFields, - { - name: "collection_id", - hidden: true, - }, - ], - }, - withVisibleCollectionPicker: { - fields: [ - ...formFields, - { - name: "collection_id", - title: t`Folder this should be in`, - type: "snippetCollection", - normalize: canonicalCollectionId, - }, - ], - }, - }, }); export default Snippets; diff --git a/frontend/src/metabase/query_builder/components/NativeQueryEditor.jsx b/frontend/src/metabase/query_builder/components/NativeQueryEditor.jsx index 0fc895a6ed72033c780766ea4323b73289505845..918db028e3d212842ed64eabe23338a89e17a598 100644 --- a/frontend/src/metabase/query_builder/components/NativeQueryEditor.jsx +++ b/frontend/src/metabase/query_builder/components/NativeQueryEditor.jsx @@ -26,10 +26,11 @@ import { isEventOverElement } from "metabase/lib/dom"; import { getEngineNativeAceMode } from "metabase/lib/engine"; import { SQLBehaviour } from "metabase/lib/ace/sql_behaviour"; import ExplicitSize from "metabase/components/ExplicitSize"; +import Modal from "metabase/components/Modal"; import Snippets from "metabase/entities/snippets"; import SnippetCollections from "metabase/entities/snippet-collections"; -import SnippetModal from "metabase/query_builder/components/template_tags/SnippetModal"; +import SnippetFormModal from "metabase/query_builder/components/template_tags/SnippetFormModal"; import Questions from "metabase/entities/questions"; import { CARD_TAG_REGEX } from "metabase-lib/queries/NativeQuery"; import { ResponsiveParametersList } from "./ResponsiveParametersList"; @@ -584,17 +585,20 @@ class NativeQueryEditor extends Component { /> {this.props.modalSnippet && ( - <SnippetModal - onSnippetUpdate={(newSnippet, oldSnippet) => { - if (newSnippet.name !== oldSnippet.name) { - setDatasetQuery(query.updateSnippetNames([newSnippet])); - } - }} - snippet={this.props.modalSnippet} - insertSnippet={this.props.insertSnippet} - closeModal={this.props.closeSnippetModal} - /> + <Modal onClose={this.props.closeSnippetModal}> + <SnippetFormModal + snippet={this.props.modalSnippet} + onCreate={this.props.insertSnippet} + onUpdate={(newSnippet, oldSnippet) => { + if (newSnippet.name !== oldSnippet.name) { + setDatasetQuery(query.updateSnippetNames([newSnippet])); + } + }} + onClose={this.props.closeSnippetModal} + /> + </Modal> )} + {hasEditingSidebar && !readOnly && ( <NativeQueryEditorSidebar runQuery={this.runQuery} diff --git a/frontend/src/metabase/query_builder/components/template_tags/SnippetForm/SnippetForm.styled.tsx b/frontend/src/metabase/query_builder/components/template_tags/SnippetForm/SnippetForm.styled.tsx new file mode 100644 index 0000000000000000000000000000000000000000..79300b12d18329cddb85822a87f5f1cd41774f31 --- /dev/null +++ b/frontend/src/metabase/query_builder/components/template_tags/SnippetForm/SnippetForm.styled.tsx @@ -0,0 +1,28 @@ +import styled from "@emotion/styled"; +import FormTextArea from "metabase/core/components/FormTextArea"; +import { color } from "metabase/lib/colors"; + +export const FormSnippetTextArea = styled(FormTextArea)` + ${FormTextArea.Root} { + width: 100%; + background-color: ${color("bg-light")}; + + font-family: Monaco, monospace; + font-size: 0.875em; + font-weight: 400; + line-height: 1.5em; + } +`; + +export const SnippetFormFooter = styled.div` + display: flex; + align-items: center; + justify-content: space-between; +`; + +export const SnippetFormFooterContent = styled.div` + display: flex; + align-items: center; + justify-content: flex-end; + gap: 0.5rem; +`; diff --git a/frontend/src/metabase/query_builder/components/template_tags/SnippetForm/SnippetForm.tsx b/frontend/src/metabase/query_builder/components/template_tags/SnippetForm/SnippetForm.tsx new file mode 100644 index 0000000000000000000000000000000000000000..0070c275b001a4a7d40a80e4169dd473e2b892f5 --- /dev/null +++ b/frontend/src/metabase/query_builder/components/template_tags/SnippetForm/SnippetForm.tsx @@ -0,0 +1,214 @@ +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 FormProvider from "metabase/core/components/FormProvider"; +import FormInput from "metabase/core/components/FormInput"; +import FormSubmitButton from "metabase/core/components/FormSubmitButton"; +import FormErrorMessage from "metabase/core/components/FormErrorMessage"; + +import * as Errors from "metabase/core/utils/errors"; + +import Snippets from "metabase/entities/snippets"; +import SnippetCollections from "metabase/entities/snippet-collections"; + +import FormCollectionPicker from "metabase/collections/containers/FormCollectionPicker"; +import { canonicalCollectionId } from "metabase/collections/utils"; + +import type { + Collection, + NativeQuerySnippet, + NativeQuerySnippetId, +} from "metabase-types/api"; + +import { + FormSnippetTextArea, + SnippetFormFooterContent, + SnippetFormFooter, +} from "./SnippetForm.styled"; + +const SNIPPET_SCHEMA = Yup.object({ + name: Yup.string() + .required(Errors.required) + .max(100, Errors.maxLength) + .default(""), + description: Yup.string().nullable().max(500, Errors.maxLength).default(null), + content: Yup.string() + .required(Errors.required) + .max(10000, Errors.maxLength) + .default(""), + collection_id: Yup.number().nullable().default(null), +}); + +type SnippetFormValues = Pick< + NativeQuerySnippet, + "name" | "description" | "content" | "collection_id" +>; + +type UpdateSnippetFormValues = Partial<SnippetFormValues> & + Pick<NativeQuerySnippet, "id"> & { + archived?: boolean; + }; + +export interface SnippetFormOwnProps { + snippet: Partial<NativeQuerySnippet>; + onCreate?: (snippet: NativeQuerySnippet) => void; + onUpdate?: ( + nextSnippet: NativeQuerySnippet, + originalSnippet: NativeQuerySnippet, + ) => void; + onArchive?: () => void; + onCancel?: () => void; +} + +interface SnippetLoaderProps { + snippetCollections: Collection[]; +} + +interface SnippetFormDispatchProps { + handleCreateSnippet: ( + snippet: SnippetFormValues, + ) => Promise<NativeQuerySnippet>; + handleUpdateSnippet: ( + snippet: UpdateSnippetFormValues, + ) => Promise<NativeQuerySnippet>; +} + +type SnippetFormProps = SnippetFormOwnProps & + SnippetLoaderProps & + SnippetFormDispatchProps; + +const mapDispatchToProps = { + handleCreateSnippet: Snippets.actions.create, + handleUpdateSnippet: Snippets.actions.update, +}; + +function SnippetForm({ + snippet, + snippetCollections, + handleCreateSnippet, + handleUpdateSnippet, + onCreate, + onUpdate, + onArchive, + onCancel, +}: SnippetFormProps) { + const isEditing = snippet.id != null; + const hasManyCollections = snippetCollections.length > 1; + + const initialValues = useMemo( + () => + SNIPPET_SCHEMA.cast( + { + ...snippet, + content: snippet.content || "", + parent_id: canonicalCollectionId(snippet.id), + }, + { stripUnknown: true }, + ), + [snippet], + ); + + const handleCreate = useCallback( + async (values: SnippetFormValues) => { + const action = await handleCreateSnippet(values); + const snippet = Snippets.HACK_getObjectFromAction(action); + onCreate?.(snippet); + }, + [handleCreateSnippet, onCreate], + ); + + const handleUpdate = useCallback( + async (values: UpdateSnippetFormValues) => { + const action = await handleUpdateSnippet(values); + const nextSnippet = Snippets.HACK_getObjectFromAction(action); + onUpdate?.(nextSnippet, snippet as NativeQuerySnippet); + }, + [snippet, handleUpdateSnippet, onUpdate], + ); + + const handleSubmit = useCallback( + async values => { + if (isEditing) { + await handleUpdate({ ...values, id: snippet.id }); + } else { + await handleCreate(values); + } + }, + [snippet.id, isEditing, handleCreate, handleUpdate], + ); + + const handleArchive = useCallback(async () => { + await handleUpdateSnippet({ + id: snippet.id as NativeQuerySnippetId, + archived: true, + }); + onArchive?.(); + }, [snippet.id, handleUpdateSnippet, onArchive]); + + return ( + <FormProvider + initialValues={initialValues} + validationSchema={SNIPPET_SCHEMA} + onSubmit={handleSubmit} + > + {({ dirty }) => ( + <Form disabled={!dirty}> + <FormSnippetTextArea + name="content" + title={t`Enter some SQL here so you can reuse it later`} + placeholder="AND canceled_at IS null\nAND account_type = 'PAID'" + autoFocus + rows={4} + /> + <FormInput + name="name" + title={t`Give your snippet a name`} + placeholder={t`Current Customers`} + /> + <FormInput + name="description" + title={t`Add a description`} + placeholder={t`It's optional but oh, so helpful`} + nullable + /> + {hasManyCollections && ( + <FormCollectionPicker + name="collection_id" + title={t`Folder this should be in`} + type="snippet-collections" + /> + )} + <SnippetFormFooter> + <SnippetFormFooterContent> + {isEditing && ( + <Button + type="button" + icon="archive" + borderless + onClick={handleArchive} + >{t`Archive`}</Button> + )} + <FormErrorMessage inline /> + </SnippetFormFooterContent> + <SnippetFormFooterContent> + {!!onCancel && ( + <Button type="button" onClick={onCancel}>{t`Cancel`}</Button> + )} + <FormSubmitButton title={t`Save`} disabled={!dirty} primary /> + </SnippetFormFooterContent> + </SnippetFormFooter> + </Form> + )} + </FormProvider> + ); +} + +export default _.compose( + SnippetCollections.loadList(), + connect(null, mapDispatchToProps), +)(SnippetForm); diff --git a/frontend/src/metabase/query_builder/components/template_tags/SnippetForm/index.ts b/frontend/src/metabase/query_builder/components/template_tags/SnippetForm/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..d87d24f678b5a6300e41d7b8968604811037a2e3 --- /dev/null +++ b/frontend/src/metabase/query_builder/components/template_tags/SnippetForm/index.ts @@ -0,0 +1,2 @@ +export { default } from "./SnippetForm"; +export type { SnippetFormOwnProps } from "./SnippetForm"; diff --git a/frontend/src/metabase/query_builder/components/template_tags/SnippetFormModal/SnippetFormModal.tsx b/frontend/src/metabase/query_builder/components/template_tags/SnippetFormModal/SnippetFormModal.tsx new file mode 100644 index 0000000000000000000000000000000000000000..136cd2114614e2fce9b7fab7d0f15ef1c94942b1 --- /dev/null +++ b/frontend/src/metabase/query_builder/components/template_tags/SnippetFormModal/SnippetFormModal.tsx @@ -0,0 +1,59 @@ +import React, { useCallback } from "react"; +import { t } from "ttag"; + +import ModalContent from "metabase/components/ModalContent"; + +import type { NativeQuerySnippet } from "metabase-types/api"; + +import SnippetForm, { SnippetFormOwnProps } from "../SnippetForm"; + +interface SnippetFormModalOwnProps + extends Omit<SnippetFormOwnProps, "onCancel"> { + onClose?: () => void; +} + +type SnippetModalProps = SnippetFormModalOwnProps; + +function SnippetFormModal({ + snippet, + onCreate, + onUpdate, + onClose, + ...props +}: SnippetModalProps) { + const isEditing = snippet.id != null; + const title = isEditing + ? t`Editing ${snippet.name}` + : t`Create your new snippet`; + + const handleCreate = useCallback( + (snippet: NativeQuerySnippet) => { + onCreate?.(snippet); + onClose?.(); + }, + [onCreate, onClose], + ); + + const handleUpdate = useCallback( + (nextSnippet: NativeQuerySnippet, originalSnippet: NativeQuerySnippet) => { + onUpdate?.(nextSnippet, originalSnippet); + onClose?.(); + }, + [onUpdate, onClose], + ); + + return ( + <ModalContent title={title} onClose={onClose}> + <SnippetForm + {...props} + snippet={snippet} + onCreate={handleCreate} + onUpdate={handleUpdate} + onArchive={onClose} + onCancel={onClose} + /> + </ModalContent> + ); +} + +export default SnippetFormModal; diff --git a/frontend/src/metabase/query_builder/components/template_tags/SnippetFormModal/SnippetFormModal.unit.spec.tsx b/frontend/src/metabase/query_builder/components/template_tags/SnippetFormModal/SnippetFormModal.unit.spec.tsx new file mode 100644 index 0000000000000000000000000000000000000000..9b13329d29b2bd2c22f53a90ed160d8924d0bc23 --- /dev/null +++ b/frontend/src/metabase/query_builder/components/template_tags/SnippetFormModal/SnippetFormModal.unit.spec.tsx @@ -0,0 +1,274 @@ +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 { setupEnterpriseTest } from "__support__/enterprise"; + +import type { NativeQuerySnippet } from "metabase-types/api"; +import { + createMockCollection, + createMockNativeQuerySnippet, +} from "metabase-types/api/mocks"; + +import SnippetFormModal from "./SnippetFormModal"; + +const TOP_SNIPPETS_FOLDER = { + id: "root", + name: "Top folder", + can_write: true, +}; + +type SetupOpts = { + snippet?: Partial<NativeQuerySnippet>; + onClose?: null | (() => void); + withDefaultFoldersList?: boolean; +}; + +async function setup({ + snippet = {}, + withDefaultFoldersList = true, + onClose = jest.fn(), +}: SetupOpts = {}) { + xhrMock.get("/api/collection/root?namespace=snippets", { + body: JSON.stringify(TOP_SNIPPETS_FOLDER), + }); + + if (withDefaultFoldersList) { + xhrMock.get("/api/collection?namespace=snippets", { + body: JSON.stringify([TOP_SNIPPETS_FOLDER]), + }); + } + + xhrMock.post("/api/native-query-snippet", (req, res) => + res.status(200).body(createMockNativeQuerySnippet(req.body())), + ); + + if (snippet.id) { + xhrMock.put(`/api/native-query-snippet/${snippet.id}`, (req, res) => + res.status(200).body(createMockNativeQuerySnippet(req.body())), + ); + } + + renderWithProviders( + <SnippetFormModal snippet={snippet} onClose={onClose || undefined} />, + ); + + await waitForElementToBeRemoved(() => screen.getByText(/Loading/i)); + + return { onClose }; +} + +function setupEditing({ + snippet = createMockNativeQuerySnippet(), + ...opts +}: SetupOpts = {}) { + return setup({ snippet, ...opts }); +} + +const LABEL = { + NAME: /Give your snippet a name/i, + DESCRIPTION: /Add a description/i, + CONTENT: /Enter some SQL here so you can reuse it later/i, + FOLDER: /Folder this should be in/i, +}; + +describe("SnippetFormModal", () => { + beforeEach(() => { + xhrMock.setup(); + }); + + afterEach(() => { + xhrMock.teardown(); + }); + + describe("new snippet", () => { + 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.getByLabelText(LABEL.CONTENT)).toBeInTheDocument(); + expect(screen.getByLabelText(LABEL.CONTENT)).toHaveValue(""); + + expect(screen.queryByText(LABEL.FOLDER)).not.toBeInTheDocument(); + + expect( + screen.getByRole("button", { name: "Cancel" }), + ).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Save" })).toBeInTheDocument(); + }); + + it("shows expected title", async () => { + await setup(); + expect(screen.getByText(/Create your new snippet/i)).toBeInTheDocument(); + }); + + it("shows folder picker if there are many folders", async () => { + xhrMock.get("/api/collection?namespace=snippets", { + body: JSON.stringify([TOP_SNIPPETS_FOLDER, createMockCollection()]), + }); + + await setup({ withDefaultFoldersList: false }); + + expect(screen.getByText(LABEL.FOLDER)).toBeInTheDocument(); + expect(screen.getByText(TOP_SNIPPETS_FOLDER.name)).toBeInTheDocument(); + }); + + it("can't submit if content is empty", async () => { + await setup(); + userEvent.type(screen.getByLabelText(LABEL.NAME), "My snippet"); + expect(screen.getByRole("button", { name: "Save" })).toBeDisabled(); + }); + + it("can't submit if name is empty", async () => { + await setup(); + userEvent.type( + screen.getByLabelText(LABEL.CONTENT), + "WHERE discount > 0", + ); + expect(screen.getByRole("button", { name: "Save" })).toBeDisabled(); + }); + + it("can submit with name and content", async () => { + await setup(); + + await act(async () => { + await userEvent.type(screen.getByLabelText(LABEL.NAME), "My snippet"); + await userEvent.type( + screen.getByLabelText(LABEL.CONTENT), + "WHERE discount > 0", + ); + }); + + expect(screen.getByRole("button", { name: "Save" })).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); + }); + + it("doesn't show the archive button", async () => { + await setup(); + expect(screen.queryByText("Archive")).not.toBeInTheDocument(); + }); + }); + + describe("editing snippet", () => { + it("shows correct initial state", async () => { + const snippet = createMockNativeQuerySnippet({ + name: "has name", + content: "has content", + description: "has description", + }); + await setupEditing({ snippet }); + + expect(screen.getByLabelText(LABEL.NAME)).toBeInTheDocument(); + expect(screen.getByLabelText(LABEL.NAME)).toHaveValue(snippet.name); + + expect(screen.getByLabelText(LABEL.DESCRIPTION)).toBeInTheDocument(); + expect(screen.getByLabelText(LABEL.DESCRIPTION)).toHaveValue( + snippet.description, + ); + + expect(screen.getByLabelText(LABEL.CONTENT)).toBeInTheDocument(); + expect(screen.getByLabelText(LABEL.CONTENT)).toHaveValue(snippet.content); + + expect(screen.queryByText(LABEL.FOLDER)).not.toBeInTheDocument(); + + expect( + screen.getByRole("button", { name: "Cancel" }), + ).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Save" })).toBeInTheDocument(); + }); + + it("shows expected title", async () => { + const snippet = createMockNativeQuerySnippet(); + await setupEditing({ snippet }); + expect(screen.getByText(`Editing ${snippet.name}`)).toBeInTheDocument(); + }); + + it("shows folder picker if there are many folders", async () => { + xhrMock.get("/api/collection?namespace=snippets", { + body: JSON.stringify([TOP_SNIPPETS_FOLDER, createMockCollection()]), + }); + + await setupEditing({ withDefaultFoldersList: false }); + + expect(screen.getByText(LABEL.FOLDER)).toBeInTheDocument(); + expect(screen.getByText(TOP_SNIPPETS_FOLDER.name)).toBeInTheDocument(); + }); + + it("can't submit until changes are made", async () => { + await setupEditing(); + expect(screen.getByRole("button", { name: "Save" })).toBeDisabled(); + }); + + it("can't submit if content is empty", async () => { + await setupEditing(); + await act(async () => { + await userEvent.clear(screen.getByLabelText(LABEL.NAME)); + }); + expect(screen.getByRole("button", { name: "Save" })).toBeDisabled(); + }); + + it("can't submit if name is empty", async () => { + await setupEditing(); + await act(async () => { + await userEvent.clear(screen.getByLabelText(LABEL.CONTENT)); + }); + expect(screen.getByRole("button", { name: "Save" })).toBeDisabled(); + }); + + it("can submit with name and content", async () => { + await setupEditing(); + + userEvent.type(screen.getByLabelText(LABEL.NAME), "My snippet"); + userEvent.type( + screen.getByLabelText(LABEL.CONTENT), + "WHERE discount > 0", + ); + + expect(screen.getByRole("button", { name: "Save" })).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); + }); + + it("closes the modal after archiving", async () => { + const { onClose } = await setupEditing(); + await act(async () => { + await userEvent.click(screen.getByText("Archive")); + }); + expect(onClose).toBeCalledTimes(1); + }); + }); +}); diff --git a/frontend/src/metabase/query_builder/components/template_tags/SnippetFormModal/index.ts b/frontend/src/metabase/query_builder/components/template_tags/SnippetFormModal/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..62db133c436052ece653d76edde975550328f619 --- /dev/null +++ b/frontend/src/metabase/query_builder/components/template_tags/SnippetFormModal/index.ts @@ -0,0 +1 @@ +export { default } from "./SnippetFormModal"; diff --git a/frontend/src/metabase/query_builder/components/template_tags/SnippetModal.jsx b/frontend/src/metabase/query_builder/components/template_tags/SnippetModal.jsx deleted file mode 100644 index 105780c52b5581c7e9b6804317e1a9d3a769482e..0000000000000000000000000000000000000000 --- a/frontend/src/metabase/query_builder/components/template_tags/SnippetModal.jsx +++ /dev/null @@ -1,68 +0,0 @@ -/* eslint-disable react/prop-types */ -import React from "react"; - -import { t } from "ttag"; - -import Icon from "metabase/components/Icon"; -import Modal from "metabase/components/Modal"; -import Link from "metabase/core/components/Link"; -import Snippets from "metabase/entities/snippets"; -import SnippetCollections from "metabase/entities/snippet-collections"; - -class SnippetModal extends React.Component { - render() { - const { - insertSnippet, - onSnippetUpdate, - closeModal, - snippet, - snippetCollections, - } = this.props; - - return ( - <Modal onClose={closeModal}> - <Snippets.ModalForm - snippet={snippet} - form={ - snippetCollections.length <= 1 - ? Snippets.forms.withoutVisibleCollectionPicker - : Snippets.forms.withVisibleCollectionPicker - } - title={ - snippet.id != null - ? t`Editing ${snippet.name}` - : t`Create your new snippet` - } - onSaved={savedSnippet => { - if (snippet.id == null) { - insertSnippet(savedSnippet); - } else { - // this will update the query if the name changed - onSnippetUpdate(savedSnippet, snippet); - } - closeModal(); - }} - onClose={closeModal} // the "x" button - submitTitle={t`Save`} - footerExtraButtons={ - // only display archive for saved snippets - snippet.id != null ? ( - <Link - onClick={async () => { - await snippet.update({ archived: true }); - closeModal(); - }} - className="flex align-center text-medium text-bold" - > - <Icon name="archive" className="mr1" /> - {t`Archive`} - </Link> - ) : null - } - /> - </Modal> - ); - } -} - -export default SnippetCollections.loadList()(SnippetModal);