diff --git a/protocol-designer/src/__tests__/persist.test.js b/protocol-designer/src/__tests__/persist.test.js new file mode 100644 index 00000000000..493345bf567 --- /dev/null +++ b/protocol-designer/src/__tests__/persist.test.js @@ -0,0 +1,65 @@ +// @flow + +import * as persist from '../persist' + +describe('persist', () => { + describe('getLocalStorageItem', () => { + let getItemMock + beforeEach(() => { + getItemMock = jest.spyOn( + Object.getPrototypeOf(global.localStorage), + 'getItem' + ) + }) + + afterEach(() => { + jest.clearAllMocks() + }) + + test('retrieves localStorage data by key and parses data when it exists', () => { + const value = { test: 'some value' } + getItemMock.mockReturnValue(JSON.stringify(value)) + + const result = persist.getLocalStorageItem('key') + + expect(result).toEqual(value) + }) + + test('returns undefined when localStorage could not be retrieved for key given', () => { + getItemMock.mockImplementation(() => { + throw new Error('something went wrong!') + }) + + const result = persist.getLocalStorageItem('key') + + expect(result).toBeUndefined() + }) + }) + + describe('setLocalStorageItem', () => { + let setItemMock + beforeEach(() => { + jest.clearAllMocks() + setItemMock = jest.spyOn( + Object.getPrototypeOf(global.localStorage), + 'setItem' + ) + }) + + afterEach(() => { + jest.clearAllMocks() + }) + + test('adds prefix to key sets localStorage item by key', () => { + const value = { a: 'a', b: 'b' } + setItemMock.mockReturnValue(undefined) + + persist.setLocalStorageItem('key', value) + + expect(setItemMock).toHaveBeenCalledWith( + 'root.key', + JSON.stringify(value) + ) + }) + }) +}) diff --git a/protocol-designer/src/components/ProtocolEditor.js b/protocol-designer/src/components/ProtocolEditor.js index 383b27d6d68..cf60f36ff8f 100644 --- a/protocol-designer/src/components/ProtocolEditor.js +++ b/protocol-designer/src/components/ProtocolEditor.js @@ -3,18 +3,19 @@ import * as React from 'react' import cx from 'classnames' import { DragDropContext } from 'react-dnd' import MouseBackEnd from 'react-dnd-mouse-backend' -import { PrereleaseModeIndicator } from './PrereleaseModeIndicator' import { ConnectedNav } from '../containers/ConnectedNav' import { ConnectedSidebar } from '../containers/ConnectedSidebar' import { ConnectedTitleBar } from '../containers/ConnectedTitleBar' import { ConnectedMainPanel } from '../containers/ConnectedMainPanel' +import { PortalRoot as MainPageModalPortalRoot } from '../components/portals/MainPageModalPortal' +import { MAIN_CONTENT_FORCED_SCROLL_CLASSNAME } from '../ui/steps' +import { PrereleaseModeIndicator } from './PrereleaseModeIndicator' +import { PortalRoot as TopPortalRoot } from './portals/TopPortal' import { NewFileModal } from './modals/NewFileModal' import { FileUploadMessageModal } from './modals/FileUploadMessageModal' import { LabwareUploadMessageModal } from './modals/LabwareUploadMessageModal' import { GateModal } from './modals/GateModal' -import { PortalRoot as MainPageModalPortalRoot } from '../components/portals/MainPageModalPortal' -import { PortalRoot as TopPortalRoot } from './portals/TopPortal' -import { MAIN_CONTENT_FORCED_SCROLL_CLASSNAME } from '../ui/steps' +import { AnnouncementModal } from './modals/AnnouncementModal' import styles from './ProtocolEditor.css' const showGateModal = @@ -39,6 +40,7 @@ function ProtocolEditorComponent() { MAIN_CONTENT_FORCED_SCROLL_CLASSNAME )} > + diff --git a/protocol-designer/src/components/modals/AnnouncementModal/AnnouncementModal.css b/protocol-designer/src/components/modals/AnnouncementModal/AnnouncementModal.css new file mode 100644 index 00000000000..815e929e65b --- /dev/null +++ b/protocol-designer/src/components/modals/AnnouncementModal/AnnouncementModal.css @@ -0,0 +1,55 @@ +@import '@opentrons/components'; + +.announcement_modal { + border-radius: 0; +} + +.modal_contents { + @apply --font-body-2-dark; + + border-radius: 0; + padding: 0; +} + +.modal_contents p { + margin-bottom: 1rem; +} + +.modules_diagrams_row { + display: flex; + justify-content: center; + align-items: center; + + /* + Keep image height at a specific ratio by shrinking the available space the + image has to take up + */ + padding-left: 19.294%; + padding-right: 19.294%; +} + +.modules_diagram { + width: 100%; + height: 100%; +} + +.separator { + color: var(--c-light-gray); +} + +.announcement_body { + padding: 2.5rem 1.5rem 1.5rem 1.5rem; +} + +.announcement_message { + margin-bottom: 2.5rem; +} + +.announcement_heading { + @apply --font-header-dark; + + display: flex; + justify-content: center; + align-items: center; + margin-bottom: 2rem; +} diff --git a/protocol-designer/src/components/modals/AnnouncementModal/__tests__/AnnouncementModal.test.js b/protocol-designer/src/components/modals/AnnouncementModal/__tests__/AnnouncementModal.test.js new file mode 100644 index 00000000000..fa452452352 --- /dev/null +++ b/protocol-designer/src/components/modals/AnnouncementModal/__tests__/AnnouncementModal.test.js @@ -0,0 +1,117 @@ +// @flow + +import React from 'react' +import { shallow } from 'enzyme' +import { Modal, OutlineButton } from '@opentrons/components' +import * as persist from '../../../../persist' +import { AnnouncementModal } from '../' +import * as announcements from '../announcements' +import type { Announcement } from '../announcements' + +jest.mock('../../../../persist.js') + +describe('AnnouncementModal', () => { + const announcementKey = 'newType' + const getLocalStorageItemMock: JestMockFn<[string], mixed> = + persist.getLocalStorageItem + + const announcementsMock: { + announcements: Array, + } = announcements + + beforeEach(() => { + getLocalStorageItemMock.mockReturnValue(announcementKey) + }) + + test('modal is not shown when announcement has been shown before', () => { + announcementsMock.announcements = [ + { + image: null, + heading: 'a test header', + message: 'test', + announcementKey, + }, + ] + + const wrapper = shallow() + + expect(wrapper.find(Modal)).toHaveLength(0) + }) + + test('announcement is shown when user has not seen it before', () => { + announcementsMock.announcements = [ + { + image: null, + heading: 'a test header', + message: 'brand new spanking feature', + announcementKey: 'newPipette', + }, + ] + + const wrapper = shallow() + const modal = wrapper.find(Modal) + + expect(modal).toHaveLength(1) + expect(modal.html()).toContain('brand new spanking feature') + }) + + test('latest announcement is always shown', () => { + announcementsMock.announcements = [ + { + image: null, + heading: 'a first header', + message: 'first announcement', + announcementKey, + }, + { + image: null, + heading: 'a second header', + message: 'second announcement', + announcementKey: 'newPipette', + }, + ] + + const wrapper = shallow() + const modal = wrapper.find(Modal) + + expect(modal).toHaveLength(1) + expect(modal.html()).toContain('second announcement') + }) + + test('optional image component is displayed when exists', () => { + announcementsMock.announcements = [ + { + image: , + heading: 'a test header', + message: 'brand new spanking feature', + announcementKey: 'newFeature', + }, + ] + + const wrapper = shallow() + const image = wrapper.find('img') + expect(image).toHaveLength(1) + }) + + test('button click saves announcement announcementKey to localStorage and closes modal', () => { + const newAnnouncementKey = 'newFeature' + announcementsMock.announcements = [ + { + image: null, + heading: 'a test header', + message: 'brand new spanking feature', + announcementKey: newAnnouncementKey, + }, + ] + + const wrapper = shallow() + const button = wrapper.find(OutlineButton) + button.simulate('click') + + expect(persist.setLocalStorageItem).toHaveBeenCalledWith( + persist.localStorageAnnouncementKey, + newAnnouncementKey + ) + expect(wrapper.find(Modal)).toHaveLength(0) + }) +}) diff --git a/protocol-designer/src/components/modals/AnnouncementModal/announcements.js b/protocol-designer/src/components/modals/AnnouncementModal/announcements.js new file mode 100644 index 00000000000..a8292a9ab13 --- /dev/null +++ b/protocol-designer/src/components/modals/AnnouncementModal/announcements.js @@ -0,0 +1,40 @@ +// @flow + +import * as React from 'react' +import styles from './AnnouncementModal.css' + +export type Announcement = {| + announcementKey: string, + image: React.Node | null, + heading: string, + message: React.Node, +|} + +export const announcements: Array = [ + { + announcementKey: 'modulesRequireRunAppUpdate', + image: ( +
+ +
+ ), + heading: "We've updated the Protocol Designer", + message: ( + <> +

+ Protocol Designer BETA now supports Temperature and Magnetic modules. +

+ +

+ Note: Protocols with modules{' '} + may require an app and robot update to run. You will + need to have the OT-2 app and robot on the latest versions ( + 3.17 and higher). +

+ + ), + }, +] diff --git a/protocol-designer/src/components/modals/AnnouncementModal/index.js b/protocol-designer/src/components/modals/AnnouncementModal/index.js new file mode 100644 index 00000000000..3bb3924e5ea --- /dev/null +++ b/protocol-designer/src/components/modals/AnnouncementModal/index.js @@ -0,0 +1,61 @@ +// @flow + +import React, { useState } from 'react' +import cx from 'classnames' +import { Modal, OutlineButton } from '@opentrons/components' +import { i18n } from '../../../localization' +import { + setLocalStorageItem, + getLocalStorageItem, + localStorageAnnouncementKey, +} from '../../../persist' +import modalStyles from '../modal.css' +import { announcements } from './announcements' +import styles from './AnnouncementModal.css' + +export const AnnouncementModal = () => { + const { announcementKey, message, heading, image } = announcements[ + announcements.length - 1 + ] + + const userHasNotSeenAnnouncement = + getLocalStorageItem(localStorageAnnouncementKey) !== announcementKey + + const [showAnnouncementModal, setShowAnnouncementModal] = useState( + userHasNotSeenAnnouncement + ) + + const handleClick = () => { + setLocalStorageItem(localStorageAnnouncementKey, announcementKey) + setShowAnnouncementModal(false) + } + + return ( + <> + {showAnnouncementModal && ( + + {image && ( + <> + {image} +
+ + )} + +
+

