@morscherlab/mint-sdk 1.1.0-beta.1 → 1.1.0-beta.3
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/__tests__/components/SectionCard.test.d.ts +1 -0
- package/dist/components/SectionCard.vue.d.ts +47 -0
- package/dist/components/index.d.ts +1 -0
- package/dist/components/index.js +2 -2
- package/dist/{components-UfkPN1bK.js → components-DFH5whuh.js} +699 -620
- package/dist/components-DFH5whuh.js.map +1 -0
- package/dist/composables/index.js +1 -1
- package/dist/composables/usePlatformContext.d.ts +3 -0
- package/dist/{composables-F4JFK9K1.js → composables-B512bWJP.js} +133 -26
- package/dist/composables-B512bWJP.js.map +1 -0
- package/dist/index.js +3 -3
- package/dist/install.js +1 -1
- package/dist/styles.css +301 -0
- package/dist/tailwind.preset.d.ts +5 -0
- package/dist/tailwind.preset.js +7 -2
- package/dist/tailwind.preset.js.map +1 -1
- package/dist/types/components.d.ts +2 -0
- package/dist/types/index.d.ts +1 -1
- package/dist/types/platform.d.ts +2 -0
- package/package.json +1 -1
- package/src/__tests__/components/SectionCard.test.ts +181 -0
- package/src/__tests__/composables/usePlatformContext.test.ts +28 -0
- package/src/__tests__/composables/usePluginClient.test.ts +329 -20
- package/src/__tests__/composables/usePluginConfig.test.ts +323 -7
- package/src/components/SectionCard.story.vue +163 -0
- package/src/components/SectionCard.vue +98 -0
- package/src/components/index.ts +1 -0
- package/src/composables/usePluginClient.ts +118 -15
- package/src/composables/usePluginConfig.ts +156 -20
- package/src/styles/components/section-card.css +161 -0
- package/src/styles/index.css +1 -0
- package/src/styles/variables.css +25 -0
- package/src/tailwind.preset.ts +5 -0
- package/src/types/components.ts +12 -0
- package/src/types/index.ts +2 -0
- package/src/types/platform.ts +2 -0
- package/dist/components-UfkPN1bK.js.map +0 -1
- package/dist/composables-F4JFK9K1.js.map +0 -1
|
@@ -38,16 +38,24 @@ type FakeResponse = Record<string, unknown>
|
|
|
38
38
|
|
|
39
39
|
let getResponse: FakeResponse = { plugin_name: 'drp', config: {} }
|
|
40
40
|
let patchResponse: FakeResponse = { plugin_name: 'drp', config: {} }
|
|
41
|
+
let putResponse: FakeResponse = { plugin_name: 'drp', config: {} }
|
|
41
42
|
let getError: Error | null = null
|
|
42
43
|
let patchError: Error | null = null
|
|
43
|
-
let
|
|
44
|
+
let recordedBaseUrls: Array<string | undefined> = []
|
|
45
|
+
let recordedCalls: {
|
|
46
|
+
method: string
|
|
47
|
+
url: string
|
|
48
|
+
data?: unknown
|
|
49
|
+
headers?: AxiosRequestConfig['headers']
|
|
50
|
+
}[] = []
|
|
44
51
|
|
|
45
52
|
function mockAxios() {
|
|
46
53
|
vi.spyOn(axios.Axios.prototype, 'get').mockImplementation(async function (
|
|
47
54
|
this: unknown,
|
|
48
55
|
url: string,
|
|
49
|
-
|
|
56
|
+
config?: AxiosRequestConfig,
|
|
50
57
|
) {
|
|
58
|
+
recordedBaseUrls.push(config?.baseURL)
|
|
51
59
|
recordedCalls.push({ method: 'GET', url })
|
|
52
60
|
if (getError) throw getError
|
|
53
61
|
return { data: getResponse }
|
|
@@ -56,12 +64,23 @@ function mockAxios() {
|
|
|
56
64
|
this: unknown,
|
|
57
65
|
url: string,
|
|
58
66
|
data?: unknown,
|
|
59
|
-
|
|
67
|
+
config?: AxiosRequestConfig,
|
|
60
68
|
) {
|
|
69
|
+
recordedBaseUrls.push(config?.baseURL)
|
|
61
70
|
recordedCalls.push({ method: 'PATCH', url, data })
|
|
62
71
|
if (patchError) throw patchError
|
|
63
72
|
return { data: patchResponse }
|
|
64
73
|
})
|
|
74
|
+
vi.spyOn(axios.Axios.prototype, 'put').mockImplementation(async function (
|
|
75
|
+
this: unknown,
|
|
76
|
+
url: string,
|
|
77
|
+
data?: unknown,
|
|
78
|
+
config?: AxiosRequestConfig,
|
|
79
|
+
) {
|
|
80
|
+
recordedBaseUrls.push(config?.baseURL)
|
|
81
|
+
recordedCalls.push({ method: 'PUT', url, data, headers: config?.headers })
|
|
82
|
+
return { data: putResponse }
|
|
83
|
+
})
|
|
65
84
|
}
|
|
66
85
|
|
|
67
86
|
describe('usePluginConfig', () => {
|
|
@@ -70,13 +89,17 @@ describe('usePluginConfig', () => {
|
|
|
70
89
|
autoLoadOnMount = false
|
|
71
90
|
getResponse = { plugin_name: 'drp', config: {} }
|
|
72
91
|
patchResponse = { plugin_name: 'drp', config: {} }
|
|
92
|
+
putResponse = { plugin_name: 'drp', config: {} }
|
|
73
93
|
getError = null
|
|
74
94
|
patchError = null
|
|
75
95
|
recordedCalls = []
|
|
96
|
+
recordedBaseUrls = []
|
|
97
|
+
delete (window as unknown as { __MINT_PLATFORM__?: unknown }).__MINT_PLATFORM__
|
|
76
98
|
mockAxios()
|
|
77
99
|
})
|
|
78
100
|
|
|
79
101
|
afterEach(() => {
|
|
102
|
+
delete (window as unknown as { __MINT_PLATFORM__?: unknown }).__MINT_PLATFORM__
|
|
80
103
|
vi.restoreAllMocks()
|
|
81
104
|
})
|
|
82
105
|
|
|
@@ -106,12 +129,148 @@ describe('usePluginConfig', () => {
|
|
|
106
129
|
expect(recordedCalls[0]!.url).toBe('/plugins/my%2Fplugin/config')
|
|
107
130
|
})
|
|
108
131
|
|
|
109
|
-
it(
|
|
110
|
-
|
|
111
|
-
|
|
132
|
+
it.each([
|
|
133
|
+
'platform',
|
|
134
|
+
undefined,
|
|
135
|
+
] as const)(
|
|
136
|
+
'should use the platform config facade when settings_api is %s',
|
|
137
|
+
async (settingsApi) => {
|
|
138
|
+
;(window as unknown as { __MINT_PLATFORM__?: unknown }).__MINT_PLATFORM__ = {
|
|
139
|
+
isIntegrated: true,
|
|
140
|
+
theme: 'light',
|
|
141
|
+
platformApiUrl: '/api',
|
|
142
|
+
plugin: {
|
|
143
|
+
id: 'drp',
|
|
144
|
+
name: 'drp',
|
|
145
|
+
version: '1.0.0',
|
|
146
|
+
route_prefix: '/drp',
|
|
147
|
+
api_prefix: '/api/drp',
|
|
148
|
+
...(settingsApi ? { settings_api: settingsApi } : {}),
|
|
149
|
+
},
|
|
150
|
+
}
|
|
151
|
+
getResponse = {
|
|
152
|
+
plugin_name: 'drp',
|
|
153
|
+
config: { n: 1 },
|
|
154
|
+
revision: 'revision-1',
|
|
155
|
+
}
|
|
156
|
+
const hook = usePluginConfig('drp')
|
|
157
|
+
|
|
158
|
+
await hook.load()
|
|
159
|
+
|
|
160
|
+
expect(recordedCalls).toEqual([
|
|
161
|
+
{ method: 'GET', url: '/plugins/drp/config' },
|
|
162
|
+
])
|
|
163
|
+
},
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
it('should use the plugin settings router for an integrated plugin capability', async () => {
|
|
167
|
+
;(window as unknown as { __MINT_PLATFORM__?: unknown }).__MINT_PLATFORM__ = {
|
|
168
|
+
isIntegrated: true,
|
|
169
|
+
theme: 'light',
|
|
170
|
+
platformApiUrl: '/api',
|
|
171
|
+
plugin: {
|
|
172
|
+
id: 'drp',
|
|
173
|
+
name: 'drp',
|
|
174
|
+
version: '1.0.0',
|
|
175
|
+
route_prefix: '/drp',
|
|
176
|
+
api_prefix: '/api/drp',
|
|
177
|
+
settings_api: 'plugin',
|
|
178
|
+
},
|
|
179
|
+
}
|
|
180
|
+
getResponse = {
|
|
181
|
+
settings: { n: 1 },
|
|
182
|
+
mode: 'integrated',
|
|
183
|
+
revision: 'revision-1',
|
|
184
|
+
}
|
|
185
|
+
putResponse = {
|
|
186
|
+
settings: { n: 2 },
|
|
187
|
+
mode: 'integrated',
|
|
188
|
+
revision: 'revision-2',
|
|
189
|
+
}
|
|
112
190
|
const hook = usePluginConfig('drp')
|
|
113
191
|
await hook.load()
|
|
114
192
|
hook.config.value = { n: 2 }
|
|
193
|
+
|
|
194
|
+
const saved = await hook.save()
|
|
195
|
+
|
|
196
|
+
expect({
|
|
197
|
+
saved,
|
|
198
|
+
calls: recordedCalls,
|
|
199
|
+
baseUrls: recordedBaseUrls,
|
|
200
|
+
config: hook.config.value,
|
|
201
|
+
}).toEqual({
|
|
202
|
+
saved: true,
|
|
203
|
+
baseUrls: ['/api/drp', '/api/drp'],
|
|
204
|
+
calls: [
|
|
205
|
+
{ method: 'GET', url: '/settings' },
|
|
206
|
+
{
|
|
207
|
+
method: 'PUT',
|
|
208
|
+
url: '/settings',
|
|
209
|
+
data: { n: 2 },
|
|
210
|
+
headers: { 'If-Match': '"revision-1"' },
|
|
211
|
+
},
|
|
212
|
+
],
|
|
213
|
+
config: { n: 2 },
|
|
214
|
+
})
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
it('should use the platform facade for an explicit different plugin name', async () => {
|
|
218
|
+
;(window as unknown as { __MINT_PLATFORM__?: unknown }).__MINT_PLATFORM__ = {
|
|
219
|
+
isIntegrated: true,
|
|
220
|
+
theme: 'light',
|
|
221
|
+
platformApiUrl: '/api',
|
|
222
|
+
plugin: {
|
|
223
|
+
id: 'current-plugin',
|
|
224
|
+
name: 'current-plugin',
|
|
225
|
+
version: '1.0.0',
|
|
226
|
+
route_prefix: '/current-plugin',
|
|
227
|
+
api_prefix: '/api/current-plugin',
|
|
228
|
+
settings_api: 'plugin',
|
|
229
|
+
},
|
|
230
|
+
}
|
|
231
|
+
getResponse = {
|
|
232
|
+
plugin_name: 'other-plugin',
|
|
233
|
+
config: { n: 1 },
|
|
234
|
+
revision: 'revision-1',
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const hook = usePluginConfig('other-plugin')
|
|
238
|
+
await hook.load()
|
|
239
|
+
|
|
240
|
+
expect({
|
|
241
|
+
calls: recordedCalls,
|
|
242
|
+
config: hook.config.value,
|
|
243
|
+
}).toEqual({
|
|
244
|
+
calls: [
|
|
245
|
+
{ method: 'GET', url: '/plugins/other-plugin/config' },
|
|
246
|
+
],
|
|
247
|
+
config: { n: 1 },
|
|
248
|
+
})
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
it('should PATCH only dirty typed fields through a versioned platform facade', async () => {
|
|
252
|
+
getResponse = {
|
|
253
|
+
plugin_name: 'drp',
|
|
254
|
+
config: {
|
|
255
|
+
n: 1,
|
|
256
|
+
allowed_experiment_types: ['metabolomics'],
|
|
257
|
+
},
|
|
258
|
+
revision: 'revision-1',
|
|
259
|
+
}
|
|
260
|
+
patchResponse = {
|
|
261
|
+
plugin_name: 'drp',
|
|
262
|
+
config: {
|
|
263
|
+
n: 2,
|
|
264
|
+
allowed_experiment_types: ['metabolomics'],
|
|
265
|
+
},
|
|
266
|
+
revision: 'revision-2',
|
|
267
|
+
}
|
|
268
|
+
const hook = usePluginConfig('drp')
|
|
269
|
+
await hook.load()
|
|
270
|
+
hook.config.value = {
|
|
271
|
+
n: 2,
|
|
272
|
+
allowed_experiment_types: ['metabolomics'],
|
|
273
|
+
}
|
|
115
274
|
await nextTick()
|
|
116
275
|
expect(hook.isDirty.value).toBe(true)
|
|
117
276
|
|
|
@@ -120,11 +279,168 @@ describe('usePluginConfig', () => {
|
|
|
120
279
|
const patchCall = recordedCalls.find((c) => c.method === 'PATCH')!
|
|
121
280
|
expect(patchCall.url).toBe('/plugins/drp/config')
|
|
122
281
|
expect(patchCall.data).toEqual({ config: { n: 2 } })
|
|
123
|
-
expect(
|
|
282
|
+
expect(patchCall.headers).toBeUndefined()
|
|
283
|
+
expect(hook.config.value).toEqual({
|
|
284
|
+
n: 2,
|
|
285
|
+
allowed_experiment_types: ['metabolomics'],
|
|
286
|
+
})
|
|
124
287
|
expect(hook.isDirty.value).toBe(false)
|
|
125
288
|
expect(hook.lastSavedAt.value).toBeInstanceOf(Date)
|
|
126
289
|
})
|
|
127
290
|
|
|
291
|
+
it('should split changed platform-owned config from a versioned typed save', async () => {
|
|
292
|
+
getResponse = {
|
|
293
|
+
plugin_name: 'drp',
|
|
294
|
+
config: {
|
|
295
|
+
n: 1,
|
|
296
|
+
allowed_experiment_types: ['metabolomics'],
|
|
297
|
+
},
|
|
298
|
+
revision: 'revision-1',
|
|
299
|
+
}
|
|
300
|
+
patchResponse = {
|
|
301
|
+
plugin_name: 'drp',
|
|
302
|
+
config: {
|
|
303
|
+
n: 2,
|
|
304
|
+
allowed_experiment_types: ['proteomics'],
|
|
305
|
+
},
|
|
306
|
+
revision: 'untrusted-platform-revision',
|
|
307
|
+
}
|
|
308
|
+
const hook = usePluginConfig('drp')
|
|
309
|
+
await hook.load()
|
|
310
|
+
hook.config.value = {
|
|
311
|
+
n: 2,
|
|
312
|
+
allowed_experiment_types: ['proteomics'],
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const saved = await hook.save()
|
|
316
|
+
patchResponse = {
|
|
317
|
+
plugin_name: 'drp',
|
|
318
|
+
config: {
|
|
319
|
+
n: 3,
|
|
320
|
+
allowed_experiment_types: ['proteomics'],
|
|
321
|
+
},
|
|
322
|
+
revision: 'revision-3',
|
|
323
|
+
}
|
|
324
|
+
hook.config.value = {
|
|
325
|
+
n: 3,
|
|
326
|
+
allowed_experiment_types: ['proteomics'],
|
|
327
|
+
}
|
|
328
|
+
const savedAgain = await hook.save()
|
|
329
|
+
|
|
330
|
+
expect({
|
|
331
|
+
saved,
|
|
332
|
+
savedAgain,
|
|
333
|
+
calls: recordedCalls,
|
|
334
|
+
config: hook.config.value,
|
|
335
|
+
isDirty: hook.isDirty.value,
|
|
336
|
+
}).toEqual({
|
|
337
|
+
saved: true,
|
|
338
|
+
savedAgain: true,
|
|
339
|
+
calls: [
|
|
340
|
+
{ method: 'GET', url: '/plugins/drp/config' },
|
|
341
|
+
{
|
|
342
|
+
method: 'PATCH',
|
|
343
|
+
url: '/plugins/drp/config',
|
|
344
|
+
data: { config: { n: 2 } },
|
|
345
|
+
},
|
|
346
|
+
{
|
|
347
|
+
method: 'PATCH',
|
|
348
|
+
url: '/plugins/drp/config',
|
|
349
|
+
data: { config: { allowed_experiment_types: ['proteomics'] } },
|
|
350
|
+
},
|
|
351
|
+
{
|
|
352
|
+
method: 'PATCH',
|
|
353
|
+
url: '/plugins/drp/config',
|
|
354
|
+
data: { config: { n: 3 } },
|
|
355
|
+
},
|
|
356
|
+
],
|
|
357
|
+
config: {
|
|
358
|
+
n: 3,
|
|
359
|
+
allowed_experiment_types: ['proteomics'],
|
|
360
|
+
},
|
|
361
|
+
isDirty: false,
|
|
362
|
+
})
|
|
363
|
+
})
|
|
364
|
+
|
|
365
|
+
it('should avoid a typed PUT when only the platform-owned config changes', async () => {
|
|
366
|
+
getResponse = {
|
|
367
|
+
plugin_name: 'drp',
|
|
368
|
+
config: {
|
|
369
|
+
n: 1,
|
|
370
|
+
allowed_experiment_types: ['metabolomics'],
|
|
371
|
+
},
|
|
372
|
+
revision: 'revision-1',
|
|
373
|
+
}
|
|
374
|
+
patchResponse = {
|
|
375
|
+
plugin_name: 'drp',
|
|
376
|
+
config: {
|
|
377
|
+
n: 1,
|
|
378
|
+
allowed_experiment_types: ['proteomics'],
|
|
379
|
+
},
|
|
380
|
+
}
|
|
381
|
+
const hook = usePluginConfig('drp')
|
|
382
|
+
await hook.load()
|
|
383
|
+
hook.config.value = {
|
|
384
|
+
n: 1,
|
|
385
|
+
allowed_experiment_types: ['proteomics'],
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
const saved = await hook.save()
|
|
389
|
+
|
|
390
|
+
expect({
|
|
391
|
+
saved,
|
|
392
|
+
calls: recordedCalls,
|
|
393
|
+
config: hook.config.value,
|
|
394
|
+
}).toEqual({
|
|
395
|
+
saved: true,
|
|
396
|
+
calls: [
|
|
397
|
+
{ method: 'GET', url: '/plugins/drp/config' },
|
|
398
|
+
{
|
|
399
|
+
method: 'PATCH',
|
|
400
|
+
url: '/plugins/drp/config',
|
|
401
|
+
data: { config: { allowed_experiment_types: ['proteomics'] } },
|
|
402
|
+
},
|
|
403
|
+
],
|
|
404
|
+
config: {
|
|
405
|
+
n: 1,
|
|
406
|
+
allowed_experiment_types: ['proteomics'],
|
|
407
|
+
},
|
|
408
|
+
})
|
|
409
|
+
})
|
|
410
|
+
|
|
411
|
+
it('should detect nested config edits and restore the saved JSON snapshot', async () => {
|
|
412
|
+
getResponse = {
|
|
413
|
+
plugin_name: 'drp',
|
|
414
|
+
config: {
|
|
415
|
+
rules: {
|
|
416
|
+
thresholds: [1, 2],
|
|
417
|
+
},
|
|
418
|
+
},
|
|
419
|
+
}
|
|
420
|
+
const hook = usePluginConfig('drp')
|
|
421
|
+
await hook.load()
|
|
422
|
+
const rules = hook.config.value.rules as { thresholds: number[] }
|
|
423
|
+
rules.thresholds[0] = 9
|
|
424
|
+
await nextTick()
|
|
425
|
+
|
|
426
|
+
const dirtyAfterMutation = hook.isDirty.value
|
|
427
|
+
hook.reset()
|
|
428
|
+
|
|
429
|
+
expect({
|
|
430
|
+
dirtyAfterMutation,
|
|
431
|
+
config: hook.config.value,
|
|
432
|
+
isDirty: hook.isDirty.value,
|
|
433
|
+
}).toEqual({
|
|
434
|
+
dirtyAfterMutation: true,
|
|
435
|
+
config: {
|
|
436
|
+
rules: {
|
|
437
|
+
thresholds: [1, 2],
|
|
438
|
+
},
|
|
439
|
+
},
|
|
440
|
+
isDirty: false,
|
|
441
|
+
})
|
|
442
|
+
})
|
|
443
|
+
|
|
128
444
|
it('isDirty reflects edits vs last saved snapshot', async () => {
|
|
129
445
|
getResponse = { plugin_name: 'drp', config: { k: 'v' } }
|
|
130
446
|
const hook = usePluginConfig('drp')
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import SectionCard from './SectionCard.vue'
|
|
3
|
+
import type { SectionCardAccent, SectionCardDivider } from '../types'
|
|
4
|
+
|
|
5
|
+
const flaskIcon =
|
|
6
|
+
'M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4'
|
|
7
|
+
const folderIcon = 'M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z'
|
|
8
|
+
|
|
9
|
+
const experiments = [
|
|
10
|
+
{ code: 'MM-2026-014', name: '13C glucose tracing — HeLa, 24h', updated: '2h ago' },
|
|
11
|
+
{ code: 'MM-2026-013', name: 'Hypoxia timecourse — passage 6', updated: '5h ago' },
|
|
12
|
+
{ code: 'MM-2026-011', name: 'Glutamine dropout, triplicate', updated: 'yesterday' },
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
function initPlayground() {
|
|
16
|
+
return {
|
|
17
|
+
title: 'Experiments',
|
|
18
|
+
count: '14 ongoing · 42 total',
|
|
19
|
+
accent: 'experiment' as SectionCardAccent,
|
|
20
|
+
uppercase: true,
|
|
21
|
+
divider: 'strong' as SectionCardDivider,
|
|
22
|
+
showIcon: true,
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
</script>
|
|
26
|
+
|
|
27
|
+
<template>
|
|
28
|
+
<Story title="Layout/SectionCard">
|
|
29
|
+
<Variant title="Playground" :init-state="initPlayground">
|
|
30
|
+
<template #default="{ state }">
|
|
31
|
+
<div style="padding: 2rem; max-width: 600px;">
|
|
32
|
+
<SectionCard
|
|
33
|
+
:title="state.title"
|
|
34
|
+
:count="state.count || undefined"
|
|
35
|
+
:icon="state.showIcon ? flaskIcon : undefined"
|
|
36
|
+
:accent="state.accent"
|
|
37
|
+
:uppercase="state.uppercase"
|
|
38
|
+
:divider="state.divider"
|
|
39
|
+
>
|
|
40
|
+
<div class="mint-section-card__rows">
|
|
41
|
+
<a
|
|
42
|
+
v-for="e in experiments"
|
|
43
|
+
:key="e.code"
|
|
44
|
+
href="#"
|
|
45
|
+
class="mint-section-card__row"
|
|
46
|
+
>
|
|
47
|
+
<span style="flex: 1; min-width: 0">
|
|
48
|
+
<span style="display: block; font-size: 0.8125rem; font-weight: 500; color: var(--text-primary)">{{ e.name }}</span>
|
|
49
|
+
<span style="display: block; font-family: 'Fira Code', monospace; font-size: 0.65625rem; color: var(--text-muted)">{{ e.code }}</span>
|
|
50
|
+
</span>
|
|
51
|
+
<span style="flex-shrink: 0; font-size: 0.71875rem; color: var(--text-muted)">{{ e.updated }}</span>
|
|
52
|
+
</a>
|
|
53
|
+
</div>
|
|
54
|
+
</SectionCard>
|
|
55
|
+
</div>
|
|
56
|
+
</template>
|
|
57
|
+
|
|
58
|
+
<template #controls="{ state }">
|
|
59
|
+
<HstText v-model="state.title" title="Title" />
|
|
60
|
+
<HstText v-model="state.count" title="Count" />
|
|
61
|
+
<HstCheckbox v-model="state.showIcon" title="Show icon chip" />
|
|
62
|
+
<HstCheckbox v-model="state.uppercase" title="Uppercase title" />
|
|
63
|
+
<HstSelect
|
|
64
|
+
v-model="state.accent"
|
|
65
|
+
title="Accent"
|
|
66
|
+
:options="{
|
|
67
|
+
primary: 'primary',
|
|
68
|
+
experiment: 'experiment',
|
|
69
|
+
project: 'project',
|
|
70
|
+
success: 'success',
|
|
71
|
+
error: 'error',
|
|
72
|
+
warning: 'warning',
|
|
73
|
+
info: 'info',
|
|
74
|
+
}"
|
|
75
|
+
/>
|
|
76
|
+
<HstSelect
|
|
77
|
+
v-model="state.divider"
|
|
78
|
+
title="Divider"
|
|
79
|
+
:options="{ strong: 'strong', light: 'light' }"
|
|
80
|
+
/>
|
|
81
|
+
</template>
|
|
82
|
+
</Variant>
|
|
83
|
+
|
|
84
|
+
<Variant title="Home category card">
|
|
85
|
+
<div style="padding: 2rem; max-width: 600px;">
|
|
86
|
+
<SectionCard
|
|
87
|
+
title="Projects"
|
|
88
|
+
count="6 active · 9 total"
|
|
89
|
+
:icon="folderIcon"
|
|
90
|
+
accent="project"
|
|
91
|
+
uppercase
|
|
92
|
+
>
|
|
93
|
+
<template #actions>
|
|
94
|
+
<a href="#" style="font-size: 0.75rem; font-weight: 500; color: var(--color-primary-hover)">All →</a>
|
|
95
|
+
</template>
|
|
96
|
+
<div class="mint-section-card__rows">
|
|
97
|
+
<a href="#" class="mint-section-card__row">
|
|
98
|
+
<span style="flex: 1; font-size: 0.8125rem; font-weight: 500; color: var(--text-primary)">Hypoxia metabolic rewiring</span>
|
|
99
|
+
<span style="font-size: 0.71875rem; color: var(--text-muted)">12 experiments</span>
|
|
100
|
+
</a>
|
|
101
|
+
<a href="#" class="mint-section-card__row">
|
|
102
|
+
<span style="flex: 1; font-size: 0.8125rem; font-weight: 500; color: var(--text-primary)">Serine one-carbon flux</span>
|
|
103
|
+
<span style="font-size: 0.71875rem; color: var(--text-muted)">7 experiments</span>
|
|
104
|
+
</a>
|
|
105
|
+
</div>
|
|
106
|
+
</SectionCard>
|
|
107
|
+
</div>
|
|
108
|
+
</Variant>
|
|
109
|
+
|
|
110
|
+
<Variant title="Detail card with footer">
|
|
111
|
+
<div style="padding: 2rem; max-width: 600px;">
|
|
112
|
+
<SectionCard title="Analysis artifacts" :count="4" divider="light">
|
|
113
|
+
<div class="mint-section-card__rows">
|
|
114
|
+
<div class="mint-section-card__row">
|
|
115
|
+
<span style="flex: 1; font-size: 0.8125rem; color: var(--text-primary)">isotopologue_matrix.csv</span>
|
|
116
|
+
<span style="font-size: 0.71875rem; color: var(--text-secondary)">DRP</span>
|
|
117
|
+
</div>
|
|
118
|
+
<div class="mint-section-card__row">
|
|
119
|
+
<span style="flex: 1; font-size: 0.8125rem; color: var(--text-primary)">natural_abundance_corrected.parquet</span>
|
|
120
|
+
<span style="font-size: 0.71875rem; color: var(--text-secondary)">DRP</span>
|
|
121
|
+
</div>
|
|
122
|
+
</div>
|
|
123
|
+
<template #footer>
|
|
124
|
+
<span style="font-size: 0.71875rem; color: var(--text-muted)">Showing 1–2 of 4</span>
|
|
125
|
+
<span style="font-size: 0.71875rem; color: var(--text-secondary)">1 / 2</span>
|
|
126
|
+
</template>
|
|
127
|
+
</SectionCard>
|
|
128
|
+
</div>
|
|
129
|
+
</Variant>
|
|
130
|
+
|
|
131
|
+
<Variant title="Fill with pinned toolbar">
|
|
132
|
+
<div style="padding: 2rem; height: 420px; display: flex;">
|
|
133
|
+
<SectionCard title="All experiments" :count="42" divider="light" fill>
|
|
134
|
+
<template #toolbar>
|
|
135
|
+
<div style="display: flex; gap: 0.5rem; padding: 0.6875rem 1rem; border-bottom: 1px solid var(--border-color)">
|
|
136
|
+
<span style="font-size: 0.78125rem; color: var(--text-secondary)">Toolbar stays put while the rows scroll</span>
|
|
137
|
+
</div>
|
|
138
|
+
</template>
|
|
139
|
+
<div class="mint-section-card__rows">
|
|
140
|
+
<div v-for="n in 20" :key="n" class="mint-section-card__row">
|
|
141
|
+
<span style="flex: 1; font-size: 0.8125rem; color: var(--text-primary)">Row {{ n }}</span>
|
|
142
|
+
<span style="font-family: 'Fira Code', monospace; font-size: 0.71875rem; color: var(--text-muted)">MM-2026-{{ String(n).padStart(3, '0') }}</span>
|
|
143
|
+
</div>
|
|
144
|
+
</div>
|
|
145
|
+
<template #footer>
|
|
146
|
+
<span style="font-size: 0.71875rem; color: var(--text-muted)">Showing 1–20 of 42</span>
|
|
147
|
+
<span style="font-size: 0.71875rem; color: var(--text-secondary)">1 / 3</span>
|
|
148
|
+
</template>
|
|
149
|
+
</SectionCard>
|
|
150
|
+
</div>
|
|
151
|
+
</Variant>
|
|
152
|
+
|
|
153
|
+
<Variant title="No icon, plain title">
|
|
154
|
+
<div style="padding: 2rem; max-width: 600px;">
|
|
155
|
+
<SectionCard title="Design data" count="96 samples · 4 timepoints" divider="light">
|
|
156
|
+
<div style="padding: 1rem; font-size: 0.8125rem; color: var(--text-secondary)">
|
|
157
|
+
Card body content sits directly on the card surface — no nested card.
|
|
158
|
+
</div>
|
|
159
|
+
</SectionCard>
|
|
160
|
+
</div>
|
|
161
|
+
</Variant>
|
|
162
|
+
</Story>
|
|
163
|
+
</template>
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/** One-card page region from the MINT 1.1 card system: 12px radius, the
|
|
3
|
+
* single --card-shadow token, a header strip (icon chip, title, mono count,
|
|
4
|
+
* actions), and hairline-separated content — no card-in-card. */
|
|
5
|
+
import { computed } from 'vue'
|
|
6
|
+
import type { SectionCardAccent, SectionCardDivider } from '../types'
|
|
7
|
+
|
|
8
|
+
interface Props {
|
|
9
|
+
title: string
|
|
10
|
+
/** Monospace meta shown next to the title, e.g. "14 ongoing · 42 total". */
|
|
11
|
+
count?: string | number
|
|
12
|
+
/** SVG path(s) for the 22px icon chip; omit to render no chip. */
|
|
13
|
+
icon?: string | string[]
|
|
14
|
+
/** Tint of the icon chip. */
|
|
15
|
+
accent?: SectionCardAccent
|
|
16
|
+
/** Uppercase category heading (home cards) vs plain heading (detail cards). */
|
|
17
|
+
uppercase?: boolean
|
|
18
|
+
/** Header hairline: 'strong' = --border-color, 'light' = --border-light. */
|
|
19
|
+
divider?: SectionCardDivider
|
|
20
|
+
/**
|
|
21
|
+
* Stretch to fill the height its parent offers, scrolling the body and
|
|
22
|
+
* pinning the header and footer. Requires a parent that gives the card a
|
|
23
|
+
* definite height (a flex or grid track).
|
|
24
|
+
*/
|
|
25
|
+
fill?: boolean
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const props = withDefaults(defineProps<Props>(), {
|
|
29
|
+
count: undefined,
|
|
30
|
+
icon: undefined,
|
|
31
|
+
accent: 'primary',
|
|
32
|
+
uppercase: false,
|
|
33
|
+
divider: 'strong',
|
|
34
|
+
fill: false,
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
const iconPaths = computed(() =>
|
|
38
|
+
props.icon === undefined ? [] : Array.isArray(props.icon) ? props.icon : [props.icon],
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
const headerClasses = computed(() => [
|
|
42
|
+
'mint-section-card__header',
|
|
43
|
+
props.divider === 'light' ? 'mint-section-card__header--divider-light' : '',
|
|
44
|
+
])
|
|
45
|
+
|
|
46
|
+
const titleClasses = computed(() => [
|
|
47
|
+
'mint-section-card__title',
|
|
48
|
+
props.uppercase ? 'mint-section-card__title--uppercase' : '',
|
|
49
|
+
])
|
|
50
|
+
|
|
51
|
+
const cardClasses = computed(() => [
|
|
52
|
+
'mint-section-card',
|
|
53
|
+
props.fill ? 'mint-section-card--fill' : '',
|
|
54
|
+
])
|
|
55
|
+
</script>
|
|
56
|
+
|
|
57
|
+
<template>
|
|
58
|
+
<section :class="cardClasses">
|
|
59
|
+
<div :class="headerClasses">
|
|
60
|
+
<span
|
|
61
|
+
v-if="iconPaths.length"
|
|
62
|
+
:class="['mint-section-card__icon-chip', `mint-section-card__icon-chip--${accent}`]"
|
|
63
|
+
>
|
|
64
|
+
<svg
|
|
65
|
+
class="mint-section-card__icon"
|
|
66
|
+
viewBox="0 0 24 24"
|
|
67
|
+
fill="none"
|
|
68
|
+
stroke="currentColor"
|
|
69
|
+
stroke-width="2"
|
|
70
|
+
stroke-linecap="round"
|
|
71
|
+
stroke-linejoin="round"
|
|
72
|
+
aria-hidden="true"
|
|
73
|
+
>
|
|
74
|
+
<path v-for="(d, i) in iconPaths" :key="i" :d="d" />
|
|
75
|
+
</svg>
|
|
76
|
+
</span>
|
|
77
|
+
<h2 :class="titleClasses">{{ title }}</h2>
|
|
78
|
+
<span v-if="count !== undefined" class="mint-section-card__count">{{ count }}</span>
|
|
79
|
+
<div class="mint-section-card__spacer"></div>
|
|
80
|
+
<slot name="actions" />
|
|
81
|
+
</div>
|
|
82
|
+
<!-- Controls belong to the card's chrome, so they sit outside the body and
|
|
83
|
+
stay put while the body scrolls. -->
|
|
84
|
+
<div v-if="$slots.toolbar" class="mint-section-card__toolbar">
|
|
85
|
+
<slot name="toolbar" />
|
|
86
|
+
</div>
|
|
87
|
+
<div class="mint-section-card__body">
|
|
88
|
+
<slot />
|
|
89
|
+
</div>
|
|
90
|
+
<div v-if="$slots.footer" class="mint-section-card__footer">
|
|
91
|
+
<slot name="footer" />
|
|
92
|
+
</div>
|
|
93
|
+
</section>
|
|
94
|
+
</template>
|
|
95
|
+
|
|
96
|
+
<style>
|
|
97
|
+
@import '../styles/components/section-card.css';
|
|
98
|
+
</style>
|
package/src/components/index.ts
CHANGED
|
@@ -126,6 +126,7 @@ export { default as DateTimePicker } from './DateTimePicker.vue'
|
|
|
126
126
|
export { default as TimeRangeInput } from './TimeRangeInput.vue'
|
|
127
127
|
export { default as ScheduleCalendar } from './ScheduleCalendar.vue'
|
|
128
128
|
export { default as ResourceCard } from './ResourceCard.vue'
|
|
129
|
+
export { default as SectionCard } from './SectionCard.vue'
|
|
129
130
|
|
|
130
131
|
// Experiment / analysis components
|
|
131
132
|
export { default as ExperimentSelectorModal } from './ExperimentSelectorModal.vue'
|