@ossy/resources 3.4.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 +4 -4
- package/src/PlatformFileField.jsx +120 -85
- package/src/PlatformReferenceField.jsx +216 -0
- package/src/ResourceContentPage.jsx +2 -2
- package/src/ResourceGrid.jsx +44 -4
- package/src/ResourceList.jsx +38 -4
- package/src/ResourcePanel.jsx +19 -2
- package/src/ResourceSelectionCheckbox.jsx +68 -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 +2 -0
- package/src/markdown.component.jsx +20 -0
- package/src/markdown.schema.js +13 -0
- package/src/markdown.spec.js +43 -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/ResourceList.jsx
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
List,
|
|
14
14
|
} from '@ossy/design-system'
|
|
15
15
|
import { formatBytes } from './utils/format-bytes.js'
|
|
16
|
+
import { ResourceSelectionCheckbox } from './ResourceSelectionCheckbox.jsx'
|
|
16
17
|
|
|
17
18
|
function useSchemaMap() {
|
|
18
19
|
const templates = useSchemas()
|
|
@@ -25,9 +26,16 @@ function useSchemaMap() {
|
|
|
25
26
|
export const ResourceList = ({
|
|
26
27
|
resources = [],
|
|
27
28
|
onClick = () => {},
|
|
29
|
+
onToggleSelect,
|
|
28
30
|
inlineFolder = null,
|
|
31
|
+
selectedIds,
|
|
32
|
+
showSelectionControls = false,
|
|
29
33
|
}) => {
|
|
30
34
|
const templateMap = useSchemaMap()
|
|
35
|
+
const selectedSet = useMemo(() => {
|
|
36
|
+
if (selectedIds instanceof Set) return selectedIds
|
|
37
|
+
return new Set(selectedIds || [])
|
|
38
|
+
}, [selectedIds])
|
|
31
39
|
|
|
32
40
|
return (
|
|
33
41
|
<List>
|
|
@@ -38,6 +46,9 @@ export const ResourceList = ({
|
|
|
38
46
|
resource={resource}
|
|
39
47
|
templateMap={templateMap}
|
|
40
48
|
onItemClick={onClick}
|
|
49
|
+
onToggleSelect={onToggleSelect}
|
|
50
|
+
showSelectionControls={showSelectionControls}
|
|
51
|
+
selected={selectedSet.has(resource.id) || Boolean(resource.selected)}
|
|
41
52
|
/>
|
|
42
53
|
))}
|
|
43
54
|
</List>
|
|
@@ -102,16 +113,26 @@ const ResourceListItem = memo(function ResourceListItem({
|
|
|
102
113
|
resource,
|
|
103
114
|
templateMap,
|
|
104
115
|
onItemClick,
|
|
116
|
+
onToggleSelect,
|
|
117
|
+
showSelectionControls = false,
|
|
118
|
+
selected = false,
|
|
105
119
|
}) {
|
|
106
120
|
const { onClick, href, onDrop, onFilesDrop, dragData, actions, name, type, content } = resource
|
|
107
121
|
const [menuOpen, setMenuOpen] = useState(false)
|
|
108
122
|
const [menuPosition, setMenuPosition] = useState(null)
|
|
109
123
|
|
|
110
|
-
const handleClick = useCallback(() => {
|
|
111
|
-
|
|
112
|
-
|
|
124
|
+
const handleClick = useCallback((event) => {
|
|
125
|
+
if (event?.metaKey || event?.ctrlKey || event?.shiftKey) {
|
|
126
|
+
event.preventDefault?.()
|
|
127
|
+
}
|
|
128
|
+
onItemClick?.(resource, event)
|
|
129
|
+
onClick?.(resource, event)
|
|
113
130
|
}, [onItemClick, onClick, resource])
|
|
114
131
|
|
|
132
|
+
const handleToggleSelect = useCallback((event) => {
|
|
133
|
+
onToggleSelect?.(resource, event)
|
|
134
|
+
}, [onToggleSelect, resource])
|
|
135
|
+
|
|
115
136
|
const handleContextMenu = useCallback((event) => {
|
|
116
137
|
if (!actions) return
|
|
117
138
|
event.preventDefault()
|
|
@@ -137,6 +158,18 @@ const ResourceListItem = memo(function ResourceListItem({
|
|
|
137
158
|
Container = DropZone.Dragable
|
|
138
159
|
}
|
|
139
160
|
|
|
161
|
+
const leading = (
|
|
162
|
+
<View layout="row" alignItems="center" gap="s" style={{ flexShrink: 0 }}>
|
|
163
|
+
{(showSelectionControls || selected) && (
|
|
164
|
+
<ResourceSelectionCheckbox
|
|
165
|
+
selected={selected}
|
|
166
|
+
onToggle={handleToggleSelect}
|
|
167
|
+
/>
|
|
168
|
+
)}
|
|
169
|
+
<ResourceIcon type={type} content={content} templateMap={templateMap} />
|
|
170
|
+
</View>
|
|
171
|
+
)
|
|
172
|
+
|
|
140
173
|
return (
|
|
141
174
|
<Container onDrop={onDrop} onFilesDrop={onFilesDrop} dragData={dragData}>
|
|
142
175
|
<List.Item
|
|
@@ -144,7 +177,8 @@ const ResourceListItem = memo(function ResourceListItem({
|
|
|
144
177
|
onClick={handleClick}
|
|
145
178
|
onContextMenu={handleContextMenu}
|
|
146
179
|
selectable
|
|
147
|
-
|
|
180
|
+
selected={selected}
|
|
181
|
+
leading={leading}
|
|
148
182
|
meta={<Size content={content} />}
|
|
149
183
|
trailing={(
|
|
150
184
|
<RowActions
|
package/src/ResourcePanel.jsx
CHANGED
|
@@ -1,8 +1,14 @@
|
|
|
1
1
|
import React, { useState, useEffect, memo } from 'react'
|
|
2
2
|
import { metadata as GetResource } from './get.action.js'
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
isDownloadableResource,
|
|
5
|
+
removeResource,
|
|
6
|
+
renameResource,
|
|
7
|
+
resourceDownloadHref,
|
|
8
|
+
updateResourceContent,
|
|
9
|
+
} from './resource.helpers.js'
|
|
4
10
|
import { useSdk } from '@ossy/sdk-react'
|
|
5
|
-
import { Switch, View, Text, Button, InputTitle, Alert, useInputValue } from '@ossy/design-system'
|
|
11
|
+
import { Switch, View, Text, Button, InputTitle, Alert, useInputValue, useLocale } from '@ossy/design-system'
|
|
6
12
|
import { ResourceFactory } from './ResourceFactory.jsx'
|
|
7
13
|
import { ResourceDetails } from './ResourceDetails.jsx'
|
|
8
14
|
import { ResourceTags } from './ResourceTags.jsx'
|
|
@@ -32,6 +38,7 @@ export const ResourcePanel = memo(function ResourcePanel ({
|
|
|
32
38
|
extraPanels = [],
|
|
33
39
|
}) {
|
|
34
40
|
const sdk = useSdk()
|
|
41
|
+
const { t } = useLocale()
|
|
35
42
|
const [contentError, setContentError] = useState()
|
|
36
43
|
const [resourceName, setResourceName] = useInputValue('')
|
|
37
44
|
const [panelViewModes, setPanelViewModes] = useState(DefaultViewModes)
|
|
@@ -39,6 +46,7 @@ export const ResourcePanel = memo(function ResourcePanel ({
|
|
|
39
46
|
const { data: resource = {} } = sdk.read(GetResource, { resourceId }, { enabled: !!resourceId })
|
|
40
47
|
const template = useSchema(resource?.type)
|
|
41
48
|
const engine = useSchemaEngine()
|
|
49
|
+
const downloadHref = isDownloadableResource(resource) ? resourceDownloadHref(resource) : null
|
|
42
50
|
|
|
43
51
|
const form = useForm({ defaultData: resource.content })
|
|
44
52
|
|
|
@@ -171,6 +179,15 @@ export const ResourcePanel = memo(function ResourcePanel ({
|
|
|
171
179
|
<Text as="h3" variant="breadcrumb">{resourceName}</Text>
|
|
172
180
|
</View.Item>
|
|
173
181
|
|
|
182
|
+
{downloadHref && (
|
|
183
|
+
<Button
|
|
184
|
+
prefix="software-download"
|
|
185
|
+
variant="command"
|
|
186
|
+
href={downloadHref}
|
|
187
|
+
download={resource.name}
|
|
188
|
+
aria-label={t('resources.panel.download')}
|
|
189
|
+
/>
|
|
190
|
+
)}
|
|
174
191
|
<Button prefix="trash-empty" variant="command-danger" onClick={onRemoveResource}/>
|
|
175
192
|
<Button prefix="pen" variant="command" onClick={() => setPanelViewModes(x => ({ ...x, 'panel-header': ViewMode.Edit }))} />
|
|
176
193
|
{ !!_onClose && (<Button prefix="close" variant="command" onClick={onCloseResource} /> ) }
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { View } from '@ossy/design-system'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Checkbox affordance for multi-select rows/tiles.
|
|
6
|
+
* Click toggles selection; does not navigate or open the resource panel.
|
|
7
|
+
*/
|
|
8
|
+
export function ResourceSelectionCheckbox ({
|
|
9
|
+
selected = false,
|
|
10
|
+
onToggle,
|
|
11
|
+
'aria-label': ariaLabel = 'Select',
|
|
12
|
+
}) {
|
|
13
|
+
return (
|
|
14
|
+
<View
|
|
15
|
+
role="checkbox"
|
|
16
|
+
aria-checked={selected}
|
|
17
|
+
aria-label={ariaLabel}
|
|
18
|
+
tabIndex={0}
|
|
19
|
+
onClick={(event) => {
|
|
20
|
+
event.preventDefault()
|
|
21
|
+
event.stopPropagation()
|
|
22
|
+
onToggle?.(event)
|
|
23
|
+
}}
|
|
24
|
+
onKeyDown={(event) => {
|
|
25
|
+
if (event.key !== 'Enter' && event.key !== ' ') return
|
|
26
|
+
event.preventDefault()
|
|
27
|
+
event.stopPropagation()
|
|
28
|
+
onToggle?.(event)
|
|
29
|
+
}}
|
|
30
|
+
style={{
|
|
31
|
+
boxSizing: 'border-box',
|
|
32
|
+
width: 18,
|
|
33
|
+
height: 18,
|
|
34
|
+
flexShrink: 0,
|
|
35
|
+
borderRadius: 4,
|
|
36
|
+
border: selected
|
|
37
|
+
? '1px solid var(--foreground-active, var(--foreground))'
|
|
38
|
+
: '1px solid var(--separator, hsl(0, 0%, 50%))',
|
|
39
|
+
background: selected
|
|
40
|
+
? 'var(--foreground-active, var(--foreground))'
|
|
41
|
+
: 'transparent',
|
|
42
|
+
display: 'inline-flex',
|
|
43
|
+
alignItems: 'center',
|
|
44
|
+
justifyContent: 'center',
|
|
45
|
+
cursor: 'pointer',
|
|
46
|
+
}}
|
|
47
|
+
>
|
|
48
|
+
{selected ? (
|
|
49
|
+
<svg
|
|
50
|
+
width="12"
|
|
51
|
+
height="12"
|
|
52
|
+
viewBox="0 0 12 12"
|
|
53
|
+
aria-hidden="true"
|
|
54
|
+
style={{ display: 'block' }}
|
|
55
|
+
>
|
|
56
|
+
<path
|
|
57
|
+
d="M2.5 6.2L4.8 8.5L9.5 3.5"
|
|
58
|
+
fill="none"
|
|
59
|
+
stroke="var(--background, Canvas)"
|
|
60
|
+
strokeWidth="1.75"
|
|
61
|
+
strokeLinecap="round"
|
|
62
|
+
strokeLinejoin="round"
|
|
63
|
+
/>
|
|
64
|
+
</svg>
|
|
65
|
+
) : null}
|
|
66
|
+
</View>
|
|
67
|
+
)
|
|
68
|
+
}
|
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
|
@@ -30,11 +30,13 @@ export * from './GenericResourceCard.jsx'
|
|
|
30
30
|
export * from './ResourceGenericView.jsx'
|
|
31
31
|
export * from './ResourceList.jsx'
|
|
32
32
|
export * from './ResourceGrid.jsx'
|
|
33
|
+
export * from './ResourceSelectionCheckbox.jsx'
|
|
33
34
|
export * from './ResourcePage.jsx'
|
|
34
35
|
export * from './ResourcePanel.jsx'
|
|
35
36
|
export * from './ResourceTags.jsx'
|
|
36
37
|
export * from './ResourcesPage.jsx'
|
|
37
38
|
export * from './PlatformFileField.jsx'
|
|
39
|
+
export * from './PlatformReferenceField.jsx'
|
|
38
40
|
export * from './Upload.jsx'
|
|
39
41
|
export * from './UploadResources.jsx'
|
|
40
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,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
|
+
}
|