{heading}

+
{message}
+ +
+ + {i18n.t('button.got_it')} + +
+
+
+ )} + + ) +} diff --git a/protocol-designer/src/images/modules/magdeck_tempdeck_combined.png b/protocol-designer/src/images/modules/magdeck_tempdeck_combined.png new file mode 100644 index 00000000000..1d3f7135994 Binary files /dev/null and b/protocol-designer/src/images/modules/magdeck_tempdeck_combined.png differ diff --git a/protocol-designer/src/localization/en/button.json b/protocol-designer/src/localization/en/button.json index 321d12b3da6..debd375c1b7 100644 --- a/protocol-designer/src/localization/en/button.json +++ b/protocol-designer/src/localization/en/button.json @@ -17,5 +17,6 @@ "save": "save", "swap": "swap", "yes": "yes", - "upload_custom_labware": "Upload custom labware" + "upload_custom_labware": "Upload custom labware", + "got_it": "Got It!" } diff --git a/protocol-designer/src/persist.js b/protocol-designer/src/persist.js index 20ee6e95578..9d6c782e76a 100644 --- a/protocol-designer/src/persist.js +++ b/protocol-designer/src/persist.js @@ -13,12 +13,7 @@ export type RehydratePersistedAction = {| }, |} -// The `path` should match where the reducer lives in the Redux state tree -export const _rehydrate = (path: string): any => { - assert( - PERSISTED_PATHS.includes(path), - `Path "${path}" is missing from PERSISTED_PATHS! The changes to this reducer will not be persisted.` - ) +export const getLocalStorageItem = (path: string): mixed => { try { const persisted = global.localStorage.getItem(_addStoragePrefix(path)) return persisted ? JSON.parse(persisted) : undefined @@ -28,6 +23,15 @@ export const _rehydrate = (path: string): any => { return undefined } +// The `path` should match where the reducer lives in the Redux state tree +export const _rehydrate = (path: string): any => { + assert( + PERSISTED_PATHS.includes(path), + `Path "${path}" is missing from PERSISTED_PATHS! The changes to this reducer will not be persisted.` + ) + return getLocalStorageItem(path) +} + export const _rehydrateAll = (): $PropertyType< RehydratePersistedAction, 'payload' @@ -49,6 +53,8 @@ function _addStoragePrefix(path: string): string { return `root.${path}` } +export const localStorageAnnouncementKey = 'announcementKey' + // paths from Redux root to all persisted reducers const PERSISTED_PATHS = [ 'analytics.hasOptedIn', @@ -65,6 +71,17 @@ function transformBeforePersist(path: string, reducerState: any) { } } +export const setLocalStorageItem = (path: string, value: any) => { + try { + global.localStorage.setItem( + _addStoragePrefix(path), + JSON.stringify(transformBeforePersist(path, value)) + ) + } catch (e) { + console.error(`error attempting to persist ${path}:`, e) + } +} + /** Subscribe this fn to the Redux store to persist selected substates */ type PersistSubscriber = () => void export const makePersistSubscriber = ( @@ -76,14 +93,7 @@ export const makePersistSubscriber = ( PERSISTED_PATHS.forEach(path => { const nextReducerState = get(state, path) if (prevReducerStates[path] !== nextReducerState) { - try { - global.localStorage.setItem( - _addStoragePrefix(path), - JSON.stringify(transformBeforePersist(path, nextReducerState)) - ) - } catch (e) { - console.error(`error attempting to persist ${path}:`, e) - } + setLocalStorageItem(path, nextReducerState) prevReducerStates[path] = nextReducerState } })