@morscherlab/mint-sdk 1.0.12 → 1.0.14

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 (63) hide show
  1. package/dist/{ExperimentPopover-CCYB1oWp.js → ExperimentPopover-B29fIHQz.js} +6 -6
  2. package/dist/ExperimentPopover-B29fIHQz.js.map +1 -0
  3. package/dist/{ExperimentPopover-D0bg_fqM.js → ExperimentPopover-gdSA9ZCF.js} +1 -1
  4. package/dist/{ExperimentSelectorModal-DeBE0YKT.js → ExperimentSelectorModal-CHsU-LIh.js} +3 -3
  5. package/dist/{ExperimentSelectorModal-DeBE0YKT.js.map → ExperimentSelectorModal-CHsU-LIh.js.map} +1 -1
  6. package/dist/{ExperimentSelectorModal-BXf-XnQw.js → ExperimentSelectorModal-DIFyL5ta.js} +1 -1
  7. package/dist/__tests__/composables/useCommandHistory.test.d.ts +1 -0
  8. package/dist/__tests__/composables/useFileImport.test.d.ts +1 -0
  9. package/dist/__tests__/composables/useOptimisticMutation.test.d.ts +1 -0
  10. package/dist/__tests__/composables/useResourceCrud.test.d.ts +1 -0
  11. package/dist/__tests__/composables/useWellPainting.test.d.ts +1 -0
  12. package/dist/__tests__/composables/useWellPlateAdapter.test.d.ts +1 -0
  13. package/dist/__tests__/composables/useWellPlateValidation.test.d.ts +1 -0
  14. package/dist/components/index.js +3 -3
  15. package/dist/{components-CqdBz8DI.js → components-Dq02EVZH.js} +10 -10
  16. package/dist/components-Dq02EVZH.js.map +1 -0
  17. package/dist/composables/index.d.ts +7 -0
  18. package/dist/composables/index.js +5 -5
  19. package/dist/composables/useCommandHistory.d.ts +29 -0
  20. package/dist/composables/useFileImport.d.ts +35 -0
  21. package/dist/composables/useOptimisticMutation.d.ts +28 -0
  22. package/dist/composables/useResourceCrud.d.ts +40 -0
  23. package/dist/composables/useWellPainting.d.ts +35 -0
  24. package/dist/composables/useWellPlateAdapter.d.ts +36 -0
  25. package/dist/composables/useWellPlateValidation.d.ts +27 -0
  26. package/dist/{composables-BWh0MpcK.js → composables-D9mexHSW.js} +668 -5
  27. package/dist/composables-D9mexHSW.js.map +1 -0
  28. package/dist/{experiment-utils-hGXMHlAc.js → experiment-utils-D11yT3AR.js} +1 -2
  29. package/dist/{experiment-utils-hGXMHlAc.js.map → experiment-utils-D11yT3AR.js.map} +1 -1
  30. package/dist/index.js +8 -8
  31. package/dist/install.js +3 -3
  32. package/dist/styles.css +4 -8
  33. package/dist/{useExperimentSelector-B3hAGvL4.js → useExperimentSelector-BBaz0w51.js} +2 -2
  34. package/dist/{useExperimentSelector-B3hAGvL4.js.map → useExperimentSelector-BBaz0w51.js.map} +1 -1
  35. package/dist/{useProtocolTemplates-BJxS5F0_.js → useProtocolTemplates-DODHlhxr.js} +3 -3
  36. package/dist/{useProtocolTemplates-BJxS5F0_.js.map → useProtocolTemplates-DODHlhxr.js.map} +1 -1
  37. package/package.json +1 -1
  38. package/src/__tests__/components/ExperimentPopover.test.ts +23 -0
  39. package/src/__tests__/composables/experiment-utils.test.ts +2 -2
  40. package/src/__tests__/composables/useAppExperiment.test.ts +2 -1
  41. package/src/__tests__/composables/useCommandHistory.test.ts +43 -0
  42. package/src/__tests__/composables/useFileImport.test.ts +44 -0
  43. package/src/__tests__/composables/useOptimisticMutation.test.ts +40 -0
  44. package/src/__tests__/composables/useResourceCrud.test.ts +56 -0
  45. package/src/__tests__/composables/useWellPainting.test.ts +52 -0
  46. package/src/__tests__/composables/useWellPlateAdapter.test.ts +50 -0
  47. package/src/__tests__/composables/useWellPlateValidation.test.ts +42 -0
  48. package/src/components/ExperimentPopover.story.vue +3 -4
  49. package/src/components/ExperimentPopover.vue +6 -6
  50. package/src/components/PluginWorkspaceView.vue +3 -3
  51. package/src/composables/experiment-utils.ts +1 -1
  52. package/src/composables/index.ts +67 -0
  53. package/src/composables/useCommandHistory.ts +113 -0
  54. package/src/composables/useFileImport.ts +231 -0
  55. package/src/composables/useOptimisticMutation.ts +107 -0
  56. package/src/composables/useResourceCrud.ts +245 -0
  57. package/src/composables/useWellPainting.ts +187 -0
  58. package/src/composables/useWellPlateAdapter.ts +147 -0
  59. package/src/composables/useWellPlateValidation.ts +85 -0
  60. package/src/styles/components/experiment-popover.css +2 -4
  61. package/dist/ExperimentPopover-CCYB1oWp.js.map +0 -1
  62. package/dist/components-CqdBz8DI.js.map +0 -1
  63. package/dist/composables-BWh0MpcK.js.map +0 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@morscherlab/mint-sdk",
