@morscherlab/mint-sdk 1.4.0-beta.17 → 1.4.0-beta.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/README.md +14 -3
  2. package/dist/{AlertBox-SnsbVow-.js → AlertBox-CzLg0UaL.js} +2 -2
  3. package/dist/{AlertBox-SnsbVow-.js.map → AlertBox-CzLg0UaL.js.map} +1 -1
  4. package/dist/{AppSidebar-CB0-RQ4m.js → AppSidebar-6oTT-gPO.js} +43 -13
  5. package/dist/AppSidebar-6oTT-gPO.js.map +1 -0
  6. package/dist/{ExperimentPopover-ChKCrklH.js → ExperimentPopover-9l4QZlvQ.js} +2 -2
  7. package/dist/{ExperimentPopover-ChKCrklH.js.map → ExperimentPopover-9l4QZlvQ.js.map} +1 -1
  8. package/dist/{ExperimentSelectorModal-BqifjNaN.js → ExperimentSelectorModal-pR5fK3pA.js} +3 -3
  9. package/dist/{ExperimentSelectorModal-BqifjNaN.js.map → ExperimentSelectorModal-pR5fK3pA.js.map} +1 -1
  10. package/dist/{SegmentedControl-hOgBVdju.js → SegmentedControl-CJ7EAmOq.js} +2 -2
  11. package/dist/{SegmentedControl-hOgBVdju.js.map → SegmentedControl-CJ7EAmOq.js.map} +1 -1
  12. package/dist/{SettingsModal-CoZK3EDN.js → SettingsModal-Dyitgzfp.js} +6 -6
  13. package/dist/{SettingsModal-CoZK3EDN.js.map → SettingsModal-Dyitgzfp.js.map} +1 -1
  14. package/dist/components/AppTopBar.vue.d.ts +7 -0
  15. package/dist/components/JobsStatusTray.vue.d.ts +11 -4
  16. package/dist/components/PluginWorkspaceView.props.d.ts +3 -0
  17. package/dist/components/PluginWorkspaceView.vue.d.ts +3 -0
  18. package/dist/components/internal/WorkspaceShellInternal.vue.d.ts +8 -0
  19. package/dist/composables/useJobCenter.d.ts +11 -3
  20. package/dist/{controlSchemaModel-CIH5Hqpw.js → controlSchemaModel-DCUOx8ll.js} +2 -2
  21. package/dist/{controlSchemaModel-CIH5Hqpw.js.map → controlSchemaModel-DCUOx8ll.js.map} +1 -1
  22. package/dist/{data-OZ94DhVC.js → data-BIiV4kKw.js} +9 -9
  23. package/dist/{data-OZ94DhVC.js.map → data-BIiV4kKw.js.map} +1 -1
  24. package/dist/data.js +7 -7
  25. package/dist/index.js +16 -16
  26. package/dist/{install-BKgb5qZw.js → install-Dg-Z6ze5.js} +13 -13
  27. package/dist/{install-BKgb5qZw.js.map → install-Dg-Z6ze5.js.map} +1 -1
  28. package/dist/install.js +1 -1
  29. package/dist/{lab-ZDKlx1Oo.js → lab-BHVT1qN2.js} +9 -9
  30. package/dist/{lab-ZDKlx1Oo.js.map → lab-BHVT1qN2.js.map} +1 -1
  31. package/dist/lab.js +4 -4
  32. package/dist/{records-B4yl2oIu.js → records-CR_pYzIP.js} +62 -62
  33. package/dist/{records-B4yl2oIu.js.map → records-CR_pYzIP.js.map} +1 -1
  34. package/dist/{runtime-Cz1GJa_O.js → runtime-cHv1phcc.js} +379 -31
  35. package/dist/runtime-cHv1phcc.js.map +1 -0
  36. package/dist/runtime.d.ts +1 -1
  37. package/dist/runtime.js +6 -6
  38. package/dist/{shell-kq9p4VsX.js → shell-BDuZf2zb.js} +4 -4
  39. package/dist/{shell-kq9p4VsX.js.map → shell-BDuZf2zb.js.map} +1 -1
  40. package/dist/shell.js +4 -4
  41. package/dist/styles.css +147 -307
  42. package/dist/types/components.d.ts +2 -0
  43. package/dist/{useExperimentSamples-BLx29CkM.js → useExperimentSamples-Dno1fFMq.js} +3 -3
  44. package/dist/{useExperimentSamples-BLx29CkM.js.map → useExperimentSamples-Dno1fFMq.js.map} +1 -1
  45. package/dist/{useListReorder-C_8RYeVP.js → useListReorder-CQ9OCScc.js} +76 -76
  46. package/dist/useListReorder-CQ9OCScc.js.map +1 -0
  47. package/dist/{useRackEditor-BhYlXKFM.js → useRackEditor-wL3kPrRT.js} +6 -6
  48. package/dist/{useRackEditor-BhYlXKFM.js.map → useRackEditor-wL3kPrRT.js.map} +1 -1
  49. package/dist/useRealtime-BW02xy4Z.js +398 -0
  50. package/dist/useRealtime-BW02xy4Z.js.map +1 -0
  51. package/dist/{useRequest-C9uwM79q.js → useRequest-CDkrXNU7.js} +2 -2
  52. package/dist/{useRequest-C9uwM79q.js.map → useRequest-CDkrXNU7.js.map} +1 -1
  53. package/package.json +1 -1
  54. package/src/__tests__/components/AppTopBar.test.ts +33 -2
  55. package/src/__tests__/components/GeneratedPluginView.test.ts +1 -1
  56. package/src/__tests__/components/JobsStatusTray.test.ts +137 -71
  57. package/src/__tests__/components/PluginWorkspaceView.test.ts +56 -1
  58. package/src/__tests__/composables/useJobCenter.test.ts +223 -3
  59. package/src/__tests__/docs/frontendDocsCatalog.test.ts +3 -3
  60. package/src/components/AppTopBar.vue +14 -0
  61. package/src/components/GeneratedPluginView.vue +4 -9
  62. package/src/components/JobsStatusTray.story.vue +78 -43
  63. package/src/components/JobsStatusTray.vue +171 -279
  64. package/src/components/PluginWorkspaceView.props.ts +3 -0
  65. package/src/components/PluginWorkspaceView.vue +17 -0
  66. package/src/components/internal/WorkspaceShellInternal.vue +12 -0
  67. package/src/composables/useJobCenter.ts +127 -6
  68. package/src/runtime.ts +0 -1
  69. package/src/styles/components/app-top-bar.css +7 -0
  70. package/src/styles/components/jobs-status-tray.css +129 -296
  71. package/src/types/components.ts +2 -0
  72. package/dist/AppSidebar-CB0-RQ4m.js.map +0 -1
  73. package/dist/runtime-Cz1GJa_O.js.map +0 -1
  74. package/dist/useListReorder-C_8RYeVP.js.map +0 -1
  75. package/dist/useRealtime-CUMbW_ln.js +0 -699
  76. package/dist/useRealtime-CUMbW_ln.js.map +0 -1
