@morscherlab/mint-sdk 1.0.13 → 1.0.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/dist/__tests__/composables/useCommandHistory.test.d.ts +1 -0
  2. package/dist/__tests__/composables/useFileImport.test.d.ts +1 -0
  3. package/dist/__tests__/composables/useOptimisticMutation.test.d.ts +1 -0
  4. package/dist/__tests__/composables/useResourceCrud.test.d.ts +1 -0
  5. package/dist/__tests__/composables/useWellPainting.test.d.ts +1 -0
  6. package/dist/__tests__/composables/useWellPlateAdapter.test.d.ts +1 -0
  7. package/dist/__tests__/composables/useWellPlateValidation.test.d.ts +1 -0
  8. package/dist/components/index.js +1 -1
  9. package/dist/{components-CzFtQExv.js → components-Dq02EVZH.js} +4 -4
  10. package/dist/components-Dq02EVZH.js.map +1 -0
  11. package/dist/composables/index.d.ts +7 -0
  12. package/dist/composables/index.js +2 -2
  13. package/dist/composables/useCommandHistory.d.ts +29 -0
  14. package/dist/composables/useFileImport.d.ts +35 -0
  15. package/dist/composables/useOptimisticMutation.d.ts +28 -0
  16. package/dist/composables/useResourceCrud.d.ts +40 -0
  17. package/dist/composables/useWellPainting.d.ts +35 -0
  18. package/dist/composables/useWellPlateAdapter.d.ts +36 -0
  19. package/dist/composables/useWellPlateValidation.d.ts +27 -0
  20. package/dist/{composables-BuG5yAb7.js → composables-D9mexHSW.js} +666 -3
  21. package/dist/composables-D9mexHSW.js.map +1 -0
  22. package/dist/index.js +3 -3
  23. package/dist/install.js +1 -1
  24. package/package.json +1 -1
  25. package/src/__tests__/composables/useCommandHistory.test.ts +43 -0
  26. package/src/__tests__/composables/useFileImport.test.ts +44 -0
  27. package/src/__tests__/composables/useOptimisticMutation.test.ts +40 -0
  28. package/src/__tests__/composables/useResourceCrud.test.ts +56 -0
  29. package/src/__tests__/composables/useWellPainting.test.ts +52 -0
  30. package/src/__tests__/composables/useWellPlateAdapter.test.ts +50 -0
  31. package/src/__tests__/composables/useWellPlateValidation.test.ts +42 -0
  32. package/src/components/PluginWorkspaceView.vue +3 -3
  33. package/src/composables/index.ts +67 -0
  34. package/src/composables/useCommandHistory.ts +113 -0
  35. package/src/composables/useFileImport.ts +231 -0
  36. package/src/composables/useOptimisticMutation.ts +107 -0
  37. package/src/composables/useResourceCrud.ts +245 -0
  38. package/src/composables/useWellPainting.ts +187 -0
  39. package/src/composables/useWellPlateAdapter.ts +147 -0
  40. package/src/composables/useWellPlateValidation.ts +85 -0
  41. package/dist/components-CzFtQExv.js.map +0 -1
  42. package/dist/composables-BuG5yAb7.js.map +0 -1
@@ -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
+ })
@@ -37,10 +37,10 @@ const props = withDefaults(defineProps<PluginWorkspaceViewProps>(), {
37
37
  pluginSwitcher: undefined,
38
38
  pillNav: undefined,
39
39
  currentPillId: undefined,
40
- showThemeToggle: false,
41
- showSettings: false,
40
+ showThemeToggle: true,
41
+ showSettings: true,
42
42
  settingsConfig: undefined,
43
- showStandaloneLabel: true,
43
+ showStandaloneLabel: false,
44
44
  standaloneLabel: 'Standalone',
45
45
  accountMenu: undefined,
46
46
  showNotifications: false,
@@ -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
+ }
@@ -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
+ }