From 498b2fbdd992c589f427a854ad20641c545cebf5 Mon Sep 17 00:00:00 2001 From: Seena Nair <55585488+seenanair@users.noreply.github.com> Date: Tue, 5 Nov 2024 10:58:48 +0000 Subject: [PATCH] Revert "Revert "Y24-022 UI Submit Pool XP tube to traction"" --- app/frontend/entrypoints/application.js | 1 + app/frontend/javascript/icons/ErrorIcon.vue | 19 + app/frontend/javascript/icons/ReadyIcon.vue | 19 + app/frontend/javascript/icons/SuccessIcon.vue | 19 + app/frontend/javascript/icons/TubeIcon.vue | 19 + .../javascript/icons/TubeSearchIcon.vue | 19 + .../components/PoolXPTubeSubmitPanel.spec.js | 674 ++++++++++++++++++ .../components/PoolXPTubeSubmitPanel.vue | 445 ++++++++++++ .../javascript/pool-xp-tube-panel/index.js | 50 ++ .../presenters/pcr_pool_xp_presenter.rb | 14 + app/views/tubes/sidebars/_default.html.erb | 13 + config/environments/development.rb | 2 + config/environments/test.rb | 2 + config/purposes/bioscan.yml | 2 +- .../presenters/pcr_pool_xp_presenter_spec.rb | 40 ++ 15 files changed, 1337 insertions(+), 1 deletion(-) create mode 100644 app/frontend/javascript/icons/ErrorIcon.vue create mode 100644 app/frontend/javascript/icons/ReadyIcon.vue create mode 100644 app/frontend/javascript/icons/SuccessIcon.vue create mode 100644 app/frontend/javascript/icons/TubeIcon.vue create mode 100644 app/frontend/javascript/icons/TubeSearchIcon.vue create mode 100644 app/frontend/javascript/pool-xp-tube-panel/components/PoolXPTubeSubmitPanel.spec.js create mode 100644 app/frontend/javascript/pool-xp-tube-panel/components/PoolXPTubeSubmitPanel.vue create mode 100644 app/frontend/javascript/pool-xp-tube-panel/index.js create mode 100644 app/models/presenters/pcr_pool_xp_presenter.rb create mode 100644 spec/models/presenters/pcr_pool_xp_presenter_spec.rb diff --git a/app/frontend/entrypoints/application.js b/app/frontend/entrypoints/application.js index e4c83293f..ffd059b48 100644 --- a/app/frontend/entrypoints/application.js +++ b/app/frontend/entrypoints/application.js @@ -63,6 +63,7 @@ import '@/javascript/multi-stamp/index.js' import '@/javascript/qc-information/index.js' import '@/javascript/tubes-to-rack/index.js' import '@/javascript/validate-paired-tubes/index.js' +import '@/javascript/pool-xp-tube-panel/index.js' // Load simple javascript files import '@/javascript/plain-javascript/page-reloader.js' diff --git a/app/frontend/javascript/icons/ErrorIcon.vue b/app/frontend/javascript/icons/ErrorIcon.vue new file mode 100644 index 000000000..0cf41035c --- /dev/null +++ b/app/frontend/javascript/icons/ErrorIcon.vue @@ -0,0 +1,19 @@ + + + diff --git a/app/frontend/javascript/icons/ReadyIcon.vue b/app/frontend/javascript/icons/ReadyIcon.vue new file mode 100644 index 000000000..e69bc747c --- /dev/null +++ b/app/frontend/javascript/icons/ReadyIcon.vue @@ -0,0 +1,19 @@ + + + diff --git a/app/frontend/javascript/icons/SuccessIcon.vue b/app/frontend/javascript/icons/SuccessIcon.vue new file mode 100644 index 000000000..5542d526e --- /dev/null +++ b/app/frontend/javascript/icons/SuccessIcon.vue @@ -0,0 +1,19 @@ + + + diff --git a/app/frontend/javascript/icons/TubeIcon.vue b/app/frontend/javascript/icons/TubeIcon.vue new file mode 100644 index 000000000..afd116364 --- /dev/null +++ b/app/frontend/javascript/icons/TubeIcon.vue @@ -0,0 +1,19 @@ + + + diff --git a/app/frontend/javascript/icons/TubeSearchIcon.vue b/app/frontend/javascript/icons/TubeSearchIcon.vue new file mode 100644 index 000000000..3282b8d8b --- /dev/null +++ b/app/frontend/javascript/icons/TubeSearchIcon.vue @@ -0,0 +1,19 @@ + + + diff --git a/app/frontend/javascript/pool-xp-tube-panel/components/PoolXPTubeSubmitPanel.spec.js b/app/frontend/javascript/pool-xp-tube-panel/components/PoolXPTubeSubmitPanel.spec.js new file mode 100644 index 000000000..fc77b27ab --- /dev/null +++ b/app/frontend/javascript/pool-xp-tube-panel/components/PoolXPTubeSubmitPanel.spec.js @@ -0,0 +1,674 @@ +import { mount, createLocalVue } from '@vue/test-utils' +import PoolXPTubeSubmitPanel from './PoolXPTubeSubmitPanel.vue' +import BootstrapVue from 'bootstrap-vue' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import flushPromises from 'flush-promises' +import ReadyIcon from '../../icons/ReadyIcon.vue' +import TubeSearchIcon from '../../icons/TubeSearchIcon.vue' +import SuccessIcon from '../../icons/SuccessIcon.vue' +import ErrorIcon from '../../icons/ErrorIcon.vue' +import TubeIcon from '../../icons/TubeIcon.vue' + +const localVue = createLocalVue() +localVue.use(BootstrapVue) + +// Default props +const defaultProps = { + barcode: '12345', + userId: 'user123', + sequencescapeApiUrl: 'http://example.com/api', + tractionServiceUrl: 'http://traction.example.com', + tractionUIUrl: 'http://traction-ui.example.com', +} + +// Helper function to create the wrapper with the given state and props +const createWrapper = (state = 'checking_tube_status', props = { ...defaultProps }) => { + return mount(PoolXPTubeSubmitPanel, { + localVue, + propsData: { + ...props, + }, + data() { + return { + state, + } + }, + }) +} + +// Helper function to mock fetch responses sequentially +const mockFetch = (responses) => { + const fetchMock = vi.spyOn(global, 'fetch') + responses.forEach((response) => { + fetchMock.mockImplementationOnce(() => + Promise.resolve({ + ok: response.ok, + json: () => Promise.resolve(response.data), + }), + ) + }) + return fetchMock +} + +//Response objects +const emptyResponse = { ok: true, data: { data: [] } } // Initial successful response +const failedResponse = { ok: false, data: { error: 'API call failed' } } // Subsequent failure response +const successResponse = { ok: true, data: { data: [{ id: '1' }] } } // Subsequent success response + +const mockTubeFoundResponse = () => { + vi.spyOn(global, 'fetch').mockImplementation(() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve({ ...successResponse.data }), + }), + ) +} +const mockTubeCheckFailureResponse = () => { + vi.spyOn(global, 'fetch').mockImplementation(() => + Promise.resolve({ + ok: false, + json: () => Promise.resolve({ ...failedResponse.data }), + }), + ) +} + +const mockNoTubeFoundResponse = () => { + vi.spyOn(global, 'fetch').mockImplementation(() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve({ ...emptyResponse.data }), + }), + ) +} + +// Helper function to check if the component displays the correct state as per the given state value +const verifyComponentState = (wrapper, state) => { + const exportButton = wrapper.find('#pool_xp_tube_export_button') + const statusLabel = wrapper.find('#pool_xp_tube_export_status') + const spinner = wrapper.find('#progress_spinner') + const statusIcon = wrapper.find('#status_icon') + switch (state) { + case 'ready_to_export': { + expect(exportButton.exists()).toBe(true) + expect(statusLabel.exists()).toBe(true) + expect(spinner.isVisible()).toBe(false) + expect(statusIcon.isVisible()).toBe(true) + expect(exportButton.attributes('disabled')).toBeUndefined() + expect(exportButton.classes()).toContain('btn-success') + expect(statusLabel.classes()).toContain('text-success') + expect(statusLabel.text()).toBe('Ready to export') + expect(exportButton.text()).toBe('Export') + const iconComponent = statusIcon.findComponent(ReadyIcon) + expect(iconComponent.exists()).toBe(true) + expect(iconComponent.props().color).toBe('green') + break + } + + case 'checking_tube_status': { + expect(exportButton.exists()).toBe(true) + expect(statusLabel.exists()).toBe(true) + expect(spinner.isVisible()).toBe(true) + expect(statusIcon.isVisible()).toBe(true) + expect(exportButton.attributes('disabled')).toBe('disabled') + expect(exportButton.classes()).toContain('btn-success') + expect(statusLabel.classes()).toContain('text-black') + expect(statusLabel.text()).toBe('Checking tube is in Traction') + expect(exportButton.text()).toBe('Please wait') + const iconComponent = statusIcon.findComponent(TubeSearchIcon) + expect(iconComponent.exists()).toBe(true) + expect(iconComponent.props().color).toContain('black') + break + } + + case 'tube_exists': { + expect(exportButton.exists()).toBe(true) + expect(statusLabel.exists()).toBe(true) + expect(spinner.isVisible()).toBe(false) + expect(statusIcon.isVisible()).toBe(true) + expect(exportButton.attributes('disabled')).toBeUndefined() + expect(exportButton.classes()).toContain('btn-primary') + expect(statusLabel.classes()).toContain('text-success') + expect(statusLabel.text()).toBe('Tube already exported to Traction') + expect(exportButton.text()).toBe('Open Traction') + const iconComponent = statusIcon.findComponent(SuccessIcon) + expect(iconComponent.exists()).toBe(true) + expect(iconComponent.props().color).toContain('green') + break + } + + case 'exporting': { + expect(exportButton.exists()).toBe(true) + expect(statusLabel.exists()).toBe(true) + expect(spinner.isVisible()).toBe(true) + expect(statusIcon.isVisible()).toBe(true) + expect(exportButton.attributes('disabled')).toBe('disabled') + expect(exportButton.classes()).toContain('btn-success') + expect(statusLabel.classes()).toContain('text-black') + expect(statusLabel.text()).toBe('Tube is being exported to Traction') + expect(exportButton.text()).toBe('Please wait') + const iconComponent = statusIcon.findComponent(TubeIcon) + expect(iconComponent.exists()).toBe(true) + expect(iconComponent.props().color).toContain('black') + break + } + + case 'tube_export_success': { + expect(exportButton.exists()).toBe(true) + expect(statusLabel.exists()).toBe(true) + expect(spinner.isVisible()).toBe(false) + expect(statusIcon.isVisible()).toBe(true) + expect(exportButton.attributes('disabled')).toBeUndefined() + expect(exportButton.classes()).toContain('btn-primary') + expect(statusLabel.classes()).toContain('text-success') + expect(statusLabel.text()).toBe('Tube has been exported to Traction') + expect(exportButton.text()).toBe('Open Traction') + const iconComponent = statusIcon.findComponent(SuccessIcon) + expect(iconComponent.exists()).toBe(true) + expect(iconComponent.props().color).toContain('green') + break + } + + case 'failure_export_tube': { + expect(exportButton.exists()).toBe(true) + expect(statusLabel.exists()).toBe(true) + expect(spinner.isVisible()).toBe(false) + expect(statusIcon.isVisible()).toBe(true) + expect(exportButton.attributes('disabled')).toBeUndefined() + expect(exportButton.classes()).toContain('btn-danger') + expect(statusLabel.classes()).toContain('text-danger') + expect(statusLabel.text()).toBe('The tube export to Traction failed. Try again') + expect(exportButton.text()).toBe('Try again') + const iconComponent = statusIcon.findComponent(ErrorIcon) + expect(iconComponent.exists()).toBe(true) + expect(iconComponent.props().color).toContain('red') + break + } + + case 'failure_tube_check': { + expect(exportButton.exists()).toBe(true) + expect(statusLabel.exists()).toBe(true) + expect(spinner.isVisible()).toBe(false) + expect(statusIcon.isVisible()).toBe(true) + expect(exportButton.attributes('disabled')).toBeUndefined() + expect(exportButton.classes()).toContain('btn-danger') + expect(statusLabel.classes()).toContain('text-danger') + expect(statusLabel.text()).toBe('The export cannot be verified. Refresh to try again') + expect(exportButton.text()).toBe('Refresh') + const iconComponent = statusIcon.findComponent(ErrorIcon) + expect(iconComponent.exists()).toBe(true) + expect(iconComponent.props().color).toContain('red') + break + } + + case 'failure_tube_check_export': { + expect(exportButton.exists()).toBe(true) + expect(statusLabel.exists()).toBe(true) + expect(spinner.isVisible()).toBe(false) + expect(statusIcon.isVisible()).toBe(true) + expect(exportButton.attributes('disabled')).toBeUndefined() + expect(exportButton.classes()).toContain('btn-danger') + expect(statusLabel.classes()).toContain('text-danger') + expect(statusLabel.text()).toBe('The export cannot be verified. Try again') + expect(exportButton.text()).toBe('Try again') + const iconComponent = statusIcon.findComponent(ErrorIcon) + expect(iconComponent.exists()).toBe(true) + expect(iconComponent.props().color).toContain('red') + break + } + + case 'invalid_props': { + expect(exportButton.exists()).toBe(true) + expect(statusLabel.exists()).toBe(true) + expect(spinner.isVisible()).toBe(false) + expect(statusIcon.isVisible()).toBe(true) + expect(exportButton.attributes('disabled')).toBe('disabled') + expect(exportButton.classes()).toContain('btn-danger') + expect(statusLabel.classes()).toContain('text-danger') + expect(statusLabel.text()).toBe('Required props are missing') + expect(exportButton.text()).toBe('Export') + const iconComponent = statusIcon.findComponent(ErrorIcon) + expect(iconComponent.exists()).toBe(true) + expect(iconComponent.props().color).toContain('red') + break + } + } +} + +describe('PoolXPTubeSubmitPanel', () => { + let wrapper + + beforeEach(() => { + mockNoTubeFoundResponse() + vi.spyOn(console, 'log').mockImplementation(() => {}) + vi.spyOn(window, 'open').mockImplementation(() => {}) + }) + + it('renders the component', () => { + wrapper = createWrapper() + expect(wrapper.exists()).toBe(true) + }) + + it.each([ + 'ready_to_export', + 'checking_tube_status', + 'tube_exists', + 'exporting', + 'tube_export_success', + 'failure_export_tube', + 'failure_tube_check', + 'failure_tube_check_export', + 'invalid_props', + ])('displays the correct status based on %s state', (stateValue) => { + const wrapper = createWrapper(stateValue) + verifyComponentState(wrapper, stateValue) + }) + + describe('methods', () => { + describe('initialiseStartState', () => { + let wrapper + beforeEach(async () => { + wrapper = createWrapper() + // Mock the sleep function to resolve immediately + wrapper.vm.sleep = vi.fn().mockImplementation(() => Promise.resolve()) + await flushPromises() + }) + it('transitions to TUBE_ALREADY_EXPORTED state when tube is found', async () => { + // Mock the fetch response to indicate a tube is found + mockTubeFoundResponse() + + // Call initialiseStartState + await wrapper.vm.initialiseStartState() + await flushPromises() + + // Check the state after initialiseStartState + expect(wrapper.vm.state).toBe('tube_exists') + }) + + it('transitions to FAILURE_TUBE_CHECK state when service error occurs', async () => { + // Mock the fetch response to indicate a service error + mockTubeCheckFailureResponse() + + // Call initialiseStartState + await wrapper.vm.initialiseStartState() + await flushPromises() + + // Check the state after initialiseStartState + expect(wrapper.vm.state).toBe('failure_tube_check') + }) + + it('transitions to READY_TO_EXPORT state when no tube is found', async () => { + // Mock the fetch response to indicate no tube is found + mockNoTubeFoundResponse() + + // Call initialiseStartState + await wrapper.vm.initialiseStartState() + await flushPromises() + + // Check the state after initialiseStartState + expect(wrapper.vm.state).toBe('ready_to_export') + }) + }) + + describe('checkTubeInTraction', () => { + let wrapper + beforeEach(async () => { + wrapper = createWrapper() + // Mock the sleep function to resolve immediately + wrapper.vm.sleep = vi.fn().mockImplementation(() => Promise.resolve()) + await flushPromises() + }) + + it('returns "found" when tube is found', async () => { + // Mock the fetch response to indicate the tube is found + mockTubeFoundResponse() + const result = await wrapper.vm.checkTubeInTraction() + expect(result).toBe('found') + }) + + it('returns "not_found" when no tube is found', async () => { + // Mock the fetch response to indicate no tube is found + mockNoTubeFoundResponse() + const result = await wrapper.vm.checkTubeInTraction() + expect(result).toBe('not_found') + }) + + it('returns "service_error" when response is not ok', async () => { + // Mock the fetch response to indicate a service error + mockTubeCheckFailureResponse() + const result = await wrapper.vm.checkTubeInTraction() + expect(result).toBe('service_error') + }) + + it('returns "service_error" when an exception is thrown', async () => { + // Mock the fetch to throw an error + vi.spyOn(global, 'fetch').mockImplementationOnce(() => Promise.reject(new Error('Network error'))) + + const result = await wrapper.vm.checkTubeInTraction() + expect(result).toBe('service_error') + }) + }) + + describe('checkTubeStatusWithRetries', () => { + let wrapper + beforeEach(async () => { + wrapper = createWrapper() + wrapper.vm.sleep = vi.fn().mockImplementation(() => Promise.resolve()) + await flushPromises() + }) + it('calls checkTubeStatusWithRetries once when tube is found on first try', async () => { + // Mock the fetch response to indicate the tube is found on the first try + vi.spyOn(wrapper.vm, 'checkTubeInTraction').mockImplementationOnce(() => Promise.resolve('found')) + + const result = await wrapper.vm.checkTubeStatusWithRetries() + expect(result).toBe('found') + expect(wrapper.vm.checkTubeInTraction).toHaveBeenCalledTimes(1) + }) + + it('calls checkTubeStatusWithRetries multiple times when tube is found after retries', async () => { + // Mock the fetch response to indicate the tube is found after some retries + vi.spyOn(wrapper.vm, 'checkTubeInTraction') + .mockImplementationOnce(() => Promise.resolve('not_found')) + .mockImplementationOnce(() => Promise.resolve('not_found')) + .mockImplementationOnce(() => Promise.resolve('found')) + + const result = await wrapper.vm.checkTubeStatusWithRetries(5, 1000) + expect(result).toBe('found') + expect(wrapper.vm.checkTubeInTraction).toHaveBeenCalledTimes(3) + }) + + it('calls checkTubeStatusWithRetries the maximum number of retries when tube is not found', async () => { + // Mock the fetch response to indicate no tube is found after all retries + vi.spyOn(wrapper.vm, 'checkTubeInTraction').mockImplementation(() => Promise.resolve('not_found')) + + const result = await wrapper.vm.checkTubeStatusWithRetries(3, 1000) + expect(result).toBe('not_found') + expect(wrapper.vm.checkTubeInTraction).toHaveBeenCalledTimes(3) + }) + + it('calls checkTubeStatusWithRetries once when service error occurs', async () => { + // Mock the fetch response to indicate a service error + vi.spyOn(wrapper.vm, 'checkTubeInTraction').mockImplementation(() => Promise.resolve('service_error')) + + const result = await wrapper.vm.checkTubeStatusWithRetries(3, 1000) + expect(result).toBe('service_error') + expect(wrapper.vm.checkTubeInTraction).toHaveBeenCalledTimes(3) + }) + }) + }) + + describe('on Mount', () => { + it('calls checkTubeInTraction on mount', async () => { + vi.spyOn(PoolXPTubeSubmitPanel.methods, 'checkTubeInTraction') + wrapper = createWrapper() + expect(PoolXPTubeSubmitPanel.methods.checkTubeInTraction).toHaveBeenCalled() + }) + + it('handles invalid props correctly on mount', async () => { + wrapper = createWrapper('checking_tube_status', { + barcode: '', + userId: '', + sequencescapeApiUrl: '', + tractionServiceUrl: '', + tractionUIUrl: 'http://traction-ui.example.com', + }) + await flushPromises() + expect(wrapper.vm.state).toBe('invalid_props') + verifyComponentState(wrapper, 'invalid_props') + }) + + it('handles fetching tube success from Traction correctly on mount', async () => { + mockTubeFoundResponse() + wrapper = createWrapper() + await flushPromises() + expect(global.fetch).toBeCalledTimes(1) + expect(global.fetch).toHaveBeenCalledWith(wrapper.vm.tractionTubeCheckUrl) + + expect(wrapper.vm.state).toBe('tube_exists') + verifyComponentState(wrapper, 'tube_exists') + }) + it('handles fetching no tubes from Traction correctly on mount', async () => { + mockNoTubeFoundResponse() + wrapper = createWrapper() + wrapper.vm.sleep = vi.fn().mockImplementation(() => Promise.resolve()) + await flushPromises() + expect(wrapper.vm.state).toBe('ready_to_export') + verifyComponentState(wrapper, 'ready_to_export') + }) + + it('handles fetching tube failure from Traction correctly on mount', async () => { + mockTubeCheckFailureResponse() + wrapper = createWrapper() + wrapper.vm.sleep = vi.fn().mockImplementation(() => Promise.resolve()) + await flushPromises() + expect(wrapper.vm.state).toBe('failure_tube_check') + verifyComponentState(wrapper, 'failure_tube_check') + }) + }) + + describe('Export action', () => { + describe('When tube is not already exported and component is in ready_to_export state', () => { + let originalSleep, wrapper + beforeEach(async () => { + mockNoTubeFoundResponse() + wrapper = createWrapper() + originalSleep = wrapper.vm.sleep + // Mock the sleep function to resolve immediately + wrapper.vm.sleep = vi.fn().mockImplementation(() => Promise.resolve()) + await flushPromises() + }) + it('dispalys the correct state', () => { + expect(wrapper.vm.state).toBe('ready_to_export') + verifyComponentState(wrapper, 'ready_to_export') + }) + it('immediately transitions to exporting state when export button is clicked', async () => { + wrapper.vm.sleep = originalSleep + await wrapper.find('#pool_xp_tube_export_button').trigger('click') + await flushPromises() + expect(wrapper.vm.state).toBe('exporting') + }) + it('transitions state correctly when export is successful', async () => { + mockTubeFoundResponse() + await wrapper.find('#pool_xp_tube_export_button').trigger('click') + await flushPromises() + expect(wrapper.vm.state).toBe('tube_export_success') + verifyComponentState(wrapper, 'tube_export_success') + + await wrapper.find('#pool_xp_tube_export_button').trigger('click') + await flushPromises() + expect(window.open).toHaveBeenCalledWith(wrapper.vm.tractionTubeOpenUrl, '_blank') + }) + it('transitions state correctly when no tube exists and export fails', async () => { + mockTubeCheckFailureResponse() + await wrapper.find('#pool_xp_tube_export_button').trigger('click') + await flushPromises() + expect(wrapper.vm.state).toBe('failure_export_tube') + verifyComponentState(wrapper, 'failure_export_tube') + }) + it('transitions states correctly when no tube exists and tube checking using traction api fails', async () => { + mockFetch([ + emptyResponse, // Initial empty response + failedResponse, // Subsequent failure response + ]) + await wrapper.find('#pool_xp_tube_export_button').trigger('click') + await flushPromises() + expect(wrapper.vm.state).toBe('failure_tube_check_export') + verifyComponentState(wrapper, 'failure_tube_check_export') + + mockNoTubeFoundResponse() + await wrapper.find('#pool_xp_tube_export_button').trigger('click') + await flushPromises() + expect(wrapper.vm.state).toBe('failure_tube_check_export') + verifyComponentState(wrapper, 'failure_tube_check_export') + }) + it('transitions states correctly when no tube exists and tube checking using traction api succeeds', async () => { + mockFetch([ + emptyResponse, // Initial empty response + failedResponse, // Subsequent success response + ]) + await wrapper.find('#pool_xp_tube_export_button').trigger('click') + await flushPromises() + expect(wrapper.vm.state).toBe('failure_tube_check_export') + verifyComponentState(wrapper, 'failure_tube_check_export') + + mockTubeFoundResponse() + await wrapper.find('#pool_xp_tube_export_button').trigger('click') + await flushPromises() + expect(wrapper.vm.state).toBe('tube_export_success') + verifyComponentState(wrapper, 'tube_export_success') + }) + }) + describe('When tube is already exported and component is in tube_exists state', () => { + let wrapper + beforeEach(async () => { + mockTubeFoundResponse() + wrapper = createWrapper() + await flushPromises() + }) + it('dispalys the correct state', () => { + expect(wrapper.vm.state).toBe('tube_exists') + verifyComponentState(wrapper, 'tube_exists') + }) + it('opens Traction in a new tab when open traction button is clicked', async () => { + await wrapper.find('#pool_xp_tube_export_button').trigger('click') + await flushPromises() + expect(window.open).toHaveBeenCalledWith(wrapper.vm.tractionTubeOpenUrl, '_blank') + }) + }) + + describe('when traction api to check tube fails on mount and component is in failure_tube_check state', () => { + let wrapper + beforeEach(async () => { + mockTubeCheckFailureResponse() + wrapper = createWrapper() + // Mock the sleep function to resolve immediately + wrapper.vm.sleep = vi.fn().mockImplementation(() => Promise.resolve()) + await flushPromises() + }) + it('displays the correct state', () => { + expect(wrapper.vm.state).toBe('failure_tube_check') + verifyComponentState(wrapper, 'failure_tube_check') + }) + it('remains in failure_tube_check state if response is again failure when refresh button is clicked', async () => { + await wrapper.find('#pool_xp_tube_export_button').trigger('click') + await flushPromises() + expect(wrapper.vm.state).toBe('failure_tube_check') + verifyComponentState(wrapper, 'failure_tube_check') + }) + + it('transitions to ready_to_export state when no existing tubes are found and the refresh button is clicked', async () => { + mockNoTubeFoundResponse() + await wrapper.find('#pool_xp_tube_export_button').trigger('click') + await flushPromises() + expect(wrapper.vm.state).toBe('ready_to_export') + verifyComponentState(wrapper, 'ready_to_export') + }) + it('transitions to tube_exists state when an existing tube is found when refresh button is clicked', async () => { + mockTubeFoundResponse() + await wrapper.find('#pool_xp_tube_export_button').trigger('click') + await flushPromises() + expect(wrapper.vm.state).toBe('tube_exists') + verifyComponentState(wrapper, 'tube_exists') + }) + }) + + describe('when export api fails and the component is in failure_export_tube state', () => { + let wrapper, originalSleep + beforeEach(async () => { + mockNoTubeFoundResponse() + wrapper = createWrapper() + originalSleep = wrapper.vm.sleep + // Mock the sleep function to resolve immediately + wrapper.vm.sleep = vi.fn().mockImplementation(() => Promise.resolve()) + await flushPromises() + mockTubeCheckFailureResponse() + await wrapper.find('#pool_xp_tube_export_button').trigger('click') + await flushPromises() + }) + it('displays the correct state', () => { + expect(wrapper.vm.state).toBe('failure_export_tube') + verifyComponentState(wrapper, 'failure_export_tube') + }) + it('immediately transitions to exporting state when export button is clicked', async () => { + wrapper.vm.sleep = originalSleep + mockNoTubeFoundResponse() + await wrapper.find('#pool_xp_tube_export_button').trigger('click') + await flushPromises() + expect(wrapper.vm.state).toBe('exporting') + verifyComponentState(wrapper, 'exporting') + }) + it('remains in failure_export_tube state if response is failure when retry button is clicked', async () => { + await wrapper.find('#pool_xp_tube_export_button').trigger('click') + await flushPromises() + expect(wrapper.vm.state).toBe('failure_export_tube') + verifyComponentState(wrapper, 'failure_export_tube') + }) + it('transitions to tube_export_success state if response is success when retry button is clicked', async () => { + mockTubeFoundResponse() + await wrapper.find('#pool_xp_tube_export_button').trigger('click') + await flushPromises() + expect(wrapper.vm.state).toBe('tube_export_success') + verifyComponentState(wrapper, 'tube_export_success') + }) + it('transitions to failure_tube_check state if response is tube check api fails when retry button is clicked', async () => { + mockFetch([ + emptyResponse, // Initial successful response + failedResponse, // Subsequent failure response + ]) + await wrapper.find('#pool_xp_tube_export_button').trigger('click') + await flushPromises() + expect(wrapper.vm.state).toBe('failure_tube_check_export') + verifyComponentState(wrapper, 'failure_tube_check_export') + }) + }) + + describe('when the traction api to check tube fails after export and the component is in failure_tube_check_export state', () => { + let wrapper, originalSleep + beforeEach(async () => { + mockNoTubeFoundResponse() + wrapper = createWrapper() + originalSleep = wrapper.vm.sleep + // Mock the sleep function to resolve immediately + wrapper.vm.sleep = vi.fn().mockImplementation(() => Promise.resolve()) + await flushPromises() + mockTubeCheckFailureResponse() + await wrapper.find('#pool_xp_tube_export_button').trigger('click') + await flushPromises() + mockFetch([ + emptyResponse, // Initial successful response + failedResponse, // Subsequent failure response + ]) + await wrapper.find('#pool_xp_tube_export_button').trigger('click') + await flushPromises() + }) + it('displays the correct state', () => { + expect(wrapper.vm.state).toBe('failure_tube_check_export') + verifyComponentState(wrapper, 'failure_tube_check_export') + }) + it('immediately transitions to exporting state when retry button is clicked', async () => { + wrapper.vm.sleep = originalSleep + mockNoTubeFoundResponse() + await wrapper.find('#pool_xp_tube_export_button').trigger('click') + await flushPromises() + expect(wrapper.vm.state).toBe('exporting') + verifyComponentState(wrapper, 'exporting') + }) + it('transitions to tube_export_success state if response is success when retry button is clicked', async () => { + mockTubeFoundResponse() + await wrapper.find('#pool_xp_tube_export_button').trigger('click') + await flushPromises() + expect(wrapper.vm.state).toBe('tube_export_success') + verifyComponentState(wrapper, 'tube_export_success') + }) + it('remains in failure_tube_check_export state if response is failure when retry button is clicked', async () => { + mockFetch([ + emptyResponse, // Initial successful response + failedResponse, // Subsequent failure response + ]) + await wrapper.find('#pool_xp_tube_export_button').trigger('click') + await flushPromises() + expect(wrapper.vm.state).toBe('failure_tube_check_export') + verifyComponentState(wrapper, 'failure_tube_check_export') + }) + }) + }) +}) diff --git a/app/frontend/javascript/pool-xp-tube-panel/components/PoolXPTubeSubmitPanel.vue b/app/frontend/javascript/pool-xp-tube-panel/components/PoolXPTubeSubmitPanel.vue new file mode 100644 index 000000000..ab1a657bf --- /dev/null +++ b/app/frontend/javascript/pool-xp-tube-panel/components/PoolXPTubeSubmitPanel.vue @@ -0,0 +1,445 @@ + + + diff --git a/app/frontend/javascript/pool-xp-tube-panel/index.js b/app/frontend/javascript/pool-xp-tube-panel/index.js new file mode 100644 index 000000000..e464821cb --- /dev/null +++ b/app/frontend/javascript/pool-xp-tube-panel/index.js @@ -0,0 +1,50 @@ +/* eslint no-console: 0 */ + +import Vue from 'vue' +import { BootstrapVue, BootstrapVueIcons } from 'bootstrap-vue' +import 'bootstrap/dist/css/bootstrap.css' +import 'bootstrap-vue/dist/bootstrap-vue.css' +import cookieJar from '@/javascript/shared/cookieJar.js' +import PoolXPTubeSubmitPanel from './components/PoolXPTubeSubmitPanel.vue' + +Vue.use(BootstrapVue) +Vue.use(BootstrapVueIcons) +document.addEventListener('DOMContentLoaded', async () => { + const assetElem = document.getElementById('pool-xp-tube-submit-panel') + const missingUserIdError = ` + Unfortunately Limber can't find your user id, which is required to add custom metadata. + Click log out and swipe in again to resolve this. + If this problem occurs repeatedly, let us know. + ` + + if (assetElem) { + /* The labware-custom-metadata element isn't on all pages. So only initialize our + * Vue app if we actually find it */ + const userId = cookieJar(document.cookie).user_id + const sequencescapeApiUrl = assetElem.dataset.sequencescapeApi + const tractionServiceUrl = assetElem.dataset.tractionServiceUrl + const tractionUIUrl = assetElem.dataset.tractionUiUrl + // UserId is required to make custom metadata, but will not be present in + // older session cookies. To avoid errors or confusion, we render + // a very basic vue component (essentially just an error message) + // if userId is missing + + if (userId) { + new Vue({ + el: '#pool-xp-tube-submit-panel', + render(h) { + let barcode = this.$el.dataset.barcode + + return h(PoolXPTubeSubmitPanel, { + props: { barcode, userId, sequencescapeApiUrl, tractionServiceUrl, tractionUIUrl }, + }) + }, + }) + } else { + new Vue({ + el: '#pool-xp-tube-submit-panel', + render: (h) => h('div', missingUserIdError), + }) + } + } +}) diff --git a/app/models/presenters/pcr_pool_xp_presenter.rb b/app/models/presenters/pcr_pool_xp_presenter.rb new file mode 100644 index 000000000..cce6fa01c --- /dev/null +++ b/app/models/presenters/pcr_pool_xp_presenter.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Presenters + # PcrPoolXpPresenter + # This class is responsible for presenting PCR Pool XP tube data. + # It inherits from FinalTubePresenter and provides methods + # to export data to Traction. + class PcrPoolXpPresenter < FinalTubePresenter + # Enables the export of the PCR Pool XP tube to Traction if tube is in passed state. + def export_to_traction + state == 'passed' + end + end +end diff --git a/app/views/tubes/sidebars/_default.html.erb b/app/views/tubes/sidebars/_default.html.erb index de8408cff..e69e7a06e 100644 --- a/app/views/tubes/sidebars/_default.html.erb +++ b/app/views/tubes/sidebars/_default.html.erb @@ -2,6 +2,17 @@ <%= render partial: 'tube_printing' %> <%= render(partial: 'labware/qc_data_upload') %> + <% if presenter.try(:export_to_traction)%> + <%= card title: 'Export to Traction' do %> +
+
+ <%end%> +<%end%> <%= card title: 'QC Information' do %>
<%= simple_state_change_form(@presenter) %> <% end %> + <% end %> + <%= card title: 'Adding a Comment' do %>
<% end %> diff --git a/config/environments/development.rb b/config/environments/development.rb index 72c5ef6fe..afcb2b5fe 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -80,6 +80,8 @@ def rewrite_localhost(url) config.request_options = { 'read_length' => 11 } config.pmb_uri = ENV.fetch('PMB_URI', rewrite_localhost('http://localhost:3002/v1/')) config.sprint_uri = 'http://sprint.psd.sanger.ac.uk/graphql' + config.traction_ui_uri = 'http://localhost:5173/#' + config.traction_service_uri = 'http://localhost:3100/v1' # Enable 'work in progress' pipelines by default in development mode, to save having to rename files. # Configured for other environments in the deployment project. diff --git a/config/environments/test.rb b/config/environments/test.rb index ed6751e91..1eecb6eb1 100644 --- a/config/environments/test.rb +++ b/config/environments/test.rb @@ -73,4 +73,6 @@ config.disable_animations = true config.sprint_uri = 'http://example_sprint.com/graphql' + config.traction_ui_uri = 'http://localhost:5173/#' + config.traction_service_uri = 'http://localhost:3100/v1' end diff --git a/config/purposes/bioscan.yml b/config/purposes/bioscan.yml index 5d33464f4..1d9e3f936 100644 --- a/config/purposes/bioscan.yml +++ b/config/purposes/bioscan.yml @@ -158,7 +158,7 @@ LBSN-9216 Lib PCR Pool XP: :target: MultiplexedLibraryTube :type: IlluminaHtp::MxTubePurpose :creator_class: LabwareCreators::TubeFromTube - :presenter_class: Presenters::FinalTubePresenter + :presenter_class: Presenters::PcrPoolXpPresenter :file_links: - name: 'Download MBrave UMI file' id: 'bioscan_mbrave' diff --git a/spec/models/presenters/pcr_pool_xp_presenter_spec.rb b/spec/models/presenters/pcr_pool_xp_presenter_spec.rb new file mode 100644 index 000000000..653a7272c --- /dev/null +++ b/spec/models/presenters/pcr_pool_xp_presenter_spec.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +require 'rails_helper' +require 'presenters/tube_presenter' +require_relative 'shared_labware_presenter_examples' + +RSpec.describe Presenters::PcrPoolXpPresenter do + let(:labware) do + build :v2_tube, purpose_name: purpose_name, state: state, barcode_number: 6, created_at: '2016-10-19 12:00:00 +0100' + end + + before { create(:stock_plate_config, uuid: 'stock-plate-purpose-uuid') } + + let(:purpose_name) { 'Limber example purpose' } + let(:title) { purpose_name } + let(:state) { 'passed' } + let(:summary_tab) do + [ + ['Barcode', 'NT6T 3980000006844'], + ['Tube type', purpose_name], + ['Current tube state', state], + ['Input plate barcode', labware.stock_plate.human_barcode], + ['Created on', '2016-10-19'] + ] + end + let(:sidebar_partial) { 'default' } + + subject { Presenters::PcrPoolXpPresenter.new(labware:) } + + it_behaves_like 'a labware presenter' + + it 'has export_to_traction option' do + expect(subject.export_to_traction).to be_truthy + end + + it 'has no export_to_traction option' do + labware.state = 'pending' + expect(subject.export_to_traction).to be_falsey + end +end