@@ -1505,7 +1505,10 @@ describe('AppTopBar', () => {
1505
1505
  })
1506
1506
  })
1507
1507
  describe('run slot', () => {
1508
- function mountRunSlot(job: TopBarJob | null | undefined, { experiment }: { experiment: boolean }) {
1508
+ function mountRunSlot(
1509
+ job: TopBarJob | null | undefined,
1510
+ { experiment, jobsDone, jobsOpen, jobsPanelId }: { experiment: boolean; jobsDone?: number; jobsOpen?: boolean; jobsPanelId?: string },
1511
+ ) {
1509
1512
  vi.mocked(usePlatform).mockReturnValueOnce({
1510
1513
  integrated: computed(() => experiment),
1511
1514
  isAdmin: computed(() => true),
@@ -1524,7 +1527,9 @@ describe('AppTopBar', () => {
1524
1527
  handleDetach: vi.fn(),
1525
1528
  } as unknown as AppExperimentState
1526
1529
  return mount(AppTopBar, {
1527
- props: job === undefined ? { title: 'LEAF' } : { title: 'LEAF', job },
1530
+ props: job === undefined
1531
+ ? { title: 'LEAF', jobsDone, jobsOpen, jobsPanelId }
1532
+ : { title: 'LEAF', job, jobsDone, jobsOpen, jobsPanelId },
1528
1533
  global: {
1529
1534
  plugins: [createPinia()],
1530
1535
  provide: { [APP_EXPERIMENT_KEY as symbol]: appExperiment },
@@ -1648,6 +1653,32 @@ describe('AppTopBar', () => {
1648
1653
  if (bar) expect(chip.get('.mint-topbar__job-bar > span').attributes('style')).toContain('width: 40%')
1649
1654
  })
1650
1655
 
1656
+ it.each([
1657
+ [null, undefined, null, undefined],
1658
+ [null, 0, null, undefined],
1659
+ [null, 2, '2', 'Jobs, 2 completed'],
1660
+ [running, 2, null, 'Extracting 40%'],
1661
+ ])('should show the completed count only on the idle button when the job is %o and jobsDone %s', async (job, jobsDone, count, name) => {
1662
+ const wrapper = mountRunSlot(job, { experiment: false, jobsDone })
1663
+ await waitForAsyncComponents(wrapper)
1664
+
1665
+ const chip = wrapper.get('.mint-topbar__job')
1666
+ expect([chip.find('.mint-topbar__job-count').exists() ? chip.get('.mint-topbar__job-count').text() : null, chip.attributes('aria-label')])
1667
+ .toEqual([count, name])
1668
+ })
1669
+
1670
+ it.each([
1671
+ [null, undefined, undefined, [undefined, undefined]],
1672
+ [null, true, 'jobs-panel', ['true', 'jobs-panel']],
1673
+ [running, false, 'jobs-panel', ['false', 'jobs-panel']],
1674
+ ])('should expose the tray state on the job control when it is %o and open is %s', async (job, jobsOpen, jobsPanelId, expected) => {
1675
+ const wrapper = mountRunSlot(job, { experiment: false, jobsOpen, jobsPanelId })
1676
+ await waitForAsyncComponents(wrapper)
1677
+
1678
+ const chip = wrapper.get('.mint-topbar__job')
1679
+ expect([chip.attributes('aria-expanded'), chip.attributes('aria-controls')]).toEqual(expected)
1680
+ })
1681
+
1651
1682
  it.each([null, running])('should emit job-click when the job control is %s', async (job) => {
1652
1683
  const wrapper = mountRunSlot(job, { experiment: false })
1653
1684
  await waitForAsyncComponents(wrapper)
@@ -147,7 +147,7 @@ describe('GeneratedPluginView', () => {
147
147
  workspace: wrapper.findComponent(PluginWorkspaceView).props(),
148
148
  result: wrapper.findComponent(GeneratedResultRenderer).text(),
149
149
  jobs: wrapper.findComponent(JobsStatusTray).exists(),
150
- jobSource: wrapper.findComponent(JobsStatusTray).props('source'),
150
+ jobSource: wrapper.findComponent(PluginWorkspaceView).props('spec')?.jobs,
151
151
  }).toMatchObject({
152
152
  workspace: {
153
153
  spec: {
@@ -1,9 +1,9 @@
1
1
  import { mount } from '@vue/test-utils'
2
2
  import { describe, expect, it, vi } from 'vitest'
3
- import { h } from 'vue'
3
+ import { defineComponent, h, nextTick } from 'vue'
4
4
 
5
5
  import JobsStatusTray from '../../components/JobsStatusTray.vue'
6
- import type { PluginJobCenterSource } from '../../composables/useJobCenter'
6
+ import { useJobCenter, type PluginJobCenterSource, type UseJobCenterReturn } from '../../composables/useJobCenter'
7
7
  import type { JobState, JobStateInput, JobStatus } from '../../types/jobs'
8
8
 
9
9
  function job(status: JobStatus, id: string = status): JobStateInput {
@@ -44,32 +44,35 @@ async function openTray(options: {
44
44
  source: PluginJobCenterSource
45
45
  slots?: Record<string, (props: { job: JobState }) => unknown>
46
46
  }) {
47
- const { slots, ...props } = options
48
- const wrapper = mount(JobsStatusTray, {
49
- attachTo: document.body,
50
- props: {
51
- ...props,
52
- loadOnMount: false,
53
- teleportTo: false,
47
+ const Host = defineComponent({
48
+ setup() {
49
+ const center = useJobCenter({ source: options.source, loadOnMount: false })
50
+ return () => [
51
+ h('button', { class: 'opener', onClick: center.toggle }, 'Jobs'),
52
+ h(JobsStatusTray, { center, teleportTo: false }, options.slots),
53
+ ]
54
54
  },
55
- slots,
56
55
  })
57
- await wrapper.get('.mint-jobs-tray__trigger').trigger('click')
56
+ const wrapper = mount(Host, { attachTo: document.body })
57
+ const opener = wrapper.get('.opener')
58
+ ;(opener.element as HTMLElement).focus()
59
+ await opener.trigger('click')
58
60
  return wrapper
59
61
  }
60
62
 
61
63
  describe('JobsStatusTray', () => {
62
- it('shows awaiting input as active without default actions', async () => {
64
+ it('should show awaiting input as active without default actions when the job has no capability', async () => {
63
65
  const wrapper = await openTray({ source: { jobs: [job('awaiting_input')] } })
64
66
 
65
67
  expect([
66
- wrapper.text().includes('Waiting for input'),
67
- wrapper.find('[aria-label="Cancel job"]').exists(),
68
- wrapper.find('[aria-label="Delete job"]').exists(),
69
- ]).toEqual([true, false, false])
68
+ wrapper.find('[data-section="active"]').exists(),
69
+ wrapper.find('[role="img"][aria-label="Waiting for input"]').exists(),
70
+ wrapper.find('[aria-label="Cancel Job awaiting_input"]').exists(),
71
+ wrapper.find('[aria-label="Delete Job awaiting_input"]').exists(),
72
+ ]).toEqual([true, true, false, false])
70
73
  })
71
74
 
72
- it('shows only actions supported by both capability and source', async () => {
75
+ it('should show only actions supported by both capability and source when rows render', async () => {
73
76
  const wrapper = await openTray({
74
77
  source: {
75
78
  jobs: [
@@ -82,24 +85,9 @@ describe('JobsStatusTray', () => {
82
85
  })
83
86
 
84
87
  expect([
85
- wrapper.find('[aria-label="Cancel job"]').exists(),
86
- wrapper.find('[aria-label="Delete job"]').exists(),
87
- ]).toEqual([true, false])
88
-
89
- await wrapper.get('[role="tab"][data-tab="history"]').trigger('click')
90
- expect(wrapper.find('[aria-label="Delete job"]').exists()).toBe(true)
91
- })
92
-
93
- it('exposes dialog state and closes on Escape', async () => {
94
- const wrapper = await openTray({ source: { jobs: [job('running')] } })
95
- const trigger = wrapper.get('.mint-jobs-tray__trigger')
96
-
97
- await wrapper.get('[role="dialog"]').trigger('keydown', { key: 'Escape' })
98
-
99
- expect([trigger.attributes('aria-expanded'), wrapper.find('[role="dialog"]').exists()]).toEqual([
100
- 'false',
101
- false,
102
- ])
88
+ wrapper.find('[data-status="awaiting_input"] [aria-label="Cancel Job input"]').exists(),
89
+ wrapper.find('[data-status="completed"] [aria-label="Delete Job done"]').exists(),
90
+ ]).toEqual([true, true])
103
91
  })
104
92
 
105
93
  it('renders an individual delete action for an active capable job', async () => {
@@ -110,83 +98,161 @@ describe('JobsStatusTray', () => {
110
98
  },
111
99
  })
112
100
 
113
- expect(wrapper.find('[aria-label="Delete job"]').exists()).toBe(true)
101
+ expect(wrapper.find('[aria-label="Delete Job awaiting_input"]').exists()).toBe(true)
102
+ })
103
+
104
+ it('should render a non-modal panel that does not trap Tab when open', async () => {
105
+ const wrapper = await openTray({ source: { jobs: [job('running')] } })
106
+ const panel = wrapper.get('.mint-jobs-tray__panel')
107
+ const tab = new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true })
108
+ panel.element.dispatchEvent(tab)
109
+
110
+ expect([
111
+ panel.attributes('role'),
112
+ panel.attributes('aria-modal'),
113
+ tab.defaultPrevented,
114
+ ]).toEqual([undefined, undefined, false])
114
115
  })
115
116
 
116
- it('traps forward Tab navigation inside the modal dialog', async () => {
117
+ it('should close and return focus to the opener when Escape is pressed', async () => {
117
118
  const wrapper = await openTray({ source: { jobs: [job('running')] } })
118
- const focusable = wrapper.findAll('button')
119
- const firstInsidePanel = wrapper.get('[aria-label="Close jobs"]')
120
- const lastInsidePanel = focusable[focusable.length - 1]!
121
- lastInsidePanel.element.focus()
119
+ expect(document.activeElement).toBe(wrapper.get('.mint-jobs-tray__panel').element)
122
120
 
123
- await wrapper.get('[role="dialog"]').trigger('keydown', { key: 'Tab' })
121
+ await wrapper.get('.mint-jobs-tray__panel').trigger('keydown', { key: 'Escape' })
124
122
 
125
- expect(document.activeElement).toBe(firstInsidePanel.element)
123
+ expect([
124
+ wrapper.find('.mint-jobs-tray__panel').exists(),
125
+ document.activeElement,
126
+ ]).toEqual([false, wrapper.get('.opener').element])
126
127
  })
127
128
 
128
- it('traps reverse Tab navigation from the dialog container', async () => {
129
+ it('should close when the page outside the panel is clicked', async () => {
129
130
  const wrapper = await openTray({ source: { jobs: [job('running')] } })
130
- const panel = wrapper.get('[role="dialog"]')
131
- const focusable = panel.findAll('button')
132
- const panelElement = panel.element as HTMLElement
133
- panelElement.focus()
134
131
 
135
- await panel.trigger('keydown', { key: 'Tab', shiftKey: true })
132
+ await wrapper.get('.mint-jobs-tray__scrim').trigger('click')
136
133
 
137
- expect(document.activeElement).toBe(focusable[focusable.length - 1]!.element)
134
+ expect(wrapper.find('.mint-jobs-tray__panel').exists()).toBe(false)
138
135
  })
139
136
 
140
- it('exposes job selection through a keyboard-accessible button', async () => {
137
+ it('should emit select from a keyboard-accessible title button when a row is chosen', async () => {
141
138
  const wrapper = await openTray({ source: { jobs: [job('running')] } })
142
139
 
143
- await wrapper.get('.mint-jobs-tray__job-select').trigger('click')
140
+ await wrapper.get('.mint-jobs-tray__row-title').trigger('click')
144
141
 
145
- expect(wrapper.emitted('select')?.[0]?.[0]).toMatchObject({ job_id: 'running' })
142
+ expect(wrapper.findComponent(JobsStatusTray).emitted('select')?.[0]?.[0]).toMatchObject({ job_id: 'running' })
146
143
  })
147
144
 
148
- it('renders the job-actions slot once per row in both tabs with the row job', async () => {
145
+ it('should render each row slot once per row with the row job when slots are given', async () => {
149
146
  const wrapper = await openTray({
150
147
  source: { jobs: [job('running', 'R-1'), job('completed', 'C-1')] },
151
148
  slots: {
152
- 'job-actions': ({ job: row }) =>
153
- h('button', { 'data-testid': 'open', 'data-job': row.job_id }, 'Open'),
149
+ 'job-actions': ({ job: row }) => h('button', { 'data-testid': 'open', 'data-job': row.job_id }, 'Open'),
150
+ 'job-title': ({ job: row }) => h('span', { 'data-testid': 'title' }, `Title ${row.job_id}`),
151
+ 'job-detail': ({ job: row }) => h('span', { 'data-testid': 'detail' }, `Detail ${row.job_id}`),
154
152
  },
155
153
  })
156
154
 
157
- const active = wrapper.findAll('[data-testid="open"]')
158
- expect(active.map((b) => b.attributes('data-job'))).toEqual(['R-1'])
159
-
160
- await wrapper.get('[role="tab"][data-tab="history"]').trigger('click')
161
-
162
- const history = wrapper.findAll('[data-testid="open"]')
163
- expect(history.map((b) => b.attributes('data-job'))).toEqual(['C-1'])
155
+ expect(['open', 'title', 'detail'].map(id => wrapper.findAll(`[data-testid="${id}"]`).map(el => el.attributes('data-job') ?? el.text())))
156
+ .toEqual([['R-1', 'C-1'], ['Title R-1', 'Title C-1'], ['Detail R-1', 'Detail C-1']])
164
157
  })
165
158
 
166
159
  it('should pin awaiting-input jobs to the top when they arrive after other active jobs', async () => {
167
160
  const wrapper = await openTray({
168
161
  source: { jobs: [job('running', 'R-1'), job('queued', 'Q-1'), job('awaiting_input', 'A-1')] },
169
162
  })
170
- expect(wrapper.findAll('.mint-jobs-tray__job-select').map((b) => b.text()))
163
+ expect(wrapper.findAll('.mint-jobs-tray__row-title').map((b) => b.text()))
171
164
  .toEqual(['Job A-1', 'Job R-1', 'Job Q-1'])
172
165
  })
173
166
 
174
167
  it('should tint the awaiting-input row when a job waits for input', async () => {
175
168
  const wrapper = await openTray({ source: { jobs: [job('awaiting_input')] } })
176
- expect(wrapper.get('.mint-jobs-tray__job').classes()).toContain('mint-jobs-tray__job--awaiting-input')
169
+ expect(wrapper.get('.mint-jobs-tray__row').classes()).toContain('mint-jobs-tray__row--awaiting-input')
177
170
  })
178
171
 
179
- it('should show a mono percent next to the status when a job is running', async () => {
172
+ it('should show a mono percent as the row value when a job is running', async () => {
180
173
  const running = job('running')
181
174
  running.progress = { ...running.progress, percent: 62 }
182
175
  const wrapper = await openTray({ source: { jobs: [running] } })
183
- expect(wrapper.get('.mint-jobs-tray__percent').text()).toBe('62%')
176
+ expect([wrapper.get('.mint-jobs-tray__value').text(), wrapper.get('[role="progressbar"]').attributes('aria-label')])
177
+ .toEqual(['62%', 'Job running progress'])
184
178
  })
185
179
 
186
- it('should summarize active jobs by state in the header when jobs are active', async () => {
187
- const wrapper = await openTray({
188
- source: { jobs: [job('running', 'R-1'), job('queued', 'Q-1'), job('awaiting_input', 'A-1')] },
180
+ it('should show the error in error ink when a job failed', async () => {
181
+ const wrapper = await openTray({ source: { jobs: [{ ...job('failed'), error: 'Peaks not found' }] } })
182
+ const detail = wrapper.get('.mint-jobs-tray__detail')
183
+ expect([detail.text(), detail.classes()]).toEqual(['Peaks not found', expect.arrayContaining(['mint-jobs-tray__detail--error'])])
184
+ })
185
+
186
+ it.each([
187
+ [[job('running', 'R-1'), job('queued', 'Q-1'), job('completed', 'C-1')], '2 active · 1 done', ['Active · 2', 'Finished · 1']],
188
+ [[job('failed', 'F-1'), job('cancelled', 'X-1')], '1 failed', ['Finished · 2']],
189
+ [[], 'No jobs', []],
190
+ ])('should count jobs in the header and sections for %#', async (jobs, count, sections) => {
191
+ const wrapper = await openTray({ source: { jobs } })
192
+ expect({
193
+ count: wrapper.get('.mint-jobs-tray__head-count').text(),
194
+ sections: wrapper.findAll('.mint-jobs-tray__section').map(el => el.text().replace(/\s+/g, ' ')),
195
+ empty: wrapper.find('.mint-jobs-tray__empty').exists(),
196
+ }).toEqual({ count, sections, empty: jobs.length === 0 })
197
+ })
198
+
199
+ it('should clear finished jobs from the Finished section when the source can clear', async () => {
200
+ const clear = vi.fn(async () => 1)
201
+ const wrapper = await openTray({ source: { jobs: [job('running', 'R-1'), job('completed', 'C-1')], clear } })
202
+
203
+ await wrapper.get('[data-section="finished"] .mint-jobs-tray__section-action').trigger('click')
204
+
205
+ expect([wrapper.find('[data-section="active"] .mint-jobs-tray__section-action').exists(), clear.mock.calls.length])
206
+ .toEqual([false, 1])
207
+ })
208
+
209
+ describe('focus and Escape', () => {
210
+ function mountWithChip() {
211
+ let center!: UseJobCenterReturn
212
+ const Host = defineComponent({
213
+ setup() {
214
+ center = useJobCenter({ source: { jobs: [job('running')] }, loadOnMount: false })
215
+ return () => [
216
+ h('button', { class: 'mint-topbar__job' }, 'Jobs'),
217
+ h('button', { class: 'outside' }, 'Elsewhere'),
218
+ h(JobsStatusTray, { center, teleportTo: false }),
219
+ ]
220
+ },
221
+ })
222
+ return { wrapper: mount(Host, { attachTo: document.body }), center: () => center }
223
+ }
224
+
225
+ it.each([
226
+ ['the page body', () => (document.activeElement as HTMLElement | null)?.blur()],
227
+ ['a detached element', () => {
228
+ const gone = document.body.appendChild(document.createElement('button'))
229
+ gone.focus()
230
+ return () => gone.remove()
231
+ }],
232
+ ])('should focus the top-bar jobs control on Escape when the opener was %s', async (_case, focusOpener) => {
233
+ const { wrapper, center } = mountWithChip()
234
+ const detach = focusOpener()
235
+ center().open()
236
+ await nextTick()
237
+ detach?.()
238
+
239
+ await wrapper.get('.mint-jobs-tray__panel').trigger('keydown', { key: 'Escape' })
240
+
241
+ expect(document.activeElement).toBe(wrapper.get('.mint-topbar__job').element)
242
+ wrapper.unmount()
243
+ })
244
+
245
+ it('should close on Escape when focus has left the open panel', async () => {
246
+ const { wrapper, center } = mountWithChip()
247
+ center().open()
248
+ await nextTick()
249
+ ;(wrapper.get('.outside').element as HTMLElement).focus()
250
+
251
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
252
+ await nextTick()
253
+
254
+ expect(wrapper.find('.mint-jobs-tray__panel').exists()).toBe(false)
255
+ wrapper.unmount()
189
256
  })
190
- expect(wrapper.get('.mint-jobs-tray__subtitle').text()).toBe('1 running · 1 queued · 1 waiting')
191
257
  })
192
258
  })
@@ -1,7 +1,7 @@
1
1
  import { flushPromises, mount } from '@vue/test-utils'
2
2
  import type { VueWrapper } from '@vue/test-utils'
3
3
  import { createPinia, setActivePinia } from 'pinia'
4
- import { computed, h, nextTick, ref } from 'vue'
4
+ import { computed, h, nextTick, ref, shallowRef } from 'vue'
5
5
  import { describe, expect, it, vi } from 'vitest'
6
6
  import AppLayout from '../../components/AppLayout.vue'
7
7
  import AppSidebar from '../../components/AppSidebar.vue'
@@ -10,6 +10,7 @@ import ExperimentPopover from '../../components/ExperimentPopover.vue'
10
10
  import ExperimentSelectorModal from '../../components/ExperimentSelectorModal.vue'
11
11
  import PluginWorkspaceView from '../../components/PluginWorkspaceView.vue'
12
12
  import AppToastContainer from '../../components/AppToastContainer.vue'
13
+ import JobsStatusTray from '../../components/JobsStatusTray.vue'
13
14
  import { defineControlModel, defineDoseDesignControlModel } from '../../composables/useControlSchema'
14
15
  import { usePlatform } from '../../composables/usePlatform'
15
16
  import { useExperimentStore } from '../../stores/experiment'
@@ -18,6 +19,7 @@ import type {
18
19
  PillNavItem,
19
20
  SidebarToolSection,
20
21
  } from '../../types'
22
+ import type { JobStateInput, JobStatus } from '../../types/jobs'
21
23
 
22
24
  vi.mock('../../composables/usePlatform', () => ({
23
25
  usePlatform: vi.fn(() => ({
@@ -743,4 +745,57 @@ describe('PluginWorkspaceView', () => {
743
745
  },
744
746
  })
745
747
  })
748
+
749
+ describe('jobs', () => {
750
+ function workspaceJob(status: JobStatus): JobStateInput {
751
+ return {
752
+ id: 'r', job_id: 'r', kind: 'analysis', name: 'Run', status,
753
+ progress: { current_file: 0, total_files: 0, current_filename: null, percent: 40, message: '', stage: status },
754
+ queue_position: 0, warnings: [], error: null, output_path: null, save_path: null, session_id: null,
755
+ folders: [], folder_path: null, user_id: null, owner_user_id: null, experiment_id: null,
756
+ extraction_result_id: null, metadata: {}, created_at: '', started_at: null, completed_at: null,
757
+ }
758
+ }
759
+ const jobsGlobal = { ...globalOptions, stubs: { ...globalOptions.stubs, Teleport: true } }
760
+
761
+ it('should give the top bar no jobs control when spec.jobs is unset', () => {
762
+ const wrapper = mount(PluginWorkspaceView, { props: { spec: { title: 'Workspace' } }, global: jobsGlobal })
763
+ expect([wrapper.findComponent(AppTopBar).props('job'), wrapper.findComponent(JobsStatusTray).exists()])
764
+ .toEqual([undefined, false])
765
+ })
766
+
767
+ it('should open the jobs tray when the top-bar chip of a running job is clicked', async () => {
768
+ const wrapper = mount(PluginWorkspaceView, {
769
+ props: { spec: { title: 'Workspace', jobs: { jobs: [workspaceJob('running')] } } },
770
+ global: jobsGlobal,
771
+ })
772
+
773
+ await wrapper.get('.mint-topbar__job').trigger('click')
774
+
775
+ const chip = wrapper.get('.mint-topbar__job')
776
+ expect([
777
+ wrapper.findComponent(AppTopBar).props('job'),
778
+ wrapper.find('.mint-jobs-tray__panel').exists(),
779
+ chip.attributes('aria-expanded'),
780
+ chip.attributes('aria-controls') === wrapper.get('.mint-jobs-tray__panel').attributes('id'),
781
+ ]).toEqual([{ status: 'running', label: 'Run running', percent: 40 }, true, 'true', true])
782
+ })
783
+
784
+ it('should emit job-select with the held job when the chip is clicked after the job completed', async () => {
785
+ const jobs = shallowRef([workspaceJob('running')])
786
+ const wrapper = mount(PluginWorkspaceView, {
787
+ props: { spec: { title: 'Workspace', jobs: { jobs } } },
788
+ global: jobsGlobal,
789
+ })
790
+ jobs.value = [workspaceJob('completed')]
791
+ await nextTick()
792
+
793
+ await wrapper.get('.mint-topbar__job').trigger('click')
794
+
795
+ expect([
796
+ wrapper.emitted<[JobStateInput]>('job-select')?.map(([job]) => job.job_id),
797
+ wrapper.find('.mint-jobs-tray__panel').exists(),
798
+ ]).toEqual([['r'], false])
799
+ })
800
+ })
746
801
  })