From d29867e0845ebbb296fc10878471c5f367e67b64 Mon Sep 17 00:00:00 2001 From: Kamil Mielnik <kamil@kamilmielnik.com> Date: Fri, 6 Oct 2023 17:33:47 +0700 Subject: [PATCH] Add custom warning message when leaving changing Data Model (segments and metrics) (#34398) * Rename component to match the filename * Fix typo * Replace useBeforeUnload with LeaveConfirmationModal in SegmentForm * Replace useBeforeUnload with LeaveConfirmationModal in MetricForm * Update setup of MetricForm tests to allow testing navigation * Update setup of SegmentForm tests to allow testing navigation * Use proper history in MetricForm * Refactor SegmentForm test setup to be consistent with the one of MetricForm * Make MetricForm and SegmentForm tests consistent * Add tests for not showing custom warning * Add tests for showing custom warning when leaving with unsaved changes * Extract FORM_URL * Remove redundant asyncs and awaits * Rename MetricForm.unit.spec.tsx & SegmentForm.unit.spec.tsx to MetricApp.unit.spec.tsx & SegmentApp.unit.spec.tsx and move them near the components they're testing * Improve a11y * Add missing endpoint mocks * Improve selectors thanks to proper a11y * Add tests for not showing warning modal when saving changes * Convert CreateMetricForm to a functional component * Convert CreateSegmentForm to a functional component * Convert UpdateMetricFormInner to a functional component * Convert UpdateSegmentFormInner to a functional component * Disable LeaveConfirmationModal on scheduled navigation * Setup metrics & segments POST endpoints * Properly mock metric and segment creation * Lift LeaveConfirmationModal from SegmentForm to SegmentApp * Lift LeaveConfirmationModal from MetricForm to MetricApp * Sort props * Get rid of useCallbackEffect usage in MetricApp * Get rid of useCallbackEffect usage in SegmentApp --- .../src/metabase-types/api/mocks/table.ts | 2 + .../components/FormLabel/FormLabel.tsx | 12 +- .../components/MetricForm/MetricForm.tsx | 14 +- .../MetricForm/MetricForm.unit.spec.tsx | 57 ------- .../components/SegmentForm/SegmentForm.tsx | 14 +- .../SegmentForm/SegmentForm.unit.spec.tsx | 60 ------- .../admin/datamodel/containers/MetricApp.jsx | 114 ++++++++----- .../containers/MetricApp.unit.spec.tsx | 157 ++++++++++++++++++ .../admin/datamodel/containers/SegmentApp.jsx | 114 ++++++++----- .../containers/SegmentApp.unit.spec.tsx | 157 ++++++++++++++++++ .../StatusListing/StatusListing.tsx | 4 +- .../test/__support__/server-mocks/metric.ts | 7 +- .../test/__support__/server-mocks/segment.ts | 7 +- 13 files changed, 517 insertions(+), 202 deletions(-) delete mode 100644 frontend/src/metabase/admin/datamodel/components/MetricForm/MetricForm.unit.spec.tsx delete mode 100644 frontend/src/metabase/admin/datamodel/components/SegmentForm/SegmentForm.unit.spec.tsx create mode 100644 frontend/src/metabase/admin/datamodel/containers/MetricApp.unit.spec.tsx create mode 100644 frontend/src/metabase/admin/datamodel/containers/SegmentApp.unit.spec.tsx diff --git a/frontend/src/metabase-types/api/mocks/table.ts b/frontend/src/metabase-types/api/mocks/table.ts index 8ce4f5e5aad..16e59f6b579 100644 --- a/frontend/src/metabase-types/api/mocks/table.ts +++ b/frontend/src/metabase-types/api/mocks/table.ts @@ -12,6 +12,8 @@ export const createMockTable = (opts?: Partial<Table>): Table => { visibility_type: null, field_order: "database", initial_sync_status: "complete", + metrics: [], + segments: [], ...opts, }; }; diff --git a/frontend/src/metabase/admin/datamodel/components/FormLabel/FormLabel.tsx b/frontend/src/metabase/admin/datamodel/components/FormLabel/FormLabel.tsx index 9c7e06a333f..f87d47949dc 100644 --- a/frontend/src/metabase/admin/datamodel/components/FormLabel/FormLabel.tsx +++ b/frontend/src/metabase/admin/datamodel/components/FormLabel/FormLabel.tsx @@ -1,4 +1,9 @@ -import type { HTMLAttributes, ReactNode, Ref } from "react"; +import type { + HTMLAttributes, + LabelHTMLAttributes, + ReactNode, + Ref, +} from "react"; import { forwardRef } from "react"; import { FormLabelContent, @@ -8,19 +13,20 @@ import { } from "./FormLabel.styled"; interface FormLabelProps extends HTMLAttributes<HTMLDivElement> { + htmlFor?: LabelHTMLAttributes<HTMLLabelElement>["htmlFor"]; title?: string; description?: string; children?: ReactNode; } const FormLabel = forwardRef(function FormLabel( - { title, description, children, ...props }: FormLabelProps, + { htmlFor, title, description, children, ...props }: FormLabelProps, ref: Ref<HTMLDivElement>, ) { return ( <FormLabelRoot {...props} ref={ref}> <FormLabelContent> - {title && <FormLabelTitle>{title}</FormLabelTitle>} + {title && <FormLabelTitle htmlFor={htmlFor}>{title}</FormLabelTitle>} {description && ( <FormLabelDescription>{description}</FormLabelDescription> )} diff --git a/frontend/src/metabase/admin/datamodel/components/MetricForm/MetricForm.tsx b/frontend/src/metabase/admin/datamodel/components/MetricForm/MetricForm.tsx index 598bbd96621..98e6b47365d 100644 --- a/frontend/src/metabase/admin/datamodel/components/MetricForm/MetricForm.tsx +++ b/frontend/src/metabase/admin/datamodel/components/MetricForm/MetricForm.tsx @@ -1,3 +1,4 @@ +import { useEffect } from "react"; import { Link } from "react-router"; import { useFormik } from "formik"; import type { FieldInputProps } from "formik"; @@ -6,7 +7,6 @@ import { formatValue } from "metabase/lib/formatting"; import Button from "metabase/core/components/Button"; import FieldSet from "metabase/components/FieldSet"; import type { Metric, StructuredQuery } from "metabase-types/api"; -import useBeforeUnload from "metabase/hooks/use-before-unload"; import * as Q from "metabase-lib/queries/utils/query"; import FormInput from "../FormInput"; import FormLabel from "../FormLabel"; @@ -31,6 +31,7 @@ export interface MetricFormProps { metric?: Metric; previewSummary?: string; updatePreviewSummary: (previewSummary: string) => void; + onIsDirtyChange: (isDirty: boolean) => void; onSubmit: (values: Partial<Metric>) => void; } @@ -38,6 +39,7 @@ const MetricForm = ({ metric, previewSummary, updatePreviewSummary, + onIsDirtyChange, onSubmit, }: MetricFormProps): JSX.Element => { const isNew = metric == null; @@ -50,7 +52,9 @@ const MetricForm = ({ onSubmit, }); - useBeforeUnload(dirty); + useEffect(() => { + onIsDirtyChange(dirty); + }, [dirty, onIsDirtyChange]); return ( <FormRoot onSubmit={handleSubmit}> @@ -73,33 +77,39 @@ const MetricForm = ({ </FormLabel> <FormBodyContent> <FormLabel + htmlFor="name" title={t`Name Your Metric`} description={t`Give your metric a name to help others find it.`} > <FormInput {...getFieldProps("name")} {...getFieldMeta("name")} + id="name" placeholder={t`Something descriptive but not too long`} /> </FormLabel> <FormLabel + htmlFor="description" title={t`Describe Your Metric`} description={t`Give your metric a description to help others understand what it's about.`} > <FormTextArea {...getFieldProps("description")} {...getFieldMeta("description")} + id="description" placeholder={t`This is a good place to be more specific about less obvious metric rules`} /> </FormLabel> {!isNew && ( <FieldSet legend={t`Reason For Changes`} noPadding={false}> <FormLabel + htmlFor="revision_message" description={t`Leave a note to explain what changes you made and why they were required.`} > <FormTextArea {...getFieldProps("revision_message")} {...getFieldMeta("revision_message")} + id="revision_message" placeholder={t`This will show up in the revision history for this metric to help everyone remember why things changed`} /> </FormLabel> diff --git a/frontend/src/metabase/admin/datamodel/components/MetricForm/MetricForm.unit.spec.tsx b/frontend/src/metabase/admin/datamodel/components/MetricForm/MetricForm.unit.spec.tsx deleted file mode 100644 index ed1a83d4f15..00000000000 --- a/frontend/src/metabase/admin/datamodel/components/MetricForm/MetricForm.unit.spec.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import userEvent from "@testing-library/user-event"; -import { renderWithProviders, screen, waitFor } from "__support__/ui"; -import MetricApp from "metabase/admin/datamodel/containers/MetricApp"; -import { Route } from "metabase/hoc/Title"; -import { callMockEvent } from "__support__/events"; -import { BEFORE_UNLOAD_UNSAVED_MESSAGE } from "metabase/hooks/use-before-unload"; -import { - setupDatabasesEndpoints, - setupSearchEndpoints, -} from "__support__/server-mocks"; -import { createMockDatabase } from "metabase-types/api/mocks"; - -const setup = () => { - setupDatabasesEndpoints([createMockDatabase()]); - setupSearchEndpoints([]); - renderWithProviders( - <Route path="/admin/datamodel/metric/create" component={MetricApp} />, - { - initialRoute: "/admin/datamodel/metric/create", - withRouter: true, - }, - ); - - const mockEventListener = jest.spyOn(window, "addEventListener"); - - return { mockEventListener }; -}; - -describe("MetricForm", () => { - afterEach(() => { - jest.restoreAllMocks(); - }); - - it("should have beforeunload event when user makes edits to a metric", async () => { - const { mockEventListener } = setup(); - - const descriptionInput = screen.getByPlaceholderText( - "Something descriptive but not too long", - ); - userEvent.type(descriptionInput, "01189998819991197253"); - - const mockEvent = await waitFor(() => { - return callMockEvent(mockEventListener, "beforeunload"); - }); - expect(mockEvent.preventDefault).toHaveBeenCalled(); - expect(mockEvent.returnValue).toBe(BEFORE_UNLOAD_UNSAVED_MESSAGE); - }); - - it("should not have an beforeunload event when metric is unedited", async () => { - const { mockEventListener } = setup(); - - const mockEvent = callMockEvent(mockEventListener, "beforeunload"); - - expect(mockEvent.preventDefault).not.toHaveBeenCalled(); - expect(mockEvent.returnValue).toBe(undefined); - }); -}); diff --git a/frontend/src/metabase/admin/datamodel/components/SegmentForm/SegmentForm.tsx b/frontend/src/metabase/admin/datamodel/components/SegmentForm/SegmentForm.tsx index 259b781ab71..2a975752441 100644 --- a/frontend/src/metabase/admin/datamodel/components/SegmentForm/SegmentForm.tsx +++ b/frontend/src/metabase/admin/datamodel/components/SegmentForm/SegmentForm.tsx @@ -1,3 +1,4 @@ +import { useEffect } from "react"; import { Link } from "react-router"; import { useFormik } from "formik"; import type { FieldInputProps } from "formik"; @@ -6,7 +7,6 @@ import { formatValue } from "metabase/lib/formatting"; import Button from "metabase/core/components/Button/Button"; import FieldSet from "metabase/components/FieldSet"; import type { Segment, StructuredQuery } from "metabase-types/api"; -import useBeforeUnload from "metabase/hooks/use-before-unload"; import * as Q from "metabase-lib/queries/utils/query"; import FormInput from "../FormInput"; import FormLabel from "../FormLabel"; @@ -30,6 +30,7 @@ export interface SegmentFormProps { segment?: Segment; previewSummary?: string; updatePreviewSummary: (previewSummary: string) => void; + onIsDirtyChange: (isDirty: boolean) => void; onSubmit: (values: Partial<Segment>) => void; } @@ -37,6 +38,7 @@ const SegmentForm = ({ segment, previewSummary, updatePreviewSummary, + onIsDirtyChange, onSubmit, }: SegmentFormProps): JSX.Element => { const isNew = segment == null; @@ -49,7 +51,9 @@ const SegmentForm = ({ onSubmit, }); - useBeforeUnload(dirty); + useEffect(() => { + onIsDirtyChange(dirty); + }, [dirty, onIsDirtyChange]); return ( <FormRoot onSubmit={handleSubmit}> @@ -72,33 +76,39 @@ const SegmentForm = ({ </FormLabel> <FormBodyContent> <FormLabel + htmlFor="name" title={t`Name Your Segment`} description={t`Give your segment a name to help others find it.`} > <FormInput {...getFieldProps("name")} {...getFieldMeta("name")} + id="name" placeholder={t`Something descriptive but not too long`} /> </FormLabel> <FormLabel + htmlFor="description" title={t`Describe Your Segment`} description={t`Give your segment a description to help others understand what it's about.`} > <FormTextArea {...getFieldProps("description")} {...getFieldMeta("description")} + id="description" placeholder={t`This is a good place to be more specific about less obvious segment rules`} /> </FormLabel> {!isNew && ( <FieldSet legend={t`Reason For Changes`} noPadding={false}> <FormLabel + htmlFor="revision_message" description={t`Leave a note to explain what changes you made and why they were required.`} > <FormTextArea {...getFieldProps("revision_message")} {...getFieldMeta("revision_message")} + id="revision_message" placeholder={t`This will show up in the revision history for this segment to help everyone remember why things changed`} /> </FormLabel> diff --git a/frontend/src/metabase/admin/datamodel/components/SegmentForm/SegmentForm.unit.spec.tsx b/frontend/src/metabase/admin/datamodel/components/SegmentForm/SegmentForm.unit.spec.tsx deleted file mode 100644 index 323995f53dd..00000000000 --- a/frontend/src/metabase/admin/datamodel/components/SegmentForm/SegmentForm.unit.spec.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import userEvent from "@testing-library/user-event"; -import { renderWithProviders, screen, waitFor } from "__support__/ui"; -import { Route } from "metabase/hoc/Title"; -import { callMockEvent } from "__support__/events"; -import { BEFORE_UNLOAD_UNSAVED_MESSAGE } from "metabase/hooks/use-before-unload"; -import SegmentApp from "metabase/admin/datamodel/containers/SegmentApp"; -import { - setupDatabasesEndpoints, - setupSearchEndpoints, -} from "__support__/server-mocks"; -import { createSampleDatabase } from "metabase-types/api/mocks/presets/sample_database"; - -const setup = () => { - renderWithProviders( - <Route path="/admin/datamodel/segment/create" component={SegmentApp} />, - { - initialRoute: "/admin/datamodel/segment/create", - withRouter: true, - }, - ); - - const mockEventListener = jest.spyOn(window, "addEventListener"); - - return { mockEventListener }; -}; - -describe("SegmentForm", () => { - beforeEach(() => { - setupDatabasesEndpoints([createSampleDatabase()]); - setupSearchEndpoints([]); - }); - - afterEach(() => { - jest.restoreAllMocks(); - }); - - it("should have beforeunload event when user makes edits to a segment", async () => { - const { mockEventListener } = setup(); - - const descriptionInput = screen.getByPlaceholderText( - "Something descriptive but not too long", - ); - userEvent.type(descriptionInput, "01189998819991197253"); - - const mockEvent = await waitFor(() => { - return callMockEvent(mockEventListener, "beforeunload"); - }); - expect(mockEvent.preventDefault).toHaveBeenCalled(); - expect(mockEvent.returnValue).toBe(BEFORE_UNLOAD_UNSAVED_MESSAGE); - }); - - it("should not have an beforeunload event when metric is segment", async () => { - const { mockEventListener } = setup(); - - const mockEvent = callMockEvent(mockEventListener, "beforeunload"); - - expect(mockEvent.preventDefault).not.toHaveBeenCalled(); - expect(mockEvent.returnValue).toBe(undefined); - }); -}); diff --git a/frontend/src/metabase/admin/datamodel/containers/MetricApp.jsx b/frontend/src/metabase/admin/datamodel/containers/MetricApp.jsx index 195f1c2b6ce..c8a778038bf 100644 --- a/frontend/src/metabase/admin/datamodel/containers/MetricApp.jsx +++ b/frontend/src/metabase/admin/datamodel/containers/MetricApp.jsx @@ -1,10 +1,11 @@ /* eslint-disable react/prop-types */ -import { Component } from "react"; +import { useCallback, useState } from "react"; import { connect } from "react-redux"; import { push } from "react-router-redux"; import * as MetabaseAnalytics from "metabase/lib/analytics"; import Metrics from "metabase/entities/metrics"; +import { LeaveConfirmationModal } from "metabase/components/LeaveConfirmationModal"; import { updatePreviewSummary } from "../datamodel"; import { getPreviewSummary } from "../selectors"; @@ -21,52 +22,91 @@ const mapStateToProps = (state, props) => ({ previewSummary: getPreviewSummary(state), }); -class UpdateMetricFormInner extends Component { - onSubmit = async metric => { - await this.props.updateMetric(metric); - MetabaseAnalytics.trackStructEvent("Data Model", "Metric Updated"); - this.props.onChangeLocation(`/admin/datamodel/metrics`); - }; +const UpdateMetricFormInner = ({ + route, + metric, + updateMetric, + onChangeLocation, + ...props +}) => { + const [isDirty, setIsDirty] = useState(false); - render() { - const { metric, ...props } = this.props; - return ( + const handleSubmit = useCallback( + async metric => { + setIsDirty(false); + + try { + await updateMetric(metric); + MetabaseAnalytics.trackStructEvent("Data Model", "Metric Updated"); + onChangeLocation("/admin/datamodel/metrics"); + } catch (error) { + setIsDirty(isDirty); + } + }, + [updateMetric, isDirty, onChangeLocation], + ); + + return ( + <> <MetricForm {...props} metric={metric.getPlainObject()} - onSubmit={this.onSubmit} + onIsDirtyChange={setIsDirty} + onSubmit={handleSubmit} /> - ); - } -} + <LeaveConfirmationModal isEnabled={isDirty} route={route} /> + </> + ); +}; const UpdateMetricForm = Metrics.load({ id: (state, props) => parseInt(props.params.id), })(UpdateMetricFormInner); -class CreateMetricForm extends Component { - onSubmit = async metric => { - await this.props.createMetric({ - ...metric, - table_id: metric.definition["source-table"], - }); - MetabaseAnalytics.trackStructEvent("Data Model", "Metric Updated"); - this.props.onChangeLocation(`/admin/datamodel/metrics`); - }; - - render() { - return <MetricForm {...this.props} onSubmit={this.onSubmit} />; - } -} - -class MetricApp extends Component { - render() { - return this.props.params.id ? ( - <UpdateMetricForm {...this.props} /> - ) : ( - <CreateMetricForm {...this.props} /> - ); +const CreateMetricForm = ({ + route, + createMetric, + onChangeLocation, + ...props +}) => { + const [isDirty, setIsDirty] = useState(false); + + const handleSubmit = useCallback( + async metric => { + setIsDirty(false); + + try { + await createMetric({ + ...metric, + table_id: metric.definition["source-table"], + }); + MetabaseAnalytics.trackStructEvent("Data Model", "Metric Updated"); + onChangeLocation("/admin/datamodel/metrics"); + } catch (error) { + setIsDirty(isDirty); + } + }, + [createMetric, isDirty, onChangeLocation], + ); + + return ( + <> + <MetricForm + {...props} + onIsDirtyChange={setIsDirty} + onSubmit={handleSubmit} + /> + <LeaveConfirmationModal isEnabled={isDirty} route={route} /> + </> + ); +}; + +const MetricApp = props => { + if (props.params.id) { + return <UpdateMetricForm {...props} />; } -} + + return <CreateMetricForm {...props} />; +}; export default connect(mapStateToProps, mapDispatchToProps)(MetricApp); diff --git a/frontend/src/metabase/admin/datamodel/containers/MetricApp.unit.spec.tsx b/frontend/src/metabase/admin/datamodel/containers/MetricApp.unit.spec.tsx new file mode 100644 index 00000000000..c52a6b593d6 --- /dev/null +++ b/frontend/src/metabase/admin/datamodel/containers/MetricApp.unit.spec.tsx @@ -0,0 +1,157 @@ +import userEvent from "@testing-library/user-event"; + +import { callMockEvent } from "__support__/events"; +import { + setupCardDataset, + setupDatabasesEndpoints, + setupMetricsEndpoints, + setupSearchEndpoints, +} from "__support__/server-mocks"; +import { renderWithProviders, screen, waitFor } from "__support__/ui"; +import { createSampleDatabase } from "metabase-types/api/mocks/presets"; +import { checkNotNull } from "metabase/core/utils/types"; +import { Route } from "metabase/hoc/Title"; +import { BEFORE_UNLOAD_UNSAVED_MESSAGE } from "metabase/hooks/use-before-unload"; + +import MetricApp from "./MetricApp"; + +const TestHome = () => <div />; + +const METRICS_URL = "/admin/datamodel/metrics"; +const FORM_URL = "/admin/datamodel/metric/create"; + +interface SetupOpts { + initialRoute?: string; +} + +const setup = ({ initialRoute = FORM_URL }: SetupOpts = {}) => { + setupDatabasesEndpoints([createSampleDatabase()]); + setupSearchEndpoints([]); + setupCardDataset(); + setupMetricsEndpoints([]); + + const { history } = renderWithProviders( + <> + <Route path="/" component={TestHome} /> + <Route path={METRICS_URL} component={TestHome} /> + <Route path={FORM_URL} component={MetricApp} /> + </>, + { + initialRoute, + withRouter: true, + }, + ); + + const mockEventListener = jest.spyOn(window, "addEventListener"); + + return { + history: checkNotNull(history), + mockEventListener, + }; +}; + +describe("MetricApp", () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("should have beforeunload event when user makes edits to a metric", async () => { + const { mockEventListener } = setup(); + + userEvent.type(screen.getByLabelText("Name Your Metric"), "Name"); + + const mockEvent = await waitFor(() => { + return callMockEvent(mockEventListener, "beforeunload"); + }); + expect(mockEvent.preventDefault).toHaveBeenCalled(); + expect(mockEvent.returnValue).toBe(BEFORE_UNLOAD_UNSAVED_MESSAGE); + }); + + it("should not have an beforeunload event when metric is unedited", () => { + const { mockEventListener } = setup(); + + const mockEvent = callMockEvent(mockEventListener, "beforeunload"); + + expect(mockEvent.preventDefault).not.toHaveBeenCalled(); + expect(mockEvent.returnValue).toBe(undefined); + }); + + it("does not show custom warning modal when leaving with no changes via SPA navigation", () => { + const { history } = setup({ initialRoute: "/" }); + + history.push(FORM_URL); + + history.goBack(); + + expect( + screen.queryByText("Changes were not saved"), + ).not.toBeInTheDocument(); + expect( + screen.queryByText( + "Navigating away from here will cause you to lose any changes you have made.", + ), + ).not.toBeInTheDocument(); + }); + + it("shows custom warning modal when leaving with unsaved changes via SPA navigation", () => { + const { history } = setup({ initialRoute: "/" }); + + history.push(FORM_URL); + + userEvent.type(screen.getByLabelText("Name Your Metric"), "Name"); + + history.goBack(); + + expect(screen.getByText("Changes were not saved")).toBeInTheDocument(); + expect( + screen.getByText( + "Navigating away from here will cause you to lose any changes you have made.", + ), + ).toBeInTheDocument(); + }); + + it("does not show custom warning modal when saving changes", async () => { + const { history } = setup(); + + userEvent.click(screen.getByText("Select a table")); + + await waitFor(() => { + expect(screen.queryByTestId("loading-spinner")).not.toBeInTheDocument(); + }); + + userEvent.click(screen.getByText("Orders")); + + await waitFor(() => { + expect(screen.queryByTestId("loading-spinner")).not.toBeInTheDocument(); + }); + + userEvent.click(screen.getByText("Add filters to narrow your answer")); + userEvent.click(screen.getByText("ID")); + userEvent.type(screen.getByPlaceholderText("Enter an ID"), "1"); + userEvent.click(screen.getByText("Add filter")); + userEvent.type(screen.getByLabelText("Name Your Metric"), "Name"); + userEvent.type( + screen.getByLabelText("Describe Your Metric"), + "Description", + ); + + await waitFor(() => { + expect(screen.getByText("Save changes")).toBeEnabled(); + }); + + userEvent.click(screen.getByText("Save changes")); + + await waitFor(() => { + expect(history.getCurrentLocation().pathname).toBe(METRICS_URL); + }); + + expect( + screen.queryByText("Changes were not saved"), + ).not.toBeInTheDocument(); + expect( + screen.queryByText( + "Navigating away from here will cause you to lose any changes you have made.", + ), + ).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/src/metabase/admin/datamodel/containers/SegmentApp.jsx b/frontend/src/metabase/admin/datamodel/containers/SegmentApp.jsx index dcdcab222dc..e605d3fef4c 100644 --- a/frontend/src/metabase/admin/datamodel/containers/SegmentApp.jsx +++ b/frontend/src/metabase/admin/datamodel/containers/SegmentApp.jsx @@ -1,10 +1,11 @@ /* eslint-disable react/prop-types */ -import { Component } from "react"; +import { useCallback, useState } from "react"; import { connect } from "react-redux"; import { push } from "react-router-redux"; import * as MetabaseAnalytics from "metabase/lib/analytics"; import Segments from "metabase/entities/segments"; +import { LeaveConfirmationModal } from "metabase/components/LeaveConfirmationModal"; import { updatePreviewSummary } from "../datamodel"; import { getPreviewSummary } from "../selectors"; @@ -21,52 +22,91 @@ const mapStateToProps = (state, props) => ({ previewSummary: getPreviewSummary(state), }); -class UpdateSegmentFormInner extends Component { - onSubmit = async segment => { - await this.props.updateSegment(segment); - MetabaseAnalytics.trackStructEvent("Data Model", "Segment Updated"); - this.props.onChangeLocation(`/admin/datamodel/segments`); - }; +const UpdateSegmentFormInner = ({ + route, + segment, + updateSegment, + onChangeLocation, + ...props +}) => { + const [isDirty, setIsDirty] = useState(false); - render() { - const { segment, ...props } = this.props; - return ( + const handleSubmit = useCallback( + async segment => { + setIsDirty(false); + + try { + await updateSegment(segment); + MetabaseAnalytics.trackStructEvent("Data Model", "Segment Updated"); + onChangeLocation("/admin/datamodel/segments"); + } catch (error) { + setIsDirty(isDirty); + } + }, + [updateSegment, isDirty, onChangeLocation], + ); + + return ( + <> <SegmentForm {...props} segment={segment.getPlainObject()} - onSubmit={this.onSubmit} + onIsDirtyChange={setIsDirty} + onSubmit={handleSubmit} /> - ); - } -} + <LeaveConfirmationModal isEnabled={isDirty} route={route} /> + </> + ); +}; const UpdateSegmentForm = Segments.load({ id: (state, props) => parseInt(props.params.id), })(UpdateSegmentFormInner); -class CreateSegmentForm extends Component { - onSubmit = async segment => { - await this.props.createSegment({ - ...segment, - table_id: segment.definition["source-table"], - }); - MetabaseAnalytics.trackStructEvent("Data Model", "Segment Updated"); - this.props.onChangeLocation(`/admin/datamodel/segments`); - }; - - render() { - return <SegmentForm {...this.props} onSubmit={this.onSubmit} />; - } -} - -class SegmentApp extends Component { - render() { - return this.props.params.id ? ( - <UpdateSegmentForm {...this.props} /> - ) : ( - <CreateSegmentForm {...this.props} /> - ); +const CreateSegmentForm = ({ + route, + createSegment, + onChangeLocation, + ...props +}) => { + const [isDirty, setIsDirty] = useState(false); + + const handleSubmit = useCallback( + async segment => { + setIsDirty(false); + + try { + await createSegment({ + ...segment, + table_id: segment.definition["source-table"], + }); + MetabaseAnalytics.trackStructEvent("Data Model", "Segment Updated"); + onChangeLocation("/admin/datamodel/segments"); + } catch (error) { + setIsDirty(isDirty); + } + }, + [createSegment, isDirty, onChangeLocation], + ); + + return ( + <> + <SegmentForm + {...props} + onIsDirtyChange={setIsDirty} + onSubmit={handleSubmit} + /> + <LeaveConfirmationModal isEnabled={isDirty} route={route} /> + </> + ); +}; + +const SegmentApp = props => { + if (props.params.id) { + return <UpdateSegmentForm {...props} />; } -} + + return <CreateSegmentForm {...props} />; +}; export default connect(mapStateToProps, mapDispatchToProps)(SegmentApp); diff --git a/frontend/src/metabase/admin/datamodel/containers/SegmentApp.unit.spec.tsx b/frontend/src/metabase/admin/datamodel/containers/SegmentApp.unit.spec.tsx new file mode 100644 index 00000000000..71c3c5046c9 --- /dev/null +++ b/frontend/src/metabase/admin/datamodel/containers/SegmentApp.unit.spec.tsx @@ -0,0 +1,157 @@ +import userEvent from "@testing-library/user-event"; + +import { callMockEvent } from "__support__/events"; +import { + setupCardDataset, + setupDatabasesEndpoints, + setupSearchEndpoints, + setupSegmentsEndpoints, +} from "__support__/server-mocks"; +import { renderWithProviders, screen, waitFor } from "__support__/ui"; +import { createSampleDatabase } from "metabase-types/api/mocks/presets"; +import { checkNotNull } from "metabase/core/utils/types"; +import { Route } from "metabase/hoc/Title"; +import { BEFORE_UNLOAD_UNSAVED_MESSAGE } from "metabase/hooks/use-before-unload"; + +import SegmentApp from "./SegmentApp"; + +const TestHome = () => <div />; + +const SEGMENTS_URL = "/admin/datamodel/segments"; +const FORM_URL = "/admin/datamodel/segment/create"; + +interface SetupOpts { + initialRoute?: string; +} + +const setup = ({ initialRoute = FORM_URL }: SetupOpts = {}) => { + setupDatabasesEndpoints([createSampleDatabase()]); + setupSearchEndpoints([]); + setupCardDataset(); + setupSegmentsEndpoints([]); + + const { history } = renderWithProviders( + <> + <Route path="/" component={TestHome} /> + <Route path={SEGMENTS_URL} component={TestHome} /> + <Route path={FORM_URL} component={SegmentApp} /> + </>, + { + initialRoute, + withRouter: true, + }, + ); + + const mockEventListener = jest.spyOn(window, "addEventListener"); + + return { + history: checkNotNull(history), + mockEventListener, + }; +}; + +describe("SegmentApp", () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("should have beforeunload event when user makes edits to a segment", async () => { + const { mockEventListener } = setup(); + + userEvent.type(screen.getByLabelText("Name Your Segment"), "Name"); + + const mockEvent = await waitFor(() => { + return callMockEvent(mockEventListener, "beforeunload"); + }); + expect(mockEvent.preventDefault).toHaveBeenCalled(); + expect(mockEvent.returnValue).toBe(BEFORE_UNLOAD_UNSAVED_MESSAGE); + }); + + it("should not have an beforeunload event when segment is unedited", () => { + const { mockEventListener } = setup(); + + const mockEvent = callMockEvent(mockEventListener, "beforeunload"); + + expect(mockEvent.preventDefault).not.toHaveBeenCalled(); + expect(mockEvent.returnValue).toBe(undefined); + }); + + it("does not show custom warning modal when leaving with no changes via SPA navigation", () => { + const { history } = setup({ initialRoute: "/" }); + + history.push(FORM_URL); + + history.goBack(); + + expect( + screen.queryByText("Changes were not saved"), + ).not.toBeInTheDocument(); + expect( + screen.queryByText( + "Navigating away from here will cause you to lose any changes you have made.", + ), + ).not.toBeInTheDocument(); + }); + + it("shows custom warning modal when leaving with unsaved changes via SPA navigation", () => { + const { history } = setup({ initialRoute: "/" }); + + history.push(FORM_URL); + + userEvent.type(screen.getByLabelText("Name Your Segment"), "Name"); + + history.goBack(); + + expect(screen.getByText("Changes were not saved")).toBeInTheDocument(); + expect( + screen.getByText( + "Navigating away from here will cause you to lose any changes you have made.", + ), + ).toBeInTheDocument(); + }); + + it("does not show custom warning modal when saving changes", async () => { + const { history } = setup(); + + userEvent.click(screen.getByText("Select a table")); + + await waitFor(() => { + expect(screen.queryByTestId("loading-spinner")).not.toBeInTheDocument(); + }); + + userEvent.click(screen.getByText("Orders")); + + await waitFor(() => { + expect(screen.queryByTestId("loading-spinner")).not.toBeInTheDocument(); + }); + + userEvent.click(screen.getByText("Add filters to narrow your answer")); + userEvent.click(screen.getByText("ID")); + userEvent.type(screen.getByPlaceholderText("Enter an ID"), "1"); + userEvent.click(screen.getByText("Add filter")); + userEvent.type(screen.getByLabelText("Name Your Segment"), "Name"); + userEvent.type( + screen.getByLabelText("Describe Your Segment"), + "Description", + ); + + await waitFor(() => { + expect(screen.getByText("Save changes")).toBeEnabled(); + }); + + userEvent.click(screen.getByText("Save changes")); + + await waitFor(() => { + expect(history.getCurrentLocation().pathname).toBe(SEGMENTS_URL); + }); + + expect( + screen.queryByText("Changes were not saved"), + ).not.toBeInTheDocument(); + expect( + screen.queryByText( + "Navigating away from here will cause you to lose any changes you have made.", + ), + ).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/src/metabase/status/components/StatusListing/StatusListing.tsx b/frontend/src/metabase/status/components/StatusListing/StatusListing.tsx index 5a16a220be2..6866432017b 100644 --- a/frontend/src/metabase/status/components/StatusListing/StatusListing.tsx +++ b/frontend/src/metabase/status/components/StatusListing/StatusListing.tsx @@ -10,7 +10,7 @@ import DatabaseStatus from "../../containers/DatabaseStatus"; import FileUploadStatus from "../FileUploadStatus"; import { StatusListingRoot } from "./StatusListing.styled"; -const StatusListingView = () => { +const StatusListing = () => { const isLoggedIn = !!useSelector(getUser); const isAdmin = useSelector(getUserIsAdmin); @@ -34,4 +34,4 @@ const StatusListingView = () => { }; // eslint-disable-next-line import/no-default-export -- deprecated usage -export default StatusListingView; +export default StatusListing; diff --git a/frontend/test/__support__/server-mocks/metric.ts b/frontend/test/__support__/server-mocks/metric.ts index e95961155a5..430badba0e9 100644 --- a/frontend/test/__support__/server-mocks/metric.ts +++ b/frontend/test/__support__/server-mocks/metric.ts @@ -1,11 +1,16 @@ import fetchMock from "fetch-mock"; import type { Metric } from "metabase-types/api"; +import { createMockMetric } from "metabase-types/api/mocks"; export function setupMetricEndpoint(metric: Metric) { fetchMock.get(`path:/api/metric/${metric.id}`, metric); } export function setupMetricsEndpoints(metrics: Metric[]) { - fetchMock.get(`path:/api/metric`, metrics); + fetchMock.post("path:/api/metric", async url => { + const metric = await fetchMock.lastCall(url)?.request?.json(); + return createMockMetric(metric); + }); + fetchMock.get("path:/api/metric", metrics); metrics.forEach(metric => setupMetricEndpoint(metric)); } diff --git a/frontend/test/__support__/server-mocks/segment.ts b/frontend/test/__support__/server-mocks/segment.ts index 9d31883db62..8a07f257d74 100644 --- a/frontend/test/__support__/server-mocks/segment.ts +++ b/frontend/test/__support__/server-mocks/segment.ts @@ -1,11 +1,16 @@ import fetchMock from "fetch-mock"; import type { Segment } from "metabase-types/api"; +import { createMockSegment } from "metabase-types/api/mocks"; export function setupSegmentEndpoint(segment: Segment) { fetchMock.get(`path:/api/segment/${segment.id}`, segment); } export function setupSegmentsEndpoints(segments: Segment[]) { - fetchMock.get(`path:/api/segment`, segments); + fetchMock.post("path:/api/segment", async url => { + const metric = await fetchMock.lastCall(url)?.request?.json(); + return createMockSegment(metric); + }); + fetchMock.get("path:/api/segment", segments); segments.forEach(segment => setupSegmentEndpoint(segment)); } -- GitLab