@morscherlab/mint-sdk 1.3.0-beta.3 → 1.3.0-beta.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/components/FilePicker.vue.d.ts +4 -1
- package/dist/components/index.js +1 -1
- package/dist/components/internal/FilePickerListInternal.vue.d.ts +2 -0
- package/dist/{components-CvY105kJ.js → components-CF0XMy8W.js} +326 -234
- package/dist/components-CF0XMy8W.js.map +1 -0
- package/dist/composables/createFilePickerAdapter.d.ts +5 -1
- package/dist/composables/filePicker/usePickerNavigation.d.ts +3 -2
- package/dist/composables/index.js +1 -1
- package/dist/composables/useFilePicker.d.ts +4 -1
- package/dist/{composables-DL4Vca7L.js → composables-Dlyz_Aiv.js} +92 -29
- package/dist/{composables-DL4Vca7L.js.map → composables-Dlyz_Aiv.js.map} +1 -1
- package/dist/index.js +2 -2
- package/dist/install.js +1 -1
- package/dist/{plotly.min-D6Ge6qgX.js → plotly.min-DDhZCNEM.js} +2 -2
- package/dist/{plotly.min-D6Ge6qgX.js.map → plotly.min-DDhZCNEM.js.map} +1 -1
- package/dist/styles.css +32 -1
- package/dist/types/auth.d.ts +4 -0
- package/dist/types/components.d.ts +4 -0
- package/dist/types/fileBrowserTypes.d.ts +13 -0
- package/dist/types/filePicker.d.ts +8 -1
- package/dist/types/index.d.ts +1 -1
- package/package.json +1 -1
- package/src/__tests__/components/ActionMenu.test.ts +10 -0
- package/src/__tests__/components/FilePicker.test.ts +65 -1
- package/src/__tests__/composables/createFilePickerAdapter.test.ts +92 -1
- package/src/__tests__/composables/usePickerNavigation.test.ts +61 -1
- package/src/__tests__/composables/usePlatformFilePickerAdapter.test.ts +18 -6
- package/src/components/ActionMenu.vue +10 -2
- package/src/components/FilePicker.vue +42 -0
- package/src/components/internal/FilePickerListInternal.vue +11 -2
- package/src/composables/createFilePickerAdapter.ts +82 -22
- package/src/composables/filePicker/usePickerNavigation.ts +29 -2
- package/src/composables/useFilePicker.ts +8 -1
- package/src/composables/usePlatformFilePickerAdapter.ts +26 -4
- package/src/styles/components/action-menu.css +10 -0
- package/src/styles/components/file-picker.css +23 -1
- package/src/types/auth.ts +4 -0
- package/src/types/components.ts +4 -0
- package/src/types/fileBrowserTypes.ts +15 -0
- package/src/types/filePicker.ts +8 -1
- package/src/types/index.ts +2 -0
- package/dist/components-CvY105kJ.js.map +0 -1
package/dist/styles.css
CHANGED
|
@@ -1836,6 +1836,15 @@ button, input, select, textarea {
|
|
|
1836
1836
|
max-width: min(18rem, calc(100vw - 1rem));
|
|
1837
1837
|
}
|
|
1838
1838
|
|
|
1839
|
+
.mint-action-menu__check {
|
|
1840
|
+
flex-shrink: 0;
|
|
1841
|
+
color: var(--color-primary);
|
|
1842
|
+
}
|
|
1843
|
+
|
|
1844
|
+
/* Unchecked radio rows keep the check's width so labels stay aligned. */
|
|
1845
|
+
.mint-action-menu__check--off {
|
|
1846
|
+
visibility: hidden;
|
|
1847
|
+
}
|
|
1839
1848
|
.mint-adduct-text {
|
|
1840
1849
|
display: inline-flex;
|
|
1841
1850
|
align-items: center;
|
|
@@ -20590,6 +20599,7 @@ html.dark .mint-experiment-selector__filters-toggle--active {
|
|
|
20590
20599
|
white-space: nowrap;
|
|
20591
20600
|
}
|
|
20592
20601
|
.mint-picker__scope,
|
|
20602
|
+
.mint-picker__sort,
|
|
20593
20603
|
.mint-picker__link {
|
|
20594
20604
|
padding: 2px 6px;
|
|
20595
20605
|
border: 0;
|
|
@@ -20600,10 +20610,20 @@ html.dark .mint-experiment-selector__filters-toggle--active {
|
|
|
20600
20610
|
cursor: pointer;
|
|
20601
20611
|
transition: background-color 150ms ease;
|
|
20602
20612
|
}
|
|
20603
|
-
.mint-picker__scope
|
|
20613
|
+
.mint-picker__scope,
|
|
20614
|
+
.mint-picker__sort {
|
|
20604
20615
|
flex-shrink: 0;
|
|
20605
20616
|
color: var(--text-secondary-strong);
|
|
20606
20617
|
}
|
|
20618
|
+
.mint-picker__sort {
|
|
20619
|
+
display: inline-flex;
|
|
20620
|
+
align-items: center;
|
|
20621
|
+
gap: 2px;
|
|
20622
|
+
}
|
|
20623
|
+
.mint-picker__sort svg {
|
|
20624
|
+
width: 12px;
|
|
20625
|
+
height: 12px;
|
|
20626
|
+
}
|
|
20607
20627
|
.mint-picker__link {
|
|
20608
20628
|
color: var(--_ink-primary);
|
|
20609
20629
|
}
|
|
@@ -20622,10 +20642,13 @@ html.dark .mint-experiment-selector__filters-toggle--active {
|
|
|
20622
20642
|
cursor: not-allowed;
|
|
20623
20643
|
}
|
|
20624
20644
|
.mint-picker__scope:hover,
|
|
20645
|
+
.mint-picker__sort:hover,
|
|
20646
|
+
.mint-picker__sort[aria-expanded='true'],
|
|
20625
20647
|
.mint-picker__link:hover {
|
|
20626
20648
|
background: var(--bg-hover);
|
|
20627
20649
|
}
|
|
20628
20650
|
.mint-picker__scope:focus-visible,
|
|
20651
|
+
.mint-picker__sort:focus-visible,
|
|
20629
20652
|
.mint-picker__link:focus-visible {
|
|
20630
20653
|
outline: none;
|
|
20631
20654
|
box-shadow: var(--focus-ring-offset);
|
|
@@ -20669,6 +20692,14 @@ html.dark .mint-experiment-selector__filters-toggle--active {
|
|
|
20669
20692
|
.mint-picker__list-head > span:nth-child(n + 3) {
|
|
20670
20693
|
text-align: right;
|
|
20671
20694
|
}
|
|
20695
|
+
.mint-picker__truncated {
|
|
20696
|
+
margin: 0;
|
|
20697
|
+
padding: 6px 12px;
|
|
20698
|
+
border-bottom: 1px solid var(--border-light);
|
|
20699
|
+
background: var(--bg-secondary);
|
|
20700
|
+
font-size: 12px;
|
|
20701
|
+
color: var(--text-secondary);
|
|
20702
|
+
}
|
|
20672
20703
|
.mint-picker__row {
|
|
20673
20704
|
box-sizing: border-box;
|
|
20674
20705
|
height: 34px;
|
package/dist/types/auth.d.ts
CHANGED
|
@@ -25,6 +25,10 @@ export interface TokenVerifyResponse {
|
|
|
25
25
|
valid: boolean;
|
|
26
26
|
username: string | null;
|
|
27
27
|
expires_at: string | null;
|
|
28
|
+
/** Platform 1.3+: `access_token` for a personal access token. */
|
|
29
|
+
token_type?: 'session' | 'access_token';
|
|
30
|
+
/** Platform 1.3+: true for a read-only personal access token. */
|
|
31
|
+
read_only?: boolean;
|
|
28
32
|
}
|
|
29
33
|
export interface RegisterRequest {
|
|
30
34
|
username: string;
|
|
@@ -7,6 +7,8 @@ interface ActionMenuItemBase {
|
|
|
7
7
|
label: string;
|
|
8
8
|
disabled?: boolean;
|
|
9
9
|
danger?: boolean;
|
|
10
|
+
/** Set on every item to make the menu a single choice (`menuitemradio`); true marks the current one. */
|
|
11
|
+
checked?: boolean;
|
|
10
12
|
}
|
|
11
13
|
export type ActionMenuItem = (ActionMenuItemBase & {
|
|
12
14
|
href?: never;
|
|
@@ -14,6 +16,8 @@ export type ActionMenuItem = (ActionMenuItemBase & {
|
|
|
14
16
|
}) | (ActionMenuItemBase & {
|
|
15
17
|
href: string;
|
|
16
18
|
target?: '_self' | '_blank' | '_parent' | '_top';
|
|
19
|
+
/** Links navigate; only buttons can be single-choice items. */
|
|
20
|
+
checked?: never;
|
|
17
21
|
});
|
|
18
22
|
export type InputType = 'text' | 'password' | 'email' | 'number' | 'search' | 'tel' | 'url';
|
|
19
23
|
export type ModalSize = 'sm' | 'md' | 'lg' | 'xl' | 'full';
|
|
@@ -75,3 +75,16 @@ export interface FileBrowserLocation {
|
|
|
75
75
|
mount_id: string;
|
|
76
76
|
path: string;
|
|
77
77
|
}
|
|
78
|
+
/** Several directory levels in one response, parents before children. */
|
|
79
|
+
export interface FileDirectoryTree {
|
|
80
|
+
listings?: FileDirectoryListing[];
|
|
81
|
+
/** True when the server's entry budget stopped before the requested depth. */
|
|
82
|
+
truncated?: boolean;
|
|
83
|
+
}
|
|
84
|
+
/** Entries whose name contains a query, below one directory. */
|
|
85
|
+
export interface FileSearchResult {
|
|
86
|
+
entries?: FileEntry[];
|
|
87
|
+
searched_directories?: number;
|
|
88
|
+
/** True when a directory, result or time budget ended the search early. */
|
|
89
|
+
truncated?: boolean;
|
|
90
|
+
}
|
|
@@ -3,6 +3,8 @@ export type PickerSource = 'server' | 'localFile';
|
|
|
3
3
|
export type PickerSelectionMode = 'single-file' | 'multi-file' | 'folder' | 'folder+files';
|
|
4
4
|
/** `list` shows one folder at a time (the default); `tree` adds disclosure triangles. */
|
|
5
5
|
export type PickerView = 'list' | 'tree';
|
|
6
|
+
/** Row order inside each folder; folders always come before files. */
|
|
7
|
+
export type PickerSort = 'newest' | 'oldest' | 'name' | 'name-desc';
|
|
6
8
|
interface PickerNodeBase {
|
|
7
9
|
path: string;
|
|
8
10
|
name: string;
|
|
@@ -38,6 +40,11 @@ export interface PickerRequest {
|
|
|
38
40
|
*/
|
|
39
41
|
refresh?: true;
|
|
40
42
|
}
|
|
43
|
+
/** What a search found before a server or walk limit stopped it. */
|
|
44
|
+
export interface PickerSearchResult {
|
|
45
|
+
nodes: PickerNode[];
|
|
46
|
+
truncated: boolean;
|
|
47
|
+
}
|
|
41
48
|
export interface PickerAdapter {
|
|
42
49
|
/**
|
|
43
50
|
* `listRoot()` returns mount points. FilePicker then shows them as header
|
|
@@ -53,7 +60,7 @@ export interface PickerAdapter {
|
|
|
53
60
|
search(query: string, scope: {
|
|
54
61
|
path: string;
|
|
55
62
|
recursive: boolean;
|
|
56
|
-
}, request?: PickerRequest): Promise<PickerNode[]>;
|
|
63
|
+
}, request?: PickerRequest): Promise<PickerNode[] | PickerSearchResult>;
|
|
57
64
|
}
|
|
58
65
|
export interface PickerCapability {
|
|
59
66
|
/** Case-insensitive suffixes, including compound extensions such as mzML.gz. */
|
package/dist/types/index.d.ts
CHANGED
|
@@ -2,7 +2,7 @@ export type { ContainerDirection, ButtonVariant, ButtonSize, ActionMenuItem, Inp
|
|
|
2
2
|
export type * from './generated-ui';
|
|
3
3
|
export type { AnalysisArtifactStatus, AnalysisArtifactStatusFilter, AnalysisArtifactSummary, AnalysisArtifactDetail, AnalysisArtifactListResponse, AnalysisArtifactTarget, AnalysisArtifactMetadataUpdate, ArtifactSaveMode, ArtifactFileSource, JsonArtifactSavePayload, FileArtifactCreatePayload, FileArtifactUpdatePayload, HandlerFileArtifactCreatePayload, HandlerFileArtifactUpdatePayload, ArtifactSavePayload, HandlerArtifactSavePayload, ArtifactSaveDialogPayload, ArtifactSaveResult, ArtifactSaveHandler, HandlerArtifactSaveHandler, ArtifactSaveDialogHandler, } from './analysisArtifactTypes';
|
|
4
4
|
export { ANALYSIS_FILE_ARTIFACT_SCHEMA } from './analysisArtifactTypes';
|
|
5
|
-
export type { FileBrowserLocation, FileBrowserSortKey, FileDirectoryListing, FileEntry, FileEntryKind, FilePathCrumb, FileSelection, ServerMount, } from './fileBrowserTypes';
|
|
5
|
+
export type { FileBrowserLocation, FileBrowserSortKey, FileDirectoryListing, FileDirectoryTree, FileEntry, FileEntryKind, FilePathCrumb, FileSearchResult, FileSelection, ServerMount, } from './fileBrowserTypes';
|
|
6
6
|
export type { JobListPayload, JobProgress, JobSnapshotPayload, JobState, JobStateInput, JobStatus, JobStreamData, } from './jobs';
|
|
7
7
|
export type { InstrumentAlert, InstrumentAlertLevel, InstrumentAlertList, InstrumentBadgeState, InstrumentLiveProgress, InstrumentLiveStatus, InstrumentState, InstrumentStatus, InstrumentStatusReporter, SampleInfo, SampleInfo as InstrumentSampleInfo, SequenceProgress, } from './instrument';
|
|
8
8
|
export type { LcmsContainerType, LcmsPolarity, LcmsSequenceItem, LcmsSequenceTableColumn, } from '../lcms';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@morscherlab/mint-sdk",
|
|
3
|
-
"version": "1.3.0-beta.
|
|
3
|
+
"version": "1.3.0-beta.4",
|
|
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",
|
|
@@ -267,4 +267,14 @@ describe('ActionMenu', () => {
|
|
|
267
267
|
|
|
268
268
|
expect(document.activeElement).toBe(trigger.element)
|
|
269
269
|
})
|
|
270
|
+
|
|
271
|
+
it('should render a checked item as a checked menu radio when checked is set', async () => {
|
|
272
|
+
const wrapper = mount(ActionMenu, {
|
|
273
|
+
attachTo: document.body,
|
|
274
|
+
props: { label: 'Sort', items: [{ id: 'a', label: 'A', checked: true }, { id: 'b', label: 'B', checked: false }] },
|
|
275
|
+
})
|
|
276
|
+
await wrapper.get('button').trigger('click')
|
|
277
|
+
const radios = [...document.body.querySelectorAll('[role="menuitemradio"]')]
|
|
278
|
+
expect(radios.map((item) => item.getAttribute('aria-checked'))).toEqual(['true', 'false'])
|
|
279
|
+
})
|
|
270
280
|
})
|
|
@@ -548,9 +548,10 @@ describe('FilePicker list view', () => {
|
|
|
548
548
|
await row(wrapper, folder.path).trigger('dblclick')
|
|
549
549
|
await flushPromises()
|
|
550
550
|
expect(wrapper.findAll('[role="option"]').map((item) => item.attributes('data-path'))).toEqual([
|
|
551
|
+
// Undated rows fall back to name order under the default newest-first sort.
|
|
552
|
+
unsupported.path,
|
|
551
553
|
one.path,
|
|
552
554
|
two.path,
|
|
553
|
-
unsupported.path,
|
|
554
555
|
])
|
|
555
556
|
})
|
|
556
557
|
|
|
@@ -767,4 +768,67 @@ describe('formatModified', () => {
|
|
|
767
768
|
it('should show the full date outside the current year', () => {
|
|
768
769
|
expect(formatModified('2025-01-02T08:00:00', now)).toBe('2025-01-02')
|
|
769
770
|
})
|
|
771
|
+
|
|
772
|
+
it('should list the newest folder first when opened', async () => {
|
|
773
|
+
const wrapper = await setup({
|
|
774
|
+
adapter: {
|
|
775
|
+
...adapter(),
|
|
776
|
+
listRoot: vi.fn(async () => [
|
|
777
|
+
{ ...folder, modifiedAt: '2026-01-01T00:00:00Z' },
|
|
778
|
+
{ ...other, modifiedAt: '2026-09-29T00:00:00Z' },
|
|
779
|
+
]),
|
|
780
|
+
},
|
|
781
|
+
})
|
|
782
|
+
expect(wrapper.findAll('[role="treeitem"]').map((item) => item.attributes('data-path'))).toEqual([
|
|
783
|
+
other.path, folder.path,
|
|
784
|
+
])
|
|
785
|
+
})
|
|
786
|
+
|
|
787
|
+
it('should reorder rows by name when Name A–Z is chosen from the sort menu', async () => {
|
|
788
|
+
const wrapper = await setup({ defaultSort: 'name-desc' })
|
|
789
|
+
await wrapper.get('.mint-picker__sort').trigger('click')
|
|
790
|
+
await wrapper.findAll('[role="menuitemradio"]').find((item) => item.text() === 'Name A–Z')!.trigger('click')
|
|
791
|
+
expect(wrapper.findAll('[role="treeitem"]').map((item) => item.attributes('data-path'))).toEqual([
|
|
792
|
+
other.path, folder.path,
|
|
793
|
+
])
|
|
794
|
+
})
|
|
795
|
+
|
|
796
|
+
it('should label the sort trigger with the active order', async () => {
|
|
797
|
+
const wrapper = await setup()
|
|
798
|
+
expect(wrapper.get('.mint-picker__sort').text()).toContain('Newest first')
|
|
799
|
+
})
|
|
800
|
+
|
|
801
|
+
it('should tell the user when search results are partial', async () => {
|
|
802
|
+
vi.useFakeTimers()
|
|
803
|
+
const base = adapter()
|
|
804
|
+
const wrapper = await setup({
|
|
805
|
+
adapter: { ...base, search: vi.fn(async () => ({ nodes: [one], truncated: true })) },
|
|
806
|
+
})
|
|
807
|
+
await wrapper.get('[aria-label="Search folders and files"]').setValue('sample')
|
|
808
|
+
await vi.advanceTimersByTimeAsync(400)
|
|
809
|
+
await flushPromises()
|
|
810
|
+
expect(wrapper.get('.mint-picker__truncated').text()).toContain('first 1')
|
|
811
|
+
})
|
|
812
|
+
|
|
813
|
+
it('should still list the partial search results', async () => {
|
|
814
|
+
vi.useFakeTimers()
|
|
815
|
+
const wrapper = await setup({
|
|
816
|
+
adapter: { ...adapter(), search: vi.fn(async () => ({ nodes: [one], truncated: true })) },
|
|
817
|
+
})
|
|
818
|
+
await wrapper.get('[aria-label="Search folders and files"]').setValue('sample')
|
|
819
|
+
await vi.advanceTimersByTimeAsync(400)
|
|
820
|
+
await flushPromises()
|
|
821
|
+
expect(wrapper.findAll('[role="treeitem"]').map((item) => item.attributes('data-path'))).toEqual([one.path])
|
|
822
|
+
})
|
|
823
|
+
|
|
824
|
+
it('should say the search stopped early when a truncated search found nothing', async () => {
|
|
825
|
+
vi.useFakeTimers()
|
|
826
|
+
const wrapper = await setup({
|
|
827
|
+
adapter: { ...adapter(), search: vi.fn(async () => ({ nodes: [], truncated: true })) },
|
|
828
|
+
})
|
|
829
|
+
await wrapper.get('[aria-label="Search folders and files"]').setValue('sample')
|
|
830
|
+
await vi.advanceTimersByTimeAsync(400)
|
|
831
|
+
await flushPromises()
|
|
832
|
+
expect(wrapper.get('.mint-picker__empty').text()).toContain('stopped')
|
|
833
|
+
})
|
|
770
834
|
})
|
|
@@ -110,7 +110,7 @@ it('bounds recursive traversal and rejects truncated listings', async () => {
|
|
|
110
110
|
mount_id, path,
|
|
111
111
|
entries: [{ kind: 'directory', name: 'next', path: path ? `${path}/next` : 'next' }],
|
|
112
112
|
}))
|
|
113
|
-
await
|
|
113
|
+
expect(await adapter.search('missing', { path: '', recursive: true })).toEqual({ nodes: [], truncated: true })
|
|
114
114
|
expect(transport.browse).toHaveBeenCalledTimes(500)
|
|
115
115
|
vi.mocked(transport.browse).mockResolvedValue({ mount_id: 'raw', path: '', entries: [], truncated: true })
|
|
116
116
|
await expect(adapter.listRoot({ refresh: true })).rejects.toThrow('listing limit')
|
|
@@ -162,3 +162,94 @@ it('names the mount holding a path and nothing for a path it cannot decode', ()
|
|
|
162
162
|
const adapter = createFilePickerAdapter(setup().transport)
|
|
163
163
|
expect([adapter.mountOf!(identity('study/run')), adapter.mountOf!('not-json')]).toEqual([identity(), undefined])
|
|
164
164
|
})
|
|
165
|
+
|
|
166
|
+
function treeSetup() {
|
|
167
|
+
const study = { kind: 'directory' as const, name: 'study', path: 'study' }
|
|
168
|
+
const run = { kind: 'file' as const, name: 'run.raw', path: 'study/run.raw' }
|
|
169
|
+
const transport: FilePickerTransport = {
|
|
170
|
+
listMounts: vi.fn(async () => [{ id: 'raw', label: 'Raw files' }]),
|
|
171
|
+
browse: vi.fn(async ({ mount_id, path }) => ({ mount_id, path, entries: [] })),
|
|
172
|
+
tree: vi.fn(async ({ mount_id }) => ({
|
|
173
|
+
listings: [
|
|
174
|
+
{ mount_id, path: '', entries: [study] },
|
|
175
|
+
{ mount_id, path: 'study', entries: [run] },
|
|
176
|
+
],
|
|
177
|
+
truncated: false,
|
|
178
|
+
})),
|
|
179
|
+
search: vi.fn(async ({ path }) => ({
|
|
180
|
+
entries: [study, run].filter((entry) => !path || entry.path.startsWith(`${path}/`)),
|
|
181
|
+
searched_directories: 2,
|
|
182
|
+
truncated: false,
|
|
183
|
+
})),
|
|
184
|
+
}
|
|
185
|
+
return { transport, adapter: createFilePickerAdapter(transport, { rootLocation: { mount_id: 'raw', path: '' } }) }
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
it('should serve a subfolder from the tree response when the transport has tree', async () => {
|
|
189
|
+
const { adapter, transport } = treeSetup()
|
|
190
|
+
await adapter.listRoot()
|
|
191
|
+
await adapter.listChildren(identity('study'))
|
|
192
|
+
expect(transport.tree).toHaveBeenCalledTimes(1)
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
it('should not call browse when the transport has tree', async () => {
|
|
196
|
+
const { adapter, transport } = treeSetup()
|
|
197
|
+
await adapter.listRoot()
|
|
198
|
+
expect(transport.browse).not.toHaveBeenCalled()
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
it('should map tree listings to their own parent folders', async () => {
|
|
202
|
+
const { adapter } = treeSetup()
|
|
203
|
+
await adapter.listRoot()
|
|
204
|
+
expect(await adapter.listChildren(identity('study'))).toMatchObject([
|
|
205
|
+
{ path: identity('study/run.raw'), parentPath: identity('study'), kind: 'file' },
|
|
206
|
+
])
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
it('should search on the server when the transport has search', async () => {
|
|
210
|
+
const { adapter, transport } = treeSetup()
|
|
211
|
+
await adapter.search('ru', { path: identity('study'), recursive: true })
|
|
212
|
+
expect(transport.search).toHaveBeenCalledWith({ mount_id: 'raw', path: 'study' }, 'ru', true, undefined)
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
it('should give server search results their parent folder', async () => {
|
|
216
|
+
const { adapter } = treeSetup()
|
|
217
|
+
expect(await adapter.search('ru', { path: '', recursive: true })).toMatchObject([
|
|
218
|
+
{ path: identity('study'), parentPath: null },
|
|
219
|
+
{ path: identity('study/run.raw'), parentPath: identity('study') },
|
|
220
|
+
])
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
it('should return partial server results marked truncated when the server stops early', async () => {
|
|
224
|
+
const { adapter, transport } = treeSetup()
|
|
225
|
+
vi.mocked(transport.search!).mockResolvedValueOnce({
|
|
226
|
+
entries: [{ kind: 'file', name: 'run.raw', path: 'study/run.raw' }], searched_directories: 5000, truncated: true,
|
|
227
|
+
})
|
|
228
|
+
expect(await adapter.search('ru', { path: '', recursive: true })).toMatchObject({
|
|
229
|
+
nodes: [{ path: identity('study/run.raw') }], truncated: true,
|
|
230
|
+
})
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
it('should reject a search entry whose name is only a suffix of its last path segment', async () => {
|
|
234
|
+
const { adapter, transport } = treeSetup()
|
|
235
|
+
vi.mocked(transport.search!).mockResolvedValueOnce({
|
|
236
|
+
entries: [{ kind: 'file', name: 'b', path: 'study/ab' }], searched_directories: 1, truncated: false,
|
|
237
|
+
})
|
|
238
|
+
await expect(adapter.search('b', { path: '', recursive: true })).rejects.toThrow(/Invalid/)
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
it('should reject a server search entry outside the searched folder', async () => {
|
|
242
|
+
const { adapter, transport } = treeSetup()
|
|
243
|
+
vi.mocked(transport.search!).mockResolvedValueOnce({
|
|
244
|
+
entries: [{ kind: 'file', name: 'x.raw', path: 'other/x.raw' }], searched_directories: 1, truncated: false,
|
|
245
|
+
})
|
|
246
|
+
await expect(adapter.search('x', { path: identity('study'), recursive: true })).rejects.toThrow(/Invalid/)
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
it('should reject a nested entry from a search limited to this folder', async () => {
|
|
250
|
+
const { adapter, transport } = treeSetup()
|
|
251
|
+
vi.mocked(transport.search!).mockResolvedValueOnce({
|
|
252
|
+
entries: [{ kind: 'file', name: 'x.raw', path: 'study/private/x.raw' }], searched_directories: 1, truncated: false,
|
|
253
|
+
})
|
|
254
|
+
await expect(adapter.search('x', { path: identity('study'), recursive: false })).rejects.toThrow(/Invalid/)
|
|
255
|
+
})
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { afterEach, expect, it, vi } from 'vitest'
|
|
2
2
|
import { effectScope, type EffectScope } from 'vue'
|
|
3
3
|
import { usePickerNavigation } from '../../composables/filePicker/usePickerNavigation'
|
|
4
|
-
import type { PickerAdapter, PickerNode } from '../../types/filePicker'
|
|
4
|
+
import type { PickerAdapter, PickerNode, PickerSort } from '../../types/filePicker'
|
|
5
5
|
|
|
6
6
|
const scopes: EffectScope[] = []
|
|
7
7
|
afterEach(() => {
|
|
@@ -147,3 +147,63 @@ it('restarts prefetch after reopening even if old requests ignore cancellation',
|
|
|
147
147
|
finish[4]!()
|
|
148
148
|
await vi.advanceTimersByTimeAsync(100)
|
|
149
149
|
})
|
|
150
|
+
|
|
151
|
+
const dated = (kind: 'folder' | 'file', name: string, modifiedAt?: string): PickerNode =>
|
|
152
|
+
kind === 'folder'
|
|
153
|
+
? { kind, path: name, name, modifiedAt }
|
|
154
|
+
: { kind, path: name, name, modifiedAt, format: 'raw' }
|
|
155
|
+
const mixed = () => [
|
|
156
|
+
dated('file', 'b.raw', '2026-09-01T00:00:00Z'),
|
|
157
|
+
dated('folder', 'old', '2026-01-01T00:00:00Z'),
|
|
158
|
+
dated('file', 'a.raw', '2026-09-29T00:00:00Z'),
|
|
159
|
+
dated('folder', 'new', '2026-09-28T00:00:00Z'),
|
|
160
|
+
dated('file', 'undated.raw'),
|
|
161
|
+
]
|
|
162
|
+
async function sortedNames(sort: PickerSort | undefined) {
|
|
163
|
+
vi.useFakeTimers()
|
|
164
|
+
const adapter: PickerAdapter = {
|
|
165
|
+
listRoot: vi.fn(async () => mixed()),
|
|
166
|
+
listChildren: vi.fn(async () => []),
|
|
167
|
+
search: vi.fn(async () => []),
|
|
168
|
+
}
|
|
169
|
+
const scope = effectScope()
|
|
170
|
+
scopes.push(scope)
|
|
171
|
+
const nav = scope.run(() => usePickerNavigation(() => adapter, () => ({}), () => true, () => sort))!
|
|
172
|
+
await nav.load()
|
|
173
|
+
return nav.rows.value.map((row) => row.node.name)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
it('should list folders first, newest first, when sorted by newest', async () => {
|
|
177
|
+
expect(await sortedNames('newest')).toEqual(['new', 'old', 'a.raw', 'b.raw', 'undated.raw'])
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
it('should list folders first, oldest first, when sorted by oldest', async () => {
|
|
181
|
+
expect(await sortedNames('oldest')).toEqual(['old', 'new', 'b.raw', 'a.raw', 'undated.raw'])
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
it('should list folders first by name when sorted by name', async () => {
|
|
185
|
+
expect(await sortedNames('name')).toEqual(['new', 'old', 'a.raw', 'b.raw', 'undated.raw'])
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
it('should list folders first by reverse name when sorted by name-desc', async () => {
|
|
189
|
+
expect(await sortedNames('name-desc')).toEqual(['old', 'new', 'undated.raw', 'b.raw', 'a.raw'])
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
it('should keep the adapter order when no sort is given', async () => {
|
|
193
|
+
expect(await sortedNames(undefined)).toEqual(['b.raw', 'old', 'a.raw', 'new', 'undated.raw'])
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
it('should show partial results and flag them when the adapter search is truncated', async () => {
|
|
197
|
+
const { nav, adapter } = setup([folder('a')])
|
|
198
|
+
vi.mocked(adapter.search).mockResolvedValueOnce({ nodes: [folder('a')], truncated: true })
|
|
199
|
+
await nav.load('a', true)
|
|
200
|
+
expect([nav.entries.value.length, nav.truncated.value]).toEqual([1, true])
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
it('should clear the truncated flag on the next plain listing', async () => {
|
|
204
|
+
const { nav, adapter } = setup([folder('a')])
|
|
205
|
+
vi.mocked(adapter.search).mockResolvedValueOnce({ nodes: [], truncated: true })
|
|
206
|
+
await nav.load('a', true)
|
|
207
|
+
await nav.load()
|
|
208
|
+
expect(nav.truncated.value).toBe(false)
|
|
209
|
+
})
|
|
@@ -26,7 +26,10 @@ it('passes cancellation, marks offline mounts, and maps compound extensions', as
|
|
|
26
26
|
],
|
|
27
27
|
})
|
|
28
28
|
.mockResolvedValueOnce({
|
|
29
|
-
|
|
29
|
+
listings: [{
|
|
30
|
+
mount_id: 'online', path: '',
|
|
31
|
+
entries: [{ kind: 'file', path: 'run.mzML.gz', name: 'run.mzML.gz', size_bytes: 7 }],
|
|
32
|
+
}],
|
|
30
33
|
})
|
|
31
34
|
const adapter = usePlatformFilePickerAdapter({ apiBaseUrl: '/api' })
|
|
32
35
|
const signal = new AbortController().signal
|
|
@@ -42,7 +45,7 @@ it('passes cancellation, marks offline mounts, and maps compound extensions', as
|
|
|
42
45
|
expect(get.mock.calls.every((call) => call[1].signal === signal)).toBe(true)
|
|
43
46
|
})
|
|
44
47
|
it('refuses truncated listings rather than returning a partial folder selection', async () => {
|
|
45
|
-
get.mockResolvedValue({ truncated: true, entries: [] })
|
|
48
|
+
get.mockResolvedValue({ listings: [{ mount_id: 'a', path: '', truncated: true, entries: [] }] })
|
|
46
49
|
await expect(
|
|
47
50
|
usePlatformFilePickerAdapter({ apiBaseUrl: '/api' }).listChildren(
|
|
48
51
|
encodePlatformPickerPath('a'),
|
|
@@ -58,22 +61,22 @@ it('scopes shallow search to one level and recursive search skips offline mounts
|
|
|
58
61
|
{ id: 'b', label: 'NAS', available: false },
|
|
59
62
|
],
|
|
60
63
|
}
|
|
61
|
-
: { entries: [{ kind: 'file', path: 'run.raw', name: 'run.raw', size_bytes: 1 }] },
|
|
64
|
+
: { listings: [{ mount_id: 'a', path: '', entries: [{ kind: 'file', path: 'run.raw', name: 'run.raw', size_bytes: 1 }] }] },
|
|
62
65
|
)
|
|
63
66
|
const adapter = usePlatformFilePickerAdapter({ apiBaseUrl: '/api' })
|
|
64
67
|
expect(await adapter.search('run', { path: '', recursive: false })).toEqual([])
|
|
65
68
|
expect(await adapter.search('run', { path: '', recursive: true })).toHaveLength(1)
|
|
66
|
-
expect(get.mock.calls.filter((call) => call[0].includes('/
|
|
69
|
+
expect(get.mock.calls.filter((call) => call[0].includes('/tree'))).toHaveLength(1)
|
|
67
70
|
})
|
|
68
71
|
it('forwards explicit refresh to mount browsing and supports a scoped root', async () => {
|
|
69
|
-
get.mockResolvedValue({ entries: [] })
|
|
72
|
+
get.mockResolvedValue({ listings: [{ mount_id: 'raw', path: 'study', entries: [] }] })
|
|
70
73
|
const adapter = usePlatformFilePickerAdapter({
|
|
71
74
|
apiBaseUrl: '/api', rootLocation: { mount_id: 'raw', path: 'study' },
|
|
72
75
|
})
|
|
73
76
|
const signal = new AbortController().signal
|
|
74
77
|
await adapter.listRoot({ refresh: true, signal })
|
|
75
78
|
expect(get).toHaveBeenCalledExactlyOnceWith(
|
|
76
|
-
'/filesystem/
|
|
79
|
+
'/filesystem/tree?mount_id=raw&path=study&refresh=true', { signal },
|
|
77
80
|
)
|
|
78
81
|
})
|
|
79
82
|
it('forwards a mount-list refresh so server directory snapshots are invalidated too', async () => {
|
|
@@ -83,3 +86,12 @@ it('forwards a mount-list refresh so server directory snapshots are invalidated
|
|
|
83
86
|
await adapter.listRoot({ refresh: true, signal })
|
|
84
87
|
expect(get).toHaveBeenCalledExactlyOnceWith('/filesystem/mounts?refresh=true', { signal })
|
|
85
88
|
})
|
|
89
|
+
|
|
90
|
+
it('should search a folder on the server with the query and scope', async () => {
|
|
91
|
+
get.mockResolvedValue({ entries: [], truncated: false })
|
|
92
|
+
await usePlatformFilePickerAdapter({ apiBaseUrl: '/api' })
|
|
93
|
+
.search('run', { path: encodePlatformPickerPath('raw', 'study'), recursive: false })
|
|
94
|
+
expect(get).toHaveBeenCalledExactlyOnceWith(
|
|
95
|
+
'/filesystem/search?mount_id=raw&path=study&q=run&recursive=false', { signal: undefined },
|
|
96
|
+
)
|
|
97
|
+
})
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
/** Compact overflow actions with an IconButton trigger and a viewport-safe teleported menu. */
|
|
3
3
|
import { computePosition, flip, offset, shift } from '@floating-ui/dom'
|
|
4
|
-
import { Ellipsis } from 'lucide-vue-next'
|
|
4
|
+
import { Check, Ellipsis } from 'lucide-vue-next'
|
|
5
5
|
import { nextTick, onMounted, ref, watch } from 'vue'
|
|
6
6
|
import { useDropdownState } from '../composables/useDropdownState'
|
|
7
7
|
import { useMenuKeyboard } from '../composables/useMenuKeyboard'
|
|
@@ -167,10 +167,18 @@ watch(isOpen, async (opened) => {
|
|
|
167
167
|
v-else
|
|
168
168
|
type="button"
|
|
169
169
|
:class="['mint-menu-item mint-action-menu__item', { 'mint-menu-item--danger mint-action-menu__item--danger': item.danger }]"
|
|
170
|
-
role="menuitem"
|
|
170
|
+
:role="item.checked === undefined ? 'menuitem' : 'menuitemradio'"
|
|
171
|
+
:aria-checked="item.checked"
|
|
171
172
|
:disabled="item.disabled"
|
|
172
173
|
@click="handleSelect(item)"
|
|
173
174
|
>
|
|
175
|
+
<Check
|
|
176
|
+
v-if="item.checked !== undefined"
|
|
177
|
+
class="mint-action-menu__check"
|
|
178
|
+
:class="{ 'mint-action-menu__check--off': !item.checked }"
|
|
179
|
+
:size="14"
|
|
180
|
+
aria-hidden="true"
|
|
181
|
+
/>
|
|
174
182
|
{{ item.label }}
|
|
175
183
|
</button>
|
|
176
184
|
</template>
|
|
@@ -12,6 +12,7 @@ import { computed, ref, useTemplateRef } from 'vue'
|
|
|
12
12
|
import BaseModal from './BaseModal.vue'
|
|
13
13
|
import BaseButton from './BaseButton.vue'
|
|
14
14
|
import BaseInput from './BaseInput.vue'
|
|
15
|
+
import ActionMenu from './ActionMenu.vue'
|
|
15
16
|
import SegmentedControl from './SegmentedControl.vue'
|
|
16
17
|
import FileUploader from './FileUploader.vue'
|
|
17
18
|
import FilePickerListInternal from './internal/FilePickerListInternal.vue'
|
|
@@ -24,6 +25,7 @@ import type {
|
|
|
24
25
|
PickerNode,
|
|
25
26
|
PickerSelection,
|
|
26
27
|
PickerSelectionMode,
|
|
28
|
+
PickerSort,
|
|
27
29
|
PickerSource,
|
|
28
30
|
PickerView,
|
|
29
31
|
} from '../types/filePicker'
|
|
@@ -39,6 +41,8 @@ interface Props {
|
|
|
39
41
|
title?: string
|
|
40
42
|
/** `list` shows one folder at a time; `tree` adds disclosure triangles. */
|
|
41
43
|
view?: PickerView
|
|
44
|
+
/** Row order on each open; the user can change it from the path bar until closing. */
|
|
45
|
+
defaultSort?: PickerSort
|
|
42
46
|
/** Host-owned visibility/selection filter; backend must still enforce access. */
|
|
43
47
|
systemFilter?: (node: PickerNode, source: PickerSource) => boolean
|
|
44
48
|
}
|
|
@@ -50,6 +54,7 @@ const props = withDefaults(defineProps<Props>(), {
|
|
|
50
54
|
initialSelection: null,
|
|
51
55
|
title: 'Choose data',
|
|
52
56
|
view: 'list',
|
|
57
|
+
defaultSort: 'newest',
|
|
53
58
|
})
|
|
54
59
|
const emit = defineEmits<{
|
|
55
60
|
'update:open': [open: boolean]
|
|
@@ -68,6 +73,7 @@ const {
|
|
|
68
73
|
source,
|
|
69
74
|
query,
|
|
70
75
|
recursive,
|
|
76
|
+
sort,
|
|
71
77
|
skipBlanks,
|
|
72
78
|
reviewing,
|
|
73
79
|
nav,
|
|
@@ -109,6 +115,19 @@ const sourceLabel = computed(() => {
|
|
|
109
115
|
}
|
|
110
116
|
return mounts.value.find((item) => item.path === props.adapter.mountOf?.(node.path))?.name ?? sourceNames.server
|
|
111
117
|
})
|
|
118
|
+
const SORT_LABELS: Record<PickerSort, string> = {
|
|
119
|
+
newest: 'Newest first',
|
|
120
|
+
oldest: 'Oldest first',
|
|
121
|
+
name: 'Name A–Z',
|
|
122
|
+
'name-desc': 'Name Z–A',
|
|
123
|
+
}
|
|
124
|
+
const sortItems = computed(() =>
|
|
125
|
+
(Object.keys(SORT_LABELS) as PickerSort[]).map((id) => ({
|
|
126
|
+
id,
|
|
127
|
+
label: SORT_LABELS[id],
|
|
128
|
+
checked: sort.value === id,
|
|
129
|
+
})),
|
|
130
|
+
)
|
|
112
131
|
const accepts = computed(() =>
|
|
113
132
|
(capability.value?.formats ?? []).map((format) => `.${format.replace(/^\./, '')}`).join(' '),
|
|
114
133
|
)
|
|
@@ -198,6 +217,28 @@ const summary = computed(() => {
|
|
|
198
217
|
</template>
|
|
199
218
|
</nav>
|
|
200
219
|
<span v-if="accepts" class="mint-picker__accepts">Accepts {{ accepts }}</span>
|
|
220
|
+
<ActionMenu
|
|
221
|
+
label="Sort"
|
|
222
|
+
:items="sortItems"
|
|
223
|
+
@select="sort = $event.id as PickerSort"
|
|
224
|
+
>
|
|
225
|
+
<template #trigger="{ open: menuOpen, toggle, keydown }">
|
|
226
|
+
<button
|
|
227
|
+
type="button"
|
|
228
|
+
class="mint-picker__sort"
|
|
229
|
+
aria-haspopup="menu"
|
|
230
|
+
:aria-expanded="menuOpen"
|
|
231
|
+
:aria-label="`Sort: ${SORT_LABELS[sort]}`"
|
|
232
|
+
@click="toggle"
|
|
233
|
+
@keydown="keydown"
|
|
234
|
+
>
|
|
235
|
+
{{ SORT_LABELS[sort] }}
|
|
236
|
+
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true">
|
|
237
|
+
<path d="M4 6l4 4 4-4" />
|
|
238
|
+
</svg>
|
|
239
|
+
</button>
|
|
240
|
+
</template>
|
|
241
|
+
</ActionMenu>
|
|
201
242
|
<button
|
|
202
243
|
type="button"
|
|
203
244
|
class="mint-picker__scope"
|
|
@@ -255,6 +296,7 @@ const summary = computed(() => {
|
|
|
255
296
|
:loading="loading"
|
|
256
297
|
:error="error"
|
|
257
298
|
:query="query"
|
|
299
|
+
:truncated="nav.truncated.value"
|
|
258
300
|
:mode="selectionMode"
|
|
259
301
|
:selected="selection.selected"
|
|
260
302
|
:selectable="selection.selectable"
|