@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.
- package/dist/{ExperimentPopover-CCYB1oWp.js → ExperimentPopover-B29fIHQz.js} +6 -6
- package/dist/ExperimentPopover-B29fIHQz.js.map +1 -0
- package/dist/{ExperimentPopover-D0bg_fqM.js → ExperimentPopover-gdSA9ZCF.js} +1 -1
- package/dist/{ExperimentSelectorModal-DeBE0YKT.js → ExperimentSelectorModal-CHsU-LIh.js} +3 -3
- package/dist/{ExperimentSelectorModal-DeBE0YKT.js.map → ExperimentSelectorModal-CHsU-LIh.js.map} +1 -1
- package/dist/{ExperimentSelectorModal-BXf-XnQw.js → ExperimentSelectorModal-DIFyL5ta.js} +1 -1
- package/dist/__tests__/composables/useCommandHistory.test.d.ts +1 -0
- package/dist/__tests__/composables/useFileImport.test.d.ts +1 -0
- package/dist/__tests__/composables/useOptimisticMutation.test.d.ts +1 -0
- package/dist/__tests__/composables/useResourceCrud.test.d.ts +1 -0
- package/dist/__tests__/composables/useWellPainting.test.d.ts +1 -0
- package/dist/__tests__/composables/useWellPlateAdapter.test.d.ts +1 -0
- package/dist/__tests__/composables/useWellPlateValidation.test.d.ts +1 -0
- package/dist/components/index.js +3 -3
- package/dist/{components-CqdBz8DI.js → components-Dq02EVZH.js} +10 -10
- package/dist/components-Dq02EVZH.js.map +1 -0
- package/dist/composables/index.d.ts +7 -0
- package/dist/composables/index.js +5 -5
- package/dist/composables/useCommandHistory.d.ts +29 -0
- package/dist/composables/useFileImport.d.ts +35 -0
- package/dist/composables/useOptimisticMutation.d.ts +28 -0
- package/dist/composables/useResourceCrud.d.ts +40 -0
- package/dist/composables/useWellPainting.d.ts +35 -0
- package/dist/composables/useWellPlateAdapter.d.ts +36 -0
- package/dist/composables/useWellPlateValidation.d.ts +27 -0
- package/dist/{composables-BWh0MpcK.js → composables-D9mexHSW.js} +668 -5
- package/dist/composables-D9mexHSW.js.map +1 -0
- package/dist/{experiment-utils-hGXMHlAc.js → experiment-utils-D11yT3AR.js} +1 -2
- package/dist/{experiment-utils-hGXMHlAc.js.map → experiment-utils-D11yT3AR.js.map} +1 -1
- package/dist/index.js +8 -8
- package/dist/install.js +3 -3
- package/dist/styles.css +4 -8
- package/dist/{useExperimentSelector-B3hAGvL4.js → useExperimentSelector-BBaz0w51.js} +2 -2
- package/dist/{useExperimentSelector-B3hAGvL4.js.map → useExperimentSelector-BBaz0w51.js.map} +1 -1
- package/dist/{useProtocolTemplates-BJxS5F0_.js → useProtocolTemplates-DODHlhxr.js} +3 -3
- package/dist/{useProtocolTemplates-BJxS5F0_.js.map → useProtocolTemplates-DODHlhxr.js.map} +1 -1
- package/package.json +1 -1
- package/src/__tests__/components/ExperimentPopover.test.ts +23 -0
- package/src/__tests__/composables/experiment-utils.test.ts +2 -2
- package/src/__tests__/composables/useAppExperiment.test.ts +2 -1
- package/src/__tests__/composables/useCommandHistory.test.ts +43 -0
- package/src/__tests__/composables/useFileImport.test.ts +44 -0
- package/src/__tests__/composables/useOptimisticMutation.test.ts +40 -0
- package/src/__tests__/composables/useResourceCrud.test.ts +56 -0
- package/src/__tests__/composables/useWellPainting.test.ts +52 -0
- package/src/__tests__/composables/useWellPlateAdapter.test.ts +50 -0
- package/src/__tests__/composables/useWellPlateValidation.test.ts +42 -0
- package/src/components/ExperimentPopover.story.vue +3 -4
- package/src/components/ExperimentPopover.vue +6 -6
- package/src/components/PluginWorkspaceView.vue +3 -3
- package/src/composables/experiment-utils.ts +1 -1
- package/src/composables/index.ts +67 -0
- package/src/composables/useCommandHistory.ts +113 -0
- package/src/composables/useFileImport.ts +231 -0
- package/src/composables/useOptimisticMutation.ts +107 -0
- package/src/composables/useResourceCrud.ts +245 -0
- package/src/composables/useWellPainting.ts +187 -0
- package/src/composables/useWellPlateAdapter.ts +147 -0
- package/src/composables/useWellPlateValidation.ts +85 -0
- package/src/styles/components/experiment-popover.css +2 -4
- package/dist/ExperimentPopover-CCYB1oWp.js.map +0 -1
- package/dist/components-CqdBz8DI.js.map +0 -1
- 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.
|
|
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
|
|
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 })).
|
|
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
|
|
52
|
-
<HstText v-model="state.experimentName" title="Experiment name (shown in
|
|
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
|
|
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)
|
|
96
|
-
|
|
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
|
-
<!--
|
|
117
|
-
<span v-if="
|
|
118
|
-
<span v-else-if="
|
|
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:
|
|
41
|
-
showSettings:
|
|
40
|
+
showThemeToggle: true,
|
|
41
|
+
showSettings: true,
|
|
42
42
|
settingsConfig: undefined,
|
|
43
|
-
showStandaloneLabel:
|
|
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
|
|
67
|
+
return undefined
|
|
68
68
|
}
|
|
69
69
|
|
|
70
70
|
export const DATE_PRESET_OPTIONS: SelectOption<string>[] = [
|
package/src/composables/index.ts
CHANGED
|
@@ -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
|
+
}
|