@morscherlab/mint-sdk 1.2.0-beta.2 → 1.2.0-beta.4
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 +5 -5
- package/dist/{ExperimentSelectorModal-DyPAUCLw.js → ExperimentSelectorModal-B6P2msT4.js} +3 -3
- package/dist/{ExperimentSelectorModal-DyPAUCLw.js.map → ExperimentSelectorModal-B6P2msT4.js.map} +1 -1
- package/dist/{SettingsModal-BgKY93zh.js → SettingsModal-CkNM8qAI.js} +4 -4
- package/dist/{SettingsModal-BgKY93zh.js.map → SettingsModal-CkNM8qAI.js.map} +1 -1
- package/dist/__tests__/composables/usePluginConfig.test.d.ts +15 -0
- package/dist/components/AutoGroupModal.vue.d.ts +1 -1
- package/dist/components/BioTemplateExperimentWorkspaceView.vue.d.ts +1 -1
- package/dist/components/JobsStatusTray.vue.d.ts +9 -2
- package/dist/components/SampleLegend.vue.d.ts +1 -1
- package/dist/components/SampleSelector.vue.d.ts +1 -1
- package/dist/components/SettingsModal.vue.d.ts +1 -1
- package/dist/components/SmartGroupFieldRecipe.vue.d.ts +1 -1
- package/dist/components/index.js +4 -4
- package/dist/{components-DkHddRMl.js → components-MGNL6tRn.js} +16 -10
- package/dist/components-MGNL6tRn.js.map +1 -0
- package/dist/composables/index.d.ts +3 -2
- package/dist/composables/index.js +8 -8
- package/dist/composables/useGeneratedAnalysis.d.ts +3 -1
- package/dist/composables/useJobsStatusTray.d.ts +25 -1
- package/dist/composables/usePluginClient.d.ts +2 -0
- package/dist/composables/usePluginConfig.d.ts +25 -0
- package/dist/{composables-C_i90sGS.js → composables-CKxOcvgh.js} +140 -5
- package/dist/composables-CKxOcvgh.js.map +1 -0
- package/dist/{experiment-Cq145oEW.js → experiment-Y9Iz7KYi.js} +2 -2
- package/dist/{experiment-Cq145oEW.js.map → experiment-Y9Iz7KYi.js.map} +1 -1
- package/dist/index.js +12 -12
- package/dist/install.js +4 -4
- package/dist/stores/auth.d.ts +6 -0
- package/dist/stores/index.js +2 -2
- package/dist/templates/index.js +2 -2
- package/dist/{templates-DsP51i-S.js → templates-BTJ9C9Kb.js} +2 -2
- package/dist/{templates-DsP51i-S.js.map → templates-BTJ9C9Kb.js.map} +1 -1
- package/dist/types/auth.d.ts +1 -0
- package/dist/{useApi-J6zFkHmn.js → useApi-CDBq6z23.js} +2 -1
- package/dist/useApi-CDBq6z23.js.map +1 -0
- package/dist/{useControlSchema-BDx2Olha.js → useControlSchema-CXhxTul7.js} +10 -3
- package/dist/useControlSchema-CXhxTul7.js.map +1 -0
- package/dist/{useExperimentSelector-CV4iNfMS.js → useExperimentSelector-D0pjj9WH.js} +2 -2
- package/dist/{useExperimentSelector-CV4iNfMS.js.map → useExperimentSelector-D0pjj9WH.js.map} +1 -1
- package/dist/{useFormBuilder-BLxG3BOX.js → useFormBuilder-CuOFB3aP.js} +2 -2
- package/dist/{useFormBuilder-BLxG3BOX.js.map → useFormBuilder-CuOFB3aP.js.map} +1 -1
- package/dist/{useProtocolTemplates-CS_SQl6U.js → useProtocolTemplates-BWhQIWV5.js} +113 -22
- package/dist/useProtocolTemplates-BWhQIWV5.js.map +1 -0
- package/package.json +1 -1
- package/src/__tests__/composables/useAuth.test.ts +2 -0
- package/src/__tests__/composables/usePluginConfig.test.ts +497 -0
- package/src/__tests__/docs/frontendDocsCatalog.test.ts +17 -6
- package/src/components/JobsStatusTray.vue +16 -1
- package/src/composables/index.ts +7 -0
- package/src/composables/useAuth.ts +3 -0
- package/src/composables/useGeneratedAnalysis.ts +14 -1
- package/src/composables/useJobsStatusTray.ts +184 -17
- package/src/composables/usePluginClient.ts +5 -0
- package/src/composables/usePluginConfig.ts +250 -0
- package/src/stores/auth.ts +1 -0
- package/src/types/auth.ts +1 -0
- package/dist/components-DkHddRMl.js.map +0 -1
- package/dist/composables-C_i90sGS.js.map +0 -1
- package/dist/useApi-J6zFkHmn.js.map +0 -1
- package/dist/useControlSchema-BDx2Olha.js.map +0 -1
- package/dist/useProtocolTemplates-CS_SQl6U.js.map +0 -1
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
ref,
|
|
6
6
|
shallowRef,
|
|
7
7
|
toValue,
|
|
8
|
+
watch,
|
|
8
9
|
type ComputedRef,
|
|
9
10
|
type MaybeRefOrGetter,
|
|
10
11
|
type Ref,
|
|
@@ -16,7 +17,12 @@ import type {
|
|
|
16
17
|
JobListPayload,
|
|
17
18
|
JobState,
|
|
18
19
|
JobStateInput,
|
|
20
|
+
JobStreamData,
|
|
19
21
|
} from '../types/jobs'
|
|
22
|
+
import type {
|
|
23
|
+
PluginEventStreamMessage,
|
|
24
|
+
UsePluginEventStreamReturn,
|
|
25
|
+
} from './usePluginClient'
|
|
20
26
|
|
|
21
27
|
export type JobsStatusTrayTab = 'active' | 'history'
|
|
22
28
|
|
|
@@ -140,11 +146,32 @@ function duplicatePluginJobId(jobId: string): Error {
|
|
|
140
146
|
return new Error(`Duplicate plugin job id across combined sources: ${jobId}`)
|
|
141
147
|
}
|
|
142
148
|
|
|
149
|
+
/** @deprecated Prefer a {@link PluginJobCenterSource}. */
|
|
150
|
+
export interface JobsStatusTrayAdapter {
|
|
151
|
+
listJobs?: () => Promise<JobListPayload | readonly JobStateInput[]>
|
|
152
|
+
cancelJob?: (job: JobState) => Promise<JobStateInput | void>
|
|
153
|
+
deleteJob?: (job: JobState) => Promise<boolean | void>
|
|
154
|
+
clearFinishedJobs?: (jobs: readonly JobState[]) => Promise<number | void>
|
|
155
|
+
}
|
|
156
|
+
|
|
143
157
|
export interface UsePluginJobCenterOptions {
|
|
144
158
|
source?: MaybeRefOrGetter<PluginJobCenterSource | undefined>
|
|
159
|
+
/** @deprecated Pass a source with its canonical jobs ref. */
|
|
160
|
+
jobs?: MaybeRefOrGetter<readonly JobStateInput[] | undefined>
|
|
161
|
+
/** @deprecated Pass source actions instead. */
|
|
162
|
+
adapter?: MaybeRefOrGetter<JobsStatusTrayAdapter | undefined>
|
|
163
|
+
/** @deprecated Event handling belongs to the source runtime. */
|
|
164
|
+
eventStream?: MaybeRefOrGetter<UsePluginEventStreamReturn<JobStreamData> | undefined>
|
|
145
165
|
loadOnMount?: boolean
|
|
146
166
|
}
|
|
147
167
|
|
|
168
|
+
export interface UseJobsStatusTrayOptions extends UsePluginJobCenterOptions {}
|
|
169
|
+
|
|
170
|
+
export interface JobStreamMessage {
|
|
171
|
+
event: string
|
|
172
|
+
data: JobStreamData
|
|
173
|
+
}
|
|
174
|
+
|
|
148
175
|
export interface UsePluginJobCenterReturn {
|
|
149
176
|
jobs: ComputedRef<JobState[]>
|
|
150
177
|
activeJobs: ComputedRef<JobState[]>
|
|
@@ -164,12 +191,30 @@ export interface UsePluginJobCenterReturn {
|
|
|
164
191
|
close: () => void
|
|
165
192
|
toggle: () => void
|
|
166
193
|
refresh: () => Promise<boolean>
|
|
194
|
+
applyStreamMessage: (message: JobStreamMessage) => void
|
|
167
195
|
cancelJob: (job: JobState) => Promise<boolean>
|
|
168
196
|
deleteJob: (job: JobState) => Promise<boolean>
|
|
169
197
|
clearFinished: () => Promise<boolean>
|
|
170
198
|
isPending: (job: JobState) => boolean
|
|
171
199
|
}
|
|
172
200
|
|
|
201
|
+
export type UseJobsStatusTrayReturn = UsePluginJobCenterReturn
|
|
202
|
+
|
|
203
|
+
interface LegacyJobsOverride {
|
|
204
|
+
readonly input: readonly JobStateInput[] | undefined
|
|
205
|
+
readonly jobs: JobState[]
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function responseJobs(
|
|
209
|
+
response: JobListPayload | readonly JobStateInput[],
|
|
210
|
+
): readonly JobStateInput[] {
|
|
211
|
+
return 'jobs' in response ? response.jobs : response
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function isSnapshotPayload(data: JobStreamData): data is { jobs: JobStateInput[] } {
|
|
215
|
+
return 'jobs' in data && Array.isArray(data.jobs)
|
|
216
|
+
}
|
|
217
|
+
|
|
173
218
|
function errorMessage(error: unknown, fallback: string): string {
|
|
174
219
|
return error instanceof Error ? error.message : fallback
|
|
175
220
|
}
|
|
@@ -183,30 +228,84 @@ function errorMessage(error: unknown, fallback: string): string {
|
|
|
183
228
|
export function usePluginJobCenter(
|
|
184
229
|
options: UsePluginJobCenterOptions = {},
|
|
185
230
|
): UsePluginJobCenterReturn {
|
|
231
|
+
const legacyOverride = shallowRef<LegacyJobsOverride | null>(null)
|
|
186
232
|
const isOpen = ref(false)
|
|
187
233
|
const activeTab = ref<JobsStatusTrayTab>('active')
|
|
188
234
|
const isLoading = ref(false)
|
|
189
235
|
const error = shallowRef<string | null>(null)
|
|
190
236
|
const pendingJobIds = ref(new Set<string>())
|
|
237
|
+
const jobRevisions = new Map<string, number>()
|
|
238
|
+
let stateRevision = 0
|
|
191
239
|
let requestGeneration = 0
|
|
192
240
|
let active = true
|
|
193
241
|
const source = computed(() => toValue(options.source))
|
|
242
|
+
const adapter = computed(() => toValue(options.adapter))
|
|
194
243
|
|
|
195
|
-
const jobs = computed<JobState[]>(() =>
|
|
196
|
-
|
|
197
|
-
|
|
244
|
+
const jobs = computed<JobState[]>(() => {
|
|
245
|
+
const currentSource = source.value
|
|
246
|
+
if (currentSource) {
|
|
247
|
+
return [...toValue(currentSource.jobs)].map(normalizeJobState)
|
|
248
|
+
}
|
|
249
|
+
const input = toValue(options.jobs)
|
|
250
|
+
const override = legacyOverride.value
|
|
251
|
+
if (override && override.input === input) return override.jobs
|
|
252
|
+
return input ? [...input].map(normalizeJobState) : []
|
|
253
|
+
})
|
|
198
254
|
const activeJobs = computed(() => jobs.value.filter(job => isActiveJobStatus(job.status)))
|
|
199
255
|
const historyJobs = computed(() => jobs.value.filter(job => !isActiveJobStatus(job.status)))
|
|
200
256
|
const deletableJobs = computed(() => historyJobs.value.filter(job => job.can_delete))
|
|
201
257
|
const featuredJob = computed(() => activeJobs.value[0] ?? historyJobs.value[0] ?? null)
|
|
202
|
-
const canRefresh = computed(() => Boolean(source.value?.refreshJobs))
|
|
203
|
-
const canCancel = computed(() => Boolean(source.value?.cancelJob))
|
|
204
|
-
const canDelete = computed(() => Boolean(source.value?.deleteJob))
|
|
258
|
+
const canRefresh = computed(() => Boolean(source.value?.refreshJobs || adapter.value?.listJobs))
|
|
259
|
+
const canCancel = computed(() => Boolean(source.value?.cancelJob || adapter.value?.cancelJob))
|
|
260
|
+
const canDelete = computed(() => Boolean(source.value?.deleteJob || adapter.value?.deleteJob))
|
|
205
261
|
const canClearFinished = computed(() => Boolean(
|
|
206
262
|
source.value?.clearFinishedJobs
|
|
207
263
|
|| source.value?.deleteJob
|
|
264
|
+
|| adapter.value?.clearFinishedJobs
|
|
265
|
+
|| adapter.value?.deleteJob,
|
|
208
266
|
))
|
|
209
267
|
|
|
268
|
+
function bumpJobRevision(jobId: string): void {
|
|
269
|
+
jobRevisions.set(jobId, (jobRevisions.get(jobId) ?? 0) + 1)
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function replaceLegacyJobs(nextJobs: readonly JobStateInput[]): void {
|
|
273
|
+
const normalized = nextJobs.map(normalizeJobState)
|
|
274
|
+
const touchedJobIds = new Set([
|
|
275
|
+
...jobs.value.map(job => job.job_id),
|
|
276
|
+
...normalized.map(job => job.job_id),
|
|
277
|
+
])
|
|
278
|
+
touchedJobIds.forEach(bumpJobRevision)
|
|
279
|
+
legacyOverride.value = {
|
|
280
|
+
input: toValue(options.jobs),
|
|
281
|
+
jobs: normalized,
|
|
282
|
+
}
|
|
283
|
+
stateRevision += 1
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function upsertLegacyJob(nextJob: JobStateInput): void {
|
|
287
|
+
const normalized = normalizeJobState(nextJob)
|
|
288
|
+
const existingIndex = jobs.value.findIndex(job => job.job_id === normalized.job_id)
|
|
289
|
+
const nextJobs = [...jobs.value]
|
|
290
|
+
if (existingIndex === -1) nextJobs.unshift(normalized)
|
|
291
|
+
else nextJobs[existingIndex] = normalized
|
|
292
|
+
legacyOverride.value = {
|
|
293
|
+
input: toValue(options.jobs),
|
|
294
|
+
jobs: nextJobs,
|
|
295
|
+
}
|
|
296
|
+
bumpJobRevision(normalized.job_id)
|
|
297
|
+
stateRevision += 1
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function removeLegacyJobs(jobIds: Set<string>): void {
|
|
301
|
+
legacyOverride.value = {
|
|
302
|
+
input: toValue(options.jobs),
|
|
303
|
+
jobs: jobs.value.filter(job => !jobIds.has(job.job_id)),
|
|
304
|
+
}
|
|
305
|
+
jobIds.forEach(bumpJobRevision)
|
|
306
|
+
stateRevision += 1
|
|
307
|
+
}
|
|
308
|
+
|
|
210
309
|
function setPending(jobId: string, pending: boolean): void {
|
|
211
310
|
const next = new Set(pendingJobIds.value)
|
|
212
311
|
if (pending) next.add(jobId)
|
|
@@ -214,15 +313,35 @@ export function usePluginJobCenter(
|
|
|
214
313
|
pendingJobIds.value = next
|
|
215
314
|
}
|
|
216
315
|
|
|
316
|
+
function applyStreamMessage(message: JobStreamMessage): void {
|
|
317
|
+
if (!active || source.value) return
|
|
318
|
+
if (message.event === 'snapshot' && isSnapshotPayload(message.data)) {
|
|
319
|
+
replaceLegacyJobs(message.data.jobs)
|
|
320
|
+
return
|
|
321
|
+
}
|
|
322
|
+
if (
|
|
323
|
+
(message.event === 'job' || message.event === 'terminal')
|
|
324
|
+
&& !isSnapshotPayload(message.data)
|
|
325
|
+
) {
|
|
326
|
+
upsertLegacyJob(message.data)
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
217
330
|
async function refresh(): Promise<boolean> {
|
|
218
|
-
const
|
|
219
|
-
|
|
331
|
+
const currentSource = source.value
|
|
332
|
+
const listJobs = currentSource?.refreshJobs ?? adapter.value?.listJobs
|
|
333
|
+
if (!listJobs) return false
|
|
220
334
|
const generation = ++requestGeneration
|
|
335
|
+
const revision = stateRevision
|
|
221
336
|
isLoading.value = true
|
|
222
337
|
error.value = null
|
|
223
338
|
try {
|
|
224
|
-
await
|
|
225
|
-
|
|
339
|
+
const response = await listJobs()
|
|
340
|
+
if (!active || generation !== requestGeneration || revision !== stateRevision) {
|
|
341
|
+
return false
|
|
342
|
+
}
|
|
343
|
+
if (!currentSource && response) replaceLegacyJobs(responseJobs(response))
|
|
344
|
+
return true
|
|
226
345
|
} catch (cause) {
|
|
227
346
|
if (active && generation === requestGeneration) {
|
|
228
347
|
error.value = errorMessage(cause, 'Failed to load jobs')
|
|
@@ -235,14 +354,23 @@ export function usePluginJobCenter(
|
|
|
235
354
|
|
|
236
355
|
async function cancelJob(job: JobState): Promise<boolean> {
|
|
237
356
|
const sourceCancel = source.value?.cancelJob
|
|
238
|
-
|
|
357
|
+
const adapterCancel = adapter.value?.cancelJob
|
|
358
|
+
if (!job.can_cancel || (!sourceCancel && !adapterCancel) || pendingJobIds.value.has(job.job_id)) {
|
|
239
359
|
return false
|
|
240
360
|
}
|
|
361
|
+
const revision = jobRevisions.get(job.job_id) ?? 0
|
|
241
362
|
setPending(job.job_id, true)
|
|
242
363
|
error.value = null
|
|
243
364
|
try {
|
|
244
|
-
|
|
245
|
-
|
|
365
|
+
const updated = sourceCancel
|
|
366
|
+
? await sourceCancel(job.job_id)
|
|
367
|
+
: await adapterCancel!(job)
|
|
368
|
+
if (!active) return false
|
|
369
|
+
if (!sourceCancel && revision === (jobRevisions.get(job.job_id) ?? 0)) {
|
|
370
|
+
if (updated) upsertLegacyJob(updated)
|
|
371
|
+
else await refresh()
|
|
372
|
+
}
|
|
373
|
+
return true
|
|
246
374
|
} catch (cause) {
|
|
247
375
|
if (active) error.value = errorMessage(cause, 'Failed to cancel job')
|
|
248
376
|
return false
|
|
@@ -253,14 +381,22 @@ export function usePluginJobCenter(
|
|
|
253
381
|
|
|
254
382
|
async function deleteJob(job: JobState): Promise<boolean> {
|
|
255
383
|
const sourceDelete = source.value?.deleteJob
|
|
256
|
-
|
|
384
|
+
const adapterDelete = adapter.value?.deleteJob
|
|
385
|
+
if (!job.can_delete || (!sourceDelete && !adapterDelete) || pendingJobIds.value.has(job.job_id)) {
|
|
257
386
|
return false
|
|
258
387
|
}
|
|
259
388
|
setPending(job.job_id, true)
|
|
260
389
|
error.value = null
|
|
261
390
|
try {
|
|
262
|
-
const deleted =
|
|
263
|
-
|
|
391
|
+
const deleted = sourceDelete
|
|
392
|
+
? await sourceDelete(job.job_id)
|
|
393
|
+
: await adapterDelete!(job)
|
|
394
|
+
if (!active) return false
|
|
395
|
+
if (!sourceDelete) {
|
|
396
|
+
if (deleted === false) await refresh()
|
|
397
|
+
else removeLegacyJobs(new Set([job.job_id]))
|
|
398
|
+
}
|
|
399
|
+
return deleted !== false
|
|
264
400
|
} catch (cause) {
|
|
265
401
|
if (active) error.value = errorMessage(cause, 'Failed to delete job')
|
|
266
402
|
return false
|
|
@@ -284,13 +420,36 @@ export function usePluginJobCenter(
|
|
|
284
420
|
const results = await Promise.all(targets.map(job => deleteJob(job)))
|
|
285
421
|
return results.every(Boolean)
|
|
286
422
|
}
|
|
287
|
-
|
|
423
|
+
const currentAdapter = adapter.value
|
|
424
|
+
if (currentAdapter?.clearFinishedJobs) {
|
|
425
|
+
const deleted = await currentAdapter.clearFinishedJobs(targets)
|
|
426
|
+
if (!active) return false
|
|
427
|
+
if (typeof deleted === 'number' && deleted !== targets.length) {
|
|
428
|
+
await refresh()
|
|
429
|
+
return false
|
|
430
|
+
}
|
|
431
|
+
removeLegacyJobs(new Set(targets.map(job => job.job_id)))
|
|
432
|
+
return true
|
|
433
|
+
}
|
|
434
|
+
if (!currentAdapter?.deleteJob) return false
|
|
435
|
+
const results = await Promise.all(targets.map(job => deleteJob(job)))
|
|
436
|
+
return results.every(Boolean)
|
|
288
437
|
} catch (cause) {
|
|
289
438
|
if (active) error.value = errorMessage(cause, 'Failed to clear jobs')
|
|
290
439
|
return false
|
|
291
440
|
}
|
|
292
441
|
}
|
|
293
442
|
|
|
443
|
+
if (options.eventStream !== undefined) {
|
|
444
|
+
watch(
|
|
445
|
+
() => toValue(options.eventStream)?.lastMessage.value ?? null,
|
|
446
|
+
(message: PluginEventStreamMessage<JobStreamData> | null) => {
|
|
447
|
+
if (message) applyStreamMessage(message)
|
|
448
|
+
},
|
|
449
|
+
{ immediate: true },
|
|
450
|
+
)
|
|
451
|
+
}
|
|
452
|
+
|
|
294
453
|
onMounted(() => {
|
|
295
454
|
if (options.loadOnMount !== false) void refresh()
|
|
296
455
|
})
|
|
@@ -318,9 +477,17 @@ export function usePluginJobCenter(
|
|
|
318
477
|
close: () => { isOpen.value = false },
|
|
319
478
|
toggle: () => { isOpen.value = !isOpen.value },
|
|
320
479
|
refresh,
|
|
480
|
+
applyStreamMessage,
|
|
321
481
|
cancelJob,
|
|
322
482
|
deleteJob,
|
|
323
483
|
clearFinished,
|
|
324
484
|
isPending: job => pendingJobIds.value.has(job.job_id),
|
|
325
485
|
}
|
|
326
486
|
}
|
|
487
|
+
|
|
488
|
+
/** @deprecated Use `usePluginJobCenter()` for the plugin job-center controller. */
|
|
489
|
+
export function useJobsStatusTray(
|
|
490
|
+
options: UseJobsStatusTrayOptions = {},
|
|
491
|
+
): UseJobsStatusTrayReturn {
|
|
492
|
+
return usePluginJobCenter(options)
|
|
493
|
+
}
|
|
@@ -538,6 +538,11 @@ function normalizePluginSettingsOptions(options: UsePluginSettingsOptions | stri
|
|
|
538
538
|
return typeof options === 'string' ? { pluginName: options } : options
|
|
539
539
|
}
|
|
540
540
|
|
|
541
|
+
/** @deprecated Use `useGeneratedPluginClient()` for generated clients. */
|
|
542
|
+
export function usePluginClient<TClient>(client: TClient): TClient {
|
|
543
|
+
return client
|
|
544
|
+
}
|
|
545
|
+
|
|
541
546
|
const PLATFORM_OWNED_SETTINGS_KEY = 'allowed_experiment_types'
|
|
542
547
|
|
|
543
548
|
function cloneRecord(value: Record<string, unknown>): Record<string, unknown> {
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import { ref, computed, onMounted, type Ref, type ComputedRef } from 'vue'
|
|
2
|
+
import { useApi } from './useApi'
|
|
3
|
+
import { usePlatformContext } from './usePlatformContext'
|
|
4
|
+
import { getInjectedPlatformContext } from './platformContextHelpers'
|
|
5
|
+
import { useRequestSyncState } from './useRequestSyncState'
|
|
6
|
+
|
|
7
|
+
const PLATFORM_OWNED_SETTINGS_KEY = 'allowed_experiment_types'
|
|
8
|
+
|
|
9
|
+
function cloneConfig(config: Record<string, unknown>): Record<string, unknown> {
|
|
10
|
+
return JSON.parse(JSON.stringify(config)) as Record<string, unknown>
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function hasOwnConfig(config: Record<string, unknown>, key: string): boolean {
|
|
14
|
+
return Object.prototype.hasOwnProperty.call(config, key)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function changedTopLevelKeys(
|
|
18
|
+
current: Record<string, unknown>,
|
|
19
|
+
saved: Record<string, unknown>,
|
|
20
|
+
): Set<string> {
|
|
21
|
+
return new Set(
|
|
22
|
+
[...new Set([...Object.keys(current), ...Object.keys(saved)])].filter(
|
|
23
|
+
key => JSON.stringify(current[key]) !== JSON.stringify(saved[key]),
|
|
24
|
+
),
|
|
25
|
+
)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function topLevelConfigPatch(
|
|
29
|
+
current: Record<string, unknown>,
|
|
30
|
+
keys: Iterable<string>,
|
|
31
|
+
): Record<string, unknown> {
|
|
32
|
+
return Object.fromEntries(
|
|
33
|
+
[...keys].map(key => [
|
|
34
|
+
key,
|
|
35
|
+
hasOwnConfig(current, key) ? current[key] : null,
|
|
36
|
+
]),
|
|
37
|
+
)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function platformOwnedConfigPatch(config: Record<string, unknown>): Record<string, unknown> {
|
|
41
|
+
return {
|
|
42
|
+
[PLATFORM_OWNED_SETTINGS_KEY]: hasOwnConfig(config, PLATFORM_OWNED_SETTINGS_KEY)
|
|
43
|
+
? config[PLATFORM_OWNED_SETTINGS_KEY]
|
|
44
|
+
: null,
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function responseConfigPayload(response: {
|
|
49
|
+
config?: Record<string, unknown>
|
|
50
|
+
settings?: Record<string, unknown>
|
|
51
|
+
}): Record<string, unknown> {
|
|
52
|
+
return response.config ?? response.settings ?? {}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface UsePluginConfigReturn {
|
|
56
|
+
/** Editable plugin configuration values. */
|
|
57
|
+
config: Ref<Record<string, unknown>>
|
|
58
|
+
/** Whether the config is currently loading. */
|
|
59
|
+
isLoading: Ref<boolean>
|
|
60
|
+
/** Whether the config is currently saving. */
|
|
61
|
+
isSaving: Ref<boolean>
|
|
62
|
+
/** Error message from the last failed operation, or null. */
|
|
63
|
+
error: Ref<string | null>
|
|
64
|
+
/** Timestamp of the last successful load, or null. */
|
|
65
|
+
lastLoadedAt: Ref<Date | null>
|
|
66
|
+
/** Timestamp of the last successful save, or null. */
|
|
67
|
+
lastSavedAt: Ref<Date | null>
|
|
68
|
+
/** Whether local config differs from the last loaded or saved snapshot. */
|
|
69
|
+
isDirty: ComputedRef<boolean>
|
|
70
|
+
/** Load plugin configuration from the platform. */
|
|
71
|
+
load: () => Promise<void>
|
|
72
|
+
/** Save plugin configuration to the platform. */
|
|
73
|
+
save: () => Promise<boolean>
|
|
74
|
+
/** Restore the last loaded or saved configuration snapshot. */
|
|
75
|
+
reset: () => void
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** @deprecated Use `usePluginSettings()` for typed plugin settings. */
|
|
79
|
+
export function usePluginConfig(pluginName?: string): UsePluginConfigReturn {
|
|
80
|
+
const injectedContext = getInjectedPlatformContext()
|
|
81
|
+
const { plugin } = usePlatformContext()
|
|
82
|
+
const platformApi = useApi()
|
|
83
|
+
const pluginApi = useApi({
|
|
84
|
+
baseUrl: injectedContext?.plugin?.api_prefix ?? plugin.value?.api_prefix,
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
const resolvedName = computed(
|
|
88
|
+
() => pluginName ?? plugin.value?.name ?? injectedContext?.plugin?.name ?? '',
|
|
89
|
+
)
|
|
90
|
+
const usesPluginSettingsApi = computed(() => {
|
|
91
|
+
const currentPlugin = injectedContext?.plugin ?? plugin.value
|
|
92
|
+
return currentPlugin?.settings_api === 'plugin'
|
|
93
|
+
&& resolvedName.value === currentPlugin.name
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
const config = ref<Record<string, unknown>>({})
|
|
97
|
+
const savedConfig = ref<Record<string, unknown>>({})
|
|
98
|
+
const revision = ref<string | null>(null)
|
|
99
|
+
const request = useRequestSyncState('Plugin config request failed.')
|
|
100
|
+
const isLoading = ref(false)
|
|
101
|
+
const isSaving = ref(false)
|
|
102
|
+
const error = request.error
|
|
103
|
+
const lastLoadedAt = request.lastLoadedAt
|
|
104
|
+
const lastSavedAt = request.lastSavedAt
|
|
105
|
+
|
|
106
|
+
const isDirty = computed(() => {
|
|
107
|
+
return JSON.stringify(config.value) !== JSON.stringify(savedConfig.value)
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
async function load(): Promise<void> {
|
|
111
|
+
const name = resolvedName.value
|
|
112
|
+
if (!name) return
|
|
113
|
+
|
|
114
|
+
isLoading.value = true
|
|
115
|
+
try {
|
|
116
|
+
const response = usesPluginSettingsApi.value
|
|
117
|
+
? await request.run(
|
|
118
|
+
() => pluginApi.get<{
|
|
119
|
+
settings: Record<string, unknown>
|
|
120
|
+
mode?: string
|
|
121
|
+
revision?: string
|
|
122
|
+
}>('/settings'),
|
|
123
|
+
{ success: 'load', errorMessage: 'Failed to load plugin config' },
|
|
124
|
+
)
|
|
125
|
+
: await request.run(
|
|
126
|
+
() => platformApi.get<{
|
|
127
|
+
plugin_name: string
|
|
128
|
+
config: Record<string, unknown>
|
|
129
|
+
revision?: string
|
|
130
|
+
}>(
|
|
131
|
+
`/plugins/${encodeURIComponent(name)}/config`,
|
|
132
|
+
),
|
|
133
|
+
{ success: 'load', errorMessage: 'Failed to load plugin config' },
|
|
134
|
+
)
|
|
135
|
+
const responseConfig = responseConfigPayload(response)
|
|
136
|
+
config.value = cloneConfig(responseConfig)
|
|
137
|
+
savedConfig.value = cloneConfig(responseConfig)
|
|
138
|
+
revision.value = response.revision ?? null
|
|
139
|
+
} catch {
|
|
140
|
+
// Error state is handled by request.run().
|
|
141
|
+
} finally {
|
|
142
|
+
isLoading.value = false
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async function save(): Promise<boolean> {
|
|
147
|
+
const name = resolvedName.value
|
|
148
|
+
if (!name) return false
|
|
149
|
+
|
|
150
|
+
isSaving.value = true
|
|
151
|
+
try {
|
|
152
|
+
const payload = cloneConfig(config.value)
|
|
153
|
+
let response: {
|
|
154
|
+
plugin_name?: string
|
|
155
|
+
config?: Record<string, unknown>
|
|
156
|
+
settings?: Record<string, unknown>
|
|
157
|
+
mode?: string
|
|
158
|
+
revision?: string
|
|
159
|
+
} | undefined
|
|
160
|
+
let nextRevision = revision.value
|
|
161
|
+
|
|
162
|
+
if (usesPluginSettingsApi.value) {
|
|
163
|
+
response = await request.run(
|
|
164
|
+
() => pluginApi.put<{
|
|
165
|
+
settings: Record<string, unknown>
|
|
166
|
+
mode?: string
|
|
167
|
+
revision?: string
|
|
168
|
+
}>(
|
|
169
|
+
'/settings',
|
|
170
|
+
payload,
|
|
171
|
+
revision.value
|
|
172
|
+
? { headers: { 'If-Match': `"${revision.value}"` } }
|
|
173
|
+
: undefined,
|
|
174
|
+
),
|
|
175
|
+
{ success: 'save', errorMessage: 'Failed to save plugin config' },
|
|
176
|
+
)
|
|
177
|
+
nextRevision = response.revision ?? nextRevision
|
|
178
|
+
} else {
|
|
179
|
+
const dirtyKeys = changedTopLevelKeys(payload, savedConfig.value)
|
|
180
|
+
const platformOwnedChanged = dirtyKeys.has(PLATFORM_OWNED_SETTINGS_KEY)
|
|
181
|
+
const typedKeys = [...dirtyKeys].filter(
|
|
182
|
+
key => key !== PLATFORM_OWNED_SETTINGS_KEY,
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
if (typedKeys.length) {
|
|
186
|
+
response = await request.run(
|
|
187
|
+
() => platformApi.patch<{
|
|
188
|
+
plugin_name: string
|
|
189
|
+
config: Record<string, unknown>
|
|
190
|
+
revision?: string
|
|
191
|
+
}>(
|
|
192
|
+
`/plugins/${encodeURIComponent(name)}/config`,
|
|
193
|
+
{ config: topLevelConfigPatch(payload, typedKeys) },
|
|
194
|
+
),
|
|
195
|
+
{ success: 'save', errorMessage: 'Failed to save plugin config' },
|
|
196
|
+
)
|
|
197
|
+
nextRevision = response.revision ?? nextRevision
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (platformOwnedChanged) {
|
|
201
|
+
response = await request.run(
|
|
202
|
+
() => platformApi.patch<{
|
|
203
|
+
plugin_name: string
|
|
204
|
+
config: Record<string, unknown>
|
|
205
|
+
revision?: string
|
|
206
|
+
}>(
|
|
207
|
+
`/plugins/${encodeURIComponent(name)}/config`,
|
|
208
|
+
{ config: platformOwnedConfigPatch(payload) },
|
|
209
|
+
),
|
|
210
|
+
{ success: 'save', errorMessage: 'Failed to save plugin config' },
|
|
211
|
+
)
|
|
212
|
+
nextRevision = response.revision ?? nextRevision
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (!response) return true
|
|
217
|
+
const responseConfig = responseConfigPayload(response)
|
|
218
|
+
config.value = cloneConfig(responseConfig)
|
|
219
|
+
savedConfig.value = cloneConfig(responseConfig)
|
|
220
|
+
revision.value = response.revision ?? nextRevision
|
|
221
|
+
return true
|
|
222
|
+
} catch {
|
|
223
|
+
return false
|
|
224
|
+
} finally {
|
|
225
|
+
isSaving.value = false
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function reset(): void {
|
|
230
|
+
config.value = cloneConfig(savedConfig.value)
|
|
231
|
+
request.clearError()
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
onMounted(() => {
|
|
235
|
+
load()
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
return {
|
|
239
|
+
config,
|
|
240
|
+
isLoading,
|
|
241
|
+
isSaving,
|
|
242
|
+
error,
|
|
243
|
+
lastLoadedAt,
|
|
244
|
+
lastSavedAt,
|
|
245
|
+
isDirty,
|
|
246
|
+
load,
|
|
247
|
+
save,
|
|
248
|
+
reset,
|
|
249
|
+
}
|
|
250
|
+
}
|
package/src/stores/auth.ts
CHANGED