@ossy/resources 3.3.0 → 3.5.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 +6 -6
- package/src/ImageResource.jsx +11 -3
- package/src/PlatformFileField.jsx +120 -85
- package/src/PlatformReferenceField.jsx +216 -0
- package/src/ResourceContentPage.jsx +5 -2
- package/src/ResourceGrid.jsx +321 -0
- package/src/ResourceGrid.stories.jsx +95 -0
- package/src/ResourceList.jsx +78 -8
- package/src/ResourcePanel.jsx +19 -2
- package/src/ResourceSelectionCheckbox.jsx +68 -0
- package/src/ResourcesPage.jsx +3 -0
- package/src/SchemaDetailView.jsx +18 -18
- package/src/SchemaPresenter.jsx +6 -4
- package/src/Upload.jsx +28 -13
- package/src/create.task.js +4 -0
- package/src/en.translations.json +4 -1
- package/src/get-resource.api.js +15 -0
- package/src/index.js +4 -0
- package/src/markdown.component.jsx +20 -0
- package/src/markdown.schema.js +13 -0
- package/src/markdown.spec.js +43 -0
- package/src/patch-content.action.js +1 -0
- package/src/patch-content.task.js +28 -0
- package/src/platform-reference-field.component.jsx +5 -0
- package/src/reference-field.helpers.js +57 -0
- package/src/reference-field.helpers.spec.js +100 -0
- package/src/resource-download.js +194 -0
- package/src/resource-download.spec.js +93 -0
- package/src/resource-read.helpers.js +5 -1
- package/src/resource-selection.js +91 -0
- package/src/resource-selection.spec.js +77 -0
- package/src/resource-upload-access.js +63 -0
- package/src/resource-upload-access.spec.js +100 -0
- package/src/resource.helpers.js +30 -5
- package/src/resources.attach-reference-links.js +100 -0
- package/src/resources.attach-reference-links.spec.js +118 -0
- package/src/resources.validate-references.js +104 -0
- package/src/resources.validate-references.spec.js +182 -0
- package/src/schema-field-display.js +12 -0
- package/src/server.js +13 -0
- package/src/sv.translations.json +4 -1
- package/src/update-content.task.js +4 -0
package/src/SchemaDetailView.jsx
CHANGED
|
@@ -1,15 +1,23 @@
|
|
|
1
1
|
import React from 'react'
|
|
2
|
-
import { Text, View, useLocale, hasTranslation } from '@ossy/design-system'
|
|
2
|
+
import { Text, View, MarkdownViewer, useLocale, hasTranslation } from '@ossy/design-system'
|
|
3
|
+
import { shouldRenderFieldAsMarkdown } from './schema-field-display.js'
|
|
3
4
|
|
|
4
5
|
const labelStyle = { fontWeight: 600, opacity: 0.65 }
|
|
5
6
|
const valueStyle = { whiteSpace: 'pre-wrap' }
|
|
6
7
|
|
|
8
|
+
function renderPlainFieldValue (value) {
|
|
9
|
+
if (value == null || value === '') return '—'
|
|
10
|
+
if (typeof value === 'boolean') return value ? 'Yes' : 'No'
|
|
11
|
+
if (typeof value === 'object') return JSON.stringify(value, null, 2)
|
|
12
|
+
return String(value)
|
|
13
|
+
}
|
|
14
|
+
|
|
7
15
|
/**
|
|
8
16
|
* Generic read-only detail projection for schema-backed resources.
|
|
9
17
|
*
|
|
10
18
|
* @param {{
|
|
11
19
|
* schemaId?: string,
|
|
12
|
-
* fields?: { name: string, label?: string, derived?: boolean }[],
|
|
20
|
+
* fields?: { name: string, label?: string, type?: string, derived?: boolean }[],
|
|
13
21
|
* data?: Record<string, unknown>,
|
|
14
22
|
* title?: string,
|
|
15
23
|
* emptyMessage?: string,
|
|
@@ -39,13 +47,7 @@ export function SchemaDetailView ({
|
|
|
39
47
|
<View gap="xs" key={name}>
|
|
40
48
|
<Text variant="small" style={labelStyle}>{name}</Text>
|
|
41
49
|
<Text variant="small" style={valueStyle}>
|
|
42
|
-
{value
|
|
43
|
-
? '—'
|
|
44
|
-
: typeof value === 'boolean'
|
|
45
|
-
? (value ? 'Yes' : 'No')
|
|
46
|
-
: typeof value === 'object'
|
|
47
|
-
? JSON.stringify(value, null, 2)
|
|
48
|
-
: String(value)}
|
|
50
|
+
{renderPlainFieldValue(value)}
|
|
49
51
|
</Text>
|
|
50
52
|
</View>
|
|
51
53
|
))
|
|
@@ -80,15 +82,13 @@ export function SchemaDetailView ({
|
|
|
80
82
|
: (
|
|
81
83
|
<Text variant="small" style={labelStyle}>{field.name}</Text>
|
|
82
84
|
)}
|
|
83
|
-
|
|
84
|
-
{value
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
: String(value)}
|
|
91
|
-
</Text>
|
|
85
|
+
{shouldRenderFieldAsMarkdown(field, value)
|
|
86
|
+
? <MarkdownViewer>{value}</MarkdownViewer>
|
|
87
|
+
: (
|
|
88
|
+
<Text variant="small" style={valueStyle}>
|
|
89
|
+
{renderPlainFieldValue(value)}
|
|
90
|
+
</Text>
|
|
91
|
+
)}
|
|
92
92
|
</View>
|
|
93
93
|
)
|
|
94
94
|
})}
|
package/src/SchemaPresenter.jsx
CHANGED
|
@@ -185,16 +185,18 @@ export function SchemaPresenter ({
|
|
|
185
185
|
return <Text>directory</Text>
|
|
186
186
|
}
|
|
187
187
|
|
|
188
|
+
// Document schemas before MIME: schema ids contain `/` and must not be
|
|
189
|
+
// treated as media types (see isMimeType).
|
|
190
|
+
if (isDocumentSchema) {
|
|
191
|
+
return <SchemaViewPresenter resource={resource} projection={projection} resourceId={resourceId} />
|
|
192
|
+
}
|
|
193
|
+
|
|
188
194
|
// File envelope is a registered schema, but View must use MIME presenters (image/video/…).
|
|
189
195
|
const mime = resolveMediaType(resource)
|
|
190
196
|
if (resource.type === '@ossy/platform/schema/file' || isMimeType(mime)) {
|
|
191
197
|
return <MimeViewPresenter resource={resource} projection={projection} resourceId={resourceId} onClose={onClose} />
|
|
192
198
|
}
|
|
193
199
|
|
|
194
|
-
if (isDocumentSchema) {
|
|
195
|
-
return <SchemaViewPresenter resource={resource} projection={projection} resourceId={resourceId} />
|
|
196
|
-
}
|
|
197
|
-
|
|
198
200
|
return <ResourceGenericView resourceId={resourceId} onClose={onClose} />
|
|
199
201
|
}
|
|
200
202
|
|
package/src/Upload.jsx
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
import React, { useState } from 'react'
|
|
2
|
-
import { Button, View, Text, UploadInput, Icon, LocalFilePreview } from '@ossy/design-system'
|
|
3
|
-
import { useRouter } from '@ossy/router-react'
|
|
1
|
+
import React, { useCallback, useState } from 'react'
|
|
2
|
+
import { Button, View, Text, UploadInput, Icon, LocalFilePreview, DropZone } from '@ossy/design-system'
|
|
4
3
|
|
|
5
4
|
const FlowStage = {
|
|
6
5
|
Error: 'Error',
|
|
@@ -14,8 +13,6 @@ export const Upload = ({
|
|
|
14
13
|
onCancel: _onCancel = () => {},
|
|
15
14
|
onDone,
|
|
16
15
|
}) => {
|
|
17
|
-
const router = useRouter()
|
|
18
|
-
const location = router.searchParams.location
|
|
19
16
|
const finishUpload = onDone ?? _onCancel
|
|
20
17
|
const [files, setFilesMetadata] = useState([])
|
|
21
18
|
const flowStage = getFlowStage(files)
|
|
@@ -37,10 +34,21 @@ export const Upload = ({
|
|
|
37
34
|
setFilesMetadata(files => files.filter(x => x.file !== file))
|
|
38
35
|
}
|
|
39
36
|
|
|
37
|
+
const setPreviewFiles = useCallback((fileList) => {
|
|
38
|
+
const next = Array.from(fileList || [])
|
|
39
|
+
if (!next.length) return
|
|
40
|
+
setFilesMetadata(next.map(file => ({ file, status: 'preview' })))
|
|
41
|
+
}, [])
|
|
42
|
+
|
|
40
43
|
const onUserInput = (e) => {
|
|
41
|
-
|
|
44
|
+
setPreviewFiles(e.target.files)
|
|
45
|
+
e.target.value = ''
|
|
42
46
|
}
|
|
43
47
|
|
|
48
|
+
const onFilesDrop = useCallback((dropped) => {
|
|
49
|
+
setPreviewFiles(dropped)
|
|
50
|
+
}, [setPreviewFiles])
|
|
51
|
+
|
|
44
52
|
const onCancel = () => {
|
|
45
53
|
setFilesMetadata([])
|
|
46
54
|
_onCancel()
|
|
@@ -68,13 +76,20 @@ export const Upload = ({
|
|
|
68
76
|
}
|
|
69
77
|
|
|
70
78
|
{files.length === 0 && (
|
|
71
|
-
<
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
79
|
+
<DropZone onFilesDrop={onFilesDrop}>
|
|
80
|
+
<View
|
|
81
|
+
data-ossy-upload-dropzone
|
|
82
|
+
style={{ flexGrow: '1', display: 'flex', minHeight: '12rem' }}
|
|
83
|
+
>
|
|
84
|
+
<UploadInput
|
|
85
|
+
id="upload-resources"
|
|
86
|
+
type="file"
|
|
87
|
+
multiple
|
|
88
|
+
onChange={onUserInput}
|
|
89
|
+
style={{ flexGrow: '1', borderRadius: 'var(--space-s)', width: '100%' }}
|
|
90
|
+
/>
|
|
91
|
+
</View>
|
|
92
|
+
</DropZone>
|
|
78
93
|
)}
|
|
79
94
|
|
|
80
95
|
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 'var(--space-m)' }}>
|
package/src/create.task.js
CHANGED
|
@@ -7,6 +7,7 @@ import { originalObjectKey } from '@ossy/platform/storage-keys'
|
|
|
7
7
|
import { commitResource } from './resource-stream.helpers.js'
|
|
8
8
|
import { ResourcesEvents } from './resources.events.js'
|
|
9
9
|
import { mediaContextFromRun, withResourceMedia } from './resources.action-media.js'
|
|
10
|
+
import { assertResourceReferencesExist } from './resources.validate-references.js'
|
|
10
11
|
|
|
11
12
|
export const metadata = { id: '@ossy/resources/tasks/create' }
|
|
12
13
|
|
|
@@ -51,6 +52,9 @@ export async function run ({ payload, integrations, req }) {
|
|
|
51
52
|
const first = result.errors[0]
|
|
52
53
|
throw Object.assign(new Error(first.message), { status: 400, type: first.code })
|
|
53
54
|
}
|
|
55
|
+
await assertResourceReferencesExist(schema, result.data, {
|
|
56
|
+
workspaceId: workspace.id,
|
|
57
|
+
})
|
|
54
58
|
const event = ResourcesEvents.Created({
|
|
55
59
|
schemaId: type,
|
|
56
60
|
createdBy,
|
package/src/en.translations.json
CHANGED
|
@@ -45,5 +45,8 @@
|
|
|
45
45
|
"resources.createDirectory.namePlaceholder": "Directory name",
|
|
46
46
|
"resources.createDirectory.submit": "Create directory",
|
|
47
47
|
"resources.createDirectory.errorEmpty": "Directory name cannot be empty",
|
|
48
|
-
"resources.createDirectory.errorOssyPrefix": "Directory name cannot start with @ossy"
|
|
48
|
+
"resources.createDirectory.errorOssyPrefix": "Directory name cannot start with @ossy",
|
|
49
|
+
"resources.panel.download": "Download",
|
|
50
|
+
"@ossy/resources/schema/markdown.body.label": "Body",
|
|
51
|
+
"@ossy/resources/schema/markdown.body.description": "Markdown content (headings, lists, links, code)."
|
|
49
52
|
}
|
package/src/get-resource.api.js
CHANGED
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
import {
|
|
2
|
+
contentDispositionAttachment,
|
|
3
|
+
downloadFilenameForResource,
|
|
4
|
+
wantsAttachmentDownload,
|
|
5
|
+
} from './resource-download.js'
|
|
1
6
|
import {
|
|
2
7
|
loadResourceForRead,
|
|
3
8
|
parseResourceReadTarget,
|
|
@@ -19,17 +24,27 @@ async function handleRead(req, res) {
|
|
|
19
24
|
|
|
20
25
|
try {
|
|
21
26
|
const resource = await loadResourceForRead(target.resourceId, req)
|
|
27
|
+
const asAttachment = wantsAttachmentDownload(req)
|
|
22
28
|
const filePayload = await readResourceFileBytes(resource, { variant: target.variant })
|
|
23
29
|
|
|
24
30
|
if (filePayload?.buffer) {
|
|
25
31
|
if (filePayload.contentType) {
|
|
26
32
|
res.setHeader('Content-Type', filePayload.contentType)
|
|
27
33
|
}
|
|
34
|
+
if (asAttachment) {
|
|
35
|
+
res.setHeader('Content-Disposition', contentDispositionAttachment(downloadFilenameForResource(resource)))
|
|
36
|
+
}
|
|
28
37
|
res.status(200).send(filePayload.buffer)
|
|
29
38
|
return
|
|
30
39
|
}
|
|
31
40
|
|
|
32
41
|
if (resource.content && typeof resource.content === 'object' && !resource.content.Key) {
|
|
42
|
+
if (asAttachment) {
|
|
43
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8')
|
|
44
|
+
res.setHeader('Content-Disposition', contentDispositionAttachment(downloadFilenameForResource(resource)))
|
|
45
|
+
res.status(200).send(JSON.stringify(resource.content, null, 2))
|
|
46
|
+
return
|
|
47
|
+
}
|
|
33
48
|
res.status(200).json(resource.content)
|
|
34
49
|
return
|
|
35
50
|
}
|
package/src/index.js
CHANGED
|
@@ -4,6 +4,7 @@ export { metadata as ListResources } from './list.action.js'
|
|
|
4
4
|
export { metadata as SearchResources } from './search.action.js'
|
|
5
5
|
export { metadata as DeleteResource } from './delete.action.js'
|
|
6
6
|
export { metadata as UpdateResourceContent } from './update-content.action.js'
|
|
7
|
+
export { metadata as PatchResourceContent } from './patch-content.action.js'
|
|
7
8
|
export { metadata as UpdateResourceLocation } from './update-location.action.js'
|
|
8
9
|
export { metadata as UpdateResourceName } from './update-name.action.js'
|
|
9
10
|
export { metadata as UpdateResourceAccess } from './update-access.action.js'
|
|
@@ -28,11 +29,14 @@ export * from './GenericResourceForm.jsx'
|
|
|
28
29
|
export * from './GenericResourceCard.jsx'
|
|
29
30
|
export * from './ResourceGenericView.jsx'
|
|
30
31
|
export * from './ResourceList.jsx'
|
|
32
|
+
export * from './ResourceGrid.jsx'
|
|
33
|
+
export * from './ResourceSelectionCheckbox.jsx'
|
|
31
34
|
export * from './ResourcePage.jsx'
|
|
32
35
|
export * from './ResourcePanel.jsx'
|
|
33
36
|
export * from './ResourceTags.jsx'
|
|
34
37
|
export * from './ResourcesPage.jsx'
|
|
35
38
|
export * from './PlatformFileField.jsx'
|
|
39
|
+
export * from './PlatformReferenceField.jsx'
|
|
36
40
|
export * from './Upload.jsx'
|
|
37
41
|
export * from './UploadResources.jsx'
|
|
38
42
|
export * from './VideoResource.jsx'
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { View, Text, MarkdownViewer } from '@ossy/design-system'
|
|
3
|
+
|
|
4
|
+
export const metadata = { id: '@ossy/resources/view/markdown' }
|
|
5
|
+
|
|
6
|
+
/** Markdown document detail view — `@ossy/resources/view/markdown`. */
|
|
7
|
+
export default function MarkdownViewComponent ({ resource }) {
|
|
8
|
+
if (!resource) return null
|
|
9
|
+
|
|
10
|
+
const body = resource.content?.body
|
|
11
|
+
|
|
12
|
+
return (
|
|
13
|
+
<View stack gap="m" inset="m">
|
|
14
|
+
<Text variant="heading-default" as="h1">{resource.name}</Text>
|
|
15
|
+
{typeof body === 'string' && body.trim()
|
|
16
|
+
? <MarkdownViewer>{body}</MarkdownViewer>
|
|
17
|
+
: <Text color="secondary">No content</Text>}
|
|
18
|
+
</View>
|
|
19
|
+
)
|
|
20
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export default {
|
|
2
|
+
id: '@ossy/resources/schema/markdown',
|
|
3
|
+
name: 'Markdown',
|
|
4
|
+
icon: 'file-document',
|
|
5
|
+
description: 'A Markdown document for notes, docs, and long-form content.',
|
|
6
|
+
fields: [
|
|
7
|
+
{
|
|
8
|
+
name: 'body',
|
|
9
|
+
type: 'richtext',
|
|
10
|
+
description: 'Markdown content (headings, lists, links, code).',
|
|
11
|
+
},
|
|
12
|
+
],
|
|
13
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { describe, expect, it } from '@jest/globals'
|
|
2
|
+
import markdownSchema from './markdown.schema.js'
|
|
3
|
+
import { shouldRenderFieldAsMarkdown } from './schema-field-display.js'
|
|
4
|
+
import { resolveViewComponentChain, resolveFormComponentChain } from '@ossy/schema/resolve'
|
|
5
|
+
import { isCanonicalSchemaId, viewComponentId } from '@ossy/schema'
|
|
6
|
+
|
|
7
|
+
describe('@ossy/resources/schema/markdown', () => {
|
|
8
|
+
it('exports a canonical markdown document schema', () => {
|
|
9
|
+
expect(isCanonicalSchemaId(markdownSchema.id)).toBe(true)
|
|
10
|
+
expect(markdownSchema.id).toBe('@ossy/resources/schema/markdown')
|
|
11
|
+
expect(markdownSchema.name).toBe('Markdown')
|
|
12
|
+
expect(markdownSchema.fields).toEqual([
|
|
13
|
+
expect.objectContaining({ name: 'body', type: 'richtext' }),
|
|
14
|
+
])
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('resolves to the markdown view then the generic detail fallback', () => {
|
|
18
|
+
expect(viewComponentId(markdownSchema.id)).toBe('@ossy/resources/view/markdown')
|
|
19
|
+
expect(resolveViewComponentChain(markdownSchema.id)).toEqual([
|
|
20
|
+
'@ossy/resources/view/markdown',
|
|
21
|
+
'@ossy/resources/view/detail',
|
|
22
|
+
])
|
|
23
|
+
expect(resolveFormComponentChain(markdownSchema.id)).toEqual([
|
|
24
|
+
'@ossy/resources/form/markdown',
|
|
25
|
+
'@ossy/resources/form/default',
|
|
26
|
+
])
|
|
27
|
+
})
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
describe('shouldRenderFieldAsMarkdown', () => {
|
|
31
|
+
it('renders non-empty richtext strings as Markdown', () => {
|
|
32
|
+
expect(shouldRenderFieldAsMarkdown({ type: 'richtext' }, '# Hello')).toBe(true)
|
|
33
|
+
expect(shouldRenderFieldAsMarkdown({ type: 'richtext' }, ' - item ')).toBe(true)
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('skips empty, whitespace, and non-richtext values', () => {
|
|
37
|
+
expect(shouldRenderFieldAsMarkdown({ type: 'richtext' }, '')).toBe(false)
|
|
38
|
+
expect(shouldRenderFieldAsMarkdown({ type: 'richtext' }, ' ')).toBe(false)
|
|
39
|
+
expect(shouldRenderFieldAsMarkdown({ type: 'textarea' }, '# Hello')).toBe(false)
|
|
40
|
+
expect(shouldRenderFieldAsMarkdown({ type: 'richtext' }, null)).toBe(false)
|
|
41
|
+
expect(shouldRenderFieldAsMarkdown(null, '# Hello')).toBe(false)
|
|
42
|
+
})
|
|
43
|
+
})
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const metadata = { id: '@ossy/resources/actions/patch-content', access: 'workspace' }
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { ResourcesEvents } from './resources.events.js'
|
|
2
|
+
import { mutateResource } from './resource-stream.helpers.js'
|
|
3
|
+
import { resolveResourceId } from './resource-read.helpers.js'
|
|
4
|
+
import { mediaContextFromRun, withResourceMedia } from './resources.action-media.js'
|
|
5
|
+
|
|
6
|
+
export const metadata = { id: '@ossy/resources/tasks/patch-content' }
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Deep-merge patch into resource `content` (ADR 0008 `Patched`).
|
|
10
|
+
* Prefer this over update-content when adding fields like blurhash/sizes
|
|
11
|
+
* without replacing the full content document.
|
|
12
|
+
*/
|
|
13
|
+
export async function run ({ payload, req }) {
|
|
14
|
+
const createdBy = payload?.userId ?? req?.userId
|
|
15
|
+
const resourceId = resolveResourceId({ payload, req })
|
|
16
|
+
const content = payload?.content
|
|
17
|
+
|
|
18
|
+
if (!resourceId) throw Object.assign(new Error('resourceId is required'), { status: 400 })
|
|
19
|
+
if (!content || typeof content !== 'object' || Array.isArray(content)) {
|
|
20
|
+
throw Object.assign(new Error('content is required'), { status: 400 })
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const resource = await mutateResource(
|
|
24
|
+
resourceId,
|
|
25
|
+
ResourcesEvents.Patched({ createdBy, content }),
|
|
26
|
+
)
|
|
27
|
+
return withResourceMedia(resource, mediaContextFromRun({ payload, req }))
|
|
28
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure helpers for document reference fields (`type: 'reference'`).
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export function resourceIdFromValue(value) {
|
|
6
|
+
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
7
|
+
return typeof value.resourceId === 'string' ? value.resourceId : null
|
|
8
|
+
}
|
|
9
|
+
return null
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Stored `{ resourceId }[]` when `max > 1`. */
|
|
13
|
+
export function resourceIdsFromValue(value) {
|
|
14
|
+
if (!Array.isArray(value)) return []
|
|
15
|
+
return value
|
|
16
|
+
.map(item => (item && typeof item.resourceId === 'string' ? item.resourceId : null))
|
|
17
|
+
.filter(Boolean)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Display label for a resource option in the reference picker. */
|
|
21
|
+
export function resourcePickerLabel(resource) {
|
|
22
|
+
if (!resource || typeof resource !== 'object') return ''
|
|
23
|
+
const name = typeof resource.name === 'string' ? resource.name.trim() : ''
|
|
24
|
+
if (name) return name
|
|
25
|
+
return typeof resource.id === 'string' ? resource.id : ''
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Sort resources for the picker: named first (A–Z), then by id.
|
|
30
|
+
* @param {Array<{ id?: string, name?: string }>} resources
|
|
31
|
+
*/
|
|
32
|
+
export function sortResourcesForPicker(resources) {
|
|
33
|
+
if (!Array.isArray(resources)) return []
|
|
34
|
+
return [...resources].sort((a, b) => {
|
|
35
|
+
const labelA = resourcePickerLabel(a).toLowerCase()
|
|
36
|
+
const labelB = resourcePickerLabel(b).toLowerCase()
|
|
37
|
+
if (labelA < labelB) return -1
|
|
38
|
+
if (labelA > labelB) return 1
|
|
39
|
+
return 0
|
|
40
|
+
})
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function commitResourceValue(name, value, onChange) {
|
|
44
|
+
onChange({
|
|
45
|
+
target: {
|
|
46
|
+
id: name,
|
|
47
|
+
name,
|
|
48
|
+
dataset: { ossyFileResourceReplace: 'true' },
|
|
49
|
+
value: JSON.stringify(value),
|
|
50
|
+
},
|
|
51
|
+
})
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Build the stored content value for a multi-reference field. */
|
|
55
|
+
export function toReferenceArray(resourceIds) {
|
|
56
|
+
return resourceIds.map(id => ({ resourceId: id }))
|
|
57
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { describe, expect, it, jest } from '@jest/globals'
|
|
2
|
+
import {
|
|
3
|
+
commitResourceValue,
|
|
4
|
+
resourceIdFromValue,
|
|
5
|
+
resourceIdsFromValue,
|
|
6
|
+
resourcePickerLabel,
|
|
7
|
+
sortResourcesForPicker,
|
|
8
|
+
toReferenceArray,
|
|
9
|
+
} from './reference-field.helpers.js'
|
|
10
|
+
|
|
11
|
+
describe('reference-field.helpers', () => {
|
|
12
|
+
describe('resourceIdFromValue', () => {
|
|
13
|
+
it('reads resourceId from a stored reference object', () => {
|
|
14
|
+
expect(resourceIdFromValue({ resourceId: 'abc' })).toBe('abc')
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('returns null for missing or invalid values', () => {
|
|
18
|
+
expect(resourceIdFromValue(null)).toBe(null)
|
|
19
|
+
expect(resourceIdFromValue('abc')).toBe(null)
|
|
20
|
+
expect(resourceIdFromValue({ resourceId: 1 })).toBe(null)
|
|
21
|
+
expect(resourceIdFromValue([{ resourceId: 'abc' }])).toBe(null)
|
|
22
|
+
})
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
describe('resourceIdsFromValue', () => {
|
|
26
|
+
it('reads resourceIds from a multi-reference array', () => {
|
|
27
|
+
expect(resourceIdsFromValue([{ resourceId: 'a' }, { resourceId: 'b' }])).toEqual(['a', 'b'])
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('skips invalid entries and non-arrays', () => {
|
|
31
|
+
expect(resourceIdsFromValue(null)).toEqual([])
|
|
32
|
+
expect(resourceIdsFromValue({ resourceId: 'a' })).toEqual([])
|
|
33
|
+
expect(resourceIdsFromValue([{ resourceId: 'a' }, null, { resourceId: 1 }, { resourceId: 'b' }])).toEqual([
|
|
34
|
+
'a',
|
|
35
|
+
'b',
|
|
36
|
+
])
|
|
37
|
+
})
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
describe('toReferenceArray', () => {
|
|
41
|
+
it('maps ids to stored reference objects', () => {
|
|
42
|
+
expect(toReferenceArray(['a', 'b'])).toEqual([{ resourceId: 'a' }, { resourceId: 'b' }])
|
|
43
|
+
})
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
describe('resourcePickerLabel', () => {
|
|
47
|
+
it('prefers trimmed name over id', () => {
|
|
48
|
+
expect(resourcePickerLabel({ id: 'r1', name: ' Author ' })).toBe('Author')
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('falls back to id when name is empty', () => {
|
|
52
|
+
expect(resourcePickerLabel({ id: 'r1', name: ' ' })).toBe('r1')
|
|
53
|
+
expect(resourcePickerLabel({ id: 'r1' })).toBe('r1')
|
|
54
|
+
})
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
describe('sortResourcesForPicker', () => {
|
|
58
|
+
it('sorts by label case-insensitively', () => {
|
|
59
|
+
const sorted = sortResourcesForPicker([
|
|
60
|
+
{ id: '2', name: 'zeta' },
|
|
61
|
+
{ id: '1', name: 'Alpha' },
|
|
62
|
+
{ id: '3', name: 'beta' },
|
|
63
|
+
])
|
|
64
|
+
expect(sorted.map(r => r.id)).toEqual(['1', '3', '2'])
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('returns empty array for non-arrays', () => {
|
|
68
|
+
expect(sortResourcesForPicker(null)).toEqual([])
|
|
69
|
+
})
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
describe('commitResourceValue', () => {
|
|
73
|
+
it('emits a file-replace shaped change event', () => {
|
|
74
|
+
const onChange = jest.fn()
|
|
75
|
+
commitResourceValue('author', { resourceId: 'r1' }, onChange)
|
|
76
|
+
expect(onChange).toHaveBeenCalledWith({
|
|
77
|
+
target: {
|
|
78
|
+
id: 'author',
|
|
79
|
+
name: 'author',
|
|
80
|
+
dataset: { ossyFileResourceReplace: 'true' },
|
|
81
|
+
value: JSON.stringify({ resourceId: 'r1' }),
|
|
82
|
+
},
|
|
83
|
+
})
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('emits arrays for multi-reference values', () => {
|
|
87
|
+
const onChange = jest.fn()
|
|
88
|
+
const value = toReferenceArray(['a', 'b'])
|
|
89
|
+
commitResourceValue('authors', value, onChange)
|
|
90
|
+
expect(onChange).toHaveBeenCalledWith({
|
|
91
|
+
target: {
|
|
92
|
+
id: 'authors',
|
|
93
|
+
name: 'authors',
|
|
94
|
+
dataset: { ossyFileResourceReplace: 'true' },
|
|
95
|
+
value: JSON.stringify([{ resourceId: 'a' }, { resourceId: 'b' }]),
|
|
96
|
+
},
|
|
97
|
+
})
|
|
98
|
+
})
|
|
99
|
+
})
|
|
100
|
+
})
|