diff --git a/.nvmrc b/.nvmrc
index 80a9956e1..bb8c76c68 100644
--- a/.nvmrc
+++ b/.nvmrc
@@ -1 +1 @@
-v20.16.0
+v22.11.0
diff --git a/.release-version b/.release-version
index deedb06e0..9b6c27198 100644
--- a/.release-version
+++ b/.release-version
@@ -1 +1 @@
-3.63.0
+3.64.0
diff --git a/Gemfile b/Gemfile
index 4fbf055d1..da5cc3652 100644
--- a/Gemfile
+++ b/Gemfile
@@ -45,7 +45,7 @@ group :test do
gem 'launchy' # Used by capybara for eg. save_and_open_screenshot
gem 'rails-controller-testing'
gem 'rspec-json_expectations'
- gem 'rspec-rails', '6.1.3'
+ gem 'rspec-rails'
gem 'selenium-webdriver', '~> 4.1', require: false
gem 'simplecov', require: false
gem 'simplecov-lcov', require: false
diff --git a/Gemfile.lock b/Gemfile.lock
index d6c0e887d..1a4c0abd5 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -28,35 +28,35 @@ GIT
GEM
remote: https://rubygems.org/
specs:
- actioncable (7.1.4)
- actionpack (= 7.1.4)
- activesupport (= 7.1.4)
+ actioncable (7.1.5)
+ actionpack (= 7.1.5)
+ activesupport (= 7.1.5)
nio4r (~> 2.0)
websocket-driver (>= 0.6.1)
zeitwerk (~> 2.6)
- actionmailbox (7.1.4)
- actionpack (= 7.1.4)
- activejob (= 7.1.4)
- activerecord (= 7.1.4)
- activestorage (= 7.1.4)
- activesupport (= 7.1.4)
+ actionmailbox (7.1.5)
+ actionpack (= 7.1.5)
+ activejob (= 7.1.5)
+ activerecord (= 7.1.5)
+ activestorage (= 7.1.5)
+ activesupport (= 7.1.5)
mail (>= 2.7.1)
net-imap
net-pop
net-smtp
- actionmailer (7.1.4)
- actionpack (= 7.1.4)
- actionview (= 7.1.4)
- activejob (= 7.1.4)
- activesupport (= 7.1.4)
+ actionmailer (7.1.5)
+ actionpack (= 7.1.5)
+ actionview (= 7.1.5)
+ activejob (= 7.1.5)
+ activesupport (= 7.1.5)
mail (~> 2.5, >= 2.5.4)
net-imap
net-pop
net-smtp
rails-dom-testing (~> 2.2)
- actionpack (7.1.4)
- actionview (= 7.1.4)
- activesupport (= 7.1.4)
+ actionpack (7.1.5)
+ actionview (= 7.1.5)
+ activesupport (= 7.1.5)
nokogiri (>= 1.8.5)
racc
rack (>= 2.2.4)
@@ -64,48 +64,52 @@ GEM
rack-test (>= 0.6.3)
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
- actiontext (7.1.4)
- actionpack (= 7.1.4)
- activerecord (= 7.1.4)
- activestorage (= 7.1.4)
- activesupport (= 7.1.4)
+ actiontext (7.1.5)
+ actionpack (= 7.1.5)
+ activerecord (= 7.1.5)
+ activestorage (= 7.1.5)
+ activesupport (= 7.1.5)
globalid (>= 0.6.0)
nokogiri (>= 1.8.5)
- actionview (7.1.4)
- activesupport (= 7.1.4)
+ actionview (7.1.5)
+ activesupport (= 7.1.5)
builder (~> 3.1)
erubi (~> 1.11)
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
- activejob (7.1.4)
- activesupport (= 7.1.4)
+ activejob (7.1.5)
+ activesupport (= 7.1.5)
globalid (>= 0.3.6)
- activemodel (7.1.4)
- activesupport (= 7.1.4)
- activerecord (7.1.4)
- activemodel (= 7.1.4)
- activesupport (= 7.1.4)
+ activemodel (7.1.5)
+ activesupport (= 7.1.5)
+ activerecord (7.1.5)
+ activemodel (= 7.1.5)
+ activesupport (= 7.1.5)
timeout (>= 0.4.0)
- activestorage (7.1.4)
- actionpack (= 7.1.4)
- activejob (= 7.1.4)
- activerecord (= 7.1.4)
- activesupport (= 7.1.4)
+ activestorage (7.1.5)
+ actionpack (= 7.1.5)
+ activejob (= 7.1.5)
+ activerecord (= 7.1.5)
+ activesupport (= 7.1.5)
marcel (~> 1.0)
- activesupport (7.1.4)
+ activesupport (7.1.5)
base64
+ benchmark (>= 0.3)
bigdecimal
concurrent-ruby (~> 1.0, >= 1.0.2)
connection_pool (>= 2.2.5)
drb
i18n (>= 1.6, < 2)
+ logger (>= 1.4.2)
minitest (>= 5.1)
mutex_m
+ securerandom (>= 0.3)
tzinfo (~> 2.0)
addressable (2.8.7)
public_suffix (>= 2.0.2, < 7.0)
ast (2.4.2)
base64 (0.2.0)
+ benchmark (0.3.0)
bigdecimal (3.1.8)
bindex (0.8.1)
bootsnap (1.18.4)
@@ -132,7 +136,7 @@ GEM
bigdecimal
rexml
crass (1.0.6)
- date (3.3.4)
+ date (3.4.0)
diff-lcs (1.5.1)
docile (1.4.0)
drb (2.2.1)
@@ -206,8 +210,8 @@ GEM
listen (3.9.0)
rb-fsevent (~> 0.10, >= 0.10.3)
rb-inotify (~> 0.9, >= 0.9.10)
- logger (1.6.0)
- loofah (2.22.0)
+ logger (1.6.1)
+ loofah (2.23.1)
crass (~> 1.0.2)
nokogiri (>= 1.12.0)
lumberjack (1.2.10)
@@ -227,7 +231,7 @@ GEM
multipart-post (2.4.0)
mutex_m (0.2.0)
nenv (0.3.0)
- net-imap (0.4.14)
+ net-imap (0.5.0)
date
net-protocol
net-pop (0.1.2)
@@ -236,14 +240,14 @@ GEM
timeout
net-smtp (0.5.0)
net-protocol
- nio4r (2.7.3)
+ nio4r (2.7.4)
nokogiri (1.16.7)
mini_portile2 (~> 2.8.2)
racc (~> 1.4)
notiffany (0.1.3)
nenv (~> 0.1)
shellany (~> 0.0)
- oj (3.16.5)
+ oj (3.16.7)
bigdecimal (>= 3.0)
ostruct (>= 0.2)
ostruct (0.6.0)
@@ -273,23 +277,22 @@ GEM
rack (>= 3.0.0)
rack-test (2.1.0)
rack (>= 1.3)
- rackup (2.1.0)
+ rackup (2.2.0)
rack (>= 3)
- webrick (~> 1.8)
- rails (7.1.4)
- actioncable (= 7.1.4)
- actionmailbox (= 7.1.4)
- actionmailer (= 7.1.4)
- actionpack (= 7.1.4)
- actiontext (= 7.1.4)
- actionview (= 7.1.4)
- activejob (= 7.1.4)
- activemodel (= 7.1.4)
- activerecord (= 7.1.4)
- activestorage (= 7.1.4)
- activesupport (= 7.1.4)
+ rails (7.1.5)
+ actioncable (= 7.1.5)
+ actionmailbox (= 7.1.5)
+ actionmailer (= 7.1.5)
+ actionpack (= 7.1.5)
+ actiontext (= 7.1.5)
+ actionview (= 7.1.5)
+ activejob (= 7.1.5)
+ activemodel (= 7.1.5)
+ activerecord (= 7.1.5)
+ activestorage (= 7.1.5)
+ activesupport (= 7.1.5)
bundler (>= 1.15.0)
- railties (= 7.1.4)
+ railties (= 7.1.5)
rails-controller-testing (1.0.5)
actionpack (>= 5.0.1.rc1)
actionview (>= 5.0.1.rc1)
@@ -301,9 +304,9 @@ GEM
rails-html-sanitizer (1.6.0)
loofah (~> 2.21)
nokogiri (~> 1.14)
- railties (7.1.4)
- actionpack (= 7.1.4)
- activesupport (= 7.1.4)
+ railties (7.1.5)
+ actionpack (= 7.1.5)
+ activesupport (= 7.1.5)
irb
rackup (>= 1.0.0)
rake (>= 12.2)
@@ -321,8 +324,7 @@ GEM
regexp_parser (2.9.2)
reline (0.5.10)
io-console (~> 0.5)
- rexml (3.3.6)
- strscan
+ rexml (3.3.9)
rspec (3.13.0)
rspec-core (~> 3.13.0)
rspec-expectations (~> 3.13.0)
@@ -336,7 +338,7 @@ GEM
rspec-mocks (3.13.1)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.13.0)
- rspec-rails (6.1.3)
+ rspec-rails (6.1.4)
actionpack (>= 6.1)
activesupport (>= 6.1)
railties (>= 6.1)
@@ -370,7 +372,8 @@ GEM
ruby-units (4.0.3)
ruby2_keywords (0.0.5)
rubyzip (2.3.2)
- selenium-webdriver (4.23.0)
+ securerandom (0.3.1)
+ selenium-webdriver (4.26.0)
base64 (~> 0.2)
logger (~> 1.4)
rexml (~> 3.2, >= 3.2.5)
@@ -392,7 +395,6 @@ GEM
sprint_client (0.1.0)
state_machines (0.6.0)
stringio (3.1.1)
- strscan (3.1.0)
syntax_tree (6.2.0)
prettier_print (>= 1.2.0)
syntax_tree-haml (4.0.3)
@@ -427,7 +429,6 @@ GEM
addressable (>= 2.8.0)
crack (>= 0.3.2)
hashdiff (>= 0.4.0, < 2.0.0)
- webrick (1.8.2)
websocket (1.2.11)
websocket-driver (0.7.6)
websocket-extensions (>= 0.1.0)
@@ -435,7 +436,7 @@ GEM
xpath (3.2.0)
nokogiri (~> 1.8)
yard (0.9.36)
- zeitwerk (2.6.18)
+ zeitwerk (2.7.1)
PLATFORMS
ruby
@@ -461,7 +462,7 @@ DEPENDENCIES
rails-controller-testing
rake
rspec-json_expectations
- rspec-rails (= 6.1.3)
+ rspec-rails
rubocop
rubocop-performance
rubocop-rails
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 @@
+
+