@morscherlab/mint-sdk 1.0.0-rc.1 → 1.0.0-rc.2
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/README.md +9 -1
- package/dist/__tests__/components/LcmsSequenceTable.test.d.ts +1 -0
- package/dist/__tests__/components/ProgressBar.test.d.ts +1 -0
- package/dist/__tests__/components/RackEditor.test.d.ts +1 -0
- package/dist/__tests__/components/SequenceProgressBar.test.d.ts +1 -0
- package/dist/__tests__/composables/useExperimentSamples.test.d.ts +1 -0
- package/dist/__tests__/utils/instrument.test.d.ts +1 -0
- package/dist/__tests__/utils/lcms.test.d.ts +1 -0
- package/dist/__tests__/utils/permissions.test.d.ts +1 -0
- package/dist/__tests__/utils/rack.test.d.ts +1 -0
- package/dist/{auth-CBG3bWEc.js → auth-B7g4J4ZF.js} +99 -5
- package/dist/auth-B7g4J4ZF.js.map +1 -0
- package/dist/components/AutoGroupModal.vue.d.ts +1 -1
- package/dist/components/BaseCheckbox.vue.d.ts +1 -1
- package/dist/components/BaseToggle.vue.d.ts +2 -2
- package/dist/components/BioTemplateExperimentWorkspaceView.vue.d.ts +1 -1
- package/dist/components/BioTemplatePackWorkspaceView.vue.d.ts +1 -1
- package/dist/components/BioTemplatePresetWorkspaceView.vue.d.ts +1 -1
- package/dist/components/DoseDesignWorkspaceView.vue.d.ts +1 -1
- package/dist/components/FormulaInput.vue.d.ts +1 -1
- package/dist/components/InstrumentAlertLog.vue.d.ts +22 -0
- package/dist/components/InstrumentStateBadge.vue.d.ts +11 -0
- package/dist/components/InstrumentStatusCard.vue.d.ts +13 -0
- package/dist/components/LcmsSequenceTable.vue.d.ts +26 -0
- package/dist/components/ProgressBar.vue.d.ts +1 -0
- package/dist/components/RackEditor.vue.d.ts +41 -3
- package/dist/components/ReagentList.vue.d.ts +1 -1
- package/dist/components/SampleSelector.vue.d.ts +5 -2
- package/dist/components/SegmentedControl.vue.d.ts +2 -0
- package/dist/components/SequenceInput.vue.d.ts +1 -1
- package/dist/components/SequenceProgressBar.vue.d.ts +15 -0
- package/dist/components/SettingsModal.vue.d.ts +3 -1
- package/dist/components/TagsInput.vue.d.ts +1 -1
- package/dist/components/WellPlate.vue.d.ts +42 -3
- package/dist/components/index.d.ts +5 -0
- package/dist/components/index.js +3 -3
- package/dist/{components-5KSfsVqf.js → components-BhK-dW99.js} +2091 -1051
- package/dist/components-BhK-dW99.js.map +1 -0
- package/dist/composables/experimentDesignData.d.ts +17 -0
- package/dist/composables/index.d.ts +2 -0
- package/dist/composables/index.js +4 -4
- package/dist/composables/useControlSchema.d.ts +11 -0
- package/dist/composables/useExperimentData.d.ts +11 -3
- package/dist/composables/useExperimentSamples.d.ts +42 -0
- package/dist/composables/usePlatformContext.d.ts +54 -0
- package/dist/{composables-D4Myb30a.js → composables-Bg7CFuNz.js} +5 -3
- package/dist/composables-Bg7CFuNz.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +168 -6
- package/dist/index.js.map +1 -0
- package/dist/install.js +2 -2
- package/dist/instrument.d.ts +7 -0
- package/dist/lcms.d.ts +27 -0
- package/dist/permissions.d.ts +46 -0
- package/dist/stores/auth.d.ts +74 -2
- package/dist/stores/index.js +1 -1
- package/dist/styles.css +3316 -1216
- package/dist/templates/builders.d.ts +7 -3
- package/dist/templates/index.d.ts +2 -2
- package/dist/templates/index.js +2 -2
- package/dist/templates/presets.d.ts +12 -0
- package/dist/templates/types.d.ts +16 -1
- package/dist/{templates-BSlxwV2c.js → templates-BorLR_7p.js} +313 -3
- package/dist/templates-BorLR_7p.js.map +1 -0
- package/dist/types/auth.d.ts +2 -0
- package/dist/types/components.d.ts +32 -3
- package/dist/types/form-builder.d.ts +2 -1
- package/dist/types/index.d.ts +4 -1
- package/dist/types/instrument.d.ts +56 -0
- package/dist/types/platform.d.ts +3 -0
- package/dist/{useExperimentData-BbbdI5xT.js → useProtocolTemplates-n6AJqSqv.js} +534 -359
- package/dist/useProtocolTemplates-n6AJqSqv.js.map +1 -0
- package/dist/utils/rack.d.ts +47 -0
- package/package.json +1 -1
- package/src/__tests__/components/AppTopBar.test.ts +15 -0
- package/src/__tests__/components/BaseTabs.test.ts +15 -0
- package/src/__tests__/components/LcmsSequenceTable.test.ts +57 -0
- package/src/__tests__/components/ProgressBar.test.ts +18 -0
- package/src/__tests__/components/RackEditor.test.ts +125 -0
- package/src/__tests__/components/SampleSelector.test.ts +25 -0
- package/src/__tests__/components/SegmentedControl.test.ts +45 -0
- package/src/__tests__/components/SequenceProgressBar.test.ts +39 -0
- package/src/__tests__/components/SettingsModal.test.ts +83 -2
- package/src/__tests__/composables/useControlSchema.test.ts +4 -0
- package/src/__tests__/composables/useExperimentData.test.ts +23 -0
- package/src/__tests__/composables/useExperimentSamples.test.ts +91 -0
- package/src/__tests__/templates/templates.test.ts +86 -0
- package/src/__tests__/utils/instrument.test.ts +47 -0
- package/src/__tests__/utils/lcms.test.ts +73 -0
- package/src/__tests__/utils/permissions.test.ts +50 -0
- package/src/__tests__/utils/rack.test.ts +120 -0
- package/src/components/AppTopBar.vue +1 -0
- package/src/components/BaseTabs.vue +22 -1
- package/src/components/InstrumentAlertLog.vue +191 -0
- package/src/components/InstrumentStateBadge.vue +50 -0
- package/src/components/InstrumentStatusCard.vue +188 -0
- package/src/components/LcmsSequenceTable.vue +191 -0
- package/src/components/ProgressBar.vue +3 -0
- package/src/components/RackEditor.vue +73 -2
- package/src/components/SampleSelector.vue +28 -9
- package/src/components/SegmentedControl.story.vue +17 -0
- package/src/components/SegmentedControl.vue +14 -3
- package/src/components/SequenceProgressBar.vue +71 -0
- package/src/components/SettingsModal.vue +42 -2
- package/src/components/WellPlate.vue +142 -21
- package/src/components/index.ts +5 -0
- package/src/components/internal/WellEditPopupInternal.vue +1 -0
- package/src/composables/experimentDesignData.ts +182 -0
- package/src/composables/index.ts +14 -0
- package/src/composables/useAuth.ts +4 -0
- package/src/composables/useAutoGroup.ts +5 -1
- package/src/composables/useControlSchema.ts +21 -0
- package/src/composables/useExperimentData.ts +57 -16
- package/src/composables/useExperimentSamples.ts +142 -0
- package/src/index.ts +27 -0
- package/src/instrument.ts +90 -0
- package/src/lcms.ts +108 -0
- package/src/permissions.ts +143 -0
- package/src/stores/auth.ts +31 -3
- package/src/styles/components/instrument-monitor.css +478 -0
- package/src/styles/components/lcms-sequence-table.css +189 -0
- package/src/styles/components/sequence-progress-bar.css +63 -0
- package/src/styles/components/tabs.css +9 -0
- package/src/styles/components/well-edit-popup.css +7 -1
- package/src/styles/components/well-plate.css +5 -0
- package/src/styles/index.css +3 -0
- package/src/templates/builders.ts +201 -0
- package/src/templates/controlSchemas.ts +68 -0
- package/src/templates/index.ts +2 -0
- package/src/templates/presets.ts +23 -0
- package/src/templates/types.ts +17 -0
- package/src/types/auth.ts +3 -0
- package/src/types/components.ts +45 -3
- package/src/types/form-builder.ts +2 -1
- package/src/types/index.ts +35 -0
- package/src/types/instrument.ts +61 -0
- package/src/types/platform.ts +4 -0
- package/src/utils/rack.ts +209 -0
- package/dist/auth-CBG3bWEc.js.map +0 -1
- package/dist/components-5KSfsVqf.js.map +0 -1
- package/dist/composables-D4Myb30a.js.map +0 -1
- package/dist/templates-BSlxwV2c.js.map +0 -1
- package/dist/useExperimentData-BbbdI5xT.js.map +0 -1
|
@@ -1,7 +1,12 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { useApi } from './useApi'
|
|
1
|
+
import { shallowRef, computed, type Ref, type ComputedRef } from 'vue'
|
|
2
|
+
import { useApi, type UseApiReturn } from './useApi'
|
|
3
3
|
import { useRequestSyncState } from './useRequestSyncState'
|
|
4
|
-
import
|
|
4
|
+
import {
|
|
5
|
+
extractSampleNamesFromDesignData,
|
|
6
|
+
extractSampleOptionsFromDesignData,
|
|
7
|
+
unwrapExperimentDesignData,
|
|
8
|
+
} from './experimentDesignData'
|
|
9
|
+
import type { TreeNode, SummaryData, SelectOption } from '../types'
|
|
5
10
|
|
|
6
11
|
export interface UseExperimentDataOptions {
|
|
7
12
|
apiBaseUrl?: string
|
|
@@ -9,8 +14,14 @@ export interface UseExperimentDataOptions {
|
|
|
9
14
|
}
|
|
10
15
|
|
|
11
16
|
export interface UseExperimentDataReturn {
|
|
12
|
-
/** Raw experiment data
|
|
17
|
+
/** Raw experiment data response from `/experiments/{id}/data`, or null. */
|
|
13
18
|
data: Ref<Record<string, unknown> | null>
|
|
19
|
+
/** Plugin-defined design_data payload unwrapped from platform response shapes, or null. */
|
|
20
|
+
designData: ComputedRef<Record<string, unknown> | null>
|
|
21
|
+
/** Sample values extracted from design_data for SampleSelector-style components. */
|
|
22
|
+
sampleNames: ComputedRef<string[]>
|
|
23
|
+
/** Sample options extracted from design_data for select/dropdown components. */
|
|
24
|
+
sampleOptions: ComputedRef<SelectOption<string>[]>
|
|
14
25
|
/** Hierarchy tree rows normalised from `tree_data` or `treeData`. */
|
|
15
26
|
treeData: ComputedRef<TreeNode[]>
|
|
16
27
|
/** Table rows normalised from `table_data` or `tableData`. */
|
|
@@ -24,54 +35,74 @@ export interface UseExperimentDataReturn {
|
|
|
24
35
|
/** Timestamp of the last successful experiment data fetch, or null. */
|
|
25
36
|
lastLoadedAt: Ref<Date | null>
|
|
26
37
|
/** Fetch experiment data for an experiment id. */
|
|
27
|
-
fetch: (experimentId: number) => Promise<
|
|
38
|
+
fetch: (experimentId: number) => Promise<Record<string, unknown> | null>
|
|
28
39
|
/** Refetch the most recently fetched experiment id. */
|
|
29
40
|
refresh: () => Promise<void>
|
|
41
|
+
/** Clear local experiment data and current fetch target. */
|
|
42
|
+
clear: () => void
|
|
30
43
|
}
|
|
31
44
|
|
|
32
45
|
/** Fetches and normalises experiment output data (tree, table, summary) from the platform API. */
|
|
33
46
|
export function useExperimentData(
|
|
34
47
|
options: UseExperimentDataOptions = {},
|
|
35
48
|
): UseExperimentDataReturn {
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
const data = ref<Record<string, unknown> | null>(null)
|
|
49
|
+
const data = shallowRef<Record<string, unknown> | null>(null)
|
|
39
50
|
const request = useRequestSyncState('Failed to fetch experiment data')
|
|
40
51
|
const isLoading = request.loading
|
|
41
52
|
const error = request.error
|
|
42
53
|
const lastLoadedAt = request.lastLoadedAt
|
|
43
54
|
let lastExperimentId: number | null = null
|
|
55
|
+
let api: UseApiReturn | null = null
|
|
56
|
+
|
|
57
|
+
function getApi(): UseApiReturn {
|
|
58
|
+
api ??= useApi({ baseUrl: options.apiBaseUrl })
|
|
59
|
+
return api
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const designData = computed<Record<string, unknown> | null>(() =>
|
|
63
|
+
unwrapExperimentDesignData(data.value),
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
const sampleNames = computed<string[]>(() =>
|
|
67
|
+
extractSampleNamesFromDesignData(designData.value),
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
const sampleOptions = computed<SelectOption<string>[]>(() =>
|
|
71
|
+
extractSampleOptionsFromDesignData(designData.value),
|
|
72
|
+
)
|
|
44
73
|
|
|
45
74
|
const treeData = computed<TreeNode[]>(() => {
|
|
46
|
-
if (!
|
|
47
|
-
const tree =
|
|
75
|
+
if (!designData.value) return []
|
|
76
|
+
const tree = designData.value.tree_data ?? designData.value.treeData
|
|
48
77
|
return Array.isArray(tree) ? tree as TreeNode[] : []
|
|
49
78
|
})
|
|
50
79
|
|
|
51
80
|
const tableData = computed<Record<string, unknown>[]>(() => {
|
|
52
|
-
if (!
|
|
53
|
-
const table =
|
|
81
|
+
if (!designData.value) return []
|
|
82
|
+
const table = designData.value.table_data ?? designData.value.tableData
|
|
54
83
|
return Array.isArray(table) ? table as Record<string, unknown>[] : []
|
|
55
84
|
})
|
|
56
85
|
|
|
57
86
|
const summaryData = computed<SummaryData | null>(() => {
|
|
58
|
-
if (!
|
|
59
|
-
const summary =
|
|
87
|
+
if (!designData.value) return null
|
|
88
|
+
const summary = designData.value.summary_data ?? designData.value.summaryData
|
|
60
89
|
if (summary && typeof summary === 'object' && 'metadata' in (summary as Record<string, unknown>)) {
|
|
61
90
|
return summary as SummaryData
|
|
62
91
|
}
|
|
63
92
|
return null
|
|
64
93
|
})
|
|
65
94
|
|
|
66
|
-
async function fetchData(experimentId: number): Promise<
|
|
95
|
+
async function fetchData(experimentId: number): Promise<Record<string, unknown> | null> {
|
|
67
96
|
lastExperimentId = experimentId
|
|
68
97
|
try {
|
|
69
98
|
data.value = await request.run(
|
|
70
|
-
() =>
|
|
99
|
+
() => getApi().get<Record<string, unknown>>(`/experiments/${experimentId}/data`),
|
|
71
100
|
{ success: 'load', errorMessage: 'Failed to fetch experiment data' },
|
|
72
101
|
)
|
|
102
|
+
return data.value
|
|
73
103
|
} catch {
|
|
74
104
|
data.value = null
|
|
105
|
+
return null
|
|
75
106
|
}
|
|
76
107
|
}
|
|
77
108
|
|
|
@@ -81,8 +112,17 @@ export function useExperimentData(
|
|
|
81
112
|
}
|
|
82
113
|
}
|
|
83
114
|
|
|
115
|
+
function clear(): void {
|
|
116
|
+
data.value = null
|
|
117
|
+
lastExperimentId = null
|
|
118
|
+
request.clearError()
|
|
119
|
+
}
|
|
120
|
+
|
|
84
121
|
return {
|
|
85
122
|
data,
|
|
123
|
+
designData,
|
|
124
|
+
sampleNames,
|
|
125
|
+
sampleOptions,
|
|
86
126
|
treeData,
|
|
87
127
|
tableData,
|
|
88
128
|
summaryData,
|
|
@@ -91,5 +131,6 @@ export function useExperimentData(
|
|
|
91
131
|
lastLoadedAt,
|
|
92
132
|
fetch: fetchData,
|
|
93
133
|
refresh,
|
|
134
|
+
clear,
|
|
94
135
|
}
|
|
95
136
|
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import {
|
|
2
|
+
computed,
|
|
3
|
+
inject,
|
|
4
|
+
toValue,
|
|
5
|
+
watch,
|
|
6
|
+
type ComputedRef,
|
|
7
|
+
type MaybeRefOrGetter,
|
|
8
|
+
type Ref,
|
|
9
|
+
} from 'vue'
|
|
10
|
+
import type { SelectOption } from '../types'
|
|
11
|
+
import { APP_EXPERIMENT_KEY } from './useAppExperiment'
|
|
12
|
+
import {
|
|
13
|
+
extractSampleNamesFromDesignData,
|
|
14
|
+
extractSampleOptionsFromDesignData,
|
|
15
|
+
unwrapExperimentDesignData,
|
|
16
|
+
type ExtractExperimentSamplesOptions,
|
|
17
|
+
} from './experimentDesignData'
|
|
18
|
+
import { parseExperimentId } from './platformContextHelpers'
|
|
19
|
+
import { useExperimentData } from './useExperimentData'
|
|
20
|
+
|
|
21
|
+
export type ExperimentIdSource = MaybeRefOrGetter<number | string | null | undefined>
|
|
22
|
+
export type ExperimentDesignDataSource = MaybeRefOrGetter<Record<string, unknown> | null | undefined>
|
|
23
|
+
|
|
24
|
+
export interface UseExperimentSamplesOptions extends ExtractExperimentSamplesOptions {
|
|
25
|
+
/** Experiment id to load from `/experiments/{id}/data`; defaults to provided AppExperiment context. */
|
|
26
|
+
experimentId?: ExperimentIdSource
|
|
27
|
+
/** Pre-fetched design_data or full platform design-data response. */
|
|
28
|
+
designData?: ExperimentDesignDataSource
|
|
29
|
+
/** API base URL override for platform calls. */
|
|
30
|
+
apiBaseUrl?: string
|
|
31
|
+
/** Auto-load when an experiment id becomes available. Defaults to true. */
|
|
32
|
+
immediate?: boolean
|
|
33
|
+
/** Enable or disable automatic loading while keeping derived samples reactive. Defaults to true. */
|
|
34
|
+
enabled?: MaybeRefOrGetter<boolean>
|
|
35
|
+
/** Use `useAppExperiment` provided state when `experimentId` is not set. Defaults to true. */
|
|
36
|
+
syncAppExperiment?: boolean
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface UseExperimentSamplesReturn {
|
|
40
|
+
/** Experiment id currently used for automatic sample loading. */
|
|
41
|
+
experimentId: ComputedRef<number | undefined>
|
|
42
|
+
/** Raw `/experiments/{id}/data` response when loaded through the platform API. */
|
|
43
|
+
data: Ref<Record<string, unknown> | null>
|
|
44
|
+
/** Plugin-defined design_data payload from props or platform response. */
|
|
45
|
+
designData: ComputedRef<Record<string, unknown> | null>
|
|
46
|
+
/** SampleSelector-compatible string values extracted from design_data. */
|
|
47
|
+
samples: ComputedRef<string[]>
|
|
48
|
+
/** Select/dropdown-friendly sample options extracted from design_data. */
|
|
49
|
+
sampleOptions: ComputedRef<SelectOption<string>[]>
|
|
50
|
+
isLoading: Ref<boolean>
|
|
51
|
+
error: Ref<string | null>
|
|
52
|
+
lastLoadedAt: Ref<Date | null>
|
|
53
|
+
/** Fetch samples for a specific experiment id, defaulting to the resolved id. */
|
|
54
|
+
fetch: (experimentId?: number | string | null) => Promise<Record<string, unknown> | null>
|
|
55
|
+
/** Refetch the current experiment id. */
|
|
56
|
+
refresh: () => Promise<void>
|
|
57
|
+
/** Clear fetched platform data. */
|
|
58
|
+
clear: () => void
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Loads experiment design_data and derives samples for SampleSelector-style UIs. */
|
|
62
|
+
export function useExperimentSamples(
|
|
63
|
+
options: UseExperimentSamplesOptions = {},
|
|
64
|
+
): UseExperimentSamplesReturn {
|
|
65
|
+
const appExperiment = options.syncAppExperiment === false
|
|
66
|
+
? undefined
|
|
67
|
+
: inject(APP_EXPERIMENT_KEY, undefined)
|
|
68
|
+
const experimentData = useExperimentData({ apiBaseUrl: options.apiBaseUrl })
|
|
69
|
+
|
|
70
|
+
const experimentId = computed<number | undefined>(() => {
|
|
71
|
+
const explicitId = parseExperimentId(toValue(options.experimentId))
|
|
72
|
+
return explicitId ?? parseExperimentId(appExperiment?.experimentId.value)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
const explicitDesignData = computed<Record<string, unknown> | null>(() =>
|
|
76
|
+
unwrapExperimentDesignData(toValue(options.designData) ?? null),
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
const designData = computed<Record<string, unknown> | null>(() =>
|
|
80
|
+
explicitDesignData.value ?? experimentData.designData.value,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
const extractOptions = computed<ExtractExperimentSamplesOptions>(() => ({
|
|
84
|
+
includeControls: options.includeControls,
|
|
85
|
+
}))
|
|
86
|
+
|
|
87
|
+
const samples = computed<string[]>(() =>
|
|
88
|
+
extractSampleNamesFromDesignData(designData.value, extractOptions.value),
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
const sampleOptions = computed<SelectOption<string>[]>(() =>
|
|
92
|
+
extractSampleOptionsFromDesignData(designData.value, extractOptions.value),
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
const enabled = computed(() => toValue(options.enabled) ?? true)
|
|
96
|
+
|
|
97
|
+
async function fetchSamples(
|
|
98
|
+
id: number | string | null | undefined = experimentId.value,
|
|
99
|
+
): Promise<Record<string, unknown> | null> {
|
|
100
|
+
const parsedId = parseExperimentId(id)
|
|
101
|
+
if (parsedId === undefined) {
|
|
102
|
+
experimentData.clear()
|
|
103
|
+
return null
|
|
104
|
+
}
|
|
105
|
+
return experimentData.fetch(parsedId)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
watch(
|
|
109
|
+
() => ({
|
|
110
|
+
designData: explicitDesignData.value,
|
|
111
|
+
enabled: enabled.value,
|
|
112
|
+
experimentId: experimentId.value,
|
|
113
|
+
}),
|
|
114
|
+
({ designData: currentDesignData, enabled: isEnabled, experimentId: currentExperimentId }) => {
|
|
115
|
+
if (options.immediate === false || !isEnabled) return
|
|
116
|
+
if (currentDesignData) {
|
|
117
|
+
experimentData.clear()
|
|
118
|
+
return
|
|
119
|
+
}
|
|
120
|
+
if (currentExperimentId === undefined) {
|
|
121
|
+
experimentData.clear()
|
|
122
|
+
return
|
|
123
|
+
}
|
|
124
|
+
void experimentData.fetch(currentExperimentId)
|
|
125
|
+
},
|
|
126
|
+
{ immediate: options.immediate !== false },
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
return {
|
|
130
|
+
experimentId,
|
|
131
|
+
data: experimentData.data,
|
|
132
|
+
designData,
|
|
133
|
+
samples,
|
|
134
|
+
sampleOptions,
|
|
135
|
+
isLoading: experimentData.isLoading,
|
|
136
|
+
error: experimentData.error,
|
|
137
|
+
lastLoadedAt: experimentData.lastLoadedAt,
|
|
138
|
+
fetch: fetchSamples,
|
|
139
|
+
refresh: experimentData.refresh,
|
|
140
|
+
clear: experimentData.clear,
|
|
141
|
+
}
|
|
142
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -19,6 +19,15 @@ export {
|
|
|
19
19
|
// Stores
|
|
20
20
|
export * from './stores'
|
|
21
21
|
|
|
22
|
+
// Permissions
|
|
23
|
+
export * from './permissions'
|
|
24
|
+
|
|
25
|
+
// Instrument monitoring
|
|
26
|
+
export * from './instrument'
|
|
27
|
+
|
|
28
|
+
// LCMS sequence helpers
|
|
29
|
+
export * from './lcms'
|
|
30
|
+
|
|
22
31
|
// Utils
|
|
23
32
|
export {
|
|
24
33
|
hexToHsl,
|
|
@@ -26,6 +35,24 @@ export {
|
|
|
26
35
|
deriveShade,
|
|
27
36
|
type Hsl,
|
|
28
37
|
} from './utils/color'
|
|
38
|
+
export {
|
|
39
|
+
LCMS_DEFAULT_CONTROL_POSITIONS,
|
|
40
|
+
createLcmsControlWellEditData,
|
|
41
|
+
getLcmsDefaultControlWellId,
|
|
42
|
+
lcmsPlateCellsToRack,
|
|
43
|
+
lcmsPlateCellsToRacks,
|
|
44
|
+
lcmsPlateTypeToRackFormat,
|
|
45
|
+
lcmsWellId,
|
|
46
|
+
parseLcmsWellId,
|
|
47
|
+
rackFormatToLcmsPlateType,
|
|
48
|
+
rackToLcmsPlateCells,
|
|
49
|
+
racksToLcmsPlateCells,
|
|
50
|
+
type LcmsControlSampleType,
|
|
51
|
+
type LcmsPlateCell,
|
|
52
|
+
type LcmsPlateCellsToRackOptions,
|
|
53
|
+
type LcmsPlateCellsToRacksOptions,
|
|
54
|
+
type LcmsPlateType,
|
|
55
|
+
} from './utils/rack'
|
|
29
56
|
|
|
30
57
|
// Biology data templates
|
|
31
58
|
export * from './templates'
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import type { SequenceProgress } from './types'
|
|
2
|
+
|
|
3
|
+
export function sequenceProgressPercent(progress: SequenceProgress | null | undefined): number {
|
|
4
|
+
if (!progress || progress.total_samples <= 0) return 0
|
|
5
|
+
return Math.round(clamp((progress.current_sample / progress.total_samples) * 100, 0, 100))
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function sequenceSamplesRemaining(progress: SequenceProgress | null | undefined): number {
|
|
9
|
+
if (!progress) return 0
|
|
10
|
+
return Math.max(0, progress.total_samples - progress.current_sample)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function estimateSequenceRemainingSeconds(
|
|
14
|
+
progress: SequenceProgress | null | undefined,
|
|
15
|
+
): number | null {
|
|
16
|
+
if (!progress) return null
|
|
17
|
+
const samplesLeft = sequenceSamplesRemaining(progress)
|
|
18
|
+
if (samplesLeft <= 0) return 0
|
|
19
|
+
|
|
20
|
+
const explicit = progress.estimated_remaining_seconds
|
|
21
|
+
if (typeof explicit === 'number' && explicit > 0) return explicit
|
|
22
|
+
|
|
23
|
+
const avg = progress.avg_sample_seconds ?? average(progress.sample_durations)
|
|
24
|
+
if (typeof avg === 'number' && avg > 0) return samplesLeft * avg
|
|
25
|
+
|
|
26
|
+
const elapsed = progress.elapsed_seconds ?? 0
|
|
27
|
+
if (elapsed > 0 && progress.current_sample > 0) {
|
|
28
|
+
return (elapsed / progress.current_sample) * samplesLeft
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return null
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function estimateSequenceFinishDate(
|
|
35
|
+
progress: SequenceProgress | null | undefined,
|
|
36
|
+
now = new Date(),
|
|
37
|
+
): Date | null {
|
|
38
|
+
if (!progress) return null
|
|
39
|
+
|
|
40
|
+
const explicit = coerceDate(progress.estimated_finish_time)
|
|
41
|
+
if (explicit) return explicit
|
|
42
|
+
|
|
43
|
+
const remaining = estimateSequenceRemainingSeconds(progress)
|
|
44
|
+
if (remaining == null || remaining <= 0) return null
|
|
45
|
+
return new Date(now.getTime() + remaining * 1000)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function formatSequenceRemaining(progress: SequenceProgress | null | undefined): string | null {
|
|
49
|
+
const remaining = estimateSequenceRemainingSeconds(progress)
|
|
50
|
+
if (remaining == null || remaining <= 0) return null
|
|
51
|
+
|
|
52
|
+
const minutes = remaining / 60
|
|
53
|
+
if (minutes < 1) return `${Math.round(remaining)}s left`
|
|
54
|
+
if (minutes < 60) return `~${Math.round(minutes)}m left`
|
|
55
|
+
|
|
56
|
+
const hours = Math.floor(minutes / 60)
|
|
57
|
+
const mins = Math.round(minutes % 60)
|
|
58
|
+
return mins > 0 ? `~${hours}h ${mins}m left` : `~${hours}h left`
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function formatSequenceEta(
|
|
62
|
+
progress: SequenceProgress | null | undefined,
|
|
63
|
+
locale?: Intl.LocalesArgument,
|
|
64
|
+
options: Intl.DateTimeFormatOptions = {},
|
|
65
|
+
): string | null {
|
|
66
|
+
const finish = estimateSequenceFinishDate(progress)
|
|
67
|
+
if (!finish) return null
|
|
68
|
+
return finish.toLocaleTimeString(locale, {
|
|
69
|
+
hour: '2-digit',
|
|
70
|
+
minute: '2-digit',
|
|
71
|
+
...options,
|
|
72
|
+
})
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function average(values: readonly number[] | undefined): number | null {
|
|
76
|
+
if (!values?.length) return null
|
|
77
|
+
const usable = values.filter(value => Number.isFinite(value) && value >= 0)
|
|
78
|
+
if (usable.length === 0) return null
|
|
79
|
+
return usable.reduce((sum, value) => sum + value, 0) / usable.length
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function coerceDate(value: string | Date | null | undefined): Date | null {
|
|
83
|
+
if (!value) return null
|
|
84
|
+
const date = value instanceof Date ? value : new Date(value)
|
|
85
|
+
return Number.isNaN(date.getTime()) ? null : date
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function clamp(value: number, min: number, max: number): number {
|
|
89
|
+
return Math.min(max, Math.max(min, value))
|
|
90
|
+
}
|
package/src/lcms.ts
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import type { LcmsPlateCell, LcmsPlateType } from './utils/rack'
|
|
2
|
+
|
|
3
|
+
export type LcmsPolarity = 'POS' | 'NEG' | 'BOTH'
|
|
4
|
+
export type LcmsContainerType = 'VIAL' | 'PLATE'
|
|
5
|
+
|
|
6
|
+
export interface LcmsSequenceItem {
|
|
7
|
+
sample_type: string
|
|
8
|
+
file_name: string
|
|
9
|
+
sample_id: string
|
|
10
|
+
path: string
|
|
11
|
+
instrument_method: string
|
|
12
|
+
position: string
|
|
13
|
+
injection_volume: number
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface LcmsSequenceTableColumn {
|
|
17
|
+
key: keyof LcmsSequenceItem | 'index' | 'actions'
|
|
18
|
+
label: string
|
|
19
|
+
align?: 'left' | 'right'
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const DEFAULT_LCMS_SEQUENCE_COLUMNS: LcmsSequenceTableColumn[] = [
|
|
23
|
+
{ key: 'index', label: '#' },
|
|
24
|
+
{ key: 'sample_type', label: 'Type' },
|
|
25
|
+
{ key: 'file_name', label: 'File Name' },
|
|
26
|
+
{ key: 'position', label: 'Position' },
|
|
27
|
+
{ key: 'instrument_method', label: 'Method' },
|
|
28
|
+
{ key: 'injection_volume', label: 'Inj Vol', align: 'right' },
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
export function extractLcmsCommonPrefix(fileNames: readonly string[]): string {
|
|
32
|
+
if (fileNames.length === 0) return ''
|
|
33
|
+
const stripped = fileNames.map(fileName => fileName.replace(/_\d{3}$/, ''))
|
|
34
|
+
const prefixes = stripped.map((fileName) => {
|
|
35
|
+
const match = fileName.match(/^(.+?)_(POS|NEG)_/i)
|
|
36
|
+
return match ? match[1] : fileName.replace(/_[^_]+$/, '')
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
let common = prefixes[0] ?? ''
|
|
40
|
+
for (const prefix of prefixes.slice(1)) {
|
|
41
|
+
while (common && !prefix.startsWith(common)) {
|
|
42
|
+
const index = common.lastIndexOf('_')
|
|
43
|
+
common = index > 0 ? common.substring(0, index) : common.substring(0, common.length - 1)
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return common
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function extractLcmsSampleName(fileName: string, prefix = ''): string {
|
|
50
|
+
let name = fileName.replace(/_\d{3}$/, '')
|
|
51
|
+
if (prefix && name.startsWith(prefix)) {
|
|
52
|
+
name = name.substring(prefix.length).replace(/^_+/, '')
|
|
53
|
+
}
|
|
54
|
+
name = name.replace(/^(POS|NEG)_/i, '')
|
|
55
|
+
return name || fileName
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function lcmsWellIdFromPosition(position: string): string {
|
|
59
|
+
return position.includes(':') ? position.split(':').pop() ?? position : position
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function inferLcmsPlateTypeFromWellIds(wellIds: readonly string[]): LcmsPlateType | null {
|
|
63
|
+
let maxCol = 0
|
|
64
|
+
let maxRow = 0
|
|
65
|
+
for (const wellId of wellIds) {
|
|
66
|
+
const match = wellId.match(/^([A-Z])(\d+)$/i)
|
|
67
|
+
if (!match) continue
|
|
68
|
+
maxRow = Math.max(maxRow, match[1].toUpperCase().charCodeAt(0) - 64)
|
|
69
|
+
maxCol = Math.max(maxCol, Number(match[2]))
|
|
70
|
+
}
|
|
71
|
+
if (maxCol === 0 && maxRow === 0) return null
|
|
72
|
+
return maxCol <= 9 && maxRow <= 6 ? '54vial' : '96well'
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function reconstructLcmsPlateCellsFromSequenceItems(
|
|
76
|
+
items: readonly LcmsSequenceItem[],
|
|
77
|
+
): { cells: LcmsPlateCell[]; plateType: LcmsPlateType } | null {
|
|
78
|
+
const unknownItems = items.filter(item => item.sample_type === 'Unknown')
|
|
79
|
+
if (unknownItems.length === 0) return null
|
|
80
|
+
|
|
81
|
+
const prefix = extractLcmsCommonPrefix(unknownItems.map(item => item.file_name))
|
|
82
|
+
const seen = new Set<string>()
|
|
83
|
+
const cells: LcmsPlateCell[] = []
|
|
84
|
+
|
|
85
|
+
for (const item of unknownItems) {
|
|
86
|
+
const wellId = lcmsWellIdFromPosition(item.position)
|
|
87
|
+
if (seen.has(wellId)) continue
|
|
88
|
+
seen.add(wellId)
|
|
89
|
+
|
|
90
|
+
const match = wellId.match(/^([A-Z])(\d+)$/i)
|
|
91
|
+
if (!match) continue
|
|
92
|
+
cells.push({
|
|
93
|
+
row: match[1].toUpperCase(),
|
|
94
|
+
column: Number(match[2]),
|
|
95
|
+
sample_name: extractLcmsSampleName(item.file_name, prefix),
|
|
96
|
+
})
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (cells.length === 0) return null
|
|
100
|
+
const plateType = inferLcmsPlateTypeFromWellIds(cells.map(cell => `${cell.row}${cell.column}`))
|
|
101
|
+
if (!plateType) return null
|
|
102
|
+
return { cells, plateType }
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function basenameFromWindowsPath(path: string | null | undefined): string {
|
|
106
|
+
if (!path) return ''
|
|
107
|
+
return path.split(/[\\/]/).pop() ?? path
|
|
108
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
export const ADMIN_ROLE = 'admin'
|
|
2
|
+
|
|
3
|
+
export const ADMIN_PANEL_PERMISSIONS = [
|
|
4
|
+
'users.view',
|
|
5
|
+
'users.invite',
|
|
6
|
+
'users.manage',
|
|
7
|
+
'platform.configure',
|
|
8
|
+
'platform.view_logs',
|
|
9
|
+
'plugins.configure',
|
|
10
|
+
'plugins.install',
|
|
11
|
+
] as const
|
|
12
|
+
|
|
13
|
+
export type AccessAudience = 'all' | 'admin' | 'user'
|
|
14
|
+
export type AccessAudienceInput = AccessAudience | readonly AccessAudience[]
|
|
15
|
+
|
|
16
|
+
export interface RoleInfo {
|
|
17
|
+
id?: number
|
|
18
|
+
slug: string
|
|
19
|
+
name?: string
|
|
20
|
+
color?: string
|
|
21
|
+
permissions?: readonly string[] | null
|
|
22
|
+
project_scope?: 'all' | 'assigned' | string
|
|
23
|
+
plugin_access?: readonly string[] | 'all' | null
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface PermissionUser {
|
|
27
|
+
role?: string | null
|
|
28
|
+
role_obj?: RoleInfo | null
|
|
29
|
+
roleObj?: RoleInfo | null
|
|
30
|
+
permissions?: readonly string[] | null
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface AccessPolicy {
|
|
34
|
+
audience?: AccessAudienceInput
|
|
35
|
+
visibleFor?: AccessAudienceInput
|
|
36
|
+
requiresAuth?: boolean
|
|
37
|
+
requiresAdmin?: boolean
|
|
38
|
+
permissions?: readonly string[]
|
|
39
|
+
anyPermissions?: readonly string[]
|
|
40
|
+
plugin?: string
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface AccessControlled {
|
|
44
|
+
access?: AccessPolicy
|
|
45
|
+
visibleFor?: AccessAudienceInput
|
|
46
|
+
requiresAdmin?: boolean
|
|
47
|
+
permissions?: readonly string[]
|
|
48
|
+
anyPermissions?: readonly string[]
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function getRoleInfo(user: PermissionUser | null | undefined): RoleInfo | null {
|
|
52
|
+
return user?.roleObj ?? user?.role_obj ?? null
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function isAdminRole(role: string | null | undefined): boolean {
|
|
56
|
+
return role === ADMIN_ROLE
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function isAdminUser(user: PermissionUser | null | undefined): boolean {
|
|
60
|
+
return isAdminRole(user?.role) || getRoleInfo(user)?.slug === ADMIN_ROLE
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function getAccessAudience(user: PermissionUser | null | undefined): Exclude<AccessAudience, 'all'> {
|
|
64
|
+
return isAdminUser(user) ? 'admin' : 'user'
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function getUserPermissions(user: PermissionUser | null | undefined): string[] {
|
|
68
|
+
const rolePermissions = getRoleInfo(user)?.permissions
|
|
69
|
+
if (rolePermissions?.length) return [...rolePermissions]
|
|
70
|
+
return [...(user?.permissions ?? [])]
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function hasAllPermissions(
|
|
74
|
+
user: PermissionUser | null | undefined,
|
|
75
|
+
permissions: readonly string[] = [],
|
|
76
|
+
): boolean {
|
|
77
|
+
if (permissions.length === 0) return true
|
|
78
|
+
if (isAdminUser(user)) return true
|
|
79
|
+
const granted = new Set(getUserPermissions(user))
|
|
80
|
+
return permissions.every(permission => granted.has(permission))
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function hasAnyPermission(
|
|
84
|
+
user: PermissionUser | null | undefined,
|
|
85
|
+
permissions: readonly string[] = [],
|
|
86
|
+
): boolean {
|
|
87
|
+
if (permissions.length === 0) return true
|
|
88
|
+
if (isAdminUser(user)) return true
|
|
89
|
+
const granted = new Set(getUserPermissions(user))
|
|
90
|
+
return permissions.some(permission => granted.has(permission))
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function canAccessAdmin(user: PermissionUser | null | undefined): boolean {
|
|
94
|
+
return isAdminUser(user) || hasAnyPermission(user, ADMIN_PANEL_PERMISSIONS)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function canAccessPlugin(
|
|
98
|
+
user: PermissionUser | null | undefined,
|
|
99
|
+
pluginName: string | null | undefined,
|
|
100
|
+
): boolean {
|
|
101
|
+
if (!pluginName) return true
|
|
102
|
+
const access = getRoleInfo(user)?.plugin_access
|
|
103
|
+
if (!access || access === 'all') return true
|
|
104
|
+
return Array.isArray(access) && access.includes(pluginName)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function normalizeAccessPolicy(rule: AccessControlled | AccessPolicy | undefined): AccessPolicy {
|
|
108
|
+
if (!rule) return {}
|
|
109
|
+
const { access: nested, ...direct } = rule as AccessPolicy & { access?: AccessPolicy }
|
|
110
|
+
return {
|
|
111
|
+
...(nested ?? {}),
|
|
112
|
+
...direct,
|
|
113
|
+
...('visibleFor' in rule && rule.visibleFor !== undefined ? { visibleFor: rule.visibleFor } : {}),
|
|
114
|
+
...('requiresAdmin' in rule && rule.requiresAdmin !== undefined ? { requiresAdmin: rule.requiresAdmin } : {}),
|
|
115
|
+
...('permissions' in rule && rule.permissions !== undefined ? { permissions: rule.permissions } : {}),
|
|
116
|
+
...('anyPermissions' in rule && rule.anyPermissions !== undefined ? { anyPermissions: rule.anyPermissions } : {}),
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function canAccessByPolicy(
|
|
121
|
+
user: PermissionUser | null | undefined,
|
|
122
|
+
rule: AccessControlled | AccessPolicy | undefined,
|
|
123
|
+
isAuthenticated = user !== null && user !== undefined,
|
|
124
|
+
): boolean {
|
|
125
|
+
const policy = normalizeAccessPolicy(rule)
|
|
126
|
+
if (policy.requiresAuth && !isAuthenticated) return false
|
|
127
|
+
if (policy.requiresAdmin && !isAdminUser(user)) return false
|
|
128
|
+
if (!audienceAllowsUser(policy.visibleFor ?? policy.audience, user)) return false
|
|
129
|
+
if (!hasAllPermissions(user, policy.permissions)) return false
|
|
130
|
+
if (!hasAnyPermission(user, policy.anyPermissions)) return false
|
|
131
|
+
if (policy.plugin && !canAccessPlugin(user, policy.plugin)) return false
|
|
132
|
+
return true
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function audienceAllowsUser(
|
|
136
|
+
audience: AccessAudienceInput | undefined,
|
|
137
|
+
user: PermissionUser | null | undefined,
|
|
138
|
+
): boolean {
|
|
139
|
+
if (audience === undefined) return true
|
|
140
|
+
const allowed = Array.isArray(audience) ? audience : [audience]
|
|
141
|
+
if (allowed.includes('all')) return true
|
|
142
|
+
return allowed.includes(getAccessAudience(user))
|
|
143
|
+
}
|