@morscherlab/mint-sdk 1.2.0-beta.4 → 1.2.0-beta.5
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/{EmptyState-p90quq8Z.js → EmptyState-NBqf2_8z.js} +2 -2
- package/dist/{EmptyState-p90quq8Z.js.map → EmptyState-NBqf2_8z.js.map} +1 -1
- package/dist/{ExperimentPopover-tLwJ7XF4.js → ExperimentPopover-CfOdh5Rm.js} +3 -3
- package/dist/{ExperimentPopover-tLwJ7XF4.js.map → ExperimentPopover-CfOdh5Rm.js.map} +1 -1
- package/dist/{ExperimentSelectorModal-B6P2msT4.js → ExperimentSelectorModal-DvIagrxY.js} +4 -4
- package/dist/{ExperimentSelectorModal-B6P2msT4.js.map → ExperimentSelectorModal-DvIagrxY.js.map} +1 -1
- package/dist/{SettingsModal-CkNM8qAI.js → SettingsModal-W3TYfnP9.js} +4 -7
- package/dist/SettingsModal-W3TYfnP9.js.map +1 -0
- package/dist/__tests__/components/PluginWorkspaceView.internal.test.d.ts +1 -0
- package/dist/__tests__/components/SecretInput.test.d.ts +1 -0
- package/dist/__tests__/components/WellEditPopupInternal.test.d.ts +1 -0
- package/dist/components/AppAvatarMenu.vue.d.ts +4 -0
- package/dist/components/AppSidebar.vue.d.ts +1 -1
- package/dist/components/BioTemplateExperimentWorkspaceView.vue.d.ts +1 -1
- package/dist/components/DropdownButton.vue.d.ts +2 -0
- package/dist/components/FitPanel.vue.d.ts +1 -1
- package/dist/components/PluginWorkspaceView.props.d.ts +29 -1
- package/dist/components/PluginWorkspaceView.vue.d.ts +5 -5
- package/dist/components/SecretInput.vue.d.ts +28 -0
- package/dist/components/SmartGroupFieldRecipe.vue.d.ts +1 -1
- package/dist/components/index.d.ts +9 -0
- package/dist/components/index.js +8 -8
- package/dist/components/internal/WellEditPopupInternal.vue.d.ts +3 -1
- package/dist/components/internal/WorkspaceControlsInternal.vue.d.ts +23 -0
- package/dist/components/internal/WorkspaceShellInternal.vue.d.ts +70 -0
- package/dist/components/internal/WorkspaceSidebarInternal.vue.d.ts +43 -0
- package/dist/{components-MGNL6tRn.js → components-B80gV0P6.js} +575 -561
- package/dist/components-B80gV0P6.js.map +1 -0
- package/dist/composables/flattenWorkspaceBindings.d.ts +61 -0
- package/dist/composables/index.d.ts +5 -0
- package/dist/composables/index.js +9 -9
- package/dist/composables/pluginWorkspaceModel.d.ts +164 -0
- package/dist/composables/useAsync.d.ts +5 -1
- package/dist/composables/useBioTemplateWorkspace.d.ts +4 -20
- package/dist/composables/useCommandHistory.d.ts +5 -1
- package/dist/composables/useFocusTrap.d.ts +21 -0
- package/dist/composables/useMenuKeyboard.d.ts +29 -0
- package/dist/composables/useOptimisticMutation.d.ts +5 -1
- package/dist/composables/usePluginWorkspace.d.ts +51 -0
- package/dist/composables/useResourceCrud.d.ts +5 -1
- package/dist/composables/useRovingFocus.d.ts +24 -0
- package/dist/composables/useWellPainting.d.ts +5 -1
- package/dist/composables/useWellPlateAdapter.d.ts +5 -1
- package/dist/composables/useWellPlateValidation.d.ts +5 -1
- package/dist/composables/workspaceRecipes.d.ts +188 -0
- package/dist/{composables-CKxOcvgh.js → composables-C8mmD5ta.js} +65 -17
- package/dist/composables-C8mmD5ta.js.map +1 -0
- package/dist/{experiment-utils-CLnzPPq5.js → experiment-utils-DV3DrvE_.js} +70 -51
- package/dist/experiment-utils-DV3DrvE_.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +13 -13
- package/dist/install.js +7 -7
- package/dist/styles.css +100 -49
- package/dist/templates/builderReadUtils.d.ts +1 -1
- package/dist/templates/index.js +4 -4
- package/dist/{templates-BTJ9C9Kb.js → templates-CK72aKO1.js} +14 -23
- package/dist/templates-CK72aKO1.js.map +1 -0
- package/dist/types/form-builder.d.ts +1 -1
- package/dist/{useControlSchema-CXhxTul7.js → useControlSchema-_OVhavKq.js} +280 -63
- package/dist/useControlSchema-_OVhavKq.js.map +1 -0
- package/dist/{useDropdownState-DSb3BC3N.js → useDropdownState-D5G4UvWd.js} +2 -2
- package/dist/{useDropdownState-DSb3BC3N.js.map → useDropdownState-D5G4UvWd.js.map} +1 -1
- package/dist/{useExperimentSelector-D0pjj9WH.js → useExperimentSelector-DSEckf_g.js} +3 -3
- package/dist/{useExperimentSelector-D0pjj9WH.js.map → useExperimentSelector-DSEckf_g.js.map} +1 -1
- package/dist/{useFormBuilder-CuOFB3aP.js → useFormBuilder-Bd714C7y.js} +2 -2
- package/dist/{useFormBuilder-CuOFB3aP.js.map → useFormBuilder-Bd714C7y.js.map} +1 -1
- package/dist/{useProtocolTemplates-BWhQIWV5.js → useProtocolTemplates-Dq5Pzwyd.js} +481 -46
- package/dist/useProtocolTemplates-Dq5Pzwyd.js.map +1 -0
- package/dist/utils/pluginSecrets.d.ts +53 -0
- package/dist/utils/records.d.ts +4 -0
- package/package.json +1 -1
- package/src/__tests__/components/AppAvatarMenu.test.ts +123 -0
- package/src/__tests__/components/BaseTabs.test.ts +67 -0
- package/src/__tests__/components/DropdownButton.test.ts +134 -0
- package/src/__tests__/components/PluginWorkspaceView.internal.test.ts +230 -0
- package/src/__tests__/components/SecretInput.test.ts +87 -0
- package/src/__tests__/components/WellEditPopupInternal.test.ts +108 -0
- package/src/__tests__/composables/usePluginClient.test.ts +140 -0
- package/src/components/AppAvatarMenu.vue +34 -3
- package/src/components/BaseModal.vue +8 -56
- package/src/components/BaseTabs.vue +26 -1
- package/src/components/BioTemplateExperimentWorkspaceView.vue +10 -21
- package/src/components/BioTemplatePackWorkspaceView.vue +11 -15
- package/src/components/BioTemplatePresetWorkspaceView.vue +8 -13
- package/src/components/ComponentBindingRenderer.vue +1 -3
- package/src/components/ControlWorkspaceView.vue +11 -22
- package/src/components/DoseDesignWorkspaceView.vue +6 -25
- package/src/components/DropdownButton.vue +32 -2
- package/src/components/PluginWorkspaceView.props.ts +38 -0
- package/src/components/PluginWorkspaceView.vue +53 -224
- package/src/components/SecretInput.story.vue +79 -0
- package/src/components/SecretInput.vue +150 -0
- package/src/components/index.ts +9 -0
- package/src/components/internal/WellEditPopupInternal.vue +20 -1
- package/src/components/internal/WorkspaceControlsInternal.vue +20 -0
- package/src/components/internal/WorkspaceShellInternal.vue +125 -0
- package/src/components/internal/WorkspaceSidebarInternal.vue +82 -0
- package/src/composables/experimentDesignData.ts +1 -3
- package/src/composables/flattenWorkspaceBindings.ts +104 -0
- package/src/composables/formBuilderRegistry.ts +5 -0
- package/src/composables/index.ts +26 -0
- package/src/composables/pluginWorkspaceModel.ts +182 -0
- package/src/composables/useAsync.ts +5 -1
- package/src/composables/useBioTemplatePackWorkspace.ts +15 -9
- package/src/composables/useBioTemplatePresetWorkspace.ts +14 -15
- package/src/composables/useBioTemplateWorkspace.ts +18 -43
- package/src/composables/useCommandHistory.ts +5 -1
- package/src/composables/useFocusTrap.ts +91 -0
- package/src/composables/useMenuKeyboard.ts +90 -0
- package/src/composables/useOptimisticMutation.ts +5 -1
- package/src/composables/usePluginClient.ts +42 -4
- package/src/composables/usePluginWorkspace.ts +208 -0
- package/src/composables/useResourceCrud.ts +5 -1
- package/src/composables/useRovingFocus.ts +66 -0
- package/src/composables/useWellPainting.ts +5 -1
- package/src/composables/useWellPlateAdapter.ts +5 -1
- package/src/composables/useWellPlateValidation.ts +5 -1
- package/src/composables/workspaceRecipes.ts +267 -0
- package/src/index.ts +16 -0
- package/src/styles/components/secret-input.css +60 -0
- package/src/styles/index.css +1 -0
- package/src/templates/builderReadUtils.ts +1 -3
- package/src/templates/componentTargetResolvers.ts +1 -3
- package/src/templates/controlSchemaTargets.ts +1 -3
- package/src/types/form-builder.ts +1 -0
- package/src/utils/formModelSync.ts +1 -3
- package/src/utils/pluginIcon.ts +1 -3
- package/src/utils/pluginSecrets.ts +102 -0
- package/src/utils/records.ts +9 -0
- package/dist/SettingsModal-CkNM8qAI.js.map +0 -1
- package/dist/components-MGNL6tRn.js.map +0 -1
- package/dist/composables-CKxOcvgh.js.map +0 -1
- package/dist/experiment-utils-CLnzPPq5.js.map +0 -1
- package/dist/templates-BTJ9C9Kb.js.map +0 -1
- package/dist/useControlSchema-CXhxTul7.js.map +0 -1
- package/dist/useProtocolTemplates-BWhQIWV5.js.map +0 -1
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { mount } from '@vue/test-utils'
|
|
3
|
+
import SecretInput from '../../components/SecretInput.vue'
|
|
4
|
+
import {
|
|
5
|
+
isPluginSecretRef,
|
|
6
|
+
keepPluginSecret,
|
|
7
|
+
pluginSecretLabel,
|
|
8
|
+
pluginSecretState,
|
|
9
|
+
} from '../../utils/pluginSecrets'
|
|
10
|
+
|
|
11
|
+
const storedRef = { $secret: 'sec_0f2a4c6e8a0c2e4a6c8e0a2c4e6a8c0e', set: true }
|
|
12
|
+
const lockedRef = { $secret: null, locked: true }
|
|
13
|
+
|
|
14
|
+
describe('pluginSecrets helpers', () => {
|
|
15
|
+
it('should classify each marker the settings response can publish', () => {
|
|
16
|
+
expect([
|
|
17
|
+
pluginSecretState(storedRef),
|
|
18
|
+
pluginSecretState(null),
|
|
19
|
+
pluginSecretState(lockedRef),
|
|
20
|
+
pluginSecretLabel(storedRef),
|
|
21
|
+
pluginSecretLabel(null),
|
|
22
|
+
pluginSecretLabel(lockedRef),
|
|
23
|
+
]).toEqual(['set', 'unset', 'environment', 'Set', 'Not set', 'Provided by environment'])
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('should only treat reserved marker shapes as references', () => {
|
|
27
|
+
expect([
|
|
28
|
+
isPluginSecretRef(storedRef),
|
|
29
|
+
isPluginSecretRef({ $mint: 'placeholder' }),
|
|
30
|
+
isPluginSecretRef({ $secret: 'x', label: 'not a marker' }),
|
|
31
|
+
isPluginSecretRef('plain-string'),
|
|
32
|
+
keepPluginSecret(storedRef),
|
|
33
|
+
keepPluginSecret('typed'),
|
|
34
|
+
]).toEqual([true, false, false, false, { ...storedRef }, null])
|
|
35
|
+
})
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
describe('SecretInput', () => {
|
|
39
|
+
it('should show the stored state without exposing a value', () => {
|
|
40
|
+
const wrapper = mount(SecretInput, { props: { modelValue: storedRef } })
|
|
41
|
+
|
|
42
|
+
expect([
|
|
43
|
+
wrapper.find('.mint-secret-input__badge').text(),
|
|
44
|
+
wrapper.find('input').exists(),
|
|
45
|
+
]).toEqual(['Set', false])
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('should submit the typed string once the user enters a new value', async () => {
|
|
49
|
+
const wrapper = mount(SecretInput, { props: { modelValue: storedRef } })
|
|
50
|
+
|
|
51
|
+
await wrapper.findAll('button')[0]!.trigger('click')
|
|
52
|
+
await wrapper.setProps({ modelValue: '' })
|
|
53
|
+
await wrapper.find('input').setValue('typed-secret')
|
|
54
|
+
|
|
55
|
+
expect(wrapper.emitted('update:modelValue')).toEqual([[''], ['typed-secret']])
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('should submit null when the user clears a stored secret', async () => {
|
|
59
|
+
const wrapper = mount(SecretInput, { props: { modelValue: storedRef } })
|
|
60
|
+
|
|
61
|
+
const clear = wrapper.findAll('button').at(-1)!
|
|
62
|
+
await clear.trigger('click')
|
|
63
|
+
|
|
64
|
+
expect(wrapper.emitted('update:modelValue')).toEqual([[null]])
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('should restore the stored reference when an edit is cancelled', async () => {
|
|
68
|
+
const wrapper = mount(SecretInput, { props: { modelValue: storedRef } })
|
|
69
|
+
|
|
70
|
+
await wrapper.findAll('button')[0]!.trigger('click')
|
|
71
|
+
await wrapper.setProps({ modelValue: 'partial' })
|
|
72
|
+
await wrapper.findAll('button')[0]!.trigger('click')
|
|
73
|
+
|
|
74
|
+
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([{ ...storedRef }])
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('should keep an environment-provided secret read-only', async () => {
|
|
78
|
+
const wrapper = mount(SecretInput, { props: { modelValue: lockedRef } })
|
|
79
|
+
|
|
80
|
+
expect([
|
|
81
|
+
wrapper.find('.mint-secret-input__badge').text(),
|
|
82
|
+
wrapper.find('.mint-secret-input__hint').exists(),
|
|
83
|
+
wrapper.findAll('button').every(button => button.attributes('disabled') !== undefined),
|
|
84
|
+
wrapper.emitted('update:modelValue'),
|
|
85
|
+
]).toEqual(['Provided by environment', true, true, undefined])
|
|
86
|
+
})
|
|
87
|
+
})
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { mount } from '@vue/test-utils'
|
|
2
|
+
import { describe, expect, it } from 'vitest'
|
|
3
|
+
import WellEditPopupInternal from '../../components/internal/WellEditPopupInternal.vue'
|
|
4
|
+
|
|
5
|
+
function mountPopup(attachTo?: HTMLElement) {
|
|
6
|
+
return mount(WellEditPopupInternal, {
|
|
7
|
+
props: {
|
|
8
|
+
wellId: 'A1',
|
|
9
|
+
position: { x: 10, y: 10 },
|
|
10
|
+
},
|
|
11
|
+
attachTo,
|
|
12
|
+
global: {
|
|
13
|
+
stubs: { teleport: true },
|
|
14
|
+
},
|
|
15
|
+
})
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe('WellEditPopupInternal focus trap', () => {
|
|
19
|
+
it('moves focus into the popup on mount', async () => {
|
|
20
|
+
const host = document.createElement('div')
|
|
21
|
+
document.body.appendChild(host)
|
|
22
|
+
const wrapper = mountPopup(host)
|
|
23
|
+
await wrapper.vm.$nextTick()
|
|
24
|
+
await wrapper.vm.$nextTick()
|
|
25
|
+
|
|
26
|
+
expect(wrapper.find('.mint-well-edit-popup').element.contains(document.activeElement)).toBe(true)
|
|
27
|
+
|
|
28
|
+
wrapper.unmount()
|
|
29
|
+
host.remove()
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('restores focus to the previously focused element on unmount', async () => {
|
|
33
|
+
const host = document.createElement('div')
|
|
34
|
+
document.body.appendChild(host)
|
|
35
|
+
const trigger = document.createElement('button')
|
|
36
|
+
host.appendChild(trigger)
|
|
37
|
+
trigger.focus()
|
|
38
|
+
expect(document.activeElement).toBe(trigger)
|
|
39
|
+
|
|
40
|
+
const wrapper = mountPopup(host)
|
|
41
|
+
await wrapper.vm.$nextTick()
|
|
42
|
+
await wrapper.vm.$nextTick()
|
|
43
|
+
expect(document.activeElement).not.toBe(trigger)
|
|
44
|
+
|
|
45
|
+
wrapper.unmount()
|
|
46
|
+
expect(document.activeElement).toBe(trigger)
|
|
47
|
+
|
|
48
|
+
host.remove()
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('wraps Tab from the last focusable element back to the first', async () => {
|
|
52
|
+
const host = document.createElement('div')
|
|
53
|
+
document.body.appendChild(host)
|
|
54
|
+
const wrapper = mountPopup(host)
|
|
55
|
+
await wrapper.vm.$nextTick()
|
|
56
|
+
await wrapper.vm.$nextTick()
|
|
57
|
+
|
|
58
|
+
const focusable = (wrapper.element as HTMLElement).querySelectorAll<HTMLElement>(
|
|
59
|
+
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])',
|
|
60
|
+
)
|
|
61
|
+
expect(focusable.length).toBeGreaterThan(1)
|
|
62
|
+
const last = focusable[focusable.length - 1]
|
|
63
|
+
const first = focusable[0]
|
|
64
|
+
last.focus()
|
|
65
|
+
|
|
66
|
+
const event = new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true })
|
|
67
|
+
document.dispatchEvent(event)
|
|
68
|
+
|
|
69
|
+
expect(document.activeElement).toBe(first)
|
|
70
|
+
|
|
71
|
+
wrapper.unmount()
|
|
72
|
+
host.remove()
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('wraps Shift+Tab from the first focusable element back to the last', async () => {
|
|
76
|
+
const host = document.createElement('div')
|
|
77
|
+
document.body.appendChild(host)
|
|
78
|
+
const wrapper = mountPopup(host)
|
|
79
|
+
await wrapper.vm.$nextTick()
|
|
80
|
+
await wrapper.vm.$nextTick()
|
|
81
|
+
|
|
82
|
+
const focusable = (wrapper.element as HTMLElement).querySelectorAll<HTMLElement>(
|
|
83
|
+
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])',
|
|
84
|
+
)
|
|
85
|
+
const first = focusable[0]
|
|
86
|
+
const last = focusable[focusable.length - 1]
|
|
87
|
+
first.focus()
|
|
88
|
+
|
|
89
|
+
const event = new KeyboardEvent('keydown', { key: 'Tab', shiftKey: true, bubbles: true, cancelable: true })
|
|
90
|
+
document.dispatchEvent(event)
|
|
91
|
+
|
|
92
|
+
expect(document.activeElement).toBe(last)
|
|
93
|
+
|
|
94
|
+
wrapper.unmount()
|
|
95
|
+
host.remove()
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('closes on Escape', async () => {
|
|
99
|
+
const wrapper = mountPopup()
|
|
100
|
+
await wrapper.vm.$nextTick()
|
|
101
|
+
|
|
102
|
+
const event = new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })
|
|
103
|
+
document.dispatchEvent(event)
|
|
104
|
+
|
|
105
|
+
expect(wrapper.emitted('close')).toHaveLength(1)
|
|
106
|
+
wrapper.unmount()
|
|
107
|
+
})
|
|
108
|
+
})
|
|
@@ -848,6 +848,146 @@ describe('usePluginClient', () => {
|
|
|
848
848
|
})
|
|
849
849
|
})
|
|
850
850
|
|
|
851
|
+
describe('secret settings references', () => {
|
|
852
|
+
const secretRef = { $secret: 'sec_0f2a4c6e8a0c2e4a6c8e0a2c4e6a8c0e', set: true }
|
|
853
|
+
|
|
854
|
+
function integratedPluginSettingsHost(): void {
|
|
855
|
+
;(window as unknown as { __MINT_PLATFORM__?: unknown }).__MINT_PLATFORM__ = {
|
|
856
|
+
isIntegrated: true,
|
|
857
|
+
theme: 'light',
|
|
858
|
+
platformApiUrl: '/api',
|
|
859
|
+
plugin: {
|
|
860
|
+
id: 'drp',
|
|
861
|
+
name: 'drp',
|
|
862
|
+
version: '1.0.0',
|
|
863
|
+
route_prefix: '/drp',
|
|
864
|
+
api_prefix: '/api/drp',
|
|
865
|
+
settings_api: 'plugin',
|
|
866
|
+
},
|
|
867
|
+
}
|
|
868
|
+
nextGetData = {
|
|
869
|
+
settings: { apiKey: secretRef, method: 'first' },
|
|
870
|
+
secret_format: 'ref',
|
|
871
|
+
mode: 'integrated',
|
|
872
|
+
revision: 'revision-1',
|
|
873
|
+
}
|
|
874
|
+
nextPutData = {
|
|
875
|
+
settings: { apiKey: secretRef, method: 'second' },
|
|
876
|
+
secret_format: 'ref',
|
|
877
|
+
mode: 'integrated',
|
|
878
|
+
revision: 'revision-2',
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
it('should round-trip an untouched secret reference unchanged', async () => {
|
|
883
|
+
integratedPluginSettingsHost()
|
|
884
|
+
|
|
885
|
+
const settings = usePluginSettings<{ apiKey?: unknown; method?: string }>({
|
|
886
|
+
pluginName: 'drp',
|
|
887
|
+
})
|
|
888
|
+
await settings.load()
|
|
889
|
+
const saved = await settings.save({ ...settings.values.value, method: 'second' })
|
|
890
|
+
|
|
891
|
+
expect({ saved, body: requestBodies[0] }).toEqual({
|
|
892
|
+
saved: true,
|
|
893
|
+
body: { apiKey: secretRef, method: 'second', secret_format: 'ref' },
|
|
894
|
+
})
|
|
895
|
+
})
|
|
896
|
+
|
|
897
|
+
it('should submit a typed string when the user enters a new secret', async () => {
|
|
898
|
+
integratedPluginSettingsHost()
|
|
899
|
+
|
|
900
|
+
const settings = usePluginSettings<{ apiKey?: unknown; method?: string }>({
|
|
901
|
+
pluginName: 'drp',
|
|
902
|
+
})
|
|
903
|
+
await settings.load()
|
|
904
|
+
await settings.save({ apiKey: 'typed-secret', method: 'first' })
|
|
905
|
+
|
|
906
|
+
expect(requestBodies[0]).toEqual({
|
|
907
|
+
apiKey: 'typed-secret',
|
|
908
|
+
method: 'first',
|
|
909
|
+
secret_format: 'ref',
|
|
910
|
+
})
|
|
911
|
+
})
|
|
912
|
+
|
|
913
|
+
it('should submit null when the user clears a secret', async () => {
|
|
914
|
+
integratedPluginSettingsHost()
|
|
915
|
+
|
|
916
|
+
const settings = usePluginSettings<{ apiKey?: unknown; method?: string }>({
|
|
917
|
+
pluginName: 'drp',
|
|
918
|
+
})
|
|
919
|
+
await settings.load()
|
|
920
|
+
await settings.save({ apiKey: null, method: 'first' })
|
|
921
|
+
|
|
922
|
+
expect(requestBodies[0]).toEqual({
|
|
923
|
+
apiKey: null,
|
|
924
|
+
method: 'first',
|
|
925
|
+
secret_format: 'ref',
|
|
926
|
+
})
|
|
927
|
+
})
|
|
928
|
+
|
|
929
|
+
it('should declare the reference format on platform config patches', async () => {
|
|
930
|
+
;(window as unknown as { __MINT_PLATFORM__?: unknown }).__MINT_PLATFORM__ = {
|
|
931
|
+
isIntegrated: true,
|
|
932
|
+
theme: 'light',
|
|
933
|
+
platformApiUrl: '/api',
|
|
934
|
+
plugin: {
|
|
935
|
+
id: 'drp',
|
|
936
|
+
name: 'drp',
|
|
937
|
+
version: '1.0.0',
|
|
938
|
+
route_prefix: '/drp',
|
|
939
|
+
api_prefix: '/api/drp',
|
|
940
|
+
},
|
|
941
|
+
}
|
|
942
|
+
nextGetData = {
|
|
943
|
+
plugin_name: 'drp',
|
|
944
|
+
config: { apiKey: secretRef, method: 'first' },
|
|
945
|
+
secret_format: 'ref',
|
|
946
|
+
revision: 'revision-1',
|
|
947
|
+
}
|
|
948
|
+
nextPatchData = {
|
|
949
|
+
plugin_name: 'drp',
|
|
950
|
+
config: { apiKey: secretRef, method: 'second' },
|
|
951
|
+
secret_format: 'ref',
|
|
952
|
+
revision: 'revision-2',
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
const settings = usePluginSettings<{ apiKey?: unknown; method?: string }>({
|
|
956
|
+
pluginName: 'drp',
|
|
957
|
+
})
|
|
958
|
+
await settings.load()
|
|
959
|
+
await settings.save({ apiKey: secretRef, method: 'second' })
|
|
960
|
+
|
|
961
|
+
expect(requestBodies[0]).toEqual({
|
|
962
|
+
config: { method: 'second', secret_format: 'ref' },
|
|
963
|
+
})
|
|
964
|
+
})
|
|
965
|
+
|
|
966
|
+
it('should omit the format marker for a host that does not publish one', async () => {
|
|
967
|
+
;(window as unknown as { __MINT_PLATFORM__?: unknown }).__MINT_PLATFORM__ = {
|
|
968
|
+
isIntegrated: true,
|
|
969
|
+
theme: 'light',
|
|
970
|
+
platformApiUrl: '/api',
|
|
971
|
+
plugin: {
|
|
972
|
+
id: 'drp',
|
|
973
|
+
name: 'drp',
|
|
974
|
+
version: '1.0.0',
|
|
975
|
+
route_prefix: '/drp',
|
|
976
|
+
api_prefix: '/api/drp',
|
|
977
|
+
settings_api: 'plugin',
|
|
978
|
+
},
|
|
979
|
+
}
|
|
980
|
+
nextGetData = { settings: { method: 'first' }, mode: 'integrated', revision: 'revision-1' }
|
|
981
|
+
nextPutData = { settings: { method: 'second' }, mode: 'integrated', revision: 'revision-2' }
|
|
982
|
+
|
|
983
|
+
const settings = usePluginSettings<{ method?: string }>({ pluginName: 'drp' })
|
|
984
|
+
await settings.load()
|
|
985
|
+
await settings.save({ method: 'second' })
|
|
986
|
+
|
|
987
|
+
expect(requestBodies[0]).toEqual({ method: 'second' })
|
|
988
|
+
})
|
|
989
|
+
})
|
|
990
|
+
|
|
851
991
|
it('should preserve the contract v2 form schema in settingsConfig', () => {
|
|
852
992
|
const settings = usePluginSettings({
|
|
853
993
|
apiBaseUrl: '/api/drp',
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
/** Avatar-triggered dropdown with user name/email header, configurable menu items, dividers, and a sign-out action. */
|
|
3
|
-
import { computed } from 'vue'
|
|
3
|
+
import { computed, nextTick, ref } from 'vue'
|
|
4
4
|
import { useDropdownState } from '../composables/useDropdownState'
|
|
5
|
+
import { useMenuKeyboard } from '../composables/useMenuKeyboard'
|
|
5
6
|
import type { AccountMenuItem } from '../types/components'
|
|
6
7
|
import ActionItemInternal from './internal/ActionItemInternal.vue'
|
|
7
8
|
|
|
@@ -27,11 +28,33 @@ const emit = defineEmits<{
|
|
|
27
28
|
'menu-close': []
|
|
28
29
|
}>()
|
|
29
30
|
|
|
30
|
-
const { isOpen, rootRef, close, toggle } = useDropdownState({
|
|
31
|
+
const { isOpen, rootRef, open, close: closeDropdown, toggle } = useDropdownState({
|
|
31
32
|
onOpen: () => emit('menu-open'),
|
|
32
33
|
onClose: () => emit('menu-close'),
|
|
33
34
|
})
|
|
34
35
|
|
|
36
|
+
const triggerRef = ref<HTMLElement | null>(null)
|
|
37
|
+
const panelRef = ref<HTMLElement | null>(null)
|
|
38
|
+
|
|
39
|
+
function menuItems(): HTMLElement[] {
|
|
40
|
+
if (!panelRef.value) return []
|
|
41
|
+
return Array.from(panelRef.value.querySelectorAll<HTMLElement>('[role="menuitem"]'))
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function close(returnFocus = false) {
|
|
45
|
+
closeDropdown()
|
|
46
|
+
if (returnFocus) {
|
|
47
|
+
void nextTick(() => triggerRef.value?.focus())
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const { handleTriggerKeydown, handlePanelKeydown } = useMenuKeyboard({
|
|
52
|
+
items: menuItems,
|
|
53
|
+
isOpen: () => isOpen.value,
|
|
54
|
+
open,
|
|
55
|
+
close,
|
|
56
|
+
})
|
|
57
|
+
|
|
35
58
|
const initials = computed(() => {
|
|
36
59
|
if (props.userInitial) return props.userInitial.slice(0, 2).toUpperCase()
|
|
37
60
|
if (props.userName) {
|
|
@@ -64,6 +87,7 @@ function handleSignOut() {
|
|
|
64
87
|
<template>
|
|
65
88
|
<div ref="rootRef" class="mint-avatar-menu">
|
|
66
89
|
<button
|
|
90
|
+
ref="triggerRef"
|
|
67
91
|
type="button"
|
|
68
92
|
class="mint-avatar-menu__trigger"
|
|
69
93
|
:class="{ 'mint-avatar-menu__trigger--open': isOpen }"
|
|
@@ -71,6 +95,7 @@ function handleSignOut() {
|
|
|
71
95
|
aria-haspopup="menu"
|
|
72
96
|
:title="userName ? `${userName} · Account menu` : 'Account menu'"
|
|
73
97
|
@click.stop="toggle"
|
|
98
|
+
@keydown="handleTriggerKeydown"
|
|
74
99
|
>
|
|
75
100
|
<span class="mint-avatar-menu__avatar">
|
|
76
101
|
<slot name="avatar" :initials="initials">
|
|
@@ -80,7 +105,13 @@ function handleSignOut() {
|
|
|
80
105
|
<span v-if="userName" class="mint-avatar-menu__name">{{ userName }}</span>
|
|
81
106
|
</button>
|
|
82
107
|
|
|
83
|
-
<div
|
|
108
|
+
<div
|
|
109
|
+
v-if="isOpen"
|
|
110
|
+
ref="panelRef"
|
|
111
|
+
class="mint-avatar-menu__panel"
|
|
112
|
+
role="menu"
|
|
113
|
+
@keydown="handlePanelKeydown"
|
|
114
|
+
>
|
|
84
115
|
<div v-if="userName || userEmail" class="mint-avatar-menu__header">
|
|
85
116
|
<div v-if="userName" class="mint-avatar-menu__user-name">{{ userName }}</div>
|
|
86
117
|
<div v-if="userEmail" class="mint-avatar-menu__user-email">{{ userEmail }}</div>
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
/** Modal dialog with backdrop, focus trap, Escape-to-close, and configurable size/variant/layout. */
|
|
3
|
-
import { computed, ref,
|
|
3
|
+
import { computed, ref, toRef, watch, onUnmounted, type ComponentPublicInstance } from 'vue'
|
|
4
4
|
import type { ModalLayout, ModalSize, ModalTab, ModalVariant } from '../types'
|
|
5
5
|
import { useEventListener } from '../composables/useEventListener'
|
|
6
|
+
import { useFocusTrap } from '../composables/useFocusTrap'
|
|
6
7
|
|
|
7
8
|
interface Props {
|
|
8
9
|
modelValue: boolean
|
|
@@ -75,47 +76,16 @@ function isPathIcon(icon: ModalTab['icon']): icon is string | string[] {
|
|
|
75
76
|
}
|
|
76
77
|
|
|
77
78
|
const containerRef = ref<HTMLElement | null>(null)
|
|
78
|
-
let previouslyFocused: HTMLElement | null = null
|
|
79
79
|
|
|
80
|
-
|
|
81
|
-
'a[href]',
|
|
82
|
-
'button:not([disabled])',
|
|
83
|
-
'input:not([disabled])',
|
|
84
|
-
'select:not([disabled])',
|
|
85
|
-
'textarea:not([disabled])',
|
|
86
|
-
'[tabindex]:not([tabindex="-1"])',
|
|
87
|
-
].join(', ')
|
|
80
|
+
useFocusTrap({ containerRef, active: toRef(props, 'modelValue') })
|
|
88
81
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
// Roving tabindex (inactive tabs/rail items) stays out of the Tab cycle.
|
|
93
|
-
.filter((el) => el.tabIndex !== -1)
|
|
94
|
-
}
|
|
82
|
+
watch(() => props.modelValue, (isOpen) => {
|
|
83
|
+
document.body.style.overflow = isOpen ? 'hidden' : ''
|
|
84
|
+
})
|
|
95
85
|
|
|
96
|
-
function
|
|
86
|
+
function handleEscapeKeydown(event: KeyboardEvent) {
|
|
97
87
|
if (event.key === 'Escape' && props.closeOnEscape && props.modelValue) {
|
|
98
88
|
close()
|
|
99
|
-
return
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
if (event.key !== 'Tab' || !containerRef.value) return
|
|
103
|
-
|
|
104
|
-
const focusable = getFocusableElements()
|
|
105
|
-
if (focusable.length === 0) {
|
|
106
|
-
event.preventDefault()
|
|
107
|
-
return
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
const first = focusable[0]
|
|
111
|
-
const last = focusable[focusable.length - 1]
|
|
112
|
-
|
|
113
|
-
if (event.shiftKey && document.activeElement === first) {
|
|
114
|
-
event.preventDefault()
|
|
115
|
-
last.focus()
|
|
116
|
-
} else if (!event.shiftKey && document.activeElement === last) {
|
|
117
|
-
event.preventDefault()
|
|
118
|
-
first.focus()
|
|
119
89
|
}
|
|
120
90
|
}
|
|
121
91
|
|
|
@@ -132,25 +102,7 @@ function handleOverlayClick(event: MouseEvent) {
|
|
|
132
102
|
}
|
|
133
103
|
}
|
|
134
104
|
|
|
135
|
-
|
|
136
|
-
if (isOpen) {
|
|
137
|
-
previouslyFocused = document.activeElement as HTMLElement | null
|
|
138
|
-
document.body.style.overflow = 'hidden'
|
|
139
|
-
await nextTick()
|
|
140
|
-
const focusable = getFocusableElements()
|
|
141
|
-
if (focusable.length > 0) {
|
|
142
|
-
focusable[0].focus()
|
|
143
|
-
} else {
|
|
144
|
-
containerRef.value?.focus()
|
|
145
|
-
}
|
|
146
|
-
} else {
|
|
147
|
-
document.body.style.overflow = ''
|
|
148
|
-
previouslyFocused?.focus()
|
|
149
|
-
previouslyFocused = null
|
|
150
|
-
}
|
|
151
|
-
})
|
|
152
|
-
|
|
153
|
-
useEventListener(() => document, 'keydown', handleKeydown)
|
|
105
|
+
useEventListener(() => document, 'keydown', handleEscapeKeydown)
|
|
154
106
|
|
|
155
107
|
onUnmounted(() => {
|
|
156
108
|
document.body.style.overflow = ''
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
/** Renders a tab bar with underline or pills variant, badges, icons, and disabled tab support. */
|
|
3
|
-
import { computed } from 'vue'
|
|
3
|
+
import { computed, type ComponentPublicInstance } from 'vue'
|
|
4
4
|
import type { TabItem, TabItemInput } from '../types'
|
|
5
5
|
import { normalizeItemInput } from '../utils/items'
|
|
6
|
+
import { useRovingFocus } from '../composables/useRovingFocus'
|
|
6
7
|
|
|
7
8
|
interface Props {
|
|
8
9
|
modelValue: string
|
|
@@ -32,23 +33,47 @@ function selectTab(tabId: string) {
|
|
|
32
33
|
emit('update:modelValue', tabId)
|
|
33
34
|
}
|
|
34
35
|
}
|
|
36
|
+
|
|
37
|
+
const tabButtonRefs = new Map<string, HTMLElement>()
|
|
38
|
+
|
|
39
|
+
function setTabButtonRef(id: string, el: Element | ComponentPublicInstance | null) {
|
|
40
|
+
if (el instanceof HTMLElement) {
|
|
41
|
+
tabButtonRefs.set(id, el)
|
|
42
|
+
} else {
|
|
43
|
+
tabButtonRefs.delete(id)
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Left/Right only — BaseTabs renders a single horizontal strip with no vertical
|
|
48
|
+
// variant, so there is no Up/Down axis to support.
|
|
49
|
+
const { handleKeydown } = useRovingFocus({
|
|
50
|
+
ids: () => normalizedTabs.value.filter(tab => !tab.disabled).map(tab => tab.id),
|
|
51
|
+
activeId: () => activeTab.value,
|
|
52
|
+
activate: selectTab,
|
|
53
|
+
getElement: (id) => tabButtonRefs.get(id),
|
|
54
|
+
})
|
|
35
55
|
</script>
|
|
36
56
|
|
|
37
57
|
<template>
|
|
38
58
|
<div :class="['mint-tabs', `mint-tabs--${variant}`]" role="tablist">
|
|
39
59
|
<button
|
|
40
60
|
v-for="tab in normalizedTabs"
|
|
61
|
+
:id="`mint-tab-${tab.id}`"
|
|
41
62
|
:key="tab.id"
|
|
63
|
+
:ref="(el) => setTabButtonRef(tab.id, el)"
|
|
42
64
|
type="button"
|
|
43
65
|
role="tab"
|
|
44
66
|
:aria-selected="activeTab === tab.id"
|
|
45
67
|
:aria-disabled="tab.disabled"
|
|
68
|
+
:aria-controls="`mint-tabpanel-${tab.id}`"
|
|
69
|
+
:tabindex="activeTab === tab.id ? 0 : -1"
|
|
46
70
|
:class="[
|
|
47
71
|
'mint-tab',
|
|
48
72
|
activeTab === tab.id ? 'mint-tab--active' : '',
|
|
49
73
|
tab.disabled ? 'mint-tab--disabled' : '',
|
|
50
74
|
]"
|
|
51
75
|
@click="selectTab(tab.id)"
|
|
76
|
+
@keydown="handleKeydown"
|
|
52
77
|
>
|
|
53
78
|
<span class="mint-tab__content">
|
|
54
79
|
<svg
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
type BioTemplateComponentPropsLookupOptions,
|
|
20
20
|
type TemplateCollectionEnvelope,
|
|
21
21
|
} from '../templates'
|
|
22
|
+
import { bioTemplateExperimentWorkspaceProps } from '../composables/workspaceRecipes'
|
|
22
23
|
import AlertBox from './AlertBox.vue'
|
|
23
24
|
import BaseButton from './BaseButton.vue'
|
|
24
25
|
import BioTemplateRenderer from './BioTemplateRenderer.vue'
|
|
@@ -114,27 +115,15 @@ const statusMessage = computed(() => {
|
|
|
114
115
|
return `${props.label} template data saves to experiment design_data.`
|
|
115
116
|
})
|
|
116
117
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
const
|
|
124
|
-
|
|
125
|
-
)
|
|
126
|
-
const resolvedHasExperiment = computed(() =>
|
|
127
|
-
props.status?.hasExperiment ?? props.hasExperiment,
|
|
128
|
-
)
|
|
129
|
-
const resolvedCurrentExperimentId = computed(() =>
|
|
130
|
-
props.status?.currentExperimentId ?? props.currentExperimentId,
|
|
131
|
-
)
|
|
132
|
-
const resolvedLastLoadedAt = computed(() =>
|
|
133
|
-
props.status?.lastLoadedAt !== undefined ? props.status.lastLoadedAt : props.lastLoadedAt,
|
|
134
|
-
)
|
|
135
|
-
const resolvedLastSavedAt = computed(() =>
|
|
136
|
-
props.status?.lastSavedAt !== undefined ? props.status.lastSavedAt : props.lastSavedAt,
|
|
137
|
-
)
|
|
118
|
+
// Thin recipe over bioTemplateExperimentWorkspaceProps(): merges the grouped `status` prop
|
|
119
|
+
// over the individual loading/error/experiment props (see workspaceRecipes.ts).
|
|
120
|
+
const resolvedStatus = computed(() => bioTemplateExperimentWorkspaceProps(props))
|
|
121
|
+
const resolvedLoading = computed(() => resolvedStatus.value.loading)
|
|
122
|
+
const resolvedError = computed(() => resolvedStatus.value.error)
|
|
123
|
+
const resolvedHasExperiment = computed(() => resolvedStatus.value.hasExperiment)
|
|
124
|
+
const resolvedCurrentExperimentId = computed(() => resolvedStatus.value.currentExperimentId)
|
|
125
|
+
const resolvedLastLoadedAt = computed(() => resolvedStatus.value.lastLoadedAt)
|
|
126
|
+
const resolvedLastSavedAt = computed(() => resolvedStatus.value.lastSavedAt)
|
|
138
127
|
|
|
139
128
|
const templateSummaries = computed(() =>
|
|
140
129
|
Object.values(extractTemplateCollection(props.target)).map(template => ({
|
|
@@ -14,6 +14,7 @@ import type {
|
|
|
14
14
|
UseBioTemplatePackWorkspaceReturn,
|
|
15
15
|
} from '../composables/useBioTemplatePackWorkspace'
|
|
16
16
|
import { useBioTemplatePackWorkspace } from '../composables/useBioTemplatePackWorkspace'
|
|
17
|
+
import { bioTemplatePackWorkspaceProps } from '../composables/workspaceRecipes'
|
|
17
18
|
import BioTemplateExperimentWorkspaceView from './BioTemplateExperimentWorkspaceView.vue'
|
|
18
19
|
|
|
19
20
|
interface BioTemplatePackWorkspaceSlotProps {
|
|
@@ -140,6 +141,15 @@ const resolvedActions = computed<PackWorkspaceActions>(() => ({
|
|
|
140
141
|
save: props.actions?.save ?? (() => resolvedWorkspace.value.saveCurrent()),
|
|
141
142
|
}))
|
|
142
143
|
|
|
144
|
+
// Thin recipe over bioTemplatePackWorkspaceProps(): forwards this view's own display props
|
|
145
|
+
// plus the pack-derived target/label/status/actions to BioTemplateExperimentWorkspaceView.
|
|
146
|
+
const experimentWorkspaceRecipe = computed(() => bioTemplatePackWorkspaceProps(props, {
|
|
147
|
+
target: target.value,
|
|
148
|
+
label: label.value,
|
|
149
|
+
status: resolvedStatus.value,
|
|
150
|
+
actions: resolvedActions.value,
|
|
151
|
+
}))
|
|
152
|
+
|
|
143
153
|
watch(
|
|
144
154
|
[() => props.pack, () => props.workspaceOptions],
|
|
145
155
|
() => {
|
|
@@ -152,21 +162,7 @@ onScopeDispose(() => generatedWorkspaceScope?.stop())
|
|
|
152
162
|
</script>
|
|
153
163
|
|
|
154
164
|
<template>
|
|
155
|
-
<BioTemplateExperimentWorkspaceView
|
|
156
|
-
:target="target"
|
|
157
|
-
kind="collection"
|
|
158
|
-
:label="label"
|
|
159
|
-
:message="message"
|
|
160
|
-
:status="resolvedStatus"
|
|
161
|
-
:actions="resolvedActions"
|
|
162
|
-
:dense="dense"
|
|
163
|
-
:readonly="readonly"
|
|
164
|
-
:show-status="showStatus"
|
|
165
|
-
:show-template-summary="showTemplateSummary"
|
|
166
|
-
:load-label="loadLabel"
|
|
167
|
-
:reset-label="resetLabel"
|
|
168
|
-
:save-label="saveLabel"
|
|
169
|
-
>
|
|
165
|
+
<BioTemplateExperimentWorkspaceView v-bind="experimentWorkspaceRecipe">
|
|
170
166
|
<template v-if="hasDefaultSlot" #default="slotProps">
|
|
171
167
|
<slot
|
|
172
168
|
v-bind="slotProps"
|