@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.
Files changed (37) hide show
  1. package/package.json +4 -4
  2. package/src/PlatformFileField.jsx +120 -85
  3. package/src/PlatformReferenceField.jsx +216 -0
  4. package/src/ResourceContentPage.jsx +2 -2
  5. package/src/ResourceGrid.jsx +44 -4
  6. package/src/ResourceList.jsx +38 -4
  7. package/src/ResourcePanel.jsx +19 -2
  8. package/src/ResourceSelectionCheckbox.jsx +68 -0
  9. package/src/SchemaDetailView.jsx +18 -18
  10. package/src/SchemaPresenter.jsx +6 -4
  11. package/src/Upload.jsx +28 -13
  12. package/src/create.task.js +4 -0
  13. package/src/en.translations.json +4 -1
  14. package/src/get-resource.api.js +15 -0
  15. package/src/index.js +2 -0
  16. package/src/markdown.component.jsx +20 -0
  17. package/src/markdown.schema.js +13 -0
  18. package/src/markdown.spec.js +43 -0
  19. package/src/platform-reference-field.component.jsx +5 -0
  20. package/src/reference-field.helpers.js +57 -0
  21. package/src/reference-field.helpers.spec.js +100 -0
  22. package/src/resource-download.js +194 -0
  23. package/src/resource-download.spec.js +93 -0
  24. package/src/resource-read.helpers.js +5 -1
  25. package/src/resource-selection.js +91 -0
  26. package/src/resource-selection.spec.js +77 -0
  27. package/src/resource-upload-access.js +63 -0
  28. package/src/resource-upload-access.spec.js +100 -0
  29. package/src/resource.helpers.js +30 -5
  30. package/src/resources.attach-reference-links.js +100 -0
  31. package/src/resources.attach-reference-links.spec.js +118 -0
  32. package/src/resources.validate-references.js +104 -0
  33. package/src/resources.validate-references.spec.js +182 -0
  34. package/src/schema-field-display.js +12 -0
  35. package/src/server.js +13 -0
  36. package/src/sv.translations.json +4 -1
  37. package/src/update-content.task.js +4 -0
@@ -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
- onItemClick(resource)
112
- onClick?.(resource)
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
- leading={<ResourceIcon type={type} content={content} templateMap={templateMap} />}
180
+ selected={selected}
181
+ leading={leading}
148
182
  meta={<Size content={content} />}
149
183
  trailing={(
150
184
  <RowActions
@@ -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 { removeResource, updateResourceContent, renameResource } from './resource.helpers.js'
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
+ }
@@ -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 == null || 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
- <Text variant="small" style={valueStyle}>
84
- {value == null || value === ''
85
- ? '—'
86
- : typeof value === 'boolean'
87
- ? (value ? 'Yes' : 'No')
88
- : typeof value === 'object'
89
- ? JSON.stringify(value, null, 2)
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
  })}
@@ -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
- setFilesMetadata(Array.from(e.target.files).map(file => ({ file, status: 'preview' })))
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
- <UploadInput
72
- id="upload-resources"
73
- type="file"
74
- multiple
75
- onChange={onUserInput}
76
- style={{ flexGrow: '1', borderRadius: 'var(--space-s)' }}
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)' }}>
@@ -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,
@@ -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
  }
@@ -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,5 @@
1
+ import { PlatformReferenceField } from './PlatformReferenceField.jsx'
2
+
3
+ export const metadata = { id: '@ossy/design-system/input/reference' }
4
+
5
+ export default PlatformReferenceField
@@ -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
+ }