@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
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import { ref, shallowRef, type Ref, type ShallowRef } from 'vue'
|
|
2
|
+
|
|
3
|
+
export interface DelimitedTable {
|
|
4
|
+
columns: string[]
|
|
5
|
+
rows: Record<string, string>[]
|
|
6
|
+
rawRows: string[][]
|
|
7
|
+
delimiter: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface ParseDelimitedTextOptions {
|
|
11
|
+
delimiter?: string
|
|
12
|
+
headers?: readonly string[]
|
|
13
|
+
hasHeaderRow?: boolean
|
|
14
|
+
trim?: boolean
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface FileImportValidationOptions {
|
|
18
|
+
accept?: string | readonly string[]
|
|
19
|
+
maxSizeBytes?: number
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface FileImportOptions<TResult = string> extends FileImportValidationOptions {
|
|
23
|
+
encoding?: string
|
|
24
|
+
parse?: (text: string, file: File) => TResult | Promise<TResult>
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface UseFileImportReturn<TResult> {
|
|
28
|
+
loading: ShallowRef<boolean>
|
|
29
|
+
error: Ref<string | null>
|
|
30
|
+
fileName: Ref<string>
|
|
31
|
+
result: Ref<TResult | null>
|
|
32
|
+
importFile: (file: File) => Promise<TResult>
|
|
33
|
+
clear: () => void
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Stateful file reader for plugin import flows with shared validation and error handling. */
|
|
37
|
+
export function useFileImport<TResult = string>(
|
|
38
|
+
options: FileImportOptions<TResult> = {},
|
|
39
|
+
): UseFileImportReturn<TResult> {
|
|
40
|
+
const loading = shallowRef(false)
|
|
41
|
+
const error = ref<string | null>(null)
|
|
42
|
+
const fileName = ref('')
|
|
43
|
+
const result = ref<TResult | null>(null) as Ref<TResult | null>
|
|
44
|
+
|
|
45
|
+
function clear(): void {
|
|
46
|
+
loading.value = false
|
|
47
|
+
error.value = null
|
|
48
|
+
fileName.value = ''
|
|
49
|
+
result.value = null
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function importFile(file: File): Promise<TResult> {
|
|
53
|
+
loading.value = true
|
|
54
|
+
error.value = null
|
|
55
|
+
fileName.value = file.name
|
|
56
|
+
|
|
57
|
+
try {
|
|
58
|
+
validateImportFile(file, options)
|
|
59
|
+
const text = await readFileAsText(file, options.encoding)
|
|
60
|
+
const parsed = options.parse
|
|
61
|
+
? await options.parse(text, file)
|
|
62
|
+
: text as TResult
|
|
63
|
+
result.value = parsed
|
|
64
|
+
return parsed
|
|
65
|
+
} catch (err) {
|
|
66
|
+
const message = readErrorMessage(err)
|
|
67
|
+
error.value = message
|
|
68
|
+
throw err
|
|
69
|
+
} finally {
|
|
70
|
+
loading.value = false
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
loading,
|
|
76
|
+
error,
|
|
77
|
+
fileName,
|
|
78
|
+
result,
|
|
79
|
+
importFile,
|
|
80
|
+
clear,
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function validateImportFile(file: File, options: FileImportValidationOptions = {}): void {
|
|
85
|
+
if (options.maxSizeBytes !== undefined && file.size > options.maxSizeBytes) {
|
|
86
|
+
throw new Error(`File is too large. Maximum size is ${formatBytes(options.maxSizeBytes)}.`)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const accept = normalizeAccept(options.accept)
|
|
90
|
+
if (accept.length === 0) return
|
|
91
|
+
|
|
92
|
+
const name = file.name.toLowerCase()
|
|
93
|
+
const type = file.type.toLowerCase()
|
|
94
|
+
const matches = accept.some((entry) => {
|
|
95
|
+
if (entry.startsWith('.')) return name.endsWith(entry)
|
|
96
|
+
if (entry.endsWith('/*')) return type.startsWith(entry.slice(0, -1))
|
|
97
|
+
return type === entry
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
if (!matches) {
|
|
101
|
+
throw new Error(`Unsupported file type. Expected ${accept.join(', ')}.`)
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function readFileAsText(file: File, encoding?: string): Promise<string> {
|
|
106
|
+
if (typeof FileReader === 'undefined' && typeof file.text === 'function') {
|
|
107
|
+
return file.text()
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return new Promise((resolve, reject) => {
|
|
111
|
+
const reader = new FileReader()
|
|
112
|
+
reader.onload = () => resolve(String(reader.result ?? ''))
|
|
113
|
+
reader.onerror = () => reject(reader.error ?? new Error('Failed to read file.'))
|
|
114
|
+
reader.readAsText(file, encoding)
|
|
115
|
+
})
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function parseDelimitedText(
|
|
119
|
+
text: string,
|
|
120
|
+
options: ParseDelimitedTextOptions = {},
|
|
121
|
+
): DelimitedTable {
|
|
122
|
+
const trim = options.trim ?? true
|
|
123
|
+
const delimiter = options.delimiter ?? detectDelimiter(text)
|
|
124
|
+
const rawRows = parseDelimitedRows(text, delimiter)
|
|
125
|
+
.filter(row => row.some(cell => cell.trim().length > 0))
|
|
126
|
+
.map(row => trim ? row.map(cell => cell.trim()) : row)
|
|
127
|
+
|
|
128
|
+
const hasHeaderRow = options.hasHeaderRow ?? !options.headers
|
|
129
|
+
const columns = options.headers
|
|
130
|
+
? [...options.headers]
|
|
131
|
+
: hasHeaderRow
|
|
132
|
+
? rawRows[0] ?? []
|
|
133
|
+
: createColumnNames(maxRowLength(rawRows))
|
|
134
|
+
|
|
135
|
+
const dataRows = hasHeaderRow && !options.headers ? rawRows.slice(1) : rawRows
|
|
136
|
+
const rows = dataRows.map(row => Object.fromEntries(
|
|
137
|
+
columns.map((column, index) => [column, row[index] ?? '']),
|
|
138
|
+
))
|
|
139
|
+
|
|
140
|
+
return {
|
|
141
|
+
columns,
|
|
142
|
+
rows,
|
|
143
|
+
rawRows,
|
|
144
|
+
delimiter,
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function detectDelimiter(text: string): string {
|
|
149
|
+
const firstLine = text.split(/\r?\n/, 1)[0] ?? ''
|
|
150
|
+
const commaCount = countChar(firstLine, ',')
|
|
151
|
+
const tabCount = countChar(firstLine, '\t')
|
|
152
|
+
const semicolonCount = countChar(firstLine, ';')
|
|
153
|
+
if (tabCount > commaCount && tabCount >= semicolonCount) return '\t'
|
|
154
|
+
if (semicolonCount > commaCount) return ';'
|
|
155
|
+
return ','
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function parseDelimitedRows(text: string, delimiter: string): string[][] {
|
|
159
|
+
const rows: string[][] = []
|
|
160
|
+
let row: string[] = []
|
|
161
|
+
let field = ''
|
|
162
|
+
let quoted = false
|
|
163
|
+
|
|
164
|
+
for (let index = 0; index < text.length; index++) {
|
|
165
|
+
const char = text[index]
|
|
166
|
+
const next = text[index + 1]
|
|
167
|
+
|
|
168
|
+
if (char === '"') {
|
|
169
|
+
if (quoted && next === '"') {
|
|
170
|
+
field += '"'
|
|
171
|
+
index++
|
|
172
|
+
} else {
|
|
173
|
+
quoted = !quoted
|
|
174
|
+
}
|
|
175
|
+
continue
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (!quoted && char === delimiter) {
|
|
179
|
+
row.push(field)
|
|
180
|
+
field = ''
|
|
181
|
+
continue
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (!quoted && (char === '\n' || char === '\r')) {
|
|
185
|
+
if (char === '\r' && next === '\n') index++
|
|
186
|
+
row.push(field)
|
|
187
|
+
rows.push(row)
|
|
188
|
+
row = []
|
|
189
|
+
field = ''
|
|
190
|
+
continue
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
field += char
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
row.push(field)
|
|
197
|
+
rows.push(row)
|
|
198
|
+
return rows
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function normalizeAccept(accept: string | readonly string[] | undefined): string[] {
|
|
202
|
+
if (!accept) return []
|
|
203
|
+
const entries = typeof accept === 'string' ? accept.split(',') : [...accept]
|
|
204
|
+
return entries
|
|
205
|
+
.map((entry: string) => entry.trim().toLowerCase())
|
|
206
|
+
.filter(Boolean)
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function createColumnNames(count: number): string[] {
|
|
210
|
+
return Array.from({ length: count }, (_, index) => `column_${index + 1}`)
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function maxRowLength(rows: readonly string[][]): number {
|
|
214
|
+
return rows.reduce((max, row) => Math.max(max, row.length), 0)
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function countChar(value: string, char: string): number {
|
|
218
|
+
return [...value].filter(candidate => candidate === char).length
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function formatBytes(bytes: number): string {
|
|
222
|
+
if (bytes < 1024) return `${bytes} B`
|
|
223
|
+
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`
|
|
224
|
+
return `${Math.round(bytes / (1024 * 1024))} MB`
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function readErrorMessage(value: unknown): string {
|
|
228
|
+
if (value instanceof Error && value.message) return value.message
|
|
229
|
+
if (typeof value === 'string' && value.trim()) return value
|
|
230
|
+
return 'Failed to import file.'
|
|
231
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { computed, ref, shallowRef, type ComputedRef, type Ref, type ShallowRef } from 'vue'
|
|
2
|
+
|
|
3
|
+
export interface OptimisticMutationContext<TVariables, TSnapshot> {
|
|
4
|
+
variables: TVariables
|
|
5
|
+
snapshot: TSnapshot | undefined
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface UseOptimisticMutationOptions<TVariables, TData, TSnapshot = unknown> {
|
|
9
|
+
mutation: (variables: TVariables) => Promise<TData>
|
|
10
|
+
snapshot?: (variables: TVariables) => TSnapshot
|
|
11
|
+
apply?: (variables: TVariables, snapshot: TSnapshot | undefined) => void
|
|
12
|
+
commit?: (data: TData, context: OptimisticMutationContext<TVariables, TSnapshot>) => void
|
|
13
|
+
rollback?: (
|
|
14
|
+
snapshot: TSnapshot | undefined,
|
|
15
|
+
variables: TVariables,
|
|
16
|
+
error: unknown,
|
|
17
|
+
) => void
|
|
18
|
+
onSuccess?: (data: TData, context: OptimisticMutationContext<TVariables, TSnapshot>) => void
|
|
19
|
+
onError?: (error: unknown, context: OptimisticMutationContext<TVariables, TSnapshot>) => void
|
|
20
|
+
onSettled?: (context: OptimisticMutationContext<TVariables, TSnapshot>) => void
|
|
21
|
+
readErrorMessage?: (error: unknown) => string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface UseOptimisticMutationReturn<TVariables, TData, TSnapshot = unknown> {
|
|
25
|
+
loading: ShallowRef<boolean>
|
|
26
|
+
error: Ref<string | null>
|
|
27
|
+
data: Ref<TData | null>
|
|
28
|
+
lastSnapshot: Ref<TSnapshot | undefined>
|
|
29
|
+
isIdle: ComputedRef<boolean>
|
|
30
|
+
run: (variables: TVariables) => Promise<TData>
|
|
31
|
+
clearError: () => void
|
|
32
|
+
reset: () => void
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Run async mutations with optional optimistic local updates and rollback. */
|
|
36
|
+
export function useOptimisticMutation<TVariables, TData, TSnapshot = unknown>(
|
|
37
|
+
options: UseOptimisticMutationOptions<TVariables, TData, TSnapshot>,
|
|
38
|
+
): UseOptimisticMutationReturn<TVariables, TData, TSnapshot> {
|
|
39
|
+
const loading = shallowRef(false)
|
|
40
|
+
const error = ref<string | null>(null)
|
|
41
|
+
const data = ref<TData | null>(null) as Ref<TData | null>
|
|
42
|
+
const lastSnapshot = ref<TSnapshot | undefined>(undefined) as Ref<TSnapshot | undefined>
|
|
43
|
+
const isIdle = computed(() => !loading.value && data.value === null && error.value === null)
|
|
44
|
+
|
|
45
|
+
function clearError(): void {
|
|
46
|
+
error.value = null
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function reset(): void {
|
|
50
|
+
loading.value = false
|
|
51
|
+
error.value = null
|
|
52
|
+
data.value = null
|
|
53
|
+
lastSnapshot.value = undefined
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function run(variables: TVariables): Promise<TData> {
|
|
57
|
+
const snapshot = options.snapshot?.(variables)
|
|
58
|
+
const context: OptimisticMutationContext<TVariables, TSnapshot> = { variables, snapshot }
|
|
59
|
+
|
|
60
|
+
loading.value = true
|
|
61
|
+
error.value = null
|
|
62
|
+
lastSnapshot.value = snapshot
|
|
63
|
+
options.apply?.(variables, snapshot)
|
|
64
|
+
|
|
65
|
+
try {
|
|
66
|
+
const result = await options.mutation(variables)
|
|
67
|
+
data.value = result
|
|
68
|
+
options.commit?.(result, context)
|
|
69
|
+
options.onSuccess?.(result, context)
|
|
70
|
+
return result
|
|
71
|
+
} catch (err) {
|
|
72
|
+
options.rollback?.(snapshot, variables, err)
|
|
73
|
+
error.value = options.readErrorMessage?.(err) ?? readErrorMessage(err)
|
|
74
|
+
options.onError?.(err, context)
|
|
75
|
+
throw err
|
|
76
|
+
} finally {
|
|
77
|
+
loading.value = false
|
|
78
|
+
options.onSettled?.(context)
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
loading,
|
|
84
|
+
error,
|
|
85
|
+
data,
|
|
86
|
+
lastSnapshot,
|
|
87
|
+
isIdle,
|
|
88
|
+
run,
|
|
89
|
+
clearError,
|
|
90
|
+
reset,
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function readErrorMessage(value: unknown): string {
|
|
95
|
+
if (value instanceof Error && value.message) return value.message
|
|
96
|
+
if (typeof value === 'string' && value.trim()) return value
|
|
97
|
+
if (
|
|
98
|
+
typeof value === 'object'
|
|
99
|
+
&& value !== null
|
|
100
|
+
&& 'message' in value
|
|
101
|
+
&& typeof (value as { message?: unknown }).message === 'string'
|
|
102
|
+
&& (value as { message: string }).message.trim()
|
|
103
|
+
) {
|
|
104
|
+
return (value as { message: string }).message
|
|
105
|
+
}
|
|
106
|
+
return 'Request failed.'
|
|
107
|
+
}
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import { computed, ref, shallowRef, type ComputedRef, type Ref, type ShallowRef } from 'vue'
|
|
2
|
+
|
|
3
|
+
export type ResourceKey = string | number
|
|
4
|
+
|
|
5
|
+
export interface ResourceCrudAdapter<TItem, TCreate = Partial<TItem>, TUpdate = Partial<TItem>, TKey extends ResourceKey = ResourceKey> {
|
|
6
|
+
list: () => Promise<TItem[]>
|
|
7
|
+
create?: (payload: TCreate) => Promise<TItem>
|
|
8
|
+
update?: (key: TKey, payload: TUpdate) => Promise<TItem>
|
|
9
|
+
remove?: (key: TKey) => Promise<void>
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface UseResourceCrudOptions<
|
|
13
|
+
TItem,
|
|
14
|
+
TCreate = Partial<TItem>,
|
|
15
|
+
TUpdate = Partial<TItem>,
|
|
16
|
+
TKey extends ResourceKey = ResourceKey,
|
|
17
|
+
> {
|
|
18
|
+
adapter: ResourceCrudAdapter<TItem, TCreate, TUpdate, TKey>
|
|
19
|
+
initialItems?: readonly TItem[]
|
|
20
|
+
getKey?: (item: TItem) => TKey
|
|
21
|
+
readErrorMessage?: (error: unknown) => string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface UseResourceCrudReturn<TItem, TCreate, TUpdate, TKey extends ResourceKey> {
|
|
25
|
+
items: Ref<TItem[]>
|
|
26
|
+
loading: ShallowRef<boolean>
|
|
27
|
+
saving: ShallowRef<boolean>
|
|
28
|
+
deleting: ShallowRef<boolean>
|
|
29
|
+
error: Ref<string | null>
|
|
30
|
+
isBusy: ComputedRef<boolean>
|
|
31
|
+
load: () => Promise<TItem[]>
|
|
32
|
+
createItem: (payload: TCreate) => Promise<TItem>
|
|
33
|
+
updateItem: (key: TKey, payload: TUpdate) => Promise<TItem>
|
|
34
|
+
deleteItem: (keyOrItem: TKey | TItem) => Promise<void>
|
|
35
|
+
setItems: (items: readonly TItem[]) => void
|
|
36
|
+
upsertLocal: (item: TItem) => void
|
|
37
|
+
removeLocal: (keyOrItem: TKey | TItem) => void
|
|
38
|
+
clearError: () => void
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Common list/create/update/delete state wrapper for generated plugin clients. */
|
|
42
|
+
export function useResourceCrud<
|
|
43
|
+
TItem,
|
|
44
|
+
TCreate = Partial<TItem>,
|
|
45
|
+
TUpdate = Partial<TItem>,
|
|
46
|
+
TKey extends ResourceKey = ResourceKey,
|
|
47
|
+
>(
|
|
48
|
+
options: UseResourceCrudOptions<TItem, TCreate, TUpdate, TKey>,
|
|
49
|
+
): UseResourceCrudReturn<TItem, TCreate, TUpdate, TKey> {
|
|
50
|
+
const items = ref<TItem[]>([...(options.initialItems ?? [])]) as Ref<TItem[]>
|
|
51
|
+
const loading = shallowRef(false)
|
|
52
|
+
const saving = shallowRef(false)
|
|
53
|
+
const deleting = shallowRef(false)
|
|
54
|
+
const error = ref<string | null>(null)
|
|
55
|
+
const isBusy = computed(() => loading.value || saving.value || deleting.value)
|
|
56
|
+
|
|
57
|
+
const readMessage = options.readErrorMessage ?? readErrorMessage
|
|
58
|
+
const getKey = options.getKey ?? defaultGetKey<TKey, TItem>
|
|
59
|
+
|
|
60
|
+
function clearError(): void {
|
|
61
|
+
error.value = null
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function setError(err: unknown): void {
|
|
65
|
+
error.value = readMessage(err)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function setItems(nextItems: readonly TItem[]): void {
|
|
69
|
+
items.value = [...nextItems]
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function upsertLocal(item: TItem): void {
|
|
73
|
+
const key = getKey(item)
|
|
74
|
+
const index = items.value.findIndex(existing => getKey(existing) === key)
|
|
75
|
+
if (index === -1) {
|
|
76
|
+
items.value = [...items.value, item]
|
|
77
|
+
return
|
|
78
|
+
}
|
|
79
|
+
items.value = [
|
|
80
|
+
...items.value.slice(0, index),
|
|
81
|
+
item,
|
|
82
|
+
...items.value.slice(index + 1),
|
|
83
|
+
]
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function removeLocal(keyOrItem: TKey | TItem): void {
|
|
87
|
+
const key = resolveKey(keyOrItem, getKey)
|
|
88
|
+
items.value = items.value.filter(item => getKey(item) !== key)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function load(): Promise<TItem[]> {
|
|
92
|
+
loading.value = true
|
|
93
|
+
clearError()
|
|
94
|
+
try {
|
|
95
|
+
const result = await options.adapter.list()
|
|
96
|
+
setItems(result)
|
|
97
|
+
return result
|
|
98
|
+
} catch (err) {
|
|
99
|
+
setError(err)
|
|
100
|
+
throw err
|
|
101
|
+
} finally {
|
|
102
|
+
loading.value = false
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function createItem(payload: TCreate): Promise<TItem> {
|
|
107
|
+
if (!options.adapter.create) {
|
|
108
|
+
throw new Error('[MINT SDK] Resource adapter does not implement create().')
|
|
109
|
+
}
|
|
110
|
+
saving.value = true
|
|
111
|
+
clearError()
|
|
112
|
+
try {
|
|
113
|
+
const result = await options.adapter.create(payload)
|
|
114
|
+
upsertLocal(result)
|
|
115
|
+
return result
|
|
116
|
+
} catch (err) {
|
|
117
|
+
setError(err)
|
|
118
|
+
throw err
|
|
119
|
+
} finally {
|
|
120
|
+
saving.value = false
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function updateItem(key: TKey, payload: TUpdate): Promise<TItem> {
|
|
125
|
+
if (!options.adapter.update) {
|
|
126
|
+
throw new Error('[MINT SDK] Resource adapter does not implement update().')
|
|
127
|
+
}
|
|
128
|
+
saving.value = true
|
|
129
|
+
clearError()
|
|
130
|
+
try {
|
|
131
|
+
const result = await options.adapter.update(key, payload)
|
|
132
|
+
upsertLocal(result)
|
|
133
|
+
return result
|
|
134
|
+
} catch (err) {
|
|
135
|
+
setError(err)
|
|
136
|
+
throw err
|
|
137
|
+
} finally {
|
|
138
|
+
saving.value = false
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async function deleteItem(keyOrItem: TKey | TItem): Promise<void> {
|
|
143
|
+
if (!options.adapter.remove) {
|
|
144
|
+
throw new Error('[MINT SDK] Resource adapter does not implement remove().')
|
|
145
|
+
}
|
|
146
|
+
const key = resolveKey(keyOrItem, getKey)
|
|
147
|
+
deleting.value = true
|
|
148
|
+
clearError()
|
|
149
|
+
try {
|
|
150
|
+
await options.adapter.remove(key)
|
|
151
|
+
removeLocal(key)
|
|
152
|
+
} catch (err) {
|
|
153
|
+
setError(err)
|
|
154
|
+
throw err
|
|
155
|
+
} finally {
|
|
156
|
+
deleting.value = false
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return {
|
|
161
|
+
items,
|
|
162
|
+
loading,
|
|
163
|
+
saving,
|
|
164
|
+
deleting,
|
|
165
|
+
error,
|
|
166
|
+
isBusy,
|
|
167
|
+
load,
|
|
168
|
+
createItem,
|
|
169
|
+
updateItem,
|
|
170
|
+
deleteItem,
|
|
171
|
+
setItems,
|
|
172
|
+
upsertLocal,
|
|
173
|
+
removeLocal,
|
|
174
|
+
clearError,
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export interface PluginResourceClientOptions<
|
|
179
|
+
TClient,
|
|
180
|
+
TItem,
|
|
181
|
+
TCreate = Partial<TItem>,
|
|
182
|
+
TUpdate = Partial<TItem>,
|
|
183
|
+
TKey extends ResourceKey = ResourceKey,
|
|
184
|
+
> {
|
|
185
|
+
client: TClient
|
|
186
|
+
list: (client: TClient) => Promise<TItem[]>
|
|
187
|
+
create?: (client: TClient, payload: TCreate) => Promise<TItem>
|
|
188
|
+
update?: (client: TClient, key: TKey, payload: TUpdate) => Promise<TItem>
|
|
189
|
+
remove?: (client: TClient, key: TKey) => Promise<void>
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export function createPluginResourceClient<
|
|
193
|
+
TClient,
|
|
194
|
+
TItem,
|
|
195
|
+
TCreate = Partial<TItem>,
|
|
196
|
+
TUpdate = Partial<TItem>,
|
|
197
|
+
TKey extends ResourceKey = ResourceKey,
|
|
198
|
+
>(
|
|
199
|
+
options: PluginResourceClientOptions<TClient, TItem, TCreate, TUpdate, TKey>,
|
|
200
|
+
): ResourceCrudAdapter<TItem, TCreate, TUpdate, TKey> {
|
|
201
|
+
return {
|
|
202
|
+
list: () => options.list(options.client),
|
|
203
|
+
create: options.create
|
|
204
|
+
? payload => options.create!(options.client, payload)
|
|
205
|
+
: undefined,
|
|
206
|
+
update: options.update
|
|
207
|
+
? (key, payload) => options.update!(options.client, key, payload)
|
|
208
|
+
: undefined,
|
|
209
|
+
remove: options.remove
|
|
210
|
+
? key => options.remove!(options.client, key)
|
|
211
|
+
: undefined,
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function defaultGetKey<TKey extends ResourceKey, TItem>(item: TItem): TKey {
|
|
216
|
+
if (item && typeof item === 'object' && 'id' in item) {
|
|
217
|
+
return (item as { id: ResourceKey }).id as TKey
|
|
218
|
+
}
|
|
219
|
+
throw new Error('[MINT SDK] Resource items need an id field or a getKey option.')
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function resolveKey<TItem, TKey extends ResourceKey>(
|
|
223
|
+
keyOrItem: TKey | TItem,
|
|
224
|
+
getKey: (item: TItem) => TKey,
|
|
225
|
+
): TKey {
|
|
226
|
+
if (typeof keyOrItem === 'string' || typeof keyOrItem === 'number') {
|
|
227
|
+
return keyOrItem as TKey
|
|
228
|
+
}
|
|
229
|
+
return getKey(keyOrItem as TItem)
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function readErrorMessage(value: unknown): string {
|
|
233
|
+
if (value instanceof Error && value.message) return value.message
|
|
234
|
+
if (typeof value === 'string' && value.trim()) return value
|
|
235
|
+
if (
|
|
236
|
+
typeof value === 'object'
|
|
237
|
+
&& value !== null
|
|
238
|
+
&& 'message' in value
|
|
239
|
+
&& typeof (value as { message?: unknown }).message === 'string'
|
|
240
|
+
&& (value as { message: string }).message.trim()
|
|
241
|
+
) {
|
|
242
|
+
return (value as { message: string }).message
|
|
243
|
+
}
|
|
244
|
+
return 'Request failed.'
|
|
245
|
+
}
|