diff --git a/demo-project/data/session_store.db b/demo-project/data/session_store.db index 66efab11a4..493003d0cf 100644 Binary files a/demo-project/data/session_store.db and b/demo-project/data/session_store.db differ diff --git a/src/components/experiment-tracking/details/details.js b/src/components/experiment-tracking/details/details.js index ef1c0cba44..6077340080 100644 --- a/src/components/experiment-tracking/details/details.js +++ b/src/components/experiment-tracking/details/details.js @@ -3,6 +3,8 @@ import classnames from 'classnames'; import RunMetadata from '../run-metadata'; import RunDataset from '../run-dataset'; import RunDetailsModal from '../run-details-modal'; +import RunExportModal from '../run-export-modal.js'; +import { ButtonTimeoutContextProvider } from '../../../utils/button-timeout-context'; import './details.css'; @@ -21,6 +23,8 @@ const Details = ({ sidebarVisible, theme, trackingDataError, + showRunExportModal, + setShowRunExportModal, }) => { const [runMetadataToEdit, setRunMetadataToEdit] = useState(null); @@ -40,13 +44,22 @@ const Details = ({ return ( <> - + + + +
{ const { updateRunDetails } = useUpdateRunDetails(); - const [exportData, setExportData] = useState([]); const toggleBookmark = () => { updateRunDetails({ @@ -33,10 +29,6 @@ export const ExperimentPrimaryToolbar = ({ }); }; - const updateExportData = useCallback(() => { - setExportData(constructExportData(runMetadata, runTrackingData)); - }, [runMetadata, runTrackingData]); - return ( - - - + setShowRunExportModal(true)} + /> ); }; diff --git a/src/components/experiment-tracking/run-details-modal/run-details-modal.js b/src/components/experiment-tracking/run-details-modal/run-details-modal.js index e01830fcca..8b9a81910e 100644 --- a/src/components/experiment-tracking/run-details-modal/run-details-modal.js +++ b/src/components/experiment-tracking/run-details-modal/run-details-modal.js @@ -1,6 +1,8 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useState, useContext } from 'react'; import { useUpdateRunDetails } from '../../../apollo/mutations'; +import { ButtonTimeoutContext } from '../../../utils/button-timeout-context'; + import Button from '../../ui/button'; import Modal from '../../ui/modal'; import Input from '../../ui/input'; @@ -15,9 +17,15 @@ const RunDetailsModal = ({ visible, }) => { const [valuesToUpdate, setValuesToUpdate] = useState({}); - const [hasNotInteracted, setHasNotInteracted] = useState(true); - const [editsAreSuccessful, setEditsAreSuccessful] = useState(false); const { updateRunDetails, error, reset } = useUpdateRunDetails(); + const { + handleClick, + hasNotInteracted, + isSuccessful, + setHasNotInteracted, + setIsSuccessful, + showModal, + } = useContext(ButtonTimeoutContext); const onApplyChanges = () => { updateRunDetails({ @@ -25,8 +33,10 @@ const RunDetailsModal = ({ runInput: { notes: valuesToUpdate.notes, title: valuesToUpdate.title }, }); + handleClick(); + if (!error) { - setEditsAreSuccessful(true); + setIsSuccessful(true); } }; @@ -39,30 +49,12 @@ const RunDetailsModal = ({ setHasNotInteracted(false); }; - const resetState = () => { - setHasNotInteracted(true); - setEditsAreSuccessful(false); - }; - + // only if the component is visible first, then apply isSuccessful to show or hide modal useEffect(() => { - let modalTimeout, resetTimeout; - - if (editsAreSuccessful) { - modalTimeout = setTimeout(() => { - setShowRunDetailsModal(false); - }, 1500); - - // Delay the reset so the user can't see the button text change. - resetTimeout = setTimeout(() => { - resetState(); - }, 2000); + if (visible && isSuccessful) { + setShowRunDetailsModal(showModal); } - - return () => { - clearTimeout(modalTimeout); - clearTimeout(resetTimeout); - }; - }, [editsAreSuccessful, setShowRunDetailsModal]); + }, [showModal, setShowRunDetailsModal, isSuccessful, visible]); useEffect(() => { setValuesToUpdate({ @@ -78,8 +70,7 @@ const RunDetailsModal = ({ * the next time the modal opens. */ reset(); - setHasNotInteracted(true); - }, [reset, runMetadataToEdit, visible]); + }, [runMetadataToEdit, visible, setHasNotInteracted, reset]); return (
@@ -124,10 +115,10 @@ const RunDetailsModal = ({ + + + +
+ +
+ ); +}; + +export default RunExportModal; diff --git a/src/components/experiment-tracking/run-export-modal.js/run-export-modal.scss b/src/components/experiment-tracking/run-export-modal.js/run-export-modal.scss new file mode 100644 index 0000000000..ba477fd05b --- /dev/null +++ b/src/components/experiment-tracking/run-export-modal.js/run-export-modal.scss @@ -0,0 +1,16 @@ +.pipeline-run-export-modal--experiment-tracking .modal__title { + text-align: left; + margin-left: 30px; +} + +.run-export-modal-button-wrapper { + display: flex; + justify-content: space-around; + + width: 100%; +} + +// set fix width for export button for when the text is shorter, eg "Done" +.pipeline-run-export-modal--experiment-tracking .button__btn--primary { + width: 160px; +} diff --git a/src/components/experiment-tracking/run-export-modal.js/run-export-modal.test.js b/src/components/experiment-tracking/run-export-modal.js/run-export-modal.test.js new file mode 100644 index 0000000000..9fc82d5a38 --- /dev/null +++ b/src/components/experiment-tracking/run-export-modal.js/run-export-modal.test.js @@ -0,0 +1,99 @@ +import React from 'react'; +import RunExportModal from './index'; +import Adapter from 'enzyme-adapter-react-16'; +import { configure, mount } from 'enzyme'; +import { render, screen } from '@testing-library/react'; +import { ButtonTimeoutContext } from '../../../utils/button-timeout-context'; + +// to help find text which is made by multiple HTML elements +// eg:
Hello world
+const findTextWithTags = (textMatch) => { + return screen.findByText((content, node) => { + const hasText = (node) => node.textContent === textMatch; + const nodeHasText = hasText(node); + const childrenDontHaveText = Array.from(node?.children || []).every( + (child) => !hasText(child) + ); + return nodeHasText && childrenDontHaveText; + }); +}; + +const mockValue = { + handleClick: jest.fn(), + isSuccessful: false, + setIsSuccessful: jest.fn(), + showModal: false, +}; +configure({ adapter: new Adapter() }); + +describe('RunExportModal', () => { + it('renders the component without crashing', () => { + const wrapper = mount( + + + + ); + + expect( + wrapper.find('.pipeline-run-export-modal--experiment-tracking').length + ).toBe(1); + }); + + it('modal closes when cancel button is clicked', () => { + const setVisible = jest.fn(); + const wrapper = mount( + + setVisible(true)} /> + + ); + const onClick = jest.spyOn(React, 'useState'); + const closeButton = wrapper.find( + '.pipeline-run-export-modal--experiment-tracking .button__btn--secondary' + ); + + onClick.mockImplementation((visible) => [visible, setVisible]); + + closeButton.simulate('click'); + + expect( + wrapper.find( + '.pipeline-run-export-modal--experiment-tracking .kui-modal--visible' + ).length + ).toBe(0); + }); + + it('Text is updated to "Done ✅" when the "Export all and close" is clicked, and modal is closed', () => { + const setVisible = jest.fn(); + const wrapper = mount( + + setVisible(true)} /> + + ); + + // original text should be "Export all and close" + const { getByText } = render( + + + + ); + expect(getByText(/Export all and close/i)).toBeVisible(); + + const onClick = jest.spyOn(React, 'useState'); + const exportAllAndCloseBtn = wrapper.find( + '.pipeline-run-export-modal--experiment-tracking .button__btn--primary' + ); + + onClick.mockImplementation((visible) => [visible, setVisible]); + exportAllAndCloseBtn.simulate('click'); + + // expect the text to be changed first + expect(findTextWithTags('Done ✅ ')).toBeTruthy(); + + // then the modal is closed + expect( + wrapper.find( + '.pipeline-run-export-modal--experiment-tracking .kui-modal--visible' + ).length + ).toBe(0); + }); +}); diff --git a/src/components/experiment-wrapper/experiment-wrapper.js b/src/components/experiment-wrapper/experiment-wrapper.js index 96a3dba283..269a05797e 100644 --- a/src/components/experiment-wrapper/experiment-wrapper.js +++ b/src/components/experiment-wrapper/experiment-wrapper.js @@ -25,6 +25,7 @@ const ExperimentWrapper = ({ theme }) => { const [selectedRunIds, setSelectedRunIds] = useState([]); const [selectedRunData, setSelectedRunData] = useState(null); const [showRunDetailsModal, setShowRunDetailsModal] = useState(false); + const [showRunExportModal, setShowRunExportModal] = useState(false); // Fetch all runs. const { subscribeToMore, data, loading } = useApolloQuery(GET_RUNS); @@ -165,15 +166,14 @@ const ExperimentWrapper = ({ theme }) => { isExperimentView onRunSelection={onRunSelection} onToggleComparisonView={onToggleComparisonView} - runMetadata={runMetadata} runsListData={data.runsList} - runTrackingData={runTrackingData} selectedRunData={selectedRunData} selectedRunIds={selectedRunIds} setEnableShowChanges={setEnableShowChanges} setSidebarVisible={setIsSidebarVisible} showRunDetailsModal={setShowRunDetailsModal} sidebarVisible={isSidebarVisible} + setShowRunExportModal={setShowRunExportModal} /> {selectedRunIds.length > 0 ? (
{ sidebarVisible={isSidebarVisible} theme={theme} trackingDataError={trackingDataError} + showRunExportModal={showRunExportModal} + setShowRunExportModal={setShowRunExportModal} /> ) : null} diff --git a/src/components/sidebar/sidebar.js b/src/components/sidebar/sidebar.js index c91f5779ed..861ca2682e 100644 --- a/src/components/sidebar/sidebar.js +++ b/src/components/sidebar/sidebar.js @@ -34,6 +34,7 @@ export const Sidebar = ({ showRunDetailsModal, sidebarVisible, visible, + setShowRunExportModal, }) => { const [pipelineIsOpen, togglePipeline] = useState(false); @@ -68,6 +69,7 @@ export const Sidebar = ({ showChangesIconDisabled={!(selectedRunIds.length > 1)} showRunDetailsModal={showRunDetailsModal} sidebarVisible={sidebarVisible} + setShowRunExportModal={setShowRunExportModal} /> diff --git a/src/utils/button-timeout-context.js b/src/utils/button-timeout-context.js new file mode 100644 index 0000000000..5481336ff0 --- /dev/null +++ b/src/utils/button-timeout-context.js @@ -0,0 +1,54 @@ +import React, { createContext, useState } from 'react'; + +export const ButtonTimeoutContext = createContext(null); + +/** + * Provides a way to pass different states to a button depending on whether + * it's successful or not. + * {@returns hasNotInteracted and setHasNotInteracted} these 2 are only used for modal with editable fields + */ +export const ButtonTimeoutContextProvider = ({ children }) => { + const [isSuccessful, setIsSuccessful] = useState(false); + const [showModal, setShowModal] = useState(false); + const [hasNotInteracted, setHasNotInteracted] = useState(true); + + const handleClick = () => { + setShowModal(true); + + const localStateTimeout = setTimeout(() => { + setIsSuccessful(true); + }, 500); + + // so user is able to see the success message on the button first before the modal goes away + const modalTimeout = setTimeout(() => { + setShowModal(false); + }, 1500); + + // Delay the reset so the user can't see the button text change. + const resetTimeout = setTimeout(() => { + setIsSuccessful(false); + setHasNotInteracted(true); + }, 2000); + + return () => { + clearTimeout(localStateTimeout); + clearTimeout(modalTimeout); + clearTimeout(resetTimeout); + }; + }; + + return ( + setHasNotInteracted(state), + setIsSuccessful: (state) => setIsSuccessful(state), + showModal, + }} + > + {children} + + ); +};