@morscherlab/mint-sdk 1.2.0-beta.15 → 1.2.0-beta.17
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +30 -0
- package/dist/{ExperimentPopover-BIZKoRTT.js → ExperimentPopover-CJdNlCZb.js} +3 -3
- package/dist/{ExperimentPopover-BIZKoRTT.js.map → ExperimentPopover-CJdNlCZb.js.map} +1 -1
- package/dist/{ExperimentSelectorModal-BeEOomw_.js → ExperimentSelectorModal-CZ1f_K9I.js} +5 -5
- package/dist/{ExperimentSelectorModal-BeEOomw_.js.map → ExperimentSelectorModal-CZ1f_K9I.js.map} +1 -1
- package/dist/{SettingsModal-Dphh3RrI.js → SettingsModal-DQUcpT4T.js} +5 -5
- package/dist/{SettingsModal-Dphh3RrI.js.map → SettingsModal-DQUcpT4T.js.map} +1 -1
- package/dist/{Skeleton-DxtwJ5gt.js → Skeleton-JaDtC8-9.js} +3 -3
- package/dist/{Skeleton-DxtwJ5gt.js.map → Skeleton-JaDtC8-9.js.map} +1 -1
- package/dist/__tests__/apiErrors.test.d.ts +1 -0
- package/dist/__tests__/components/PlotlyChart.test.d.ts +1 -0
- package/dist/apiErrors.d.ts +26 -0
- package/dist/components/BaseInput.vue.d.ts +1 -1
- package/dist/components/BaseSelect.vue.d.ts +1 -1
- package/dist/components/BaseTextarea.vue.d.ts +1 -1
- package/dist/components/BioTemplateExperimentWorkspaceView.vue.d.ts +2 -2
- package/dist/components/BioTemplatePackWorkspaceView.vue.d.ts +2 -2
- package/dist/components/ConcentrationInput.vue.d.ts +1 -1
- package/dist/components/DatePicker.vue.d.ts +1 -1
- package/dist/components/DateTimePicker.vue.d.ts +1 -1
- package/dist/components/FileBrowserModal.vue.d.ts +1 -1
- package/dist/components/FilePicker.vue.d.ts +3 -3
- package/dist/components/FileUploader.vue.d.ts +5 -2
- package/dist/components/FormulaInput.vue.d.ts +1 -1
- package/dist/components/GeneratedPlotlyResult.vue.d.ts +1 -3
- package/dist/components/MobileSupportGate.vue.d.ts +1 -1
- package/dist/components/MoleculeInput.vue.d.ts +1 -1
- package/dist/components/NumberInput.vue.d.ts +1 -1
- package/dist/components/PlotlyChart.vue.d.ts +59 -0
- package/dist/components/ResourceCard.vue.d.ts +1 -1
- package/dist/components/SecretInput.vue.d.ts +1 -1
- package/dist/components/SequenceInput.vue.d.ts +1 -1
- package/dist/components/StatusIndicator.vue.d.ts +1 -1
- package/dist/components/TagsInput.vue.d.ts +1 -1
- package/dist/components/TimePicker.vue.d.ts +1 -1
- package/dist/components/TimeRangeInput.vue.d.ts +1 -1
- package/dist/components/UnitInput.vue.d.ts +1 -1
- package/dist/components/WellPlate.vue.d.ts +1 -1
- package/dist/components/index.d.ts +1 -0
- package/dist/components/index.js +8 -8
- package/dist/{components-6IIxCSI8.js → components-DT7ywo_B.js} +581 -367
- package/dist/components-DT7ywo_B.js.map +1 -0
- package/dist/composables/filePicker/localFiles.d.ts +1 -1
- package/dist/composables/filePicker/usePickerNavigation.d.ts +4 -3
- package/dist/composables/index.js +9 -9
- package/dist/composables/useApi.d.ts +2 -0
- package/dist/composables/useFilePicker.d.ts +5 -6
- package/dist/{composables-hY2Q2xPI.js → composables-DmF_8iyX.js} +13 -10
- package/dist/{composables-hY2Q2xPI.js.map → composables-DmF_8iyX.js.map} +1 -1
- package/dist/{errors-_W0-LbA6.js → errors-DZDb1cIb.js} +66 -5
- package/dist/errors-DZDb1cIb.js.map +1 -0
- package/dist/{experiment-Cwl9AYxw.js → experiment-CHm_TwJy.js} +2 -2
- package/dist/{experiment-Cwl9AYxw.js.map → experiment-CHm_TwJy.js.map} +1 -1
- package/dist/{experiment-utils-DV3DrvE_.js → experiment-utils-CV2Wza3z.js} +1 -1
- package/dist/{experiment-utils-DV3DrvE_.js.map → experiment-utils-CV2Wza3z.js.map} +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +15 -15
- package/dist/install.js +2 -2
- package/dist/stores/index.js +2 -2
- package/dist/styles.css +29 -18
- package/dist/templates/index.js +2 -2
- package/dist/{templates-Qq113pyZ.js → templates-CXARYr6_.js} +2 -2
- package/dist/{templates-Qq113pyZ.js.map → templates-CXARYr6_.js.map} +1 -1
- package/dist/types/filePicker.d.ts +9 -1
- package/dist/{useControlSchema-aMZlb4Sc.js → useControlSchema-BAzmP1_y.js} +42 -37
- package/dist/useControlSchema-BAzmP1_y.js.map +1 -0
- package/dist/{useDropdownState-DitCtrTT.js → useDropdownState-C7hRiVQf.js} +2 -2
- package/dist/{useDropdownState-DitCtrTT.js.map → useDropdownState-C7hRiVQf.js.map} +1 -1
- package/dist/{useExperimentSelector-I8HC8hUu.js → useExperimentSelector-D_vobhnW.js} +4 -4
- package/dist/{useExperimentSelector-I8HC8hUu.js.map → useExperimentSelector-D_vobhnW.js.map} +1 -1
- package/dist/{useFormBuilder-TCxNnqeb.js → useFormBuilder-DEHejpiL.js} +2 -2
- package/dist/{useFormBuilder-TCxNnqeb.js.map → useFormBuilder-DEHejpiL.js.map} +1 -1
- package/dist/{validation-nBbSn1TI.js → validation-OuEr0iAS.js} +9 -8
- package/dist/{validation-nBbSn1TI.js.map → validation-OuEr0iAS.js.map} +1 -1
- package/package.json +1 -1
- package/src/__tests__/apiErrors.test.ts +81 -0
- package/src/__tests__/components/FilePicker.state.test.ts +128 -1
- package/src/__tests__/components/FilePicker.test.ts +160 -1
- package/src/__tests__/components/GeneratedPlotlyResult.test.ts +52 -5
- package/src/__tests__/components/PlotlyChart.test.ts +305 -0
- package/src/__tests__/components/SegmentedControl.test.ts +19 -0
- package/src/__tests__/composables/useApi.test.ts +82 -9
- package/src/__tests__/composables/useGeneratedAnalysis.test.ts +7 -2
- package/src/__tests__/composables/usePluginClient.test.ts +3 -0
- package/src/apiErrors.ts +85 -0
- package/src/components/FilePicker.story.vue +44 -0
- package/src/components/FilePicker.vue +23 -40
- package/src/components/FileUploader.vue +32 -14
- package/src/components/GeneratedPlotlyResult.vue +34 -69
- package/src/components/GeneratedResultRenderer.vue +0 -5
- package/src/components/PlotlyChart.story.vue +61 -0
- package/src/components/PlotlyChart.vue +233 -0
- package/src/components/SegmentedControl.vue +2 -2
- package/src/components/index.ts +1 -0
- package/src/composables/filePicker/localFiles.ts +6 -3
- package/src/composables/filePicker/usePickerNavigation.ts +29 -8
- package/src/composables/filePicker/usePickerSelection.ts +20 -5
- package/src/composables/filePicker/validation.ts +1 -0
- package/src/composables/useApi.ts +33 -4
- package/src/composables/useFilePicker.ts +41 -22
- package/src/composables/useGeneratedAnalysis.ts +10 -17
- package/src/composables/usePluginClient.ts +3 -0
- package/src/index.ts +3 -0
- package/src/styles/components/file-picker.css +6 -5
- package/src/types/filePicker.ts +9 -2
- package/dist/components-6IIxCSI8.js.map +0 -1
- package/dist/errors-_W0-LbA6.js.map +0 -1
- package/dist/useControlSchema-aMZlb4Sc.js.map +0 -1
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* Render native Plotly data with MINT loading, empty, theme, and error states.
|
|
4
|
+
*
|
|
5
|
+
* Plotly is loaded only when the component is mounted, so importing the SDK does
|
|
6
|
+
* not add Plotly to the application's initial bundle.
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* ```vue
|
|
10
|
+
* <PlotlyChart
|
|
11
|
+
* title="QC overview"
|
|
12
|
+
* :data="traces"
|
|
13
|
+
* :layout="{ xaxis: { title: { text: 'Sample' } } }"
|
|
14
|
+
* :config="{ toImageButtonOptions: { filename: 'qc-overview' } }"
|
|
15
|
+
* />
|
|
16
|
+
* ```
|
|
17
|
+
*/
|
|
18
|
+
import type * as PlotlyType from 'plotly.js'
|
|
19
|
+
import { nextTick, onBeforeUnmount, onMounted, ref, toRaw, watch } from 'vue'
|
|
20
|
+
|
|
21
|
+
import ChartContainer from './ChartContainer.vue'
|
|
22
|
+
|
|
23
|
+
interface Props {
|
|
24
|
+
data: PlotlyType.Data[]
|
|
25
|
+
layout?: Partial<PlotlyType.Layout>
|
|
26
|
+
config?: Partial<PlotlyType.Config>
|
|
27
|
+
title?: string
|
|
28
|
+
description?: string
|
|
29
|
+
loading?: boolean
|
|
30
|
+
empty?: boolean
|
|
31
|
+
emptyMessage?: string
|
|
32
|
+
ariaLabel?: string
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const props = withDefaults(defineProps<Props>(), {
|
|
36
|
+
layout: () => ({}),
|
|
37
|
+
config: () => ({}),
|
|
38
|
+
loading: false,
|
|
39
|
+
empty: false,
|
|
40
|
+
emptyMessage: 'No data available',
|
|
41
|
+
ariaLabel: 'Interactive Plotly chart',
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
const chartElement = ref<HTMLDivElement | null>(null)
|
|
45
|
+
const failed = ref(false)
|
|
46
|
+
let plotly: typeof PlotlyType | null = null
|
|
47
|
+
let resizeObserver: ResizeObserver | null = null
|
|
48
|
+
let themeObserver: MutationObserver | null = null
|
|
49
|
+
let renderedElement: HTMLDivElement | null = null
|
|
50
|
+
let rendering = false
|
|
51
|
+
let disposed = false
|
|
52
|
+
let revision = 0
|
|
53
|
+
|
|
54
|
+
async function loadPlotly(): Promise<typeof PlotlyType> {
|
|
55
|
+
const module = await import('plotly.js-dist-min')
|
|
56
|
+
return ('default' in module ? module.default : module) as unknown as typeof PlotlyType
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function cssValue(name: string, fallback: string): string {
|
|
60
|
+
const value = getComputedStyle(chartElement.value ?? document.documentElement).getPropertyValue(name).trim()
|
|
61
|
+
return value || getComputedStyle(document.documentElement).getPropertyValue(name).trim() || fallback
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Unwrap nested Vue proxies while copying Plotly's mutable input tree. Keep
|
|
65
|
+
// native dates/typed arrays and callbacks instead of round-tripping through JSON.
|
|
66
|
+
function clonePlotlyInput<T>(value: T): T {
|
|
67
|
+
const raw = toRaw(value)
|
|
68
|
+
if (Array.isArray(raw)) return raw.map(clonePlotlyInput) as T
|
|
69
|
+
if (raw !== null && typeof raw === 'object') {
|
|
70
|
+
if (Object.getPrototypeOf(raw) === Object.prototype) {
|
|
71
|
+
return Object.fromEntries(Object.entries(raw).map(([key, item]) => [key, clonePlotlyInput(item)])) as T
|
|
72
|
+
}
|
|
73
|
+
return structuredClone(raw)
|
|
74
|
+
}
|
|
75
|
+
return raw
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function themedLayout(): Partial<PlotlyType.Layout> {
|
|
79
|
+
const textColor = cssValue('--text-primary', '#1E293B')
|
|
80
|
+
const gridColor = cssValue('--border-color', '#E5E7EB')
|
|
81
|
+
const layout = clonePlotlyInput(props.layout)
|
|
82
|
+
return {
|
|
83
|
+
autosize: true,
|
|
84
|
+
paper_bgcolor: 'rgba(0,0,0,0)',
|
|
85
|
+
plot_bgcolor: 'rgba(0,0,0,0)',
|
|
86
|
+
...layout,
|
|
87
|
+
font: { color: textColor, family: cssValue('--font-sans', 'Arial, sans-serif'), ...layout.font },
|
|
88
|
+
xaxis: { gridcolor: gridColor, ...layout.xaxis },
|
|
89
|
+
yaxis: { gridcolor: gridColor, ...layout.yaxis },
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function purgeChart(): void {
|
|
94
|
+
resizeObserver?.disconnect()
|
|
95
|
+
if (renderedElement) plotly?.purge(renderedElement)
|
|
96
|
+
renderedElement = null
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function renderChart(): Promise<void> {
|
|
100
|
+
revision++
|
|
101
|
+
if (rendering || disposed || props.loading || props.empty) return
|
|
102
|
+
rendering = true
|
|
103
|
+
try {
|
|
104
|
+
while (!disposed && !props.loading && !props.empty) {
|
|
105
|
+
const currentRevision = revision
|
|
106
|
+
failed.value = false
|
|
107
|
+
await nextTick()
|
|
108
|
+
if (disposed || props.loading || props.empty) break
|
|
109
|
+
try {
|
|
110
|
+
plotly ??= await loadPlotly()
|
|
111
|
+
if (disposed || props.loading || props.empty) break
|
|
112
|
+
if (currentRevision !== revision) continue
|
|
113
|
+
const element = chartElement.value
|
|
114
|
+
if (!element) break
|
|
115
|
+
renderedElement = element
|
|
116
|
+
// Plotly mutates traces and nested layout fields; keep caller-owned values intact.
|
|
117
|
+
await plotly.react(
|
|
118
|
+
element,
|
|
119
|
+
clonePlotlyInput(props.data),
|
|
120
|
+
themedLayout(),
|
|
121
|
+
{ responsive: true, displaylogo: false, ...props.config },
|
|
122
|
+
)
|
|
123
|
+
if (disposed || props.loading || props.empty || element !== chartElement.value) {
|
|
124
|
+
purgeChart()
|
|
125
|
+
} else {
|
|
126
|
+
resizeObserver?.observe(element)
|
|
127
|
+
}
|
|
128
|
+
} catch {
|
|
129
|
+
purgeChart()
|
|
130
|
+
if (!disposed && currentRevision === revision) failed.value = true
|
|
131
|
+
}
|
|
132
|
+
if (currentRevision === revision) break
|
|
133
|
+
}
|
|
134
|
+
} finally {
|
|
135
|
+
rendering = false
|
|
136
|
+
if (disposed || props.loading || props.empty) purgeChart()
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
onMounted(() => {
|
|
141
|
+
if (typeof ResizeObserver !== 'undefined') {
|
|
142
|
+
resizeObserver = new ResizeObserver(() => {
|
|
143
|
+
if (rendering || !plotly || !renderedElement) return
|
|
144
|
+
// A tab can become hidden or unmount before Plotly's async resize finishes.
|
|
145
|
+
void Promise.resolve(plotly.Plots.resize(renderedElement)).catch(() => {})
|
|
146
|
+
})
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (typeof MutationObserver !== 'undefined') {
|
|
150
|
+
themeObserver = new MutationObserver(() => void renderChart())
|
|
151
|
+
themeObserver.observe(document.documentElement, {
|
|
152
|
+
attributes: true,
|
|
153
|
+
attributeFilter: ['class', 'style'],
|
|
154
|
+
})
|
|
155
|
+
}
|
|
156
|
+
void renderChart()
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
watch(
|
|
160
|
+
[() => props.data, () => props.layout, () => props.config, () => props.loading, () => props.empty],
|
|
161
|
+
() => {
|
|
162
|
+
if (props.loading || props.empty) {
|
|
163
|
+
resizeObserver?.disconnect()
|
|
164
|
+
if (!rendering) purgeChart()
|
|
165
|
+
}
|
|
166
|
+
void renderChart()
|
|
167
|
+
},
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
onBeforeUnmount(() => {
|
|
171
|
+
disposed = true
|
|
172
|
+
resizeObserver?.disconnect()
|
|
173
|
+
themeObserver?.disconnect()
|
|
174
|
+
// An in-flight react must finish before its resources can be purged completely.
|
|
175
|
+
if (!rendering) purgeChart()
|
|
176
|
+
})
|
|
177
|
+
</script>
|
|
178
|
+
|
|
179
|
+
<template>
|
|
180
|
+
<ChartContainer
|
|
181
|
+
:title="title"
|
|
182
|
+
:description="description"
|
|
183
|
+
:loading="loading"
|
|
184
|
+
:empty="empty"
|
|
185
|
+
:empty-message="emptyMessage"
|
|
186
|
+
>
|
|
187
|
+
<template v-if="$slots.toolbar" #toolbar>
|
|
188
|
+
<slot name="toolbar" />
|
|
189
|
+
</template>
|
|
190
|
+
<template v-if="$slots.empty" #empty>
|
|
191
|
+
<slot name="empty" />
|
|
192
|
+
</template>
|
|
193
|
+
|
|
194
|
+
<template v-if="failed">
|
|
195
|
+
<slot name="error">
|
|
196
|
+
<div class="mint-plotly-chart__error">
|
|
197
|
+
<p role="alert">Unable to display this chart.</p>
|
|
198
|
+
</div>
|
|
199
|
+
</slot>
|
|
200
|
+
</template>
|
|
201
|
+
<div
|
|
202
|
+
ref="chartElement"
|
|
203
|
+
v-show="!failed"
|
|
204
|
+
class="mint-plotly-chart__plot"
|
|
205
|
+
role="group"
|
|
206
|
+
:aria-label="ariaLabel"
|
|
207
|
+
/>
|
|
208
|
+
|
|
209
|
+
<template v-if="$slots.legend" #legend>
|
|
210
|
+
<slot name="legend" />
|
|
211
|
+
</template>
|
|
212
|
+
</ChartContainer>
|
|
213
|
+
</template>
|
|
214
|
+
|
|
215
|
+
<style scoped>
|
|
216
|
+
.mint-plotly-chart__plot {
|
|
217
|
+
width: 100%;
|
|
218
|
+
min-height: 24rem;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
.mint-plotly-chart__error {
|
|
222
|
+
margin: 0;
|
|
223
|
+
padding: 1rem;
|
|
224
|
+
border: 1px solid var(--mint-error-border);
|
|
225
|
+
border-radius: 0.75rem;
|
|
226
|
+
background: var(--mint-error-bg);
|
|
227
|
+
color: var(--mint-error-hover);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
.mint-plotly-chart__error :deep(p) {
|
|
231
|
+
margin: 0;
|
|
232
|
+
}
|
|
233
|
+
</style>
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
/** Renders a segmented button group for single-option selection,
|
|
2
|
+
/** Renders a segmented button group for single-option selection, defaulting to the card variant. */
|
|
3
3
|
import { computed, type ComponentPublicInstance } from 'vue'
|
|
4
4
|
import type { SegmentedOption, SegmentedOptionInput, SegmentedControlVariant, SegmentedControlSize } from '../types'
|
|
5
5
|
import { normalizeOptionInput } from '../utils/options'
|
|
@@ -16,7 +16,7 @@ interface Props {
|
|
|
16
16
|
}
|
|
17
17
|
|
|
18
18
|
const props = withDefaults(defineProps<Props>(), {
|
|
19
|
-
variant: '
|
|
19
|
+
variant: 'card',
|
|
20
20
|
size: 'md',
|
|
21
21
|
fullWidth: false,
|
|
22
22
|
disabled: false,
|
package/src/components/index.ts
CHANGED
|
@@ -99,6 +99,7 @@ export { default as SampleHierarchyTree } from './SampleHierarchyTree.vue'
|
|
|
99
99
|
export { default as ProtocolStepEditor } from './ProtocolStepEditor.vue'
|
|
100
100
|
|
|
101
101
|
// Scientific display components
|
|
102
|
+
export { default as PlotlyChart } from './PlotlyChart.vue'
|
|
102
103
|
export { default as ScientificNumber } from './ScientificNumber.vue'
|
|
103
104
|
/** Renders a chemical formula string (H₂O, C₆H₁₂O₆, Ca(OH)₂) with typographically correct subscripts and superscripts for charges. @deprecated Unused by the MINT platform and scheduled for removal in MINT 1.3; no platform replacement is planned. */
|
|
104
105
|
export { default as ChemicalFormula } from './ChemicalFormula.vue'
|
|
@@ -2,10 +2,13 @@ import type { PickerAdapter, PickerFile, PickerNode } from '../../types/filePick
|
|
|
2
2
|
import { pickerFormat } from './validation'
|
|
3
3
|
|
|
4
4
|
/** Construct metadata only: no FileReader, arrayBuffer or upload. */
|
|
5
|
-
export function localFilesAdapter(
|
|
5
|
+
export function localFilesAdapter(
|
|
6
|
+
files: File[],
|
|
7
|
+
relativePaths?: (string | undefined)[],
|
|
8
|
+
): PickerAdapter {
|
|
6
9
|
const nodes = new Map<string, PickerNode>()
|
|
7
|
-
for (const file of files) {
|
|
8
|
-
const relative = file.webkitRelativePath || file.name
|
|
10
|
+
for (const [index, file] of files.entries()) {
|
|
11
|
+
const relative = relativePaths?.[index] || file.webkitRelativePath || file.name
|
|
9
12
|
const parts = relative.split('/')
|
|
10
13
|
let parent: string | null = null
|
|
11
14
|
for (let i = 0; i < parts.length - 1; i++) {
|
|
@@ -6,7 +6,11 @@ import type {
|
|
|
6
6
|
PickerTreeRow,
|
|
7
7
|
} from '../../types/filePicker'
|
|
8
8
|
|
|
9
|
-
export function usePickerNavigation(
|
|
9
|
+
export function usePickerNavigation(
|
|
10
|
+
adapter: () => PickerAdapter,
|
|
11
|
+
request: () => PickerRequest,
|
|
12
|
+
systemFilter: (node: PickerNode) => boolean = () => true,
|
|
13
|
+
) {
|
|
10
14
|
const path = shallowRef('')
|
|
11
15
|
const trail = ref<{ path: string; label: string }[]>([])
|
|
12
16
|
const entries = shallowRef<PickerNode[]>([])
|
|
@@ -23,6 +27,17 @@ export function usePickerNavigation(adapter: () => PickerAdapter, request: () =>
|
|
|
23
27
|
const pending = new Map<string, Promise<PickerNode[]>>()
|
|
24
28
|
const childControllers = new Set<AbortController>()
|
|
25
29
|
|
|
30
|
+
function isAllowed(node: PickerNode): boolean {
|
|
31
|
+
const seen = new Set<string>()
|
|
32
|
+
let current: PickerNode | undefined = node
|
|
33
|
+
while (current && !seen.has(current.path)) {
|
|
34
|
+
if (!systemFilter(current)) return false
|
|
35
|
+
seen.add(current.path)
|
|
36
|
+
current = current.parentPath ? nodes.value.get(current.parentPath) : undefined
|
|
37
|
+
}
|
|
38
|
+
return true
|
|
39
|
+
}
|
|
40
|
+
|
|
26
41
|
function register(items: PickerNode[], parent?: string) {
|
|
27
42
|
for (const item of items)
|
|
28
43
|
nodes.value.set(item.path, {
|
|
@@ -56,7 +71,11 @@ export function usePickerNavigation(adapter: () => PickerAdapter, request: () =>
|
|
|
56
71
|
loading.value = false
|
|
57
72
|
error.value = ''
|
|
58
73
|
}
|
|
59
|
-
async function loadChildren(
|
|
74
|
+
async function loadChildren(
|
|
75
|
+
folder: string,
|
|
76
|
+
ancestors: string[] = [],
|
|
77
|
+
refresh = false,
|
|
78
|
+
): Promise<PickerNode[]> {
|
|
60
79
|
if (ancestors.includes(folder)) throw new Error('The adapter returned a circular folder tree.')
|
|
61
80
|
const cached = children.value.get(folder)
|
|
62
81
|
if (cached) return cached
|
|
@@ -66,7 +85,7 @@ export function usePickerNavigation(adapter: () => PickerAdapter, request: () =>
|
|
|
66
85
|
const abort = new AbortController()
|
|
67
86
|
childControllers.add(abort)
|
|
68
87
|
const task = adapter()
|
|
69
|
-
.listChildren(folder, { ...request(), signal: abort.signal })
|
|
88
|
+
.listChildren(folder, { ...request(), ...(refresh && { refresh }), signal: abort.signal })
|
|
70
89
|
.then((items) => {
|
|
71
90
|
if (token !== epoch) throw new DOMException('Cancelled', 'AbortError')
|
|
72
91
|
const retained = new Set(items.map((item) => item.path))
|
|
@@ -91,11 +110,11 @@ export function usePickerNavigation(adapter: () => PickerAdapter, request: () =>
|
|
|
91
110
|
pending.set(folder, task)
|
|
92
111
|
return task
|
|
93
112
|
}
|
|
94
|
-
async function load(query = '', recursive = false) {
|
|
113
|
+
async function load(query = '', recursive = false, refresh = false) {
|
|
95
114
|
const token = ++navigation
|
|
96
115
|
controller?.abort()
|
|
97
116
|
controller = new AbortController()
|
|
98
|
-
const options = { ...request(), signal: controller.signal }
|
|
117
|
+
const options = { ...request(), ...(refresh && { refresh }), signal: controller.signal }
|
|
99
118
|
loading.value = true
|
|
100
119
|
error.value = ''
|
|
101
120
|
entries.value = []
|
|
@@ -103,7 +122,7 @@ export function usePickerNavigation(adapter: () => PickerAdapter, request: () =>
|
|
|
103
122
|
const items = query.trim()
|
|
104
123
|
? await adapter().search(query.trim(), { path: path.value, recursive }, options)
|
|
105
124
|
: path.value
|
|
106
|
-
? await loadChildren(path.value)
|
|
125
|
+
? await loadChildren(path.value, [], refresh)
|
|
107
126
|
: await adapter().listRoot(options)
|
|
108
127
|
if (token !== navigation) return
|
|
109
128
|
register(items, query.trim() ? undefined : path.value)
|
|
@@ -116,7 +135,7 @@ export function usePickerNavigation(adapter: () => PickerAdapter, request: () =>
|
|
|
116
135
|
}
|
|
117
136
|
}
|
|
118
137
|
async function expand(node: PickerNode) {
|
|
119
|
-
if (node.kind !== 'folder' || node.unavailableReason) return
|
|
138
|
+
if (node.kind !== 'folder' || node.unavailableReason || !isAllowed(node)) return
|
|
120
139
|
if (expanded.value.has(node.path)) {
|
|
121
140
|
expanded.value.delete(node.path)
|
|
122
141
|
return
|
|
@@ -138,7 +157,7 @@ export function usePickerNavigation(adapter: () => PickerAdapter, request: () =>
|
|
|
138
157
|
}
|
|
139
158
|
}
|
|
140
159
|
function enter(node: PickerNode) {
|
|
141
|
-
if (node.kind !== 'folder' || node.unavailableReason) return
|
|
160
|
+
if (node.kind !== 'folder' || node.unavailableReason || !isAllowed(node)) return
|
|
142
161
|
const index = trail.value.findIndex((crumb) => crumb.path === node.path)
|
|
143
162
|
trail.value =
|
|
144
163
|
index >= 0
|
|
@@ -160,6 +179,7 @@ export function usePickerNavigation(adapter: () => PickerAdapter, request: () =>
|
|
|
160
179
|
for (const item of items) {
|
|
161
180
|
if (ancestors.has(item.path) || depth > 30) continue
|
|
162
181
|
const node = nodes.value.get(item.path) ?? item
|
|
182
|
+
if (!isAllowed(node)) continue
|
|
163
183
|
result.push({
|
|
164
184
|
node,
|
|
165
185
|
depth,
|
|
@@ -187,6 +207,7 @@ export function usePickerNavigation(adapter: () => PickerAdapter, request: () =>
|
|
|
187
207
|
loading,
|
|
188
208
|
error,
|
|
189
209
|
rows,
|
|
210
|
+
isAllowed,
|
|
190
211
|
clear,
|
|
191
212
|
invalidate,
|
|
192
213
|
register,
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { computed, ref, shallowRef } from 'vue'
|
|
1
|
+
import { computed, ref, shallowRef, watch } from 'vue'
|
|
2
2
|
import type {
|
|
3
3
|
PickerCapability,
|
|
4
4
|
PickerExcluded,
|
|
@@ -36,6 +36,8 @@ export function usePickerSelection(
|
|
|
36
36
|
return false
|
|
37
37
|
}
|
|
38
38
|
function exclusion(node: PickerNode) {
|
|
39
|
+
if (!nav.isAllowed(node))
|
|
40
|
+
return { path: node.path, name: node.name, reason: 'forbidden' as const }
|
|
39
41
|
const own = pickerExclusion(node, capability(), skipBlanks())
|
|
40
42
|
if (own) return own
|
|
41
43
|
const seen = new Set<string>()
|
|
@@ -72,7 +74,9 @@ export function usePickerSelection(
|
|
|
72
74
|
focusPath.value = node.path
|
|
73
75
|
if (!selectable(node)) return
|
|
74
76
|
const multi = mode() === 'multi-file' || mode() === 'folder+files'
|
|
75
|
-
|
|
77
|
+
// Multi modes behave like a checklist: a plain click (or Space) adds or
|
|
78
|
+
// removes one row, Shift extends a range, Ctrl/Cmd is accepted for habit.
|
|
79
|
+
const additive = multi && (modifiers.metaKey || modifiers.ctrlKey || modifiers.toggle)
|
|
76
80
|
if (multi && modifiers.shiftKey && anchor) {
|
|
77
81
|
const rows = nav.rows.value.map((row) => row.node)
|
|
78
82
|
const from = rows.findIndex((item) => item.path === anchor)
|
|
@@ -141,12 +145,13 @@ export function usePickerSelection(
|
|
|
141
145
|
const roots = computed(() =>
|
|
142
146
|
[...keys.value]
|
|
143
147
|
.map((key) => nav.nodes.value.get(key))
|
|
144
|
-
.filter((node): node is PickerNode => !!node && !ancestorSelected(node)),
|
|
148
|
+
.filter((node): node is PickerNode => !!node && !ancestorSelected(node) && nav.isAllowed(node)),
|
|
145
149
|
)
|
|
146
150
|
const chosen = computed(() => {
|
|
147
151
|
const files: PickerFile[] = []
|
|
148
152
|
const excluded: PickerExcluded[] = []
|
|
149
153
|
for (const node of nav.nodes.value.values()) {
|
|
154
|
+
if (!nav.isAllowed(node)) continue
|
|
150
155
|
if (!keys.value.has(node.path) && !ancestorSelected(node)) continue
|
|
151
156
|
const reason =
|
|
152
157
|
exclusion(node) ??
|
|
@@ -170,6 +175,7 @@ export function usePickerSelection(
|
|
|
170
175
|
const seen = new Set<string>()
|
|
171
176
|
async function walk(node: PickerNode, ancestors: string[] = []): Promise<PickerFile[]> {
|
|
172
177
|
if (token !== generation) throw new DOMException('Cancelled', 'AbortError')
|
|
178
|
+
if (!nav.isAllowed(node)) return []
|
|
173
179
|
if (node.kind === 'file') return [node]
|
|
174
180
|
if (node.unavailableReason) return []
|
|
175
181
|
if (ancestors.includes(node.path))
|
|
@@ -213,8 +219,17 @@ export function usePickerSelection(
|
|
|
213
219
|
if (token === generation) resolving.value = false
|
|
214
220
|
}
|
|
215
221
|
}
|
|
216
|
-
const focused = computed(
|
|
217
|
-
|
|
222
|
+
const focused = computed(() => {
|
|
223
|
+
const node = nav.nodes.value.get(focusPath.value)
|
|
224
|
+
return node && nav.isAllowed(node) ? node : roots.value[roots.value.length - 1] ?? null
|
|
225
|
+
})
|
|
226
|
+
watch(
|
|
227
|
+
() => new Map([...nav.nodes.value].map(([path, node]) => [path, nav.isAllowed(node)])),
|
|
228
|
+
(allowed, previous) => {
|
|
229
|
+
if (keys.value.size && [...allowed].some(([path, value]) =>
|
|
230
|
+
previous.has(path) && value !== previous.get(path),
|
|
231
|
+
)) void resolve()
|
|
232
|
+
},
|
|
218
233
|
)
|
|
219
234
|
function folderProgress(node: PickerNode): string {
|
|
220
235
|
if (node.kind !== 'folder' || node.fileCount == null) return ''
|
|
@@ -31,6 +31,7 @@ export function pickerReason(excluded: PickerExcluded | null, node?: PickerNode)
|
|
|
31
31
|
blank: 'Skipped by the blank-file rule.',
|
|
32
32
|
unreadable: node?.unavailableReason || 'This item cannot be read.',
|
|
33
33
|
deselected: 'Removed from the selection.',
|
|
34
|
+
forbidden: 'This item is not permitted by the system filter.',
|
|
34
35
|
}[excluded.reason]
|
|
35
36
|
}
|
|
36
37
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import axios, { type AxiosInstance, type AxiosRequestConfig, type InternalAxiosRequestConfig } from 'axios'
|
|
2
2
|
import { useSettingsStore } from '../stores/settings'
|
|
3
3
|
import { useAuthStore } from '../stores/auth'
|
|
4
|
+
import { mintApiErrorFromResponse } from '../apiErrors'
|
|
4
5
|
|
|
5
6
|
let apiClientInstance: AxiosInstance | null = null
|
|
6
7
|
let interceptorAttached = false
|
|
@@ -8,11 +9,13 @@ let interceptorAttached = false
|
|
|
8
9
|
interface MintAxiosRequestConfig extends AxiosRequestConfig {
|
|
9
10
|
_mintSkipAuth?: boolean
|
|
10
11
|
_mintAuthToken?: string
|
|
12
|
+
_mintTypedErrors?: boolean
|
|
11
13
|
}
|
|
12
14
|
|
|
13
15
|
interface MintInternalAxiosRequestConfig extends InternalAxiosRequestConfig {
|
|
14
16
|
_mintSkipAuth?: boolean
|
|
15
17
|
_mintAuthToken?: string
|
|
18
|
+
_mintTypedErrors?: boolean
|
|
16
19
|
}
|
|
17
20
|
|
|
18
21
|
type MutableHeaders = Record<string, unknown> & {
|
|
@@ -141,6 +144,8 @@ export interface ApiClientOptions {
|
|
|
141
144
|
baseUrl?: string
|
|
142
145
|
timeout?: number
|
|
143
146
|
withAuth?: boolean
|
|
147
|
+
/** Normalize failed responses into MintApiError. Generated clients enable this by default. */
|
|
148
|
+
typedErrors?: boolean
|
|
144
149
|
}
|
|
145
150
|
|
|
146
151
|
export interface UseApiReturn {
|
|
@@ -187,11 +192,34 @@ export function useApi(options: ApiClientOptions = {}): UseApiReturn {
|
|
|
187
192
|
})
|
|
188
193
|
apiClient.interceptors.response.use(
|
|
189
194
|
(response) => response,
|
|
190
|
-
(error: unknown) => {
|
|
191
|
-
if (axios.isAxiosError(error) && error.response
|
|
195
|
+
async (error: unknown) => {
|
|
196
|
+
if (axios.isAxiosError(error) && error.response) {
|
|
192
197
|
const request = error.config as MintInternalAxiosRequestConfig | undefined
|
|
193
|
-
if (request?.
|
|
194
|
-
|
|
198
|
+
if (request?._mintTypedErrors) {
|
|
199
|
+
if (error.response.status === 401 && request._mintSkipAuth !== true) {
|
|
200
|
+
authenticationRequiredError(error, request._mintAuthToken)
|
|
201
|
+
}
|
|
202
|
+
let payload: unknown = error.response.data
|
|
203
|
+
if (payload instanceof Blob) {
|
|
204
|
+
try {
|
|
205
|
+
payload = JSON.parse(await payload.text())
|
|
206
|
+
} catch {
|
|
207
|
+
// Keep non-JSON download errors available as the original body.
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return Promise.reject(mintApiErrorFromResponse(
|
|
211
|
+
error.response.status,
|
|
212
|
+
payload,
|
|
213
|
+
{
|
|
214
|
+
requestId: String(error.response.headers?.['x-request-id'] ?? '') || null,
|
|
215
|
+
cause: error,
|
|
216
|
+
},
|
|
217
|
+
))
|
|
218
|
+
}
|
|
219
|
+
if (error.response.status === 401) {
|
|
220
|
+
if (request?._mintSkipAuth !== true) {
|
|
221
|
+
return Promise.reject(authenticationRequiredError(error, request?._mintAuthToken))
|
|
222
|
+
}
|
|
195
223
|
}
|
|
196
224
|
}
|
|
197
225
|
return Promise.reject(error)
|
|
@@ -213,6 +241,7 @@ export function useApi(options: ApiClientOptions = {}): UseApiReturn {
|
|
|
213
241
|
const base: MintAxiosRequestConfig = {
|
|
214
242
|
baseURL: getBaseUrl(),
|
|
215
243
|
timeout: options.timeout ?? settingsStore.requestTimeout,
|
|
244
|
+
_mintTypedErrors: options.typedErrors,
|
|
216
245
|
...config,
|
|
217
246
|
}
|
|
218
247
|
// Strip auth header if explicitly disabled
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { computed, nextTick, onScopeDispose, ref, shallowRef, watch } from 'vue'
|
|
1
|
+
import { computed, nextTick, onScopeDispose, ref, shallowRef, toRaw, watch } from 'vue'
|
|
2
2
|
import type {
|
|
3
3
|
PickerAdapter,
|
|
4
4
|
PickerCapabilities,
|
|
@@ -19,6 +19,7 @@ interface PickerStateProps {
|
|
|
19
19
|
capabilities: PickerCapabilities
|
|
20
20
|
initialPath: string
|
|
21
21
|
initialSelection: PickerSelection | null
|
|
22
|
+
systemFilter?: (node: PickerNode, source: PickerSource) => boolean
|
|
22
23
|
}
|
|
23
24
|
type PickerEmit = {
|
|
24
25
|
(event: 'update:open', value: boolean): void
|
|
@@ -47,10 +48,14 @@ export function useFilePicker(props: PickerStateProps, emit: PickerEmit) {
|
|
|
47
48
|
const skipBlanks = shallowRef(false)
|
|
48
49
|
const reviewing = shallowRef(false)
|
|
49
50
|
const localFiles = shallowRef<File[]>([])
|
|
50
|
-
const
|
|
51
|
+
const localPaths = new WeakMap<File, string>()
|
|
52
|
+
const localAdapter = computed(() =>
|
|
53
|
+
localFilesAdapter(localFiles.value, localFiles.value.map((file) => localPaths.get(toRaw(file)))),
|
|
54
|
+
)
|
|
51
55
|
const nav = usePickerNavigation(
|
|
52
56
|
() => (source.value === 'localFile' ? localAdapter.value : props.adapter),
|
|
53
57
|
() => ({ source: source.value }),
|
|
58
|
+
(node) => props.systemFilter?.(node, source.value) !== false,
|
|
54
59
|
)
|
|
55
60
|
const capability = computed(() => props.capabilities[source.value])
|
|
56
61
|
const selection = usePickerSelection(
|
|
@@ -79,14 +84,7 @@ export function useFilePicker(props: PickerStateProps, emit: PickerEmit) {
|
|
|
79
84
|
}
|
|
80
85
|
})
|
|
81
86
|
|
|
82
|
-
const filesInput = ref<HTMLInputElement | null>(null)
|
|
83
|
-
const folderInput = ref<HTMLInputElement | null>(null)
|
|
84
87
|
const searchArea = ref<HTMLElement | null>(null)
|
|
85
|
-
const accept = computed(
|
|
86
|
-
() =>
|
|
87
|
-
capability.value?.formats.map((format) => `.${format.replace(/^\./, '')}`).join(',') ||
|
|
88
|
-
undefined,
|
|
89
|
-
)
|
|
90
88
|
const confirmLabel = computed(
|
|
91
89
|
() =>
|
|
92
90
|
({
|
|
@@ -133,12 +131,17 @@ export function useFilePicker(props: PickerStateProps, emit: PickerEmit) {
|
|
|
133
131
|
selection.reset(seed?.source === source.value ? seed : null)
|
|
134
132
|
reviewing.value = false
|
|
135
133
|
if (!allowedSources.value.includes(source.value)) return
|
|
136
|
-
if (seed?.source === 'localFile' && source.value === 'localFile')
|
|
134
|
+
if (seed?.source === 'localFile' && source.value === 'localFile') {
|
|
135
|
+
seed.files.forEach((file, index) => {
|
|
136
|
+
if (seed.relativePaths?.[index]) localPaths.set(toRaw(file), seed.relativePaths[index]!)
|
|
137
|
+
})
|
|
138
|
+
localFiles.value = seed.files
|
|
139
|
+
}
|
|
137
140
|
await nav.load(query.value, recursive.value)
|
|
138
141
|
if (token !== openGeneration || !props.open) return
|
|
139
142
|
if (seed?.source === 'localFile' && source.value === 'localFile') {
|
|
140
143
|
// Restored local File objects are already the confirmed subset.
|
|
141
|
-
|
|
144
|
+
await selectLocalFiles()
|
|
142
145
|
}
|
|
143
146
|
if (selection.keys.value.size) await selection.resolve()
|
|
144
147
|
}
|
|
@@ -238,7 +241,7 @@ export function useFilePicker(props: PickerStateProps, emit: PickerEmit) {
|
|
|
238
241
|
}
|
|
239
242
|
async function refresh() {
|
|
240
243
|
nav.invalidate()
|
|
241
|
-
await nav.load(query.value, recursive.value)
|
|
244
|
+
await nav.load(query.value, recursive.value, true)
|
|
242
245
|
if (selection.keys.value.size) await selection.resolve()
|
|
243
246
|
}
|
|
244
247
|
function selectParent(node: PickerNode) {
|
|
@@ -251,12 +254,14 @@ export function useFilePicker(props: PickerStateProps, emit: PickerEmit) {
|
|
|
251
254
|
nav.register([parent])
|
|
252
255
|
selection.select(parent)
|
|
253
256
|
}
|
|
254
|
-
async function chooseLocal(
|
|
257
|
+
async function chooseLocal(files: File[], _rootName?: string, relativePaths?: string[]) {
|
|
255
258
|
const token = openGeneration
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
259
|
+
if (!files.length || !props.open || source.value !== 'localFile') return
|
|
260
|
+
files.forEach((file, index) => {
|
|
261
|
+
if (relativePaths?.[index]) localPaths.set(toRaw(file), relativePaths[index]!)
|
|
262
|
+
})
|
|
263
|
+
localFiles.value = files
|
|
264
|
+
clearTimeout(timer)
|
|
260
265
|
nav.clear()
|
|
261
266
|
selection.reset()
|
|
262
267
|
path.value = ''
|
|
@@ -264,7 +269,16 @@ export function useFilePicker(props: PickerStateProps, emit: PickerEmit) {
|
|
|
264
269
|
query.value = ''
|
|
265
270
|
await nav.load()
|
|
266
271
|
if (token !== openGeneration || !props.open || source.value !== 'localFile') return
|
|
267
|
-
|
|
272
|
+
await selectLocalFiles()
|
|
273
|
+
}
|
|
274
|
+
async function selectLocalFiles() {
|
|
275
|
+
const token = openGeneration
|
|
276
|
+
const items = props.selectionMode === 'single-file' || props.selectionMode === 'multi-file'
|
|
277
|
+
? await localAdapter.value.search('', { path: '', recursive: true })
|
|
278
|
+
: nav.entries.value
|
|
279
|
+
if (token !== openGeneration || !props.open || source.value !== 'localFile') return
|
|
280
|
+
nav.register(items)
|
|
281
|
+
const candidates = items.filter((node) => selection.selectable(node))
|
|
268
282
|
for (const node of props.selectionMode === 'single-file' || props.selectionMode === 'folder'
|
|
269
283
|
? candidates.slice(0, 1)
|
|
270
284
|
: candidates)
|
|
@@ -280,7 +294,7 @@ export function useFilePicker(props: PickerStateProps, emit: PickerEmit) {
|
|
|
280
294
|
source.value === 'localFile'
|
|
281
295
|
? {
|
|
282
296
|
source: 'localFile',
|
|
283
|
-
files: chosen.value.files.flatMap((node) => (node.file ? [node.file] : [])),
|
|
297
|
+
files: chosen.value.files.flatMap((node) => (node.file ? [toRaw(node.file)] : [])),
|
|
284
298
|
}
|
|
285
299
|
: {
|
|
286
300
|
source: 'server',
|
|
@@ -288,6 +302,14 @@ export function useFilePicker(props: PickerStateProps, emit: PickerEmit) {
|
|
|
288
302
|
files: [...chosen.value.files],
|
|
289
303
|
excluded: [...chosen.value.excluded],
|
|
290
304
|
}
|
|
305
|
+
if (result.source === 'localFile') {
|
|
306
|
+
const relativePaths = result.files.map((file) =>
|
|
307
|
+
localPaths.get(file) || file.webkitRelativePath || file.name,
|
|
308
|
+
)
|
|
309
|
+
if (relativePaths.some((path, index) =>
|
|
310
|
+
path !== (result.files[index]!.webkitRelativePath || result.files[index]!.name),
|
|
311
|
+
)) result.relativePaths = relativePaths
|
|
312
|
+
}
|
|
291
313
|
lastConfirmed = result
|
|
292
314
|
emit('select', result)
|
|
293
315
|
emit('update:open', false)
|
|
@@ -326,10 +348,7 @@ export function useFilePicker(props: PickerStateProps, emit: PickerEmit) {
|
|
|
326
348
|
focused,
|
|
327
349
|
resolving,
|
|
328
350
|
resolutionError,
|
|
329
|
-
filesInput,
|
|
330
|
-
folderInput,
|
|
331
351
|
searchArea,
|
|
332
|
-
accept,
|
|
333
352
|
confirmLabel,
|
|
334
353
|
folderCount,
|
|
335
354
|
canConfirm,
|