3
- "version": "1.0.12",
3
+ "version": "1.0.14",
4
4
  "description": "MINT Platform SDK — Vue 3 components, composables, and types for plugin development. MINT = Mass-spec INtegrated Toolkit.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -21,6 +21,29 @@ describe('ExperimentPopover', () => {
21
21
  document.body.innerHTML = ''
22
22
  })
23
23
 
24
+ it('prefers the experiment name in the trigger when both name and code are available', () => {
25
+ const wrapper = mountPopover({
26
+ experimentName: 'Dose response run',
27
+ experimentCode: 'EXP-001',
28
+ })
29
+
30
+ const trigger = wrapper.find('.mint-experiment-popover__trigger')
31
+ expect(trigger.text()).toContain('Dose response run')
32
+ expect(trigger.text()).not.toContain('EXP-001')
33
+
34
+ wrapper.unmount()
35
+ })
36
+
37
+ it('falls back to the experiment code in the trigger when the name is unavailable', () => {
38
+ const wrapper = mountPopover({
39
+ experimentCode: 'EXP-001',
40
+ })
41
+
42
+ expect(wrapper.find('.mint-experiment-popover__trigger').text()).toContain('EXP-001')
43
+
44
+ wrapper.unmount()
45
+ })
46
+
24
47
  it('opens and closes the popover on trigger and outside click', async () => {
25
48
  const wrapper = mountPopover({
26
49
  experimentName: 'Dose response run',
@@ -22,9 +22,9 @@ describe('experiment-utils', () => {
22
22
  expect(getExperimentStatusVariant(undefined)).toBe('default')
23
23
  })
24
24
 
25
- it('resolves formal experiment codes before generated compact fallbacks', () => {
25
+ it('resolves only formal experiment codes', () => {
26
26
  expect(resolveExperimentCode({ id: 42, experiment_code: 'DR-042' })).toBe('DR-042')
27
- expect(resolveExperimentCode({ id: 42 })).toBe('EXP-42')
27
+ expect(resolveExperimentCode({ id: 42 })).toBeUndefined()
28
28
  expect(resolveExperimentCode({ id: null })).toBeUndefined()
29
29
  })
30
30
  })
@@ -121,12 +121,13 @@ describe('useAppExperiment', () => {
121
121
  })
122
122
 
123
123
  it('should update experimentName and experimentId on set', () => {
124
- const { set, experimentName, experimentId } = useAppExperiment()
124
+ const { set, experimentName, experimentCode, experimentId } = useAppExperiment()
125
125
  const experiment = makeExperiment({ id: 7, name: 'Alpha Run' })
126
126
 
127
127
  set(experiment)
128
128
 
129
129
  expect(experimentName.value).toBe('Alpha Run')
130
+ expect(experimentCode.value).toBeUndefined()
130
131
  expect(experimentId.value).toBe(7)
131
132
  })
132
133
 
@@ -0,0 +1,43 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import { useCommandHistory, type CommandHistoryCommand } from '../../composables/useCommandHistory'
4
+
5
+ describe('useCommandHistory', () => {
6
+ it('executes commands and tracks undo/redo stacks', () => {
7
+ const values: string[] = []
8
+ const command: CommandHistoryCommand = {
9
+ description: 'Add A',
10
+ execute: () => { values.push('A') },
11
+ undo: () => { values.pop() },
12
+ }
13
+ const history = useCommandHistory()
14
+
15
+ history.execute(command)
16
+
17
+ expect(values).toEqual(['A'])
18
+ expect(history.canUndo.value).toBe(true)
19
+ expect(history.undoDescription.value).toBe('Add A')
20
+
21
+ history.undo()
22
+ expect(values).toEqual([])
23
+ expect(history.canRedo.value).toBe(true)
24
+ expect(history.redoDescription.value).toBe('Add A')
25
+
26
+ history.redo()
27
+ expect(values).toEqual(['A'])
28
+ expect(history.canUndo.value).toBe(true)
29
+ })
30
+
31
+ it('enforces max depth and supports skipExecute', () => {
32
+ const values: number[] = []
33
+ const history = useCommandHistory({ maxDepth: 2 })
34
+
35
+ history.execute({ execute: () => { values.push(1) }, undo: () => undefined })
36
+ history.execute({ execute: () => { values.push(2) }, undo: () => undefined })
37
+ history.execute({ execute: () => { values.push(3) }, undo: () => undefined })
38
+ history.execute({ execute: () => { values.push(4) }, undo: () => undefined }, { skipExecute: true })
39
+
40
+ expect(values).toEqual([1, 2, 3])
41
+ expect(history.undoStack.value).toHaveLength(2)
42
+ })
43
+ })
@@ -0,0 +1,44 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import {
4
+ detectDelimiter,
5
+ parseDelimitedText,
6
+ useFileImport,
7
+ validateImportFile,
8
+ } from '../../composables/useFileImport'
9
+
10
+ describe('file import helpers', () => {
11
+ it('parses quoted CSV fields', () => {
12
+ const parsed = parseDelimitedText('name,notes\nSample A,"hello, world"\n')
13
+
14
+ expect(parsed.delimiter).toBe(',')
15
+ expect(parsed.columns).toEqual(['name', 'notes'])
16
+ expect(parsed.rows).toEqual([{ name: 'Sample A', notes: 'hello, world' }])
17
+ })
18
+
19
+ it('detects TSV data', () => {
20
+ expect(detectDelimiter('name\tvalue\nA\t1')).toBe('\t')
21
+ })
22
+
23
+ it('validates accepted extensions and size', () => {
24
+ const file = new File(['abc'], 'plate.csv', { type: 'text/csv' })
25
+
26
+ expect(() => validateImportFile(file, { accept: '.csv', maxSizeBytes: 10 })).not.toThrow()
27
+ expect(() => validateImportFile(file, { accept: '.xlsx' })).toThrow('Unsupported file type')
28
+ expect(() => validateImportFile(file, { maxSizeBytes: 1 })).toThrow('File is too large')
29
+ })
30
+
31
+ it('reads and parses a file through useFileImport', async () => {
32
+ const file = new File(['name\nA\n'], 'samples.csv', { type: 'text/csv' })
33
+ const importer = useFileImport({
34
+ accept: '.csv',
35
+ parse: text => parseDelimitedText(text),
36
+ })
37
+
38
+ const result = await importer.importFile(file)
39
+
40
+ expect(importer.fileName.value).toBe('samples.csv')
41
+ expect(result.rows).toEqual([{ name: 'A' }])
42
+ expect(importer.loading.value).toBe(false)
43
+ })
44
+ })
@@ -0,0 +1,40 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import { useOptimisticMutation } from '../../composables/useOptimisticMutation'
4
+
5
+ describe('useOptimisticMutation', () => {
6
+ it('applies optimistic state and commits server data', async () => {
7
+ const items = ['A']
8
+ const mutation = useOptimisticMutation<string, string, string[]>({
9
+ snapshot: () => [...items],
10
+ apply: value => { items.push(value) },
11
+ mutation: async (value: string) => value.toLowerCase(),
12
+ commit: result => { items[items.length - 1] = result },
13
+ })
14
+
15
+ const result = await mutation.run('B')
16
+
17
+ expect(result).toBe('b')
18
+ expect(items).toEqual(['A', 'b'])
19
+ expect(mutation.loading.value).toBe(false)
20
+ expect(mutation.error.value).toBeNull()
21
+ })
22
+
23
+ it('rolls back and rethrows failed mutations', async () => {
24
+ const items = ['A']
25
+ const failure = new Error('backend rejected')
26
+ const mutation = useOptimisticMutation<string, never, string[]>({
27
+ snapshot: () => [...items],
28
+ apply: value => { items.push(value) },
29
+ mutation: async () => { throw failure },
30
+ rollback: snapshot => {
31
+ items.splice(0, items.length, ...(snapshot ?? []))
32
+ },
33
+ })
34
+
35
+ await expect(mutation.run('B')).rejects.toThrow('backend rejected')
36
+
37
+ expect(items).toEqual(['A'])
38
+ expect(mutation.error.value).toBe('backend rejected')
39
+ })
40
+ })
@@ -0,0 +1,56 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import { useResourceCrud } from '../../composables/useResourceCrud'
4
+
5
+ interface Item {
6
+ id: string
7
+ name: string
8
+ }
9
+
10
+ describe('useResourceCrud', () => {
11
+ it('loads and mutates local resource state through an adapter', async () => {
12
+ const remote: Item[] = [{ id: 'a', name: 'Alpha' }]
13
+ const resource = useResourceCrud<Item, Pick<Item, 'name'>, Partial<Item>, string>({
14
+ adapter: {
15
+ list: async () => [...remote],
16
+ create: async payload => {
17
+ const item = { id: payload.name.toLowerCase(), name: payload.name }
18
+ remote.push(item)
19
+ return item
20
+ },
21
+ update: async (id, payload) => {
22
+ const item = { id, name: payload.name ?? 'Untitled' }
23
+ remote.splice(remote.findIndex(candidate => candidate.id === id), 1, item)
24
+ return item
25
+ },
26
+ remove: async id => {
27
+ remote.splice(remote.findIndex(candidate => candidate.id === id), 1)
28
+ },
29
+ },
30
+ })
31
+
32
+ await resource.load()
33
+ expect(resource.items.value).toEqual([{ id: 'a', name: 'Alpha' }])
34
+
35
+ await resource.createItem({ name: 'Beta' })
36
+ expect(resource.items.value.map(item => item.name)).toEqual(['Alpha', 'Beta'])
37
+
38
+ await resource.updateItem('beta', { name: 'Bravo' })
39
+ expect(resource.items.value.find(item => item.id === 'beta')?.name).toBe('Bravo')
40
+
41
+ await resource.deleteItem('a')
42
+ expect(resource.items.value.map(item => item.id)).toEqual(['beta'])
43
+ })
44
+
45
+ it('normalizes adapter errors', async () => {
46
+ const resource = useResourceCrud<Item>({
47
+ adapter: {
48
+ list: async () => { throw new Error('nope') },
49
+ },
50
+ })
51
+
52
+ await expect(resource.load()).rejects.toThrow('nope')
53
+ expect(resource.error.value).toBe('nope')
54
+ expect(resource.loading.value).toBe(false)
55
+ })
56
+ })
@@ -0,0 +1,52 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import {
4
+ coordinateToWellId,
5
+ useWellPainting,
6
+ wellIdToCoordinate,
7
+ wellIdsInRectangle,
8
+ } from '../../composables/useWellPainting'
9
+
10
+ describe('useWellPainting', () => {
11
+ it('converts well coordinates and computes rectangle selections', () => {
12
+ expect(wellIdToCoordinate('B2')).toEqual({ row: 1, col: 1 })
13
+ expect(coordinateToWellId({ row: 0, col: 0 })).toBe('A1')
14
+ expect(wellIdsInRectangle({ row: 0, col: 0 }, { row: 1, col: 1 }, 96)).toEqual([
15
+ 'A1',
16
+ 'A2',
17
+ 'B1',
18
+ 'B2',
19
+ ])
20
+ })
21
+
22
+ it('emits rectangle selections in select mode', () => {
23
+ const completed: string[][] = []
24
+ const painting = useWellPainting({
25
+ format: 96,
26
+ initialMode: 'select',
27
+ onComplete: result => completed.push(result.wellIds),
28
+ })
29
+
30
+ painting.onWellMouseDown('A1')
31
+ painting.onWellMouseMove('B2')
32
+ expect(painting.previewWellIds.value).toEqual(['A1', 'A2', 'B1', 'B2'])
33
+ painting.onWellMouseUp()
34
+
35
+ expect(completed).toEqual([['A1', 'A2', 'B1', 'B2']])
36
+ })
37
+
38
+ it('tracks paint trails for non-select modes', () => {
39
+ const completed: { mode: string; wells: string[] }[] = []
40
+ const painting = useWellPainting({
41
+ format: 96,
42
+ initialMode: 'paint',
43
+ onComplete: result => completed.push({ mode: result.mode, wells: result.wellIds }),
44
+ })
45
+
46
+ painting.onWellMouseDown('A1')
47
+ painting.onWellMouseMove('A3')
48
+ painting.onWellMouseUp()
49
+
50
+ expect(completed).toEqual([{ mode: 'paint', wells: ['A1', 'A3'] }])
51
+ })
52
+ })
@@ -0,0 +1,50 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import {
4
+ createColumnConditions,
5
+ createRowConditions,
6
+ createWellPlateWells,
7
+ } from '../../composables/useWellPlateAdapter'
8
+
9
+ describe('well plate adapter helpers', () => {
10
+ it('maps domain entries to WellPlate wells', () => {
11
+ const wells = createWellPlateWells(
12
+ [{ id: 'B3', type: 'sample', signal: 42 }],
13
+ {
14
+ getWellId: entry => entry.id,
15
+ getSampleType: entry => entry.type,
16
+ getValue: entry => entry.signal,
17
+ getMetadata: () => ({ source: 'analysis' }),
18
+ },
19
+ )
20
+
21
+ expect(wells.B3).toEqual({
22
+ id: 'B3',
23
+ row: 1,
24
+ col: 2,
25
+ state: 'filled',
26
+ sampleType: 'sample',
27
+ value: 42,
28
+ metadata: { source: 'analysis' },
29
+ })
30
+ })
31
+
32
+ it('builds row and column condition spans from axis entries', () => {
33
+ const entry = {
34
+ label: 'Drug A',
35
+ color: '#123456',
36
+ start: 2,
37
+ unit: 'uM',
38
+ concentrations: [{ value: 1, replicates: 2 }, { value: 10 }],
39
+ }
40
+
41
+ expect(createColumnConditions([entry])[0]).toMatchObject({
42
+ cols: [2, 3, 4],
43
+ concentrations: [1, 1, 10],
44
+ })
45
+ expect(createRowConditions([{ ...entry, start: 'B' }])[0]).toMatchObject({
46
+ rows: ['B', 'C', 'D'],
47
+ concentrations: [1, 1, 10],
48
+ })
49
+ })
50
+ })
@@ -0,0 +1,42 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import { runWellPlateValidation, useWellPlateValidation } from '../../composables/useWellPlateValidation'
4
+
5
+ describe('well plate validation helpers', () => {
6
+ it('runs rule-driven validation', () => {
7
+ const warnings = runWellPlateValidation(
8
+ { assigned: 0 },
9
+ [
10
+ {
11
+ type: 'empty',
12
+ severity: 'warning',
13
+ validate: context => context.assigned === 0 && 'No wells assigned',
14
+ },
15
+ ],
16
+ )
17
+
18
+ expect(warnings).toEqual([
19
+ { type: 'empty', severity: 'warning', message: 'No wells assigned' },
20
+ ])
21
+ })
22
+
23
+ it('derives blocking state and counts', () => {
24
+ const validation = useWellPlateValidation({
25
+ context: { overlaps: ['A1'] },
26
+ rules: [
27
+ {
28
+ type: 'overlap',
29
+ severity: 'error',
30
+ validate: context => ({
31
+ message: `${context.overlaps.length} overlap`,
32
+ affectedWells: context.overlaps,
33
+ }),
34
+ },
35
+ ],
36
+ })
37
+
38
+ expect(validation.hasBlockingIssues.value).toBe(true)
39
+ expect(validation.errorCount.value).toBe(1)
40
+ expect(validation.warningCount.value).toBe(1)
41
+ })
42
+ })
@@ -48,8 +48,8 @@ const STATUSES = ['planned', 'ongoing', 'completed', 'ready_to_extract', 'proces
48
48
  </template>
49
49
 
50
50
  <template #controls="{ state }">
51
- <HstText v-model="state.experimentCode" title="Experiment code (shown in trigger)" />
52
- <HstText v-model="state.experimentName" title="Experiment name (shown in panel)" />
51
+ <HstText v-model="state.experimentCode" title="Experiment code (shown in panel)" />
52
+ <HstText v-model="state.experimentName" title="Experiment name (shown in trigger)" />
53
53
  <HstSelect
54
54
  v-model="state.experimentStatus"
55
55
  title="Status"
@@ -93,8 +93,7 @@ const STATUSES = ['planned', 'ongoing', 'completed', 'ready_to_extract', 'proces
93
93
  />
94
94
  </div>
95
95
  <div style="text-align: center; font-size: 0.75rem; color: var(--text-muted); padding: 0 1rem 1rem;">
96
- Trigger shows only the code (<code>EXP-042</code>). Click to open the panel — full name, status, and actions
97
- live inside.
96
+ Trigger shows the name. Click to open the panel — formal code, status, and actions live inside.
98
97
  </div>
99
98
  </Variant>
100
99
 
@@ -92,8 +92,8 @@ onUnmounted(() => {
92
92
  { 'mint-experiment-popover__split--with-save': showSave && experimentName },
93
93
  ]"
94
94
  >
95
- <!-- Left: experiment trigger (opens popover) shows only the code for
96
- maximum topbar compactness; full name + status live in the panel. -->
95
+ <!-- Left: experiment trigger (opens popover). Prefer the human-readable
96
+ name; the formal code, when present, stays in the detail panel. -->
97
97
  <button
98
98
  type="button"
99
99
  :class="[
@@ -101,7 +101,7 @@ onUnmounted(() => {
101
101
  { 'mint-experiment-popover__trigger--active': isOpen },
102
102
  { 'mint-experiment-popover__trigger--empty': !experimentCode && !experimentName },
103
103
  ]"
104
- :title="experimentName || undefined"
104
+ :title="experimentName || experimentCode || undefined"
105
105
  @click.stop="toggle"
106
106
  >
107
107
  <!-- Flask icon -->
@@ -113,9 +113,9 @@ onUnmounted(() => {
113
113
  d="M19.428 15.428a2 2 0 00-1.022-.547l-2.387-.477a6 6 0 00-3.86.517l-.318.158a6 6 0 01-3.86.517L6.05 15.21a2 2 0 00-1.806.547M8 4h8l-1 1v5.172a2 2 0 00.586 1.414l5 5c1.26 1.26.367 3.414-1.415 3.414H4.828c-1.782 0-2.674-2.154-1.414-3.414l5-5A2 2 0 009 10.172V5L8 4z"
114
114
  />
115
115
  </svg>
116
- <!-- Code preferred, name as fallback, "No experiment" as empty state -->
117
- <span v-if="experimentCode" class="mint-experiment-popover__trigger-code">{{ experimentCode }}</span>
118
- <span v-else-if="experimentName" class="mint-experiment-popover__trigger-text">{{ experimentName }}</span>
116
+ <!-- Name preferred, code as fallback, "No experiment" as empty state -->
117
+ <span v-if="experimentName" class="mint-experiment-popover__trigger-text">{{ experimentName }}</span>
118
+ <span v-else-if="experimentCode" class="mint-experiment-popover__trigger-code">{{ experimentCode }}</span>
119
119
  <span v-else class="mint-experiment-popover__trigger-text">No experiment</span>
120
120
  <!-- Chevron -->
121
121
  <svg class="mint-experiment-popover__trigger-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
@@ -37,10 +37,10 @@ const props = withDefaults(defineProps<PluginWorkspaceViewProps>(), {
37
37
  pluginSwitcher: undefined,
38
38
  pillNav: undefined,
39
39
  currentPillId: undefined,
40
- showThemeToggle: false,
41
- showSettings: false,
40
+ showThemeToggle: true,
41
+ showSettings: true,
42
42
  settingsConfig: undefined,
43
- showStandaloneLabel: true,
43
+ showStandaloneLabel: false,
44
44
  standaloneLabel: 'Standalone',
45
45
  accountMenu: undefined,
46
46
  showNotifications: false,
@@ -64,7 +64,7 @@ export function getExperimentStatusVariant(status?: ExperimentStatus | string |
64
64
 
65
65
  export function resolveExperimentCode(experiment: ExperimentCodeSource): string | undefined {
66
66
  if (experiment.experiment_code) return experiment.experiment_code
67
- return experiment.id != null ? `EXP-${experiment.id}` : undefined
67
+ return undefined
68
68
  }
69
69
 
70
70
  export const DATE_PRESET_OPTIONS: SelectOption<string>[] = [
@@ -191,6 +191,41 @@ export {
191
191
  type RequestSyncSuccessKind,
192
192
  type UseRequestSyncStateReturn,
193
193
  } from './useRequestSyncState'
194
+ export {
195
+ useCommandHistory,
196
+ type CommandHistoryCommand,
197
+ type CommandHistoryExecuteOptions,
198
+ type CommandHistoryResult,
199
+ type UseCommandHistoryOptions,
200
+ type UseCommandHistoryReturn,
201
+ } from './useCommandHistory'
202
+ export {
203
+ useOptimisticMutation,
204
+ type OptimisticMutationContext,
205
+ type UseOptimisticMutationOptions,
206
+ type UseOptimisticMutationReturn,
207
+ } from './useOptimisticMutation'
208
+ export {
209
+ createPluginResourceClient,
210
+ useResourceCrud,
211
+ type PluginResourceClientOptions,
212
+ type ResourceCrudAdapter,
213
+ type ResourceKey,
214
+ type UseResourceCrudOptions,
215
+ type UseResourceCrudReturn,
216
+ } from './useResourceCrud'
217
+ export {
218
+ detectDelimiter,
219
+ parseDelimitedText,
220
+ readFileAsText,
221
+ useFileImport,
222
+ validateImportFile,
223
+ type DelimitedTable,
224
+ type FileImportOptions,
225
+ type FileImportValidationOptions,
226
+ type ParseDelimitedTextOptions,
227
+ type UseFileImportReturn,
228
+ } from './useFileImport'
194
229
  export {
195
230
  useSelectionLimit,
196
231
  type SelectionLimitSource,
@@ -248,6 +283,38 @@ export {
248
283
  type UseTextSearchOptions,
249
284
  type UseTextSearchReturn,
250
285
  } from './useTextSearch'
286
+ export {
287
+ coordinateToWellId,
288
+ useWellPainting,
289
+ wellIdToCoordinate,
290
+ wellIdsInRectangle,
291
+ type UseWellPaintingOptions,
292
+ type UseWellPaintingReturn,
293
+ type WellCoordinate,
294
+ type WellPaintResult,
295
+ type WellPlateSource,
296
+ } from './useWellPainting'
297
+ export {
298
+ createColumnConditions,
299
+ createRowConditions,
300
+ createWellPlateWells,
301
+ useWellPlateAdapter,
302
+ type AxisConditionEntry,
303
+ type UseWellPlateAdapterReturn,
304
+ type WellPlateAdapterOptions,
305
+ type WellPlateAdapterSource,
306
+ type WellPlateEntry,
307
+ } from './useWellPlateAdapter'
308
+ export {
309
+ runWellPlateValidation,
310
+ useWellPlateValidation,
311
+ type UseWellPlateValidationOptions,
312
+ type UseWellPlateValidationReturn,
313
+ type WellPlateValidationRule,
314
+ type WellPlateValidationSeverity,
315
+ type WellPlateValidationSource,
316
+ type WellPlateValidationWarning,
317
+ } from './useWellPlateValidation'
251
318
  export {
252
319
  controlsToFormSchema,
253
320
  controlsToSectionFormSchema,
@@ -0,0 +1,113 @@
1
+ import { computed, ref, type ComputedRef, type Ref } from 'vue'
2
+
3
+ export type CommandHistoryResult = void | Promise<void>
4
+
5
+ export interface CommandHistoryCommand {
6
+ execute: () => CommandHistoryResult
7
+ undo: () => CommandHistoryResult
8
+ redo?: () => CommandHistoryResult
9
+ description?: string
10
+ }
11
+
12
+ export interface CommandHistoryExecuteOptions {
13
+ skipExecute?: boolean
14
+ }
15
+
16
+ export interface UseCommandHistoryOptions {
17
+ maxDepth?: number
18
+ }
19
+
20
+ export interface UseCommandHistoryReturn<TCommand extends CommandHistoryCommand = CommandHistoryCommand> {
21
+ undoStack: Ref<TCommand[]>
22
+ redoStack: Ref<TCommand[]>
23
+ canUndo: ComputedRef<boolean>
24
+ canRedo: ComputedRef<boolean>
25
+ undoDescription: ComputedRef<string>
26
+ redoDescription: ComputedRef<string>
27
+ execute: (command: TCommand, options?: CommandHistoryExecuteOptions) => CommandHistoryResult
28
+ push: (command: TCommand) => void
29
+ undo: () => CommandHistoryResult
30
+ redo: () => CommandHistoryResult
31
+ clear: () => void
32
+ }
33
+
34
+ /** Generic command-stack state for plugin editors with undo/redo controls. */
35
+ export function useCommandHistory<TCommand extends CommandHistoryCommand = CommandHistoryCommand>(
36
+ options: UseCommandHistoryOptions = {},
37
+ ): UseCommandHistoryReturn<TCommand> {
38
+ const maxDepth = Math.max(1, options.maxDepth ?? 50)
39
+ const undoStack = ref<TCommand[]>([]) as Ref<TCommand[]>
40
+ const redoStack = ref<TCommand[]>([]) as Ref<TCommand[]>
41
+
42
+ const canUndo = computed(() => undoStack.value.length > 0)
43
+ const canRedo = computed(() => redoStack.value.length > 0)
44
+ const undoDescription = computed(() => undoStack.value[undoStack.value.length - 1]?.description ?? '')
45
+ const redoDescription = computed(() => redoStack.value[redoStack.value.length - 1]?.description ?? '')
46
+
47
+ function push(command: TCommand): void {
48
+ undoStack.value = [...undoStack.value.slice(-(maxDepth - 1)), command]
49
+ redoStack.value = []
50
+ }
51
+
52
+ function execute(
53
+ command: TCommand,
54
+ executeOptions: CommandHistoryExecuteOptions = {},
55
+ ): CommandHistoryResult {
56
+ if (executeOptions.skipExecute) {
57
+ push(command)
58
+ return
59
+ }
60
+
61
+ return afterMaybePromise(command.execute(), () => push(command))
62
+ }
63
+
64
+ function undo(): CommandHistoryResult {
65
+ const command = undoStack.value[undoStack.value.length - 1]
66
+ if (!command) return
67
+
68
+ return afterMaybePromise(command.undo(), () => {
69
+ undoStack.value = undoStack.value.slice(0, -1)
70
+ redoStack.value = [...redoStack.value, command]
71
+ })
72
+ }
73
+
74
+ function redo(): CommandHistoryResult {
75
+ const command = redoStack.value[redoStack.value.length - 1]
76
+ if (!command) return
77
+
78
+ return afterMaybePromise((command.redo ?? command.execute)(), () => {
79
+ redoStack.value = redoStack.value.slice(0, -1)
80
+ undoStack.value = [...undoStack.value.slice(-(maxDepth - 1)), command]
81
+ })
82
+ }
83
+
84
+ function clear(): void {
85
+ undoStack.value = []
86
+ redoStack.value = []
87
+ }
88
+
89
+ return {
90
+ undoStack,
91
+ redoStack,
92
+ canUndo,
93
+ canRedo,
94
+ undoDescription,
95
+ redoDescription,
96
+ execute,
97
+ push,
98
+ undo,
99
+ redo,
100
+ clear,
101
+ }
102
+ }
103
+
104
+ function afterMaybePromise(result: CommandHistoryResult, callback: () => void): CommandHistoryResult {
105
+ if (isPromiseLike(result)) {
106
+ return result.then(callback)
107
+ }
108
+ callback()
109
+ }
110
+
111
+ function isPromiseLike(value: unknown): value is Promise<void> {
112
+ return !!value && typeof (value as { then?: unknown }).then === 'function'
113
+ }