@morscherlab/mint-sdk 1.0.0 → 1.0.1
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/BaseModal-B9UA8Y_I.js +165 -0
- package/dist/BaseModal-B9UA8Y_I.js.map +1 -0
- package/dist/BaseSelect-DksaKYq_.js +176 -0
- package/dist/BaseSelect-DksaKYq_.js.map +1 -0
- package/dist/ExperimentPopover-CCYB1oWp.js +361 -0
- package/dist/ExperimentPopover-CCYB1oWp.js.map +1 -0
- package/dist/ExperimentPopover-D0bg_fqM.js +3 -0
- package/dist/ExperimentSelectorModal-B_kPbXcg.js +4 -0
- package/dist/ExperimentSelectorModal-wm7yUdAr.js +720 -0
- package/dist/ExperimentSelectorModal-wm7yUdAr.js.map +1 -0
- package/dist/SettingsModal-L7Ejny45.js +5 -0
- package/dist/SettingsModal-LEKI6Ebl.js +521 -0
- package/dist/SettingsModal-LEKI6Ebl.js.map +1 -0
- package/dist/{auth-BulIv_km.js → auth-D9q2GIcv.js} +3 -80
- package/dist/auth-D9q2GIcv.js.map +1 -0
- package/dist/components/DataFrame.vue.d.ts +3 -0
- package/dist/components/ExperimentDataViewer.vue.d.ts +2 -0
- package/dist/components/PluginWorkspaceView.vue.d.ts +2 -2
- package/dist/components/index.js +7 -2
- package/dist/{components-DtX3LDLq.js → components-CdjRzHI2.js} +533 -2025
- package/dist/components-CdjRzHI2.js.map +1 -0
- package/dist/composables/index.js +9 -3
- package/dist/composables/usePluginClient.d.ts +2 -1
- package/dist/{composables-wNt7VtkF.js → composables-DJgqPrlR.js} +7 -12
- package/dist/{composables-wNt7VtkF.js.map → composables-DJgqPrlR.js.map} +1 -1
- package/dist/experiment-utils-hGXMHlAc.js +109 -0
- package/dist/experiment-utils-hGXMHlAc.js.map +1 -0
- package/dist/index.js +16 -5
- package/dist/index.js.map +1 -1
- package/dist/install.js +7 -2
- package/dist/install.js.map +1 -1
- package/dist/permissions.js +81 -0
- package/dist/permissions.js.map +1 -0
- package/dist/stores/index.js +1 -1
- package/dist/styles.css +3233 -3185
- package/dist/templates/index.js +3 -1
- package/dist/templates-Do43ZIMb.js +5065 -0
- package/dist/templates-Do43ZIMb.js.map +1 -0
- package/dist/{templates-DSbHJC4v.js → useControlSchema-0n8Bcftq.js} +10 -5335
- package/dist/useControlSchema-0n8Bcftq.js.map +1 -0
- package/dist/useDropdownState-Ben4DnjJ.js +47 -0
- package/dist/useDropdownState-Ben4DnjJ.js.map +1 -0
- package/dist/useEventListener-CfVkP9Xz.js +57 -0
- package/dist/useEventListener-CfVkP9Xz.js.map +1 -0
- package/dist/useExperimentSelector-BpZklTbV.js +469 -0
- package/dist/useExperimentSelector-BpZklTbV.js.map +1 -0
- package/dist/useFormBuilder-COfYWDuC.js +729 -0
- package/dist/useFormBuilder-COfYWDuC.js.map +1 -0
- package/dist/{useProtocolTemplates-DwBhEPPU.js → useProtocolTemplates-TUQO_F3n.js} +8 -1298
- package/dist/useProtocolTemplates-TUQO_F3n.js.map +1 -0
- package/dist/utils/pluginIcon.d.ts +29 -2
- package/package.json +5 -1
- package/src/__tests__/components/DataFrame.test.ts +37 -0
- package/src/__tests__/components/PluginIcon.test.ts +77 -0
- package/src/__tests__/composables/usePluginClient.test.ts +11 -10
- package/src/components/AppTopBar.vue +7 -6
- package/src/components/DataFrame.vue +27 -2
- package/src/components/ExperimentDataViewer.vue +5 -1
- package/src/components/PluginIcon.story.vue +31 -1
- package/src/components/PluginIcon.vue +94 -4
- package/src/composables/usePluginClient.ts +3 -12
- package/src/styles/components/dataframe.css +26 -0
- package/src/styles/components/plugin-icon.css +5 -0
- package/src/utils/pluginIcon.ts +159 -2
- package/dist/auth-BulIv_km.js.map +0 -1
- package/dist/components-DtX3LDLq.js.map +0 -1
- package/dist/templates-DSbHJC4v.js.map +0 -1
- package/dist/useProtocolTemplates-DwBhEPPU.js.map +0 -1
|
@@ -1,7 +1,34 @@
|
|
|
1
|
-
export type PluginIconFormat = 'path' | 'data-url' | 'https-url' | 'fallback';
|
|
1
|
+
export type PluginIconFormat = 'path' | 'data-url' | 'https-url' | 'structured' | 'fallback';
|
|
2
|
+
export interface StructuredPluginIconStop {
|
|
3
|
+
offset: string;
|
|
4
|
+
color: string;
|
|
5
|
+
opacity?: number;
|
|
6
|
+
}
|
|
7
|
+
export interface StructuredPluginIconBackground {
|
|
8
|
+
fill: string;
|
|
9
|
+
radius: number;
|
|
10
|
+
}
|
|
11
|
+
export interface StructuredPluginIconPath {
|
|
12
|
+
d: string;
|
|
13
|
+
fill?: string;
|
|
14
|
+
stroke?: string;
|
|
15
|
+
strokeWidth?: number;
|
|
16
|
+
opacity?: number;
|
|
17
|
+
}
|
|
18
|
+
export interface StructuredPluginIcon {
|
|
19
|
+
type: 'mint-icon';
|
|
20
|
+
viewBox: string;
|
|
21
|
+
background?: StructuredPluginIconBackground;
|
|
22
|
+
gradient?: {
|
|
23
|
+
type: 'linear';
|
|
24
|
+
angle: number;
|
|
25
|
+
stops: StructuredPluginIconStop[];
|
|
26
|
+
};
|
|
27
|
+
paths: StructuredPluginIconPath[];
|
|
28
|
+
}
|
|
2
29
|
export interface DetectedPluginIcon {
|
|
3
30
|
format: PluginIconFormat;
|
|
4
|
-
value: string;
|
|
31
|
+
value: string | StructuredPluginIcon;
|
|
5
32
|
}
|
|
6
33
|
export declare const PLUGIN_ICON_FALLBACK_PATH = "M14 7v4a1 1 0 0 0 1 1h4M5 3h9l5 5v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2z";
|
|
7
34
|
export declare function normalizePluginIconSource(icon?: string): string;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@morscherlab/mint-sdk",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.1",
|
|
4
4
|
"description": "MINT Platform SDK — Vue 3 components, composables, and types for plugin development. MINT = Mass-spec INtegrated Toolkit.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -31,6 +31,10 @@
|
|
|
31
31
|
"types": "./dist/stores/index.d.ts",
|
|
32
32
|
"import": "./dist/stores/index.js"
|
|
33
33
|
},
|
|
34
|
+
"./permissions": {
|
|
35
|
+
"types": "./dist/permissions.d.ts",
|
|
36
|
+
"import": "./dist/permissions.js"
|
|
37
|
+
},
|
|
34
38
|
"./templates": {
|
|
35
39
|
"types": "./dist/templates/index.d.ts",
|
|
36
40
|
"import": "./dist/templates/index.js"
|
|
@@ -509,6 +509,43 @@ describe('DataFrame', () => {
|
|
|
509
509
|
expect(column.key).toBe('name')
|
|
510
510
|
expect(row.name).toBe('Alice')
|
|
511
511
|
})
|
|
512
|
+
|
|
513
|
+
it('should emit row-click from cell clicks when clickableRows is true', async () => {
|
|
514
|
+
const wrapper = mount(DataFrame, {
|
|
515
|
+
props: { data: mockData, columns: mockColumns, clickableRows: true },
|
|
516
|
+
})
|
|
517
|
+
const firstCell = wrapper.find('.mint-dataframe__td')
|
|
518
|
+
await firstCell.trigger('click')
|
|
519
|
+
|
|
520
|
+
expect(wrapper.emitted('cell-click')).toHaveLength(1)
|
|
521
|
+
expect(wrapper.emitted('row-click')).toHaveLength(1)
|
|
522
|
+
const [row, index] = wrapper.emitted('row-click')?.[0] as [Record<string, unknown>, number]
|
|
523
|
+
expect(row.name).toBe('Alice')
|
|
524
|
+
expect(index).toBe(0)
|
|
525
|
+
})
|
|
526
|
+
|
|
527
|
+
it('should make clickable rows keyboard accessible', async () => {
|
|
528
|
+
const wrapper = mount(DataFrame, {
|
|
529
|
+
props: { data: mockData, columns: mockColumns, clickableRows: true },
|
|
530
|
+
})
|
|
531
|
+
const firstRow = wrapper.find('.mint-dataframe__row')
|
|
532
|
+
expect(firstRow.attributes('tabindex')).toBe('0')
|
|
533
|
+
expect(firstRow.attributes('role')).toBe('button')
|
|
534
|
+
|
|
535
|
+
await firstRow.trigger('keydown', { key: 'Enter' })
|
|
536
|
+
await firstRow.trigger('keydown', { key: ' ' })
|
|
537
|
+
|
|
538
|
+
expect(wrapper.emitted('row-click')).toHaveLength(2)
|
|
539
|
+
})
|
|
540
|
+
|
|
541
|
+
it('should keep non-clickable rows out of the tab order', () => {
|
|
542
|
+
const wrapper = mount(DataFrame, {
|
|
543
|
+
props: { data: mockData, columns: mockColumns },
|
|
544
|
+
})
|
|
545
|
+
const firstRow = wrapper.find('.mint-dataframe__row')
|
|
546
|
+
expect(firstRow.attributes('tabindex')).toBeUndefined()
|
|
547
|
+
expect(firstRow.attributes('role')).toBeUndefined()
|
|
548
|
+
})
|
|
512
549
|
})
|
|
513
550
|
|
|
514
551
|
describe('empty state', () => {
|
|
@@ -1,9 +1,30 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest'
|
|
2
2
|
import { mount } from '@vue/test-utils'
|
|
3
3
|
import PluginIcon from '../../components/PluginIcon.vue'
|
|
4
|
+
import { PLUGIN_ICON_FALLBACK_PATH } from '../../utils/pluginIcon'
|
|
4
5
|
|
|
5
6
|
const SAMPLE_PATH = 'M13 10V3L4 14h7v7l9-11h-7z'
|
|
6
7
|
const SAMPLE_HTTPS_URL = 'https://example.com/icon.png'
|
|
8
|
+
const SAMPLE_STRUCTURED_ICON = JSON.stringify({
|
|
9
|
+
type: 'mint-icon',
|
|
10
|
+
viewBox: '0 0 24 24',
|
|
11
|
+
gradient: {
|
|
12
|
+
type: 'linear',
|
|
13
|
+
angle: 135,
|
|
14
|
+
stops: [
|
|
15
|
+
{ offset: '0%', color: '#AAECBF' },
|
|
16
|
+
{ offset: '100%', color: '#14A1A9' },
|
|
17
|
+
],
|
|
18
|
+
},
|
|
19
|
+
background: { fill: 'gradient', radius: 5 },
|
|
20
|
+
paths: [
|
|
21
|
+
{
|
|
22
|
+
d: 'M7 17c5.8-.2 9.5-3.8 10-10-5.7.5-9.3 4.2-10 10z',
|
|
23
|
+
fill: 'white',
|
|
24
|
+
opacity: 0.95,
|
|
25
|
+
},
|
|
26
|
+
],
|
|
27
|
+
})
|
|
7
28
|
|
|
8
29
|
describe('PluginIcon', () => {
|
|
9
30
|
describe('format detection', () => {
|
|
@@ -15,6 +36,62 @@ describe('PluginIcon', () => {
|
|
|
15
36
|
expect(wrapper.find('img').exists()).toBe(false)
|
|
16
37
|
})
|
|
17
38
|
|
|
39
|
+
it('should render structured gradient vector icons without an image tag', () => {
|
|
40
|
+
const wrapper = mount(PluginIcon, { props: { icon: SAMPLE_STRUCTURED_ICON } })
|
|
41
|
+
const svg = wrapper.find('svg.mint-plugin-icon__svg--structured')
|
|
42
|
+
const rect = wrapper.find('rect')
|
|
43
|
+
const stops = wrapper.findAll('stop')
|
|
44
|
+
const path = wrapper.find('path')
|
|
45
|
+
|
|
46
|
+
expect(svg.exists()).toBe(true)
|
|
47
|
+
expect(svg.attributes('viewBox')).toBe('0 0 24 24')
|
|
48
|
+
expect(rect.attributes('fill')).toMatch(/^url\(#mint-plugin-icon-gradient-/)
|
|
49
|
+
expect(rect.attributes('rx')).toBe('5')
|
|
50
|
+
expect(stops).toHaveLength(2)
|
|
51
|
+
expect(stops[0].attributes('stop-color')).toBe('#AAECBF')
|
|
52
|
+
expect(stops[1].attributes('stop-color')).toBe('#14A1A9')
|
|
53
|
+
expect(path.attributes('fill')).toBe('white')
|
|
54
|
+
expect(wrapper.find('img').exists()).toBe(false)
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('should reject structured icons without paths as fallback', () => {
|
|
58
|
+
const icon = JSON.stringify({
|
|
59
|
+
type: 'mint-icon',
|
|
60
|
+
background: { fill: '#14A1A9', radius: 5 },
|
|
61
|
+
paths: [],
|
|
62
|
+
})
|
|
63
|
+
const wrapper = mount(PluginIcon, { props: { icon } })
|
|
64
|
+
expect(wrapper.find('svg path').attributes('d')).toBe(PLUGIN_ICON_FALLBACK_PATH)
|
|
65
|
+
expect(wrapper.find('.mint-plugin-icon__svg--structured').exists()).toBe(false)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('should accept structured gradient shorthand with from and to colors', () => {
|
|
69
|
+
const icon = JSON.stringify({
|
|
70
|
+
type: 'mint-icon',
|
|
71
|
+
gradient: { type: 'linear', from: '#AAECBF', to: '#14A1A9' },
|
|
72
|
+
background: { fill: 'gradient' },
|
|
73
|
+
paths: [{ d: SAMPLE_PATH, fill: 'white' }],
|
|
74
|
+
})
|
|
75
|
+
const wrapper = mount(PluginIcon, { props: { icon } })
|
|
76
|
+
const stops = wrapper.findAll('stop')
|
|
77
|
+
expect(stops).toHaveLength(2)
|
|
78
|
+
expect(stops[0].attributes('stop-color')).toBe('#AAECBF')
|
|
79
|
+
expect(stops[1].attributes('stop-color')).toBe('#14A1A9')
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('should ignore unsafe structured icon colors and still render safe paths', () => {
|
|
83
|
+
const icon = JSON.stringify({
|
|
84
|
+
type: 'mint-icon',
|
|
85
|
+
background: { fill: 'url(javascript:alert(1))', radius: 5 },
|
|
86
|
+
paths: [{ d: SAMPLE_PATH, fill: 'javascript:alert(1)', stroke: '#14A1A9' }],
|
|
87
|
+
})
|
|
88
|
+
const wrapper = mount(PluginIcon, { props: { icon } })
|
|
89
|
+
const path = wrapper.find('path')
|
|
90
|
+
expect(wrapper.find('rect').exists()).toBe(false)
|
|
91
|
+
expect(path.attributes('fill')).toBe('none')
|
|
92
|
+
expect(path.attributes('stroke')).toBe('#14A1A9')
|
|
93
|
+
})
|
|
94
|
+
|
|
18
95
|
it('should render SVG path when icon starts with lowercase m and a space', () => {
|
|
19
96
|
const wrapper = mount(PluginIcon, { props: { icon: 'm 5,5 l 10,10' } })
|
|
20
97
|
const path = wrapper.find('svg path')
|
|
@@ -356,7 +356,7 @@ describe('usePluginClient', () => {
|
|
|
356
356
|
expect(requestConfigs).toHaveLength(0)
|
|
357
357
|
})
|
|
358
358
|
|
|
359
|
-
it('
|
|
359
|
+
it('does not synthesize page selector items when contract nav items are absent', () => {
|
|
360
360
|
expect(getPluginPageSelectorItems({
|
|
361
361
|
...contract,
|
|
362
362
|
plugin: {
|
|
@@ -366,15 +366,16 @@ describe('usePluginClient', () => {
|
|
|
366
366
|
icon: 'PLUGIN_ICON',
|
|
367
367
|
navItems: [],
|
|
368
368
|
},
|
|
369
|
-
})).toEqual([
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
369
|
+
})).toEqual([])
|
|
370
|
+
expect(requestConfigs).toHaveLength(0)
|
|
371
|
+
})
|
|
372
|
+
|
|
373
|
+
it('does not synthesize page selector items when contract nav items are omitted', () => {
|
|
374
|
+
const { navItems: _navItems, ...pluginWithoutNavItems } = contract.plugin
|
|
375
|
+
expect(getPluginPageSelectorItems({
|
|
376
|
+
...contract,
|
|
377
|
+
plugin: pluginWithoutNavItems,
|
|
378
|
+
})).toEqual([])
|
|
378
379
|
expect(requestConfigs).toHaveLength(0)
|
|
379
380
|
})
|
|
380
381
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
/** Full application top bar with brand logo, page selector or plugin switcher, centered pill nav, experiment popover, and avatar menu. */
|
|
3
|
-
import { ref, computed, inject } from 'vue'
|
|
3
|
+
import { ref, computed, inject, defineAsyncComponent } from 'vue'
|
|
4
4
|
import type {
|
|
5
5
|
PillNavOption,
|
|
6
6
|
TopBarSettingsConfig,
|
|
@@ -16,9 +16,6 @@ import type {
|
|
|
16
16
|
} from '../types/components'
|
|
17
17
|
import { normalizeItemInput } from '../utils/items'
|
|
18
18
|
import ThemeToggle from './ThemeToggle.vue'
|
|
19
|
-
import SettingsModal from './SettingsModal.vue'
|
|
20
|
-
import ExperimentPopover from './ExperimentPopover.vue'
|
|
21
|
-
import ExperimentSelectorModal from './ExperimentSelectorModal.vue'
|
|
22
19
|
import AppTopBarPageSelectorInternal from './internal/AppTopBarPageSelectorInternal.vue'
|
|
23
20
|
import AppTopBarPillNavInternal from './internal/AppTopBarPillNavInternal.vue'
|
|
24
21
|
import AppAvatarMenu from './AppAvatarMenu.vue'
|
|
@@ -101,6 +98,10 @@ const props = withDefaults(defineProps<Props>(), {
|
|
|
101
98
|
hasNotificationDot: false,
|
|
102
99
|
})
|
|
103
100
|
|
|
101
|
+
const SettingsModal = defineAsyncComponent(() => import('./SettingsModal.vue'))
|
|
102
|
+
const ExperimentPopover = defineAsyncComponent(() => import('./ExperimentPopover.vue'))
|
|
103
|
+
const ExperimentSelectorModal = defineAsyncComponent(() => import('./ExperimentSelectorModal.vue'))
|
|
104
|
+
|
|
104
105
|
const emit = defineEmits<{
|
|
105
106
|
'profile-click': []
|
|
106
107
|
'admin-click': []
|
|
@@ -411,7 +412,7 @@ const effectiveCurrentPageSelectorId = computed(() =>
|
|
|
411
412
|
</header>
|
|
412
413
|
|
|
413
414
|
<SettingsModal
|
|
414
|
-
v-if="showSettings"
|
|
415
|
+
v-if="showSettings && settingsOpen"
|
|
415
416
|
v-model="settingsOpen"
|
|
416
417
|
:title="settingsConfig?.title"
|
|
417
418
|
:tabs="normalizedSettingsTabs"
|
|
@@ -436,7 +437,7 @@ const effectiveCurrentPageSelectorId = computed(() =>
|
|
|
436
437
|
</SettingsModal>
|
|
437
438
|
|
|
438
439
|
<ExperimentSelectorModal
|
|
439
|
-
v-if="appExperiment && !isStandalone"
|
|
440
|
+
v-if="appExperiment && !isStandalone && appExperiment.selectorModal.value.modelValue"
|
|
440
441
|
v-bind="appExperiment.selectorModal.value"
|
|
441
442
|
@update:model-value="$event ? appExperiment.openModal() : appExperiment.closeModal()"
|
|
442
443
|
@select="appExperiment.handleSelect($event)"
|
|
@@ -60,6 +60,8 @@ interface Props {
|
|
|
60
60
|
selectable?: boolean
|
|
61
61
|
/** Currently selected row keys */
|
|
62
62
|
selectedKeys?: (string | number)[]
|
|
63
|
+
/** Make each body row act as a click/keyboard target. */
|
|
64
|
+
clickableRows?: boolean
|
|
63
65
|
}
|
|
64
66
|
|
|
65
67
|
const props = withDefaults(defineProps<Props>(), {
|
|
@@ -75,6 +77,7 @@ const props = withDefaults(defineProps<Props>(), {
|
|
|
75
77
|
searchPlaceholder: 'Search...',
|
|
76
78
|
selectable: false,
|
|
77
79
|
selectedKeys: () => [],
|
|
80
|
+
clickableRows: false,
|
|
78
81
|
})
|
|
79
82
|
|
|
80
83
|
/**
|
|
@@ -181,6 +184,24 @@ function handlePageChange(page: number) {
|
|
|
181
184
|
emit('update:pagination', { ...props.pagination, page })
|
|
182
185
|
}
|
|
183
186
|
|
|
187
|
+
function handleRowClick(row: Record<string, unknown>, index: number) {
|
|
188
|
+
emit('row-click', row, index)
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function handleRowKeydown(event: KeyboardEvent, row: Record<string, unknown>, index: number) {
|
|
192
|
+
if (!props.clickableRows) return
|
|
193
|
+
if (event.key !== 'Enter' && event.key !== ' ') return
|
|
194
|
+
event.preventDefault()
|
|
195
|
+
handleRowClick(row, index)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function handleCellClick(row: Record<string, unknown>, col: DataFrameColumn, index: number) {
|
|
199
|
+
emit('cell-click', getCellValue(row, col.key), col, row)
|
|
200
|
+
if (props.clickableRows) {
|
|
201
|
+
handleRowClick(row, index)
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
184
205
|
function isColumnSortable(col: DataFrameColumn): boolean {
|
|
185
206
|
return col.sortable === true || (props.sortable && col.sortable !== false)
|
|
186
207
|
}
|
|
@@ -310,9 +331,13 @@ const wrapperStyle = computed(() => {
|
|
|
310
331
|
{
|
|
311
332
|
'mint-dataframe__row--striped': striped && rowIndex % 2 === 1,
|
|
312
333
|
'mint-dataframe__row--selected': rowSelection.isSelected(getRowKey(row, rowIndex)),
|
|
334
|
+
'mint-dataframe__row--clickable': clickableRows,
|
|
313
335
|
},
|
|
314
336
|
]"
|
|
315
|
-
|
|
337
|
+
:tabindex="clickableRows ? 0 : undefined"
|
|
338
|
+
:role="clickableRows ? 'button' : undefined"
|
|
339
|
+
@click="handleRowClick(row, rowIndex)"
|
|
340
|
+
@keydown="handleRowKeydown($event, row, rowIndex)"
|
|
316
341
|
>
|
|
317
342
|
<td v-if="selectable" class="mint-dataframe__td mint-dataframe__td--checkbox">
|
|
318
343
|
<input
|
|
@@ -332,7 +357,7 @@ const wrapperStyle = computed(() => {
|
|
|
332
357
|
`mint-dataframe__td--align-${col.align ?? 'left'}`,
|
|
333
358
|
{ 'mint-dataframe__td--ellipsis': col.ellipsis },
|
|
334
359
|
]"
|
|
335
|
-
@click.stop="
|
|
360
|
+
@click.stop="handleCellClick(row, col, rowIndex)"
|
|
336
361
|
>
|
|
337
362
|
<slot :name="`cell-${col.key}`" :value="getCellValue(row, col.key)" :row="row" :column="col" :index="rowIndex">
|
|
338
363
|
{{ formatCell(col, row, rowIndex) }}
|
|
@@ -22,12 +22,14 @@ interface Props {
|
|
|
22
22
|
loading?: boolean
|
|
23
23
|
downloadJsonUrl?: string
|
|
24
24
|
downloadCsvUrl?: string
|
|
25
|
+
autoFetch?: boolean
|
|
25
26
|
}
|
|
26
27
|
|
|
27
28
|
const props = withDefaults(defineProps<Props>(), {
|
|
28
29
|
title: 'Data',
|
|
29
30
|
defaultView: 'summary',
|
|
30
31
|
loading: false,
|
|
32
|
+
autoFetch: true,
|
|
31
33
|
})
|
|
32
34
|
|
|
33
35
|
// Auto-fetch when experimentId is provided and no prop data is given
|
|
@@ -39,7 +41,7 @@ const hasPropData = computed(() =>
|
|
|
39
41
|
watch(
|
|
40
42
|
() => props.experimentId,
|
|
41
43
|
(id) => {
|
|
42
|
-
if (id && !hasPropData.value) {
|
|
44
|
+
if (props.autoFetch && id && !hasPropData.value) {
|
|
43
45
|
expData.fetch(id)
|
|
44
46
|
}
|
|
45
47
|
},
|
|
@@ -158,6 +160,7 @@ function handleDownloadCsv() {
|
|
|
158
160
|
Open in {{ pluginName || 'Plugin' }}
|
|
159
161
|
</BaseButton>
|
|
160
162
|
<BaseButton
|
|
163
|
+
v-if="downloadJsonUrl"
|
|
161
164
|
variant="ghost"
|
|
162
165
|
size="sm"
|
|
163
166
|
@click="handleDownloadJson"
|
|
@@ -165,6 +168,7 @@ function handleDownloadCsv() {
|
|
|
165
168
|
JSON
|
|
166
169
|
</BaseButton>
|
|
167
170
|
<BaseButton
|
|
171
|
+
v-if="downloadCsvUrl"
|
|
168
172
|
variant="ghost"
|
|
169
173
|
size="sm"
|
|
170
174
|
@click="handleDownloadCsv"
|
|
@@ -3,6 +3,27 @@ import { reactive } from 'vue'
|
|
|
3
3
|
import PluginIcon from './PluginIcon.vue'
|
|
4
4
|
|
|
5
5
|
const SAMPLE_PATH = 'M13 10V3L4 14h7v7l9-11h-7z'
|
|
6
|
+
const SAMPLE_STRUCTURED = JSON.stringify({
|
|
7
|
+
type: 'mint-icon',
|
|
8
|
+
viewBox: '0 0 24 24',
|
|
9
|
+
gradient: {
|
|
10
|
+
type: 'linear',
|
|
11
|
+
angle: 135,
|
|
12
|
+
stops: [
|
|
13
|
+
{ offset: '0%', color: '#AAECBF' },
|
|
14
|
+
{ offset: '100%', color: '#14A1A9' },
|
|
15
|
+
],
|
|
16
|
+
},
|
|
17
|
+
background: { fill: 'gradient', radius: 5 },
|
|
18
|
+
paths: [
|
|
19
|
+
{
|
|
20
|
+
d: 'M7 17c5.8-.2 9.5-3.8 10-10-5.7.5-9.3 4.2-10 10zM8.5 15.5c2.2-1.7 4.1-3.4 6-6',
|
|
21
|
+
fill: 'none',
|
|
22
|
+
stroke: 'white',
|
|
23
|
+
strokeWidth: 1.8,
|
|
24
|
+
},
|
|
25
|
+
],
|
|
26
|
+
})
|
|
6
27
|
// Tiny 1x1 transparent PNG.
|
|
7
28
|
const SAMPLE_PNG = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkAAIAAAoAAv/lxKUAAAAASUVORK5CYII='
|
|
8
29
|
const SAMPLE_HTTPS = 'https://upload.wikimedia.org/wikipedia/commons/thumb/4/4f/Vue.js_Logo_2.svg/64px-Vue.js_Logo_2.svg.png'
|
|
@@ -50,16 +71,25 @@ function initState() {
|
|
|
50
71
|
|
|
51
72
|
<Variant title="Format showcase">
|
|
52
73
|
<div style="padding: 2rem; display: flex; gap: 1rem; align-items: center;">
|
|
74
|
+
<PluginIcon :icon="SAMPLE_STRUCTURED" size="lg" variant="tinted" />
|
|
53
75
|
<PluginIcon :icon="SAMPLE_PATH" size="lg" variant="tinted" />
|
|
54
76
|
<PluginIcon :icon="SAMPLE_PNG" size="lg" variant="tinted" />
|
|
55
77
|
<PluginIcon :icon="SAMPLE_HTTPS" size="lg" variant="tinted" />
|
|
56
78
|
<PluginIcon icon="" size="lg" variant="tinted" />
|
|
57
79
|
</div>
|
|
58
80
|
<p style="padding: 0 2rem; color: var(--text-muted); font-size: 0.875rem;">
|
|
59
|
-
SVG path · data URL (PNG) · https URL · empty (fallback)
|
|
81
|
+
Structured vector · SVG path · data URL (PNG) · https URL · empty (fallback)
|
|
60
82
|
</p>
|
|
61
83
|
</Variant>
|
|
62
84
|
|
|
85
|
+
<Variant title="Structured vector">
|
|
86
|
+
<div style="padding: 2rem; display: flex; gap: 1rem; align-items: center;">
|
|
87
|
+
<PluginIcon :icon="SAMPLE_STRUCTURED" size="sm" />
|
|
88
|
+
<PluginIcon :icon="SAMPLE_STRUCTURED" size="md" />
|
|
89
|
+
<PluginIcon :icon="SAMPLE_STRUCTURED" size="lg" />
|
|
90
|
+
</div>
|
|
91
|
+
</Variant>
|
|
92
|
+
|
|
63
93
|
<Variant title="Tone overrides">
|
|
64
94
|
<div style="padding: 2rem; display: flex; gap: 1rem; align-items: center;">
|
|
65
95
|
<PluginIcon :icon="SAMPLE_PATH" size="lg" variant="solid" tone="#0EA5E9" />
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
/** Renders a plugin's icon as a sized chip. Auto-detects format:
|
|
3
|
+
* - Structured MINT icon JSON ({"type":"mint-icon",...}) → controlled multi-color SVG
|
|
3
4
|
* - SVG path data (e.g. "M13 10V3...") → inline <svg><path>
|
|
4
5
|
* - Raster data URL (data:image/png|jpeg|jpg|gif|webp;...) → <img>
|
|
5
6
|
* - https:// URL → <img> with no-referrer + lazy loading
|
|
@@ -8,7 +9,7 @@
|
|
|
8
9
|
* http:// URLs and data:image/svg+xml URLs are deliberately rejected
|
|
9
10
|
* (mixed-content + XSS, see spec 2026-05-04-plugin-icon-component-design.md). */
|
|
10
11
|
import { computed } from 'vue'
|
|
11
|
-
import { detectPluginIcon, type DetectedPluginIcon } from '../utils/pluginIcon'
|
|
12
|
+
import { detectPluginIcon, type DetectedPluginIcon, type StructuredPluginIcon } from '../utils/pluginIcon'
|
|
12
13
|
|
|
13
14
|
defineOptions({ name: 'PluginIcon' })
|
|
14
15
|
|
|
@@ -25,6 +26,51 @@ const props = withDefaults(defineProps<Props>(), {
|
|
|
25
26
|
})
|
|
26
27
|
|
|
27
28
|
const detected = computed<DetectedPluginIcon>(() => detectPluginIcon(props.icon))
|
|
29
|
+
const structuredIcon = computed<StructuredPluginIcon | undefined>(() =>
|
|
30
|
+
detected.value.format === 'structured' ? detected.value.value as StructuredPluginIcon : undefined,
|
|
31
|
+
)
|
|
32
|
+
const iconValue = computed<string>(() =>
|
|
33
|
+
typeof detected.value.value === 'string' ? detected.value.value : '',
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
const gradientId = computed(() => {
|
|
37
|
+
const source = props.icon ?? ''
|
|
38
|
+
let hash = 0
|
|
39
|
+
for (let index = 0; index < source.length; index += 1) {
|
|
40
|
+
hash = ((hash << 5) - hash + source.charCodeAt(index)) | 0
|
|
41
|
+
}
|
|
42
|
+
return `mint-plugin-icon-gradient-${Math.abs(hash)}`
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
const gradientCoords = computed(() => {
|
|
46
|
+
const angle = structuredIcon.value?.gradient?.angle ?? 135
|
|
47
|
+
const radians = (angle - 90) * Math.PI / 180
|
|
48
|
+
const x = Math.cos(radians) * 50
|
|
49
|
+
const y = Math.sin(radians) * 50
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
x1: `${50 - x}%`,
|
|
53
|
+
y1: `${50 - y}%`,
|
|
54
|
+
x2: `${50 + x}%`,
|
|
55
|
+
y2: `${50 + y}%`,
|
|
56
|
+
}
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
const structuredViewBox = computed(() => {
|
|
60
|
+
const parts = structuredIcon.value?.viewBox.split(/\s+/).map(Number) ?? [0, 0, 24, 24]
|
|
61
|
+
return {
|
|
62
|
+
x: parts[0],
|
|
63
|
+
y: parts[1],
|
|
64
|
+
width: parts[2],
|
|
65
|
+
height: parts[3],
|
|
66
|
+
}
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
const backgroundFill = computed(() =>
|
|
70
|
+
structuredIcon.value?.background?.fill === 'gradient'
|
|
71
|
+
? `url(#${gradientId.value})`
|
|
72
|
+
: structuredIcon.value?.background?.fill,
|
|
73
|
+
)
|
|
28
74
|
|
|
29
75
|
const rootClasses = computed(() => [
|
|
30
76
|
'mint-plugin-icon',
|
|
@@ -40,7 +86,51 @@ const rootStyle = computed(() =>
|
|
|
40
86
|
<template>
|
|
41
87
|
<span :class="rootClasses" :style="rootStyle">
|
|
42
88
|
<svg
|
|
43
|
-
v-if="
|
|
89
|
+
v-if="structuredIcon"
|
|
90
|
+
class="mint-plugin-icon__svg mint-plugin-icon__svg--structured"
|
|
91
|
+
:viewBox="structuredIcon.viewBox"
|
|
92
|
+
aria-hidden="true"
|
|
93
|
+
>
|
|
94
|
+
<defs v-if="structuredIcon.gradient">
|
|
95
|
+
<linearGradient
|
|
96
|
+
:id="gradientId"
|
|
97
|
+
:x1="gradientCoords.x1"
|
|
98
|
+
:y1="gradientCoords.y1"
|
|
99
|
+
:x2="gradientCoords.x2"
|
|
100
|
+
:y2="gradientCoords.y2"
|
|
101
|
+
>
|
|
102
|
+
<stop
|
|
103
|
+
v-for="stop in structuredIcon.gradient.stops"
|
|
104
|
+
:key="`${stop.offset}-${stop.color}`"
|
|
105
|
+
:offset="stop.offset"
|
|
106
|
+
:stop-color="stop.color"
|
|
107
|
+
:stop-opacity="stop.opacity"
|
|
108
|
+
/>
|
|
109
|
+
</linearGradient>
|
|
110
|
+
</defs>
|
|
111
|
+
<rect
|
|
112
|
+
v-if="structuredIcon.background"
|
|
113
|
+
:x="structuredViewBox.x"
|
|
114
|
+
:y="structuredViewBox.y"
|
|
115
|
+
:width="structuredViewBox.width"
|
|
116
|
+
:height="structuredViewBox.height"
|
|
117
|
+
:rx="structuredIcon.background.radius"
|
|
118
|
+
:fill="backgroundFill"
|
|
119
|
+
/>
|
|
120
|
+
<path
|
|
121
|
+
v-for="(path, index) in structuredIcon.paths"
|
|
122
|
+
:key="index"
|
|
123
|
+
:d="path.d"
|
|
124
|
+
:fill="path.fill ?? 'none'"
|
|
125
|
+
:stroke="path.stroke"
|
|
126
|
+
:stroke-width="path.strokeWidth"
|
|
127
|
+
:opacity="path.opacity"
|
|
128
|
+
stroke-linecap="round"
|
|
129
|
+
stroke-linejoin="round"
|
|
130
|
+
/>
|
|
131
|
+
</svg>
|
|
132
|
+
<svg
|
|
133
|
+
v-else-if="detected.format === 'path' || detected.format === 'fallback'"
|
|
44
134
|
class="mint-plugin-icon__svg"
|
|
45
135
|
viewBox="0 0 24 24"
|
|
46
136
|
fill="none"
|
|
@@ -50,12 +140,12 @@ const rootStyle = computed(() =>
|
|
|
50
140
|
stroke-linejoin="round"
|
|
51
141
|
aria-hidden="true"
|
|
52
142
|
>
|
|
53
|
-
<path :d="
|
|
143
|
+
<path :d="iconValue" />
|
|
54
144
|
</svg>
|
|
55
145
|
<img
|
|
56
146
|
v-else
|
|
57
147
|
class="mint-plugin-icon__img"
|
|
58
|
-
:src="
|
|
148
|
+
:src="iconValue"
|
|
59
149
|
alt=""
|
|
60
150
|
referrerpolicy="no-referrer"
|
|
61
151
|
loading="lazy"
|
|
@@ -63,6 +63,7 @@ export interface PluginContract {
|
|
|
63
63
|
icon?: string
|
|
64
64
|
color?: string
|
|
65
65
|
navItems?: PluginNavItemContract[]
|
|
66
|
+
analysisResultReaders?: string[]
|
|
66
67
|
capabilities?: Record<string, unknown>
|
|
67
68
|
}
|
|
68
69
|
endpoints: PluginEndpointContract[]
|
|
@@ -230,21 +231,11 @@ function pluginPageIdFromPath(path: string, fallbackIndex: number): string {
|
|
|
230
231
|
return normalizedPath.replace(/^\/+/, '').replace(/\/+/g, '-') || `page-${fallbackIndex + 1}`
|
|
231
232
|
}
|
|
232
233
|
|
|
233
|
-
/** Convert PluginMetadata.nav_items into AppTopBar pageSelector items for topbar page switches
|
|
234
|
+
/** Convert PluginMetadata.nav_items into AppTopBar pageSelector items for topbar page switches and homepage PluginCards. */
|
|
234
235
|
export function getPluginPageSelectorItems(contract: PluginContract): PageSelectorItem[] {
|
|
235
236
|
const pluginIcon = contract.plugin.icon ?? ''
|
|
236
237
|
const navItems = contract.plugin.navItems ?? []
|
|
237
|
-
|
|
238
|
-
? navItems
|
|
239
|
-
: [{
|
|
240
|
-
path: '/',
|
|
241
|
-
label: contract.plugin.name ?? 'Dashboard',
|
|
242
|
-
id: 'dashboard',
|
|
243
|
-
icon: pluginIcon || undefined,
|
|
244
|
-
description: contract.plugin.description,
|
|
245
|
-
}]
|
|
246
|
-
|
|
247
|
-
return source.map((item, index) => ({
|
|
238
|
+
return navItems.map((item, index) => ({
|
|
248
239
|
id: item.id || pluginPageIdFromPath(item.path, index),
|
|
249
240
|
label: item.label,
|
|
250
241
|
to: normalizePluginNavPath(item.path),
|
|
@@ -192,6 +192,7 @@
|
|
|
192
192
|
border-right: none !important;
|
|
193
193
|
border-bottom: 1px solid var(--border-color) !important;
|
|
194
194
|
background: transparent !important;
|
|
195
|
+
transition: background-color 0.15s ease, box-shadow 0.15s ease;
|
|
195
196
|
}
|
|
196
197
|
|
|
197
198
|
.mint-dataframe__td--sm {
|
|
@@ -248,6 +249,31 @@
|
|
|
248
249
|
background-color: var(--bg-hover);
|
|
249
250
|
}
|
|
250
251
|
|
|
252
|
+
.mint-dataframe__row--clickable {
|
|
253
|
+
cursor: pointer;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
.mint-dataframe__row--clickable:hover > .mint-dataframe__td,
|
|
257
|
+
.mint-dataframe__row--clickable:focus-visible > .mint-dataframe__td {
|
|
258
|
+
background: var(--color-primary-soft) !important;
|
|
259
|
+
box-shadow:
|
|
260
|
+
inset 0 1px 0 rgba(99, 102, 241, 0.18),
|
|
261
|
+
inset 0 -1px 0 rgba(99, 102, 241, 0.18);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
.mint-dataframe__row--clickable:hover > .mint-dataframe__td:first-child,
|
|
265
|
+
.mint-dataframe__row--clickable:focus-visible > .mint-dataframe__td:first-child {
|
|
266
|
+
box-shadow:
|
|
267
|
+
inset 3px 0 0 var(--color-primary),
|
|
268
|
+
inset 0 1px 0 rgba(99, 102, 241, 0.18),
|
|
269
|
+
inset 0 -1px 0 rgba(99, 102, 241, 0.18);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
.mint-dataframe__row--clickable:focus-visible {
|
|
273
|
+
outline: 2px solid var(--focus-ring-color, var(--color-primary));
|
|
274
|
+
outline-offset: -2px;
|
|
275
|
+
}
|
|
276
|
+
|
|
251
277
|
.mint-dataframe__row--striped {
|
|
252
278
|
background-color: var(--bg-tertiary);
|
|
253
279
|
}
|