@ossy/resources 3.10.0 → 3.11.0
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/package.json +4 -4
- package/src/CreateDirectory.jsx +47 -53
- package/src/CreateDocument.jsx +1 -0
- package/src/GenericResourceForm.jsx +49 -31
- package/src/ResourceDialogMove.jsx +99 -50
- package/src/ResourceGrid.jsx +36 -84
- package/src/ResourceList.jsx +10 -1
- package/src/ResourcePanel.jsx +96 -17
- package/src/ResourceShareDialog.jsx +50 -12
- package/src/Upload.jsx +25 -8
- package/src/batch-delete.flow.js +81 -0
- package/src/batch-download.flow.js +69 -0
- package/src/cancel-upload.action.js +4 -0
- package/src/cancel-upload.flow.js +40 -0
- package/src/close-share.action.js +4 -0
- package/src/confirm-upload.action.js +4 -0
- package/src/copy-share-link.action.js +4 -0
- package/src/create-directory.action.js +5 -0
- package/src/create-directory.flow.js +30 -0
- package/src/create-directory.form.js +5 -0
- package/src/create-directory.schema.js +9 -0
- package/src/create-document.flow.js +36 -0
- package/src/create-document.form.js +5 -0
- package/src/create-document.schema.js +11 -0
- package/src/create.action.js +5 -1
- package/src/delete-directory.flow.js +39 -0
- package/src/delete-file.flow.js +32 -0
- package/src/done-selecting.action.js +5 -0
- package/src/download-file.flow.js +31 -0
- package/src/download-resource.action.js +4 -0
- package/src/download-selected.action.js +4 -0
- package/src/en.translations.json +18 -0
- package/src/finish-upload.action.js +4 -0
- package/src/index.js +42 -0
- package/src/list.task.js +17 -6
- package/src/list.task.spec.js +81 -0
- package/src/make-private-resource.flow.js +72 -0
- package/src/make-private.action.js +4 -0
- package/src/make-public.action.js +4 -0
- package/src/move-file.flow.js +78 -0
- package/src/move.form.js +5 -0
- package/src/move.schema.js +9 -0
- package/src/open-delete-directory.action.js +7 -0
- package/src/open-move.action.js +4 -0
- package/src/open-remove-selected.action.js +5 -0
- package/src/open-rename.action.js +4 -0
- package/src/open-share.action.js +4 -0
- package/src/open-sort.action.js +5 -0
- package/src/open-view.action.js +5 -0
- package/src/rename-file.flow.js +43 -0
- package/src/rename.form.js +5 -0
- package/src/rename.schema.js +9 -0
- package/src/resource-list-query.js +182 -0
- package/src/resource-list-query.spec.js +75 -0
- package/src/resource-selection.js +25 -0
- package/src/resource-selection.spec.js +40 -0
- package/src/resource.helpers.js +8 -1
- package/src/resources-list-options.js +169 -0
- package/src/resources-list-options.spec.js +141 -0
- package/src/resources.action-media.js +6 -0
- package/src/resources.queries.js +91 -6
- package/src/search-payload.js +10 -0
- package/src/search-payload.spec.js +20 -0
- package/src/search.task.js +2 -1
- package/src/share-resource.flow.js +59 -0
- package/src/sort-ascending.action.js +5 -0
- package/src/sort-by-created.action.js +5 -0
- package/src/sort-by-last-updated.action.js +5 -0
- package/src/sort-by-name.action.js +5 -0
- package/src/sort-by-size.action.js +5 -0
- package/src/sort-by-type.action.js +5 -0
- package/src/sort-descending.action.js +5 -0
- package/src/sort-storage-created.flow.js +87 -0
- package/src/sort-storage-last-updated.flow.js +119 -0
- package/src/sort-storage-name-asc.flow.js +34 -0
- package/src/sort-storage-size-desc.flow.js +34 -0
- package/src/sort-storage-size.flow.js +87 -0
- package/src/sort-storage-type.flow.js +83 -0
- package/src/sort-storage.flow.js +86 -0
- package/src/sv.translations.json +18 -0
- package/src/toggle-selection-mode.action.js +4 -0
- package/src/upload-file.flow.js +44 -0
- package/src/useResourceList.js +94 -0
- package/src/view-grid.action.js +5 -0
- package/src/view-list.action.js +5 -0
- package/src/view-storage.flow.js +49 -0
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import uploadFileFlow from './upload-file.flow.js'
|
|
2
|
+
import { metadata as OpenRename } from './open-rename.action.js'
|
|
3
|
+
import { metadata as UpdateResourceName } from './update-name.action.js'
|
|
4
|
+
import { metadata as RenameForm } from './rename.form.js'
|
|
5
|
+
|
|
6
|
+
export const metadata = {
|
|
7
|
+
id: '@ossy/resources/flows/rename-file',
|
|
8
|
+
feature: 'resources',
|
|
9
|
+
requires: ['server', 'database'],
|
|
10
|
+
timeout: 150_000,
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* After uploading a file, open rename in the resource panel, change the name,
|
|
15
|
+
* confirm, and see the new name in storage.
|
|
16
|
+
*/
|
|
17
|
+
export default {
|
|
18
|
+
title: 'Rename uploaded file',
|
|
19
|
+
description:
|
|
20
|
+
'Signed-in user uploads a file, renames it from the resource panel, and sees the new name in storage',
|
|
21
|
+
steps: [
|
|
22
|
+
...uploadFileFlow.steps,
|
|
23
|
+
{ click: { selector: '[data-resource-name="e2e-upload.txt"]' } },
|
|
24
|
+
{ result: { action: OpenRename, timeout: 15000 } },
|
|
25
|
+
{ action: OpenRename },
|
|
26
|
+
{ result: { action: UpdateResourceName, timeout: 10000 } },
|
|
27
|
+
{ form: RenameForm, fields: { name: 'e2e-renamed.txt' } },
|
|
28
|
+
{ action: UpdateResourceName },
|
|
29
|
+
{
|
|
30
|
+
result: {
|
|
31
|
+
selector: '[data-resource-name="e2e-renamed.txt"]',
|
|
32
|
+
timeout: 20000,
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
result: {
|
|
37
|
+
selector: '[data-resource-name="e2e-upload.txt"]',
|
|
38
|
+
hidden: true,
|
|
39
|
+
timeout: 10000,
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
],
|
|
43
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { useCallback, useMemo } from 'react'
|
|
2
|
+
import { useSearchParams } from '@ossy/router-react'
|
|
3
|
+
import { RESOURCE_LIST_SORT_FIELDS } from './resources-list-options.js'
|
|
4
|
+
|
|
5
|
+
export const RESOURCE_LIST_QUERY_PAGE = 'page'
|
|
6
|
+
export const RESOURCE_LIST_QUERY_SORT = 'sort'
|
|
7
|
+
export const RESOURCE_LIST_QUERY_ORDER = 'order'
|
|
8
|
+
|
|
9
|
+
export const DEFAULT_RESOURCE_LIST_SORT_BY = 'name'
|
|
10
|
+
export const DEFAULT_RESOURCE_LIST_SORT_ORDER = 'asc'
|
|
11
|
+
|
|
12
|
+
function normalizeSortBy (value, fallback = DEFAULT_RESOURCE_LIST_SORT_BY) {
|
|
13
|
+
if (typeof value !== 'string') return fallback
|
|
14
|
+
return RESOURCE_LIST_SORT_FIELDS.includes(value) ? value : fallback
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function normalizeSortOrder (value, fallback = DEFAULT_RESOURCE_LIST_SORT_ORDER) {
|
|
18
|
+
if (typeof value !== 'string') return fallback
|
|
19
|
+
const order = value.trim().toLowerCase()
|
|
20
|
+
return order === 'desc' ? 'desc' : 'asc'
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function normalizePageIndex (value) {
|
|
24
|
+
const n = Number(value)
|
|
25
|
+
if (!Number.isFinite(n)) return 0
|
|
26
|
+
return Math.max(0, Math.trunc(n))
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Parse storage list controls from a query object (`page` is 1-based).
|
|
31
|
+
*
|
|
32
|
+
* @param {Record<string, unknown>} [search]
|
|
33
|
+
* @param {{ defaultSortBy?: string, defaultSortOrder?: 'asc' | 'desc' }} [options]
|
|
34
|
+
*/
|
|
35
|
+
export function parseResourceListQuery (search = {}, {
|
|
36
|
+
defaultSortBy = DEFAULT_RESOURCE_LIST_SORT_BY,
|
|
37
|
+
defaultSortOrder = DEFAULT_RESOURCE_LIST_SORT_ORDER,
|
|
38
|
+
} = {}) {
|
|
39
|
+
const pageRaw = Number(search?.[RESOURCE_LIST_QUERY_PAGE])
|
|
40
|
+
const pageIndex = Number.isFinite(pageRaw) && pageRaw >= 1
|
|
41
|
+
? Math.trunc(pageRaw) - 1
|
|
42
|
+
: 0
|
|
43
|
+
|
|
44
|
+
return {
|
|
45
|
+
pageIndex,
|
|
46
|
+
sortBy: normalizeSortBy(search?.[RESOURCE_LIST_QUERY_SORT], defaultSortBy),
|
|
47
|
+
sortOrder: normalizeSortOrder(search?.[RESOURCE_LIST_QUERY_ORDER], defaultSortOrder),
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Serialize list controls for the URL. Defaults become `null` so they can be
|
|
53
|
+
* omitted / deleted (`page=1`, `sort=name`, `order=asc`).
|
|
54
|
+
*
|
|
55
|
+
* @param {{
|
|
56
|
+
* pageIndex?: number,
|
|
57
|
+
* sortBy?: string,
|
|
58
|
+
* sortOrder?: 'asc' | 'desc',
|
|
59
|
+
* }} [state]
|
|
60
|
+
* @param {{ defaultSortBy?: string, defaultSortOrder?: 'asc' | 'desc' }} [options]
|
|
61
|
+
* @returns {{ page: string | null, sort: string | null, order: string | null }}
|
|
62
|
+
*/
|
|
63
|
+
export function serializeResourceListQuery ({
|
|
64
|
+
pageIndex = 0,
|
|
65
|
+
sortBy = DEFAULT_RESOURCE_LIST_SORT_BY,
|
|
66
|
+
sortOrder = DEFAULT_RESOURCE_LIST_SORT_ORDER,
|
|
67
|
+
} = {}, {
|
|
68
|
+
defaultSortBy = DEFAULT_RESOURCE_LIST_SORT_BY,
|
|
69
|
+
defaultSortOrder = DEFAULT_RESOURCE_LIST_SORT_ORDER,
|
|
70
|
+
} = {}) {
|
|
71
|
+
const safePageIndex = normalizePageIndex(pageIndex)
|
|
72
|
+
const field = normalizeSortBy(sortBy, defaultSortBy)
|
|
73
|
+
const order = normalizeSortOrder(sortOrder, defaultSortOrder)
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
[RESOURCE_LIST_QUERY_PAGE]: safePageIndex > 0 ? String(safePageIndex + 1) : null,
|
|
77
|
+
[RESOURCE_LIST_QUERY_SORT]: field === defaultSortBy ? null : field,
|
|
78
|
+
[RESOURCE_LIST_QUERY_ORDER]: order === defaultSortOrder ? null : order,
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Query string fragment for list controls, without a leading `?`.
|
|
84
|
+
*/
|
|
85
|
+
export function resourceListQuerySearchString (state, options) {
|
|
86
|
+
return Object.entries(serializeResourceListQuery(state, options))
|
|
87
|
+
.filter(([, value]) => value != null)
|
|
88
|
+
.map(([key, value]) => `${key}=${encodeURIComponent(value)}`)
|
|
89
|
+
.join('&')
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Storage list page/sort as URL search params — same pattern as `r` for the
|
|
94
|
+
* open resource. Defaults stay out of the URL.
|
|
95
|
+
*
|
|
96
|
+
* @param {{
|
|
97
|
+
* defaultSortBy?: string,
|
|
98
|
+
* defaultSortOrder?: 'asc' | 'desc',
|
|
99
|
+
* }} [options]
|
|
100
|
+
*/
|
|
101
|
+
export function useResourceListQuery ({
|
|
102
|
+
defaultSortBy = DEFAULT_RESOURCE_LIST_SORT_BY,
|
|
103
|
+
defaultSortOrder = DEFAULT_RESOURCE_LIST_SORT_ORDER,
|
|
104
|
+
} = {}) {
|
|
105
|
+
const searchParams = useSearchParams()
|
|
106
|
+
const { pageIndex, sortBy, sortOrder } = parseResourceListQuery(searchParams, {
|
|
107
|
+
defaultSortBy,
|
|
108
|
+
defaultSortOrder,
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
const patch = useCallback((updates) => {
|
|
112
|
+
if (typeof searchParams.patch === 'function') {
|
|
113
|
+
searchParams.patch(updates)
|
|
114
|
+
return
|
|
115
|
+
}
|
|
116
|
+
for (const [name, value] of Object.entries(updates || {})) {
|
|
117
|
+
if (value == null || value === '') searchParams.delete(name)
|
|
118
|
+
else searchParams.set(name, value)
|
|
119
|
+
}
|
|
120
|
+
}, [searchParams.patch, searchParams.delete, searchParams.set])
|
|
121
|
+
|
|
122
|
+
const setPage = useCallback((nextIndex) => {
|
|
123
|
+
const nextPageIndex = normalizePageIndex(nextIndex)
|
|
124
|
+
patch({
|
|
125
|
+
[RESOURCE_LIST_QUERY_PAGE]: nextPageIndex > 0 ? String(nextPageIndex + 1) : null,
|
|
126
|
+
})
|
|
127
|
+
}, [patch])
|
|
128
|
+
|
|
129
|
+
const nextPage = useCallback(() => {
|
|
130
|
+
setPage(pageIndex + 1)
|
|
131
|
+
}, [pageIndex, setPage])
|
|
132
|
+
|
|
133
|
+
const previousPage = useCallback(() => {
|
|
134
|
+
setPage(Math.max(0, pageIndex - 1))
|
|
135
|
+
}, [pageIndex, setPage])
|
|
136
|
+
|
|
137
|
+
const resetPage = useCallback(() => {
|
|
138
|
+
setPage(0)
|
|
139
|
+
}, [setPage])
|
|
140
|
+
|
|
141
|
+
const setSort = useCallback((nextSortBy, nextSortOrder) => {
|
|
142
|
+
patch(serializeResourceListQuery({
|
|
143
|
+
pageIndex: 0,
|
|
144
|
+
sortBy: nextSortBy ?? sortBy,
|
|
145
|
+
sortOrder: nextSortOrder ?? sortOrder,
|
|
146
|
+
}, { defaultSortBy, defaultSortOrder }))
|
|
147
|
+
}, [patch, sortBy, sortOrder, defaultSortBy, defaultSortOrder])
|
|
148
|
+
|
|
149
|
+
const setSortBy = useCallback((nextSortBy) => {
|
|
150
|
+
if (nextSortBy == null) return
|
|
151
|
+
setSort(nextSortBy, sortOrder)
|
|
152
|
+
}, [setSort, sortOrder])
|
|
153
|
+
|
|
154
|
+
const setSortOrder = useCallback((nextSortOrder) => {
|
|
155
|
+
if (nextSortOrder == null) return
|
|
156
|
+
setSort(sortBy, nextSortOrder)
|
|
157
|
+
}, [setSort, sortBy])
|
|
158
|
+
|
|
159
|
+
return useMemo(() => ({
|
|
160
|
+
pageIndex,
|
|
161
|
+
sortBy,
|
|
162
|
+
sortOrder,
|
|
163
|
+
setPage,
|
|
164
|
+
nextPage,
|
|
165
|
+
previousPage,
|
|
166
|
+
resetPage,
|
|
167
|
+
setSort,
|
|
168
|
+
setSortBy,
|
|
169
|
+
setSortOrder,
|
|
170
|
+
}), [
|
|
171
|
+
pageIndex,
|
|
172
|
+
sortBy,
|
|
173
|
+
sortOrder,
|
|
174
|
+
setPage,
|
|
175
|
+
nextPage,
|
|
176
|
+
previousPage,
|
|
177
|
+
resetPage,
|
|
178
|
+
setSort,
|
|
179
|
+
setSortBy,
|
|
180
|
+
setSortOrder,
|
|
181
|
+
])
|
|
182
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { describe, expect, it } from '@jest/globals'
|
|
2
|
+
import {
|
|
3
|
+
DEFAULT_RESOURCE_LIST_SORT_BY,
|
|
4
|
+
DEFAULT_RESOURCE_LIST_SORT_ORDER,
|
|
5
|
+
parseResourceListQuery,
|
|
6
|
+
resourceListQuerySearchString,
|
|
7
|
+
serializeResourceListQuery,
|
|
8
|
+
} from './resource-list-query.js'
|
|
9
|
+
|
|
10
|
+
describe('resource-list-query', () => {
|
|
11
|
+
describe('parseResourceListQuery', () => {
|
|
12
|
+
it('defaults to page 0, name, asc', () => {
|
|
13
|
+
expect(parseResourceListQuery({})).toEqual({
|
|
14
|
+
pageIndex: 0,
|
|
15
|
+
sortBy: DEFAULT_RESOURCE_LIST_SORT_BY,
|
|
16
|
+
sortOrder: DEFAULT_RESOURCE_LIST_SORT_ORDER,
|
|
17
|
+
})
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('reads 1-based page from the query', () => {
|
|
21
|
+
expect(parseResourceListQuery({ page: '2' }).pageIndex).toBe(1)
|
|
22
|
+
expect(parseResourceListQuery({ page: '1' }).pageIndex).toBe(0)
|
|
23
|
+
expect(parseResourceListQuery({ page: '0' }).pageIndex).toBe(0)
|
|
24
|
+
expect(parseResourceListQuery({ page: 'nope' }).pageIndex).toBe(0)
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('accepts known sort fields and orders', () => {
|
|
28
|
+
expect(parseResourceListQuery({ sort: 'created', order: 'desc' })).toEqual({
|
|
29
|
+
pageIndex: 0,
|
|
30
|
+
sortBy: 'created',
|
|
31
|
+
sortOrder: 'desc',
|
|
32
|
+
})
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('ignores unknown sort fields', () => {
|
|
36
|
+
expect(parseResourceListQuery({ sort: 'mystery' }).sortBy).toBe('name')
|
|
37
|
+
})
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
describe('serializeResourceListQuery', () => {
|
|
41
|
+
it('omits defaults', () => {
|
|
42
|
+
expect(serializeResourceListQuery({
|
|
43
|
+
pageIndex: 0,
|
|
44
|
+
sortBy: 'name',
|
|
45
|
+
sortOrder: 'asc',
|
|
46
|
+
})).toEqual({
|
|
47
|
+
page: null,
|
|
48
|
+
sort: null,
|
|
49
|
+
order: null,
|
|
50
|
+
})
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('writes 1-based page and non-default sort', () => {
|
|
54
|
+
expect(serializeResourceListQuery({
|
|
55
|
+
pageIndex: 2,
|
|
56
|
+
sortBy: 'lastUpdated',
|
|
57
|
+
sortOrder: 'desc',
|
|
58
|
+
})).toEqual({
|
|
59
|
+
page: '3',
|
|
60
|
+
sort: 'lastUpdated',
|
|
61
|
+
order: 'desc',
|
|
62
|
+
})
|
|
63
|
+
})
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
describe('resourceListQuerySearchString', () => {
|
|
67
|
+
it('builds a query fragment without defaults or a leading ?', () => {
|
|
68
|
+
expect(resourceListQuerySearchString({
|
|
69
|
+
pageIndex: 0,
|
|
70
|
+
sortBy: 'size',
|
|
71
|
+
sortOrder: 'asc',
|
|
72
|
+
})).toBe('sort=size')
|
|
73
|
+
})
|
|
74
|
+
})
|
|
75
|
+
})
|
|
@@ -89,3 +89,28 @@ export function applyResourceSelectionGesture ({
|
|
|
89
89
|
nextAnchorId: targetId,
|
|
90
90
|
}
|
|
91
91
|
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Keep selected resource records so batch download/remove still work after
|
|
95
|
+
* paging away. Drops ids that are no longer selected; prefers `knownResources`
|
|
96
|
+
* (usually the current page) and falls back to the previous map.
|
|
97
|
+
*
|
|
98
|
+
* @param {Iterable<string> | Set<string>} selectedIds
|
|
99
|
+
* @param {Map<string, object> | null | undefined} previousById
|
|
100
|
+
* @param {object[]} [knownResources]
|
|
101
|
+
* @returns {Map<string, object>}
|
|
102
|
+
*/
|
|
103
|
+
export function syncSelectedResources (selectedIds, previousById, knownResources = []) {
|
|
104
|
+
const ids = selectedIds instanceof Set ? selectedIds : new Set(selectedIds || [])
|
|
105
|
+
const knownById = new Map()
|
|
106
|
+
for (const resource of knownResources) {
|
|
107
|
+
if (resource?.id) knownById.set(resource.id, resource)
|
|
108
|
+
}
|
|
109
|
+
const prev = previousById instanceof Map ? previousById : new Map()
|
|
110
|
+
const next = new Map()
|
|
111
|
+
for (const id of ids) {
|
|
112
|
+
const resource = knownById.get(id) ?? prev.get(id)
|
|
113
|
+
if (resource) next.set(id, resource)
|
|
114
|
+
}
|
|
115
|
+
return next
|
|
116
|
+
}
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
isMultiSelectGesture,
|
|
6
6
|
isRangeSelectModifier,
|
|
7
7
|
isToggleSelectModifier,
|
|
8
|
+
syncSelectedResources,
|
|
8
9
|
} from './resource-selection.js'
|
|
9
10
|
|
|
10
11
|
describe('resource-selection', () => {
|
|
@@ -74,4 +75,43 @@ describe('resource-selection', () => {
|
|
|
74
75
|
expect(result.nextAnchorId).toBe('a')
|
|
75
76
|
})
|
|
76
77
|
})
|
|
78
|
+
|
|
79
|
+
describe('syncSelectedResources', () => {
|
|
80
|
+
it('keeps previously selected resources that are not on the current page', () => {
|
|
81
|
+
const previous = new Map([
|
|
82
|
+
['a', { id: 'a', name: 'one' }],
|
|
83
|
+
['b', { id: 'b', name: 'two' }],
|
|
84
|
+
])
|
|
85
|
+
const next = syncSelectedResources(
|
|
86
|
+
new Set(['a', 'c']),
|
|
87
|
+
previous,
|
|
88
|
+
[{ id: 'c', name: 'three' }],
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
expect([...next.keys()]).toEqual(['a', 'c'])
|
|
92
|
+
expect(next.get('a')).toEqual({ id: 'a', name: 'one' })
|
|
93
|
+
expect(next.get('c')).toEqual({ id: 'c', name: 'three' })
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('prefers the current page record when both exist', () => {
|
|
97
|
+
const previous = new Map([['a', { id: 'a', name: 'stale' }]])
|
|
98
|
+
const next = syncSelectedResources(
|
|
99
|
+
['a'],
|
|
100
|
+
previous,
|
|
101
|
+
[{ id: 'a', name: 'fresh' }],
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
expect(next.get('a')).toEqual({ id: 'a', name: 'fresh' })
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('drops resources that are no longer selected', () => {
|
|
108
|
+
const previous = new Map([
|
|
109
|
+
['a', { id: 'a' }],
|
|
110
|
+
['b', { id: 'b' }],
|
|
111
|
+
])
|
|
112
|
+
const next = syncSelectedResources(new Set(['b']), previous, [])
|
|
113
|
+
|
|
114
|
+
expect([...next.keys()]).toEqual(['b'])
|
|
115
|
+
})
|
|
116
|
+
})
|
|
77
117
|
})
|
package/src/resource.helpers.js
CHANGED
|
@@ -34,10 +34,17 @@ export {
|
|
|
34
34
|
isMultiSelectGesture,
|
|
35
35
|
isRangeSelectModifier,
|
|
36
36
|
isToggleSelectModifier,
|
|
37
|
+
syncSelectedResources,
|
|
37
38
|
} from './resource-selection.js'
|
|
38
39
|
|
|
39
40
|
function invalidateLocation(sdk, location) {
|
|
40
|
-
|
|
41
|
+
const normalized = normalizeLocation(location)
|
|
42
|
+
if (typeof sdk.invalidateLocation === 'function') {
|
|
43
|
+
sdk.invalidateLocation(normalized)
|
|
44
|
+
return
|
|
45
|
+
}
|
|
46
|
+
// Fallback for older sdk-react builds: exact unpaginated key only.
|
|
47
|
+
sdk.invalidate(cacheKey(ListResources, { location: normalized }))
|
|
41
48
|
}
|
|
42
49
|
|
|
43
50
|
export async function createDocument(sdk, document) {
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
export const RESOURCE_LIST_SORT_FIELDS = Object.freeze([
|
|
2
|
+
'name',
|
|
3
|
+
'created',
|
|
4
|
+
'lastUpdated',
|
|
5
|
+
'size',
|
|
6
|
+
'type',
|
|
7
|
+
])
|
|
8
|
+
|
|
9
|
+
export const DEFAULT_RESOURCE_LIST_LIMIT = 50
|
|
10
|
+
export const MAX_RESOURCE_LIST_LIMIT = 200
|
|
11
|
+
|
|
12
|
+
const LIST_OPTION_KEYS = new Set(['limit', 'offset', 'sortBy', 'sortOrder', 'sort'])
|
|
13
|
+
|
|
14
|
+
export function isDirectoryResourceType(type) {
|
|
15
|
+
return type === '@ossy/platform/schema/directory' || type === 'directory'
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function resourceSizeOf(resource) {
|
|
19
|
+
const length = resource?.content?.ContentLength
|
|
20
|
+
if (typeof length === 'number' && Number.isFinite(length)) return length
|
|
21
|
+
if (typeof resource?.size === 'number' && Number.isFinite(resource.size)) return resource.size
|
|
22
|
+
return 0
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function parsePositiveInt(value, { fallback = null, min = 0, max = Number.MAX_SAFE_INTEGER } = {}) {
|
|
26
|
+
if (value === undefined || value === null || value === '') return fallback
|
|
27
|
+
const n = Number(value)
|
|
28
|
+
if (!Number.isFinite(n)) return fallback
|
|
29
|
+
return Math.min(max, Math.max(min, Math.trunc(n)))
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function normalizeSortField(value) {
|
|
33
|
+
if (typeof value !== 'string') return null
|
|
34
|
+
const field = value.trim()
|
|
35
|
+
return RESOURCE_LIST_SORT_FIELDS.includes(field) ? field : null
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function normalizeSortOrder(value) {
|
|
39
|
+
if (typeof value !== 'string') return 'asc'
|
|
40
|
+
const order = value.trim().toLowerCase()
|
|
41
|
+
return order === 'desc' ? 'desc' : 'asc'
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Pull pagination/sort controls out of a resources query object so they are not
|
|
46
|
+
* treated as Mongo field filters (e.g. `state.limit`).
|
|
47
|
+
*/
|
|
48
|
+
export function extractResourceListOptions(query = {}) {
|
|
49
|
+
const source = query && typeof query === 'object' ? query : {}
|
|
50
|
+
const rest = { ...source }
|
|
51
|
+
|
|
52
|
+
let sortBy = normalizeSortField(rest.sortBy)
|
|
53
|
+
let sortOrder = normalizeSortOrder(rest.sortOrder)
|
|
54
|
+
|
|
55
|
+
if (!sortBy && typeof rest.sort === 'string' && rest.sort.trim()) {
|
|
56
|
+
const [field, order] = rest.sort.trim().split(':')
|
|
57
|
+
sortBy = normalizeSortField(field)
|
|
58
|
+
if (order) sortOrder = normalizeSortOrder(order)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const limit = parsePositiveInt(rest.limit, {
|
|
62
|
+
fallback: null,
|
|
63
|
+
min: 1,
|
|
64
|
+
max: MAX_RESOURCE_LIST_LIMIT,
|
|
65
|
+
})
|
|
66
|
+
const offset = parsePositiveInt(rest.offset, {
|
|
67
|
+
fallback: 0,
|
|
68
|
+
min: 0,
|
|
69
|
+
}) ?? 0
|
|
70
|
+
|
|
71
|
+
for (const key of LIST_OPTION_KEYS) delete rest[key]
|
|
72
|
+
|
|
73
|
+
const paginated = limit != null
|
|
74
|
+
return {
|
|
75
|
+
filter: rest,
|
|
76
|
+
sortBy: sortBy ?? (paginated ? 'name' : null),
|
|
77
|
+
sortOrder,
|
|
78
|
+
limit,
|
|
79
|
+
offset,
|
|
80
|
+
paginated,
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function mergeResourceListParams(payload = {}, reqQuery = {}) {
|
|
85
|
+
const merged = { ...(payload?.query ?? {}) }
|
|
86
|
+
for (const key of LIST_OPTION_KEYS) {
|
|
87
|
+
if (payload?.[key] !== undefined) merged[key] = payload[key]
|
|
88
|
+
else if (reqQuery?.[key] !== undefined) merged[key] = reqQuery[key]
|
|
89
|
+
}
|
|
90
|
+
if (payload?.location !== undefined) merged.location = payload.location
|
|
91
|
+
else if (merged.location === undefined && reqQuery?.location !== undefined) {
|
|
92
|
+
merged.location = reqQuery.location
|
|
93
|
+
}
|
|
94
|
+
return merged
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function compareStrings(a, b) {
|
|
98
|
+
return String(a ?? '').localeCompare(String(b ?? ''), undefined, {
|
|
99
|
+
sensitivity: 'base',
|
|
100
|
+
numeric: true,
|
|
101
|
+
})
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function compareValues(a, b, sortBy) {
|
|
105
|
+
switch (sortBy) {
|
|
106
|
+
case 'created':
|
|
107
|
+
case 'lastUpdated':
|
|
108
|
+
return (Number(a?.[sortBy]) || 0) - (Number(b?.[sortBy]) || 0)
|
|
109
|
+
case 'size':
|
|
110
|
+
return resourceSizeOf(a) - resourceSizeOf(b)
|
|
111
|
+
case 'type':
|
|
112
|
+
return compareStrings(a?.type, b?.type)
|
|
113
|
+
case 'name':
|
|
114
|
+
default:
|
|
115
|
+
return compareStrings(a?.name, b?.name)
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Stable folder-first sort used by storage UI and list API defaults.
|
|
121
|
+
*/
|
|
122
|
+
export function sortResources(resources = [], { sortBy = 'name', sortOrder = 'asc' } = {}) {
|
|
123
|
+
const field = normalizeSortField(sortBy) ?? 'name'
|
|
124
|
+
const order = normalizeSortOrder(sortOrder)
|
|
125
|
+
const direction = order === 'desc' ? -1 : 1
|
|
126
|
+
|
|
127
|
+
return [...resources].sort((a, b) => {
|
|
128
|
+
const aDir = isDirectoryResourceType(a?.type)
|
|
129
|
+
const bDir = isDirectoryResourceType(b?.type)
|
|
130
|
+
if (aDir && !bDir) return -1
|
|
131
|
+
if (!aDir && bDir) return 1
|
|
132
|
+
const primary = compareValues(a, b, field) * direction
|
|
133
|
+
if (primary !== 0) return primary
|
|
134
|
+
return compareStrings(a?.name, b?.name)
|
|
135
|
+
})
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function paginateResources(resources = [], { limit = DEFAULT_RESOURCE_LIST_LIMIT, offset = 0 } = {}) {
|
|
139
|
+
const safeLimit = parsePositiveInt(limit, {
|
|
140
|
+
fallback: DEFAULT_RESOURCE_LIST_LIMIT,
|
|
141
|
+
min: 1,
|
|
142
|
+
max: MAX_RESOURCE_LIST_LIMIT,
|
|
143
|
+
})
|
|
144
|
+
const safeOffset = parsePositiveInt(offset, { fallback: 0, min: 0 }) ?? 0
|
|
145
|
+
const total = resources.length
|
|
146
|
+
const items = resources.slice(safeOffset, safeOffset + safeLimit)
|
|
147
|
+
return {
|
|
148
|
+
items,
|
|
149
|
+
total,
|
|
150
|
+
limit: safeLimit,
|
|
151
|
+
offset: safeOffset,
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function mongoSortForResourceList({ sortBy = 'name', sortOrder = 'asc' } = {}) {
|
|
156
|
+
const field = normalizeSortField(sortBy) ?? 'name'
|
|
157
|
+
const order = normalizeSortOrder(sortOrder) === 'desc' ? -1 : 1
|
|
158
|
+
const fieldPath = field === 'size'
|
|
159
|
+
? 'state.content.ContentLength'
|
|
160
|
+
: `state.${field}`
|
|
161
|
+
|
|
162
|
+
// Directories first, then the requested field. Secondary name keeps ties stable.
|
|
163
|
+
const sort = {
|
|
164
|
+
_isDirectory: 1,
|
|
165
|
+
[fieldPath]: order,
|
|
166
|
+
}
|
|
167
|
+
if (field !== 'name') sort['state.name'] = 1
|
|
168
|
+
return sort
|
|
169
|
+
}
|