diff --git a/api-client/src/protocols/getProtocolIds.ts b/api-client/src/protocols/getProtocolIds.ts new file mode 100644 index 00000000000..0dac91f24ba --- /dev/null +++ b/api-client/src/protocols/getProtocolIds.ts @@ -0,0 +1,11 @@ +import { GET, request } from '../request' + +import type { ResponsePromise } from '../request' +import type { HostConfig } from '../types' +import type { ProtocolsIds } from './types' + +export function getProtocolIds( + config: HostConfig +): ResponsePromise { + return request(GET, `/protocols/ids`, null, config) +} diff --git a/api-client/src/protocols/index.ts b/api-client/src/protocols/index.ts index 8ce01e80103..58b790eadab 100644 --- a/api-client/src/protocols/index.ts +++ b/api-client/src/protocols/index.ts @@ -3,6 +3,7 @@ export { getProtocolAnalyses } from './getProtocolAnalyses' export { deleteProtocol } from './deleteProtocol' export { createProtocol } from './createProtocol' export { getProtocols } from './getProtocols' +export { getProtocolIds } from './getProtocolIds' export * from './types' export * from './utils' diff --git a/api-client/src/protocols/types.ts b/api-client/src/protocols/types.ts index 676c158e067..7a264e46183 100644 --- a/api-client/src/protocols/types.ts +++ b/api-client/src/protocols/types.ts @@ -38,3 +38,8 @@ export interface Protocols { links?: ResourceLinks data: ProtocolResource[] } + +export interface ProtocolsIds { + links?: ResourceLinks + data: string[] +} diff --git a/app/src/App/OnDeviceDisplayApp.tsx b/app/src/App/OnDeviceDisplayApp.tsx index e1e4671d9c2..e6beaf92bfc 100644 --- a/app/src/App/OnDeviceDisplayApp.tsx +++ b/app/src/App/OnDeviceDisplayApp.tsx @@ -37,7 +37,7 @@ import { InitialLoadingScreen } from '../pages/OnDeviceDisplay/InitialLoadingScr import { PortalRoot as ModalPortalRoot } from './portal' import { getOnDeviceDisplaySettings, updateConfigValue } from '../redux/config' import { SLEEP_NEVER_MS } from './constants' -import { useCurrentRunRoute } from './hooks' +import { useCurrentRunRoute, useProtocolReceiptToast } from './hooks' import type { Dispatch } from '../redux/types' import type { RouteProps } from './types' @@ -250,6 +250,7 @@ export const OnDeviceDisplayApp = (): JSX.Element => { ) : ( + {onDeviceDisplayRoutes.map( ({ Component, exact, path }: RouteProps) => { @@ -276,7 +277,13 @@ export const OnDeviceDisplayApp = (): JSX.Element => { function TopLevelRedirects(): JSX.Element | null { const runRouteMatch = useRouteMatch({ path: '/runs/:runId' }) const currentRunRoute = useCurrentRunRoute() + if (runRouteMatch != null && currentRunRoute == null) return return currentRunRoute != null ? : null } + +function ProtocolReceiptToasts(): null { + useProtocolReceiptToast() + return null +} diff --git a/app/src/App/__tests__/OnDeviceDisplayApp.test.tsx b/app/src/App/__tests__/OnDeviceDisplayApp.test.tsx index 229c67dc470..88f9d80404f 100644 --- a/app/src/App/__tests__/OnDeviceDisplayApp.test.tsx +++ b/app/src/App/__tests__/OnDeviceDisplayApp.test.tsx @@ -23,7 +23,7 @@ import { NameRobot } from '../../pages/OnDeviceDisplay/NameRobot' import { InitialLoadingScreen } from '../../pages/OnDeviceDisplay/InitialLoadingScreen' import { getOnDeviceDisplaySettings } from '../../redux/config' import { getIsShellReady } from '../../redux/shell' -import { useCurrentRunRoute } from '../hooks' +import { useCurrentRunRoute, useProtocolReceiptToast } from '../hooks' import type { OnDeviceDisplaySettings } from '../../redux/config/types' @@ -101,6 +101,9 @@ const mockgetIsShellReady = getIsShellReady as jest.MockedFunction< const mockUseCurrentRunRoute = useCurrentRunRoute as jest.MockedFunction< typeof useCurrentRunRoute > +const mockUseProtocolReceiptToasts = useProtocolReceiptToast as jest.MockedFunction< + typeof useProtocolReceiptToast +> const render = (path = '/') => { return renderWithProviders( @@ -211,4 +214,8 @@ describe('OnDeviceDisplayApp', () => { mockgetIsShellReady.mockReturnValue(true) getByText('Mock Loading') }) + it('renders protocol receipt toasts', () => { + render('/') + expect(mockUseProtocolReceiptToasts).toHaveBeenCalled() + }) }) diff --git a/app/src/App/hooks.ts b/app/src/App/hooks.ts index 5f3350ccb3d..c26d4cd83c2 100644 --- a/app/src/App/hooks.ts +++ b/app/src/App/hooks.ts @@ -1,11 +1,16 @@ import * as React from 'react' +import difference from 'lodash/difference' +import { useTranslation } from 'react-i18next' +import { useQueryClient } from 'react-query' import { useDispatch } from 'react-redux' import { useInterval } from '@opentrons/components' -import { checkShellUpdate } from '../redux/shell' - -import type { Dispatch } from '../redux/types' -import { useAllRunsQuery } from '@opentrons/react-api-client' import { + useAllProtocolIdsQuery, + useAllRunsQuery, + useHost, +} from '@opentrons/react-api-client' +import { + getProtocol, RUN_ACTION_TYPE_PLAY, RUN_STATUS_BLOCKED_BY_OPEN_DOOR, RUN_STATUS_IDLE, @@ -13,9 +18,14 @@ import { RUN_STATUS_FAILED, RUN_STATUS_SUCCEEDED, } from '@opentrons/api-client' +import { checkShellUpdate } from '../redux/shell' +import { useToaster } from '../organisms/ToasterOven' + +import type { Dispatch } from '../redux/types' const CURRENT_RUN_POLL = 5000 const UPDATE_RECHECK_INTERVAL_MS = 60000 +const PROTOCOL_IDS_RECHECK_INTERVAL_MS = 3000 export function useSoftwareUpdatePoll(): void { const dispatch = useDispatch() @@ -25,6 +35,74 @@ export function useSoftwareUpdatePoll(): void { useInterval(checkAppUpdate, UPDATE_RECHECK_INTERVAL_MS) } +export function useProtocolReceiptToast(): void { + const host = useHost() + const { t } = useTranslation('protocol_info') + const { makeToast } = useToaster() + const queryClient = useQueryClient() + const protocolIdsQuery = useAllProtocolIdsQuery( + { + refetchInterval: PROTOCOL_IDS_RECHECK_INTERVAL_MS, + }, + true + ) + const protocolIds = protocolIdsQuery.data?.data ?? [] + const protocolIdsRef = React.useRef(protocolIds) + const hasRefetched = React.useRef(true) + + if (protocolIdsQuery.isRefetching === true) { + hasRefetched.current = false + } + + React.useEffect(() => { + const newProtocolIds = difference(protocolIds, protocolIdsRef.current) + if (!hasRefetched.current && newProtocolIds.length > 0) { + Promise.all( + newProtocolIds.map(protocolId => { + if (host != null) { + return ( + getProtocol(host, protocolId).then( + data => + data.data.data.metadata.protocolName ?? + data.data.data.files[0].name + ) ?? '' + ) + } else { + return Promise.reject( + new Error( + 'no host provider info inside of useProtocolReceiptToast' + ) + ) + } + }) + ) + .then(protocolNames => { + protocolNames.forEach(name => { + makeToast( + t('protocol_added', { + protocol_name: name, + }), + 'success' + ) + }) + }) + .then(() => { + queryClient + .invalidateQueries([host, 'protocols']) + .catch((e: Error) => + console.error(`error invalidating protocols query: ${e.message}`) + ) + }) + .catch((e: Error) => { + console.error(e) + }) + } + protocolIdsRef.current = protocolIds + // dont want this hook to rerun when other deps change + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [protocolIds]) +} + export function useCurrentRunRoute(): string | null { const { data: allRuns } = useAllRunsQuery( { pageLength: 1 }, diff --git a/app/src/assets/localization/en/protocol_info.json b/app/src/assets/localization/en/protocol_info.json index f7e063f8ca1..80bf5b38a1c 100644 --- a/app/src/assets/localization/en/protocol_info.json +++ b/app/src/assets/localization/en/protocol_info.json @@ -54,6 +54,7 @@ "pin_protocol": "Pin protocol", "pinned_protocol": "Pinned protocol", "pinned_protocols": "Pinned Protocols", + "protocol_added": "Successfully received {{protocol_name}}", "protocol_finishing": "Finishing Protocol On {{robot_name}}", "protocol_loading": "Opening Protocol On {{robot_name}}", "protocol_name_title": "Protocol Name", diff --git a/react-api-client/src/protocols/index.ts b/react-api-client/src/protocols/index.ts index 376e3c50546..13a4b94a9b0 100644 --- a/react-api-client/src/protocols/index.ts +++ b/react-api-client/src/protocols/index.ts @@ -1,4 +1,5 @@ export { useAllProtocolsQuery } from './useAllProtocolsQuery' +export { useAllProtocolIdsQuery } from './useAllProtocolIdsQuery' export { useProtocolQuery } from './useProtocolQuery' export { useProtocolAnalysesQuery } from './useProtocolAnalysesQuery' export { useCreateProtocolMutation } from './useCreateProtocolMutation' diff --git a/react-api-client/src/protocols/useAllProtocolIdsQuery.ts b/react-api-client/src/protocols/useAllProtocolIdsQuery.ts new file mode 100644 index 00000000000..0adf939eab7 --- /dev/null +++ b/react-api-client/src/protocols/useAllProtocolIdsQuery.ts @@ -0,0 +1,29 @@ +import { UseQueryResult, useQuery } from 'react-query' +import { getProtocolIds } from '@opentrons/api-client' +import { useHost } from '../api' +import type { HostConfig, ProtocolsIds } from '@opentrons/api-client' +import type { UseQueryOptions } from 'react-query' + +const POLLING_INTERVAL = 1000 + +export function useAllProtocolIdsQuery( + options?: UseQueryOptions, + enablePolling?: boolean +): UseQueryResult { + const host = useHost() + const allOptions: UseQueryOptions = { + ...options, + enabled: host !== null && (enablePolling == null || enablePolling), + refetchInterval: + enablePolling != null + ? options?.refetchInterval ?? POLLING_INTERVAL + : false, + } + const query = useQuery( + [host, 'protocols', 'ids'], + () => getProtocolIds(host as HostConfig).then(response => response.data), + allOptions + ) + + return query +} diff --git a/robot-server/robot_server/protocols/protocol_store.py b/robot-server/robot_server/protocols/protocol_store.py index a98a91c6450..ce844867fad 100644 --- a/robot-server/robot_server/protocols/protocol_store.py +++ b/robot-server/robot_server/protocols/protocol_store.py @@ -199,6 +199,14 @@ def get_all(self) -> List[ProtocolResource]: for r in all_sql_resources ] + @lru_cache(maxsize=_CACHE_ENTRIES) + def get_all_ids(self) -> List[str]: + """Get all protocol ids currently saved in this store.""" + select_ids = sqlalchemy.select(protocol_table.c.id).order_by(sqlite_rowid) + with self._sql_engine.begin() as transaction: + protocol_ids = transaction.execute(select_ids).scalars().all() + return protocol_ids + def get_id_by_hash(self, hash: str) -> Optional[str]: """Get all protocol hashes keyed by protocol id.""" for p in self.get_all(): @@ -359,6 +367,7 @@ def _sql_remove(self, protocol_id: str) -> None: def _clear_caches(self) -> None: self.get.cache_clear() + self.get_all_ids.cache_clear() self.get_all.cache_clear() self.has.cache_clear() diff --git a/robot-server/robot_server/protocols/router.py b/robot-server/robot_server/protocols/router.py index bfdb4fda2b9..80be1f7736a 100644 --- a/robot-server/robot_server/protocols/router.py +++ b/robot-server/robot_server/protocols/router.py @@ -16,7 +16,6 @@ FileHasher, ) from opentrons_shared_data.robot.dev_types import RobotType - from robot_server.errors import ErrorDetails, ErrorBody from robot_server.hardware import get_robot_type from robot_server.service.task_runner import TaskRunner, get_task_runner @@ -306,6 +305,28 @@ async def get_protocols( ) +@protocols_router.get( + path="/protocols/ids", + summary="Get uploaded protocol ids", + responses={status.HTTP_200_OK: {"model": SimpleMultiBody[str]}}, +) +async def get_protocol_ids( + protocol_store: ProtocolStore = Depends(get_protocol_store), +) -> PydanticResponse[SimpleMultiBody[str]]: + """Get a list of all protocol ids stored on the server. + + Args: + protocol_store: In-memory database of protocol resources. + """ + protocol_ids = protocol_store.get_all_ids() + + meta = MultiBodyMeta(cursor=0, totalLength=len(protocol_ids)) + + return await PydanticResponse.create( + content=SimpleMultiBody.construct(data=protocol_ids, meta=meta) + ) + + @protocols_router.get( path="/protocols/{protocolId}", summary="Get an uploaded protocol", diff --git a/robot-server/robot_server/service/json_api/response.py b/robot-server/robot_server/service/json_api/response.py index 0834dd57081..1f704a810f7 100644 --- a/robot-server/robot_server/service/json_api/response.py +++ b/robot-server/robot_server/service/json_api/response.py @@ -13,7 +13,7 @@ class ResourceModel(BaseModel): id: str = Field(..., description="Unique identifier of the resource.") -ResponseDataT = TypeVar("ResponseDataT", bound=BaseModel) +ResponseDataT = TypeVar("ResponseDataT") ResponseLinksT = TypeVar("ResponseLinksT") diff --git a/robot-server/tests/protocols/test_protocol_store.py b/robot-server/tests/protocols/test_protocol_store.py index 0147f6ddce2..5fc0232924d 100644 --- a/robot-server/tests/protocols/test_protocol_store.py +++ b/robot-server/tests/protocols/test_protocol_store.py @@ -371,3 +371,52 @@ def test_get_referencing_run_ids( run_store.remove(run_id="run-id-2") assert subject.get_referencing_run_ids("protocol-id-1") == [] + + +def test_get_protocol_ids( + subject: ProtocolStore, +) -> None: + """It should return a list of protocol ids.""" + protocol_resource_1 = ProtocolResource( + protocol_id="protocol-id-1", + created_at=datetime(year=2021, month=1, day=1, tzinfo=timezone.utc), + source=ProtocolSource( + directory=None, + main_file=Path("/dev/null"), + config=JsonProtocolConfig(schema_version=123), + files=[], + metadata={}, + robot_type="OT-2 Standard", + content_hash="abc1", + ), + protocol_key=None, + ) + + protocol_resource_2 = ProtocolResource( + protocol_id="protocol-id-2", + created_at=datetime(year=2021, month=1, day=2, tzinfo=timezone.utc), + source=ProtocolSource( + directory=None, + main_file=Path("/dev/null"), + config=JsonProtocolConfig(schema_version=123), + files=[], + metadata={}, + robot_type="OT-2 Standard", + content_hash="abc2", + ), + protocol_key=None, + ) + + assert subject.get_all_ids() == [] + + subject.insert(protocol_resource_1) + + assert subject.get_all_ids() == ["protocol-id-1"] + + subject.insert(protocol_resource_2) + assert subject.get_all_ids() == ["protocol-id-1", "protocol-id-2"] + + subject.remove(protocol_id="protocol-id-1") + subject.remove(protocol_id="protocol-id-2") + + assert subject.get_all_ids() == [] diff --git a/robot-server/tests/protocols/test_protocols_router.py b/robot-server/tests/protocols/test_protocols_router.py index 3fc9998f2d4..1887d989435 100644 --- a/robot-server/tests/protocols/test_protocols_router.py +++ b/robot-server/tests/protocols/test_protocols_router.py @@ -51,6 +51,7 @@ ProtocolLinks, create_protocol, get_protocols, + get_protocol_ids, get_protocol_by_id, delete_protocol_by_id, get_protocol_analyses, @@ -200,6 +201,38 @@ async def test_get_protocols( assert result.status_code == 200 +async def test_get_protocol_ids_no_protocols( + decoy: Decoy, + protocol_store: ProtocolStore, +) -> None: + """It should return an empty collection response with no protocols loaded.""" + decoy.when(protocol_store.get_all_ids()).then_return([]) + + result = await get_protocol_ids(protocol_store=protocol_store) + + assert result.content.data == [] + assert result.content.meta == MultiBodyMeta(cursor=0, totalLength=0) + assert result.status_code == 200 + + +async def test_get_protocol_ids( + decoy: Decoy, + protocol_store: ProtocolStore, +) -> None: + """It should return stored protocol ids.""" + decoy.when(protocol_store.get_all_ids()).then_return( + ["protocol_id_1", "protocol_id_2"] + ) + + result = await get_protocol_ids( + protocol_store=protocol_store, + ) + + assert result.content.data == ["protocol_id_1", "protocol_id_2"] + assert result.content.meta == MultiBodyMeta(cursor=0, totalLength=2) + assert result.status_code == 200 + + async def test_get_protocol_by_id( decoy: Decoy, protocol_store: ProtocolStore,