@ossy/resources 3.8.0 → 3.10.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 +3 -3
- package/src/CreateDirectory.page.jsx +2 -1
- package/src/CreateDocument.jsx +1 -1
- package/src/CreateDocument.page.jsx +2 -1
- package/src/GenericResourceForm.jsx +18 -12
- package/src/ResourceDetails.jsx +1 -43
- package/src/ResourceGrid.jsx +19 -0
- package/src/ResourceList.jsx +15 -1
- package/src/ResourcePanel.jsx +82 -1
- package/src/ResourceShareDialog.jsx +183 -0
- package/src/SchemaDetailView.jsx +15 -3
- package/src/Upload.page.jsx +2 -1
- package/src/document-create.helpers.js +19 -0
- package/src/document-create.helpers.spec.js +9 -1
- package/src/en.translations.json +16 -0
- package/src/index.js +1 -0
- package/src/resource-create.page.jsx +8 -1
- package/src/resource-detail.page.jsx +1 -1
- package/src/resource-share.js +67 -0
- package/src/resource-share.spec.js +68 -0
- package/src/resource.helpers.js +9 -0
- package/src/schema-field-display.js +31 -0
- package/src/schema-field-display.spec.js +37 -0
- package/src/sv.translations.json +16 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ossy/resources",
|
|
3
3
|
"description": "Resource domain — aggregate and events for the Ossy resource model",
|
|
4
|
-
"version": "3.
|
|
4
|
+
"version": "3.10.0",
|
|
5
5
|
"private": false,
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "./src/index.js",
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
"dependencies": {
|
|
26
26
|
"@ossy/event-store": "^3.8.0",
|
|
27
27
|
"@ossy/fold": "^3.4.0",
|
|
28
|
-
"@ossy/platform": "^3.
|
|
28
|
+
"@ossy/platform": "^3.9.0",
|
|
29
29
|
"@ossy/schema": "^3.8.0"
|
|
30
30
|
},
|
|
31
31
|
"peerDependencies": {
|
|
@@ -49,5 +49,5 @@
|
|
|
49
49
|
"/src",
|
|
50
50
|
"README.md"
|
|
51
51
|
],
|
|
52
|
-
"gitHead": "
|
|
52
|
+
"gitHead": "198084eb98871876396ae625d043dd0f03aa0325"
|
|
53
53
|
}
|
|
@@ -8,12 +8,13 @@ export const metadata = {
|
|
|
8
8
|
sv: '/resources/create/mapp',
|
|
9
9
|
en: '/resources/create/directory',
|
|
10
10
|
},
|
|
11
|
+
layout: '@ossy/app/layout/blank',
|
|
11
12
|
}
|
|
12
13
|
|
|
13
14
|
|
|
14
15
|
export const CreateDirectoryPage = () => {
|
|
15
16
|
return (
|
|
16
|
-
<View layout="off-center-s" style={{
|
|
17
|
+
<View surface="base" layout="off-center-s" inset="s" style={{ minHeight: '100dvh' }}>
|
|
17
18
|
<View data-region="content" surface="primary" roundness="m" inset="m">
|
|
18
19
|
<CreateDirectory/>
|
|
19
20
|
</View>
|
package/src/CreateDocument.jsx
CHANGED
|
@@ -94,13 +94,13 @@ export const CreateDocument = () => {
|
|
|
94
94
|
|
|
95
95
|
return (
|
|
96
96
|
<>
|
|
97
|
+
{error && <Alert variant="danger">{error}</Alert>}
|
|
97
98
|
<GenericResourceForm
|
|
98
99
|
schemaId={schemaId}
|
|
99
100
|
mode="create"
|
|
100
101
|
onSubmit={onSubmit}
|
|
101
102
|
onCancel={onDone}
|
|
102
103
|
/>
|
|
103
|
-
{error && <Alert>{error}</Alert>}
|
|
104
104
|
</>
|
|
105
105
|
)
|
|
106
106
|
}
|
|
@@ -8,12 +8,13 @@ export const metadata = {
|
|
|
8
8
|
sv: '/resources/create/dokument',
|
|
9
9
|
en: '/resources/create/document',
|
|
10
10
|
},
|
|
11
|
+
layout: '@ossy/app/layout/blank',
|
|
11
12
|
}
|
|
12
13
|
|
|
13
14
|
|
|
14
15
|
export const CreateDocumentPage = () => {
|
|
15
16
|
return (
|
|
16
|
-
<View layout="off-center-m" style={{
|
|
17
|
+
<View surface="base" layout="off-center-m" inset="s" style={{ minHeight: '100dvh' }}>
|
|
17
18
|
<View data-region="content" surface='primary' roundness='m' inset="m">
|
|
18
19
|
<CreateDocument />
|
|
19
20
|
</View>
|
|
@@ -33,27 +33,33 @@ function ResourceCreateFormFallback ({
|
|
|
33
33
|
const [content, setContent] = useState(initialContent)
|
|
34
34
|
const [error, setError] = useState()
|
|
35
35
|
|
|
36
|
-
const handleSubmit = () => {
|
|
37
|
-
|
|
36
|
+
const handleSubmit = (event) => {
|
|
37
|
+
event?.preventDefault?.()
|
|
38
|
+
const title = name?.trim() || (typeof content?.name === 'string' ? content.name.trim() : '')
|
|
39
|
+
if (!title) {
|
|
38
40
|
setError(t('resources.form.nameRequired'))
|
|
39
41
|
return
|
|
40
42
|
}
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
43
|
+
try {
|
|
44
|
+
if (template) {
|
|
45
|
+
const result = validateContent(engine, template, content)
|
|
46
|
+
if (!result.ok) {
|
|
47
|
+
setError(result.message)
|
|
48
|
+
return
|
|
49
|
+
}
|
|
50
|
+
setError(undefined)
|
|
51
|
+
onSubmit?.({ name: title, content: result.data, type: schemaId })
|
|
45
52
|
return
|
|
46
53
|
}
|
|
47
54
|
setError(undefined)
|
|
48
|
-
onSubmit?.({ name:
|
|
49
|
-
|
|
55
|
+
onSubmit?.({ name: title, content, type: schemaId })
|
|
56
|
+
} catch (err) {
|
|
57
|
+
setError(err?.message || t('resources.form.createFailed'))
|
|
50
58
|
}
|
|
51
|
-
setError(undefined)
|
|
52
|
-
onSubmit?.({ name: name.trim(), content, type: schemaId })
|
|
53
59
|
}
|
|
54
60
|
|
|
55
61
|
return (
|
|
56
|
-
<View stack gap="m">
|
|
62
|
+
<View as="form" stack gap="m" onSubmit={handleSubmit}>
|
|
57
63
|
<InputTitle
|
|
58
64
|
id="resource-name"
|
|
59
65
|
type="text"
|
|
@@ -73,7 +79,7 @@ function ResourceCreateFormFallback ({
|
|
|
73
79
|
{error && <Alert variant="danger">{error}</Alert>}
|
|
74
80
|
<View layout="row" gap="s" justifyContent="flex-end">
|
|
75
81
|
{onCancel && <Button variant="link" onClick={onCancel} label="design-system.cancel" />}
|
|
76
|
-
<Button variant="cta"
|
|
82
|
+
<Button type="submit" variant="cta">
|
|
77
83
|
{submitLabel || (template?.name ? `${t('design-system.add')} ${template.name}` : t('resources.form.createResource'))}
|
|
78
84
|
</Button>
|
|
79
85
|
</View>
|
package/src/ResourceDetails.jsx
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
import React
|
|
1
|
+
import React from 'react'
|
|
2
2
|
import { metadata as GetResource } from './get.action.js'
|
|
3
|
-
import { updateResourceAccess } from './resource.helpers.js'
|
|
4
3
|
import { useSdk } from '@ossy/sdk-react'
|
|
5
4
|
import { useSchema } from './useSchemas.js'
|
|
6
5
|
import { Text, View } from '@ossy/design-system'
|
|
@@ -10,8 +9,6 @@ export const ResourceDetails = ({ resourceId }) => {
|
|
|
10
9
|
const sdk = useSdk()
|
|
11
10
|
const { data: resource } = sdk.read(GetResource, { resourceId }, { enabled: !!resourceId })
|
|
12
11
|
const template = useSchema(resource?.type)
|
|
13
|
-
const [accessBusy, setAccessBusy] = useState(false)
|
|
14
|
-
const [accessError, setAccessError] = useState(null)
|
|
15
12
|
|
|
16
13
|
const details = {
|
|
17
14
|
Id: resource?.id,
|
|
@@ -22,19 +19,6 @@ export const ResourceDetails = ({ resourceId }) => {
|
|
|
22
19
|
Created: resource?.created && new Date(resource?.created).toLocaleString(),
|
|
23
20
|
}
|
|
24
21
|
|
|
25
|
-
const showAccessToggle = resource?.id
|
|
26
|
-
&& resource?.type !== '@ossy/platform/schema/directory'
|
|
27
|
-
&& resource?.type !== 'directory'
|
|
28
|
-
|
|
29
|
-
const onAccessChange = (event) => {
|
|
30
|
-
const next = event.target.checked ? 'public' : 'restricted'
|
|
31
|
-
setAccessError(null)
|
|
32
|
-
setAccessBusy(true)
|
|
33
|
-
updateResourceAccess(sdk, { id: resourceId, access: next })
|
|
34
|
-
.catch(() => setAccessError('Could not update access'))
|
|
35
|
-
.finally(() => setAccessBusy(false))
|
|
36
|
-
}
|
|
37
|
-
|
|
38
22
|
return (
|
|
39
23
|
<View gap="s" style={{ maxWidth: '320px' }}>
|
|
40
24
|
{
|
|
@@ -48,32 +32,6 @@ export const ResourceDetails = ({ resourceId }) => {
|
|
|
48
32
|
</View>
|
|
49
33
|
))
|
|
50
34
|
}
|
|
51
|
-
{showAccessToggle && (
|
|
52
|
-
<View gap="xs" style={{ paddingTop: 'var(--space-s)' }}>
|
|
53
|
-
<View layout="row" gap="m" alignItems="center">
|
|
54
|
-
<Text variant="small" style={{ fontWeight: 'bold', flexShrink: 0 }}>
|
|
55
|
-
Public media link
|
|
56
|
-
</Text>
|
|
57
|
-
<span style={{ flexGrow: 1 }} />
|
|
58
|
-
<input
|
|
59
|
-
type="checkbox"
|
|
60
|
-
checked={(resource?.access || 'restricted') === 'public'}
|
|
61
|
-
disabled={accessBusy}
|
|
62
|
-
onChange={onAccessChange}
|
|
63
|
-
aria-label="Allow anyone with the link to view media"
|
|
64
|
-
/>
|
|
65
|
-
</View>
|
|
66
|
-
<Text variant="small" style={{ opacity: 0.85, lineHeight: 1.35 }}>
|
|
67
|
-
When on, image and file URLs use the CDN and work without signing in.
|
|
68
|
-
When off, only workspace members get time-limited download links.
|
|
69
|
-
</Text>
|
|
70
|
-
{accessError && (
|
|
71
|
-
<Text variant="small" style={{ color: 'var(--palette-danger, #c00)' }}>
|
|
72
|
-
{accessError}
|
|
73
|
-
</Text>
|
|
74
|
-
)}
|
|
75
|
-
</View>
|
|
76
|
-
)}
|
|
77
35
|
</View>
|
|
78
36
|
)
|
|
79
37
|
}
|
package/src/ResourceGrid.jsx
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
} from '@ossy/design-system'
|
|
14
14
|
import { formatBytes } from './utils/format-bytes.js'
|
|
15
15
|
import { ResourceSelectionCheckbox } from './ResourceSelectionCheckbox.jsx'
|
|
16
|
+
import { resourceAccessIcon, isShareableResource } from './resource-share.js'
|
|
16
17
|
|
|
17
18
|
const TILE_WIDTH = 160
|
|
18
19
|
|
|
@@ -189,6 +190,7 @@ const ResourceGridItem = memo(function ResourceGridItem({
|
|
|
189
190
|
inset="m"
|
|
190
191
|
gap="s"
|
|
191
192
|
data-resource-id={resource.id}
|
|
193
|
+
data-resource-access={resource.access || 'restricted'}
|
|
192
194
|
style={{
|
|
193
195
|
width: TILE_WIDTH,
|
|
194
196
|
textDecoration: 'none',
|
|
@@ -217,6 +219,23 @@ const ResourceGridItem = memo(function ResourceGridItem({
|
|
|
217
219
|
/>
|
|
218
220
|
</View>
|
|
219
221
|
)}
|
|
222
|
+
{isShareableResource(resource) && (
|
|
223
|
+
<View
|
|
224
|
+
style={{
|
|
225
|
+
position: 'absolute',
|
|
226
|
+
top: 'var(--space-xs, 4px)',
|
|
227
|
+
left: showSelectionControls ? 'calc(var(--space-xs, 4px) + 28px)' : 'var(--space-xs, 4px)',
|
|
228
|
+
zIndex: 1,
|
|
229
|
+
}}
|
|
230
|
+
aria-hidden="true"
|
|
231
|
+
>
|
|
232
|
+
<Icon
|
|
233
|
+
size="s"
|
|
234
|
+
name={resourceAccessIcon(resource)}
|
|
235
|
+
style={{ fill: 'hsl(0, 0%, 55%)' }}
|
|
236
|
+
/>
|
|
237
|
+
</View>
|
|
238
|
+
)}
|
|
220
239
|
{actions ? (
|
|
221
240
|
<View
|
|
222
241
|
style={{
|
package/src/ResourceList.jsx
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
} from '@ossy/design-system'
|
|
15
15
|
import { formatBytes } from './utils/format-bytes.js'
|
|
16
16
|
import { ResourceSelectionCheckbox } from './ResourceSelectionCheckbox.jsx'
|
|
17
|
+
import { resourceAccessIcon, isShareableResource } from './resource-share.js'
|
|
17
18
|
|
|
18
19
|
function useSchemaMap() {
|
|
19
20
|
const templates = useSchemas()
|
|
@@ -179,7 +180,19 @@ const ResourceListItem = memo(function ResourceListItem({
|
|
|
179
180
|
selectable
|
|
180
181
|
selected={selected}
|
|
181
182
|
leading={leading}
|
|
182
|
-
meta={
|
|
183
|
+
meta={(
|
|
184
|
+
<View layout="row" alignItems="center" gap="s" style={{ flexShrink: 0 }}>
|
|
185
|
+
{isShareableResource(resource) && (
|
|
186
|
+
<Icon
|
|
187
|
+
size="s"
|
|
188
|
+
name={resourceAccessIcon(resource)}
|
|
189
|
+
aria-hidden="true"
|
|
190
|
+
style={{ fill: 'hsl(0, 0%, 55%)', flexShrink: 0 }}
|
|
191
|
+
/>
|
|
192
|
+
)}
|
|
193
|
+
<Size content={content} />
|
|
194
|
+
</View>
|
|
195
|
+
)}
|
|
183
196
|
trailing={(
|
|
184
197
|
<RowActions
|
|
185
198
|
actions={actions}
|
|
@@ -190,6 +203,7 @@ const ResourceListItem = memo(function ResourceListItem({
|
|
|
190
203
|
/>
|
|
191
204
|
)}
|
|
192
205
|
data-resource-id={resource.id}
|
|
206
|
+
data-resource-access={resource.access || 'restricted'}
|
|
193
207
|
>
|
|
194
208
|
{typeof name === 'string' ? name : ''}
|
|
195
209
|
</List.Item>
|
package/src/ResourcePanel.jsx
CHANGED
|
@@ -5,18 +5,36 @@ import {
|
|
|
5
5
|
removeResource,
|
|
6
6
|
renameResource,
|
|
7
7
|
resourceDownloadHref,
|
|
8
|
+
updateResourceAccess,
|
|
8
9
|
updateResourceContent,
|
|
9
10
|
} from './resource.helpers.js'
|
|
10
11
|
import { useSdk } from '@ossy/sdk-react'
|
|
11
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
Switch,
|
|
14
|
+
View,
|
|
15
|
+
Text,
|
|
16
|
+
Button,
|
|
17
|
+
InputTitle,
|
|
18
|
+
Alert,
|
|
19
|
+
useInputValue,
|
|
20
|
+
useLocale,
|
|
21
|
+
Dropdown,
|
|
22
|
+
ContextMenu,
|
|
23
|
+
} from '@ossy/design-system'
|
|
12
24
|
import { ResourceFactory } from './ResourceFactory.jsx'
|
|
13
25
|
import { ResourceDetails } from './ResourceDetails.jsx'
|
|
14
26
|
import { ResourceTags } from './ResourceTags.jsx'
|
|
15
27
|
import { ResourceDescription } from './ResourceDescription.jsx'
|
|
28
|
+
import { ResourceShareDialog } from './ResourceShareDialog.jsx'
|
|
16
29
|
import { useForm } from './useForm.js'
|
|
17
30
|
import { useSchema } from './useSchemas.js'
|
|
18
31
|
import { useSchemaEngine } from './useSchemaEngine.js'
|
|
19
32
|
import { validateContent } from '@ossy/schema'
|
|
33
|
+
import {
|
|
34
|
+
isShareableResource,
|
|
35
|
+
resourceAccessIcon,
|
|
36
|
+
resourceAccessOf,
|
|
37
|
+
} from './resource-share.js'
|
|
20
38
|
|
|
21
39
|
const ViewMode = {
|
|
22
40
|
Closed: 'Closed',
|
|
@@ -42,14 +60,28 @@ export const ResourcePanel = memo(function ResourcePanel ({
|
|
|
42
60
|
const [contentError, setContentError] = useState()
|
|
43
61
|
const [resourceName, setResourceName] = useInputValue('')
|
|
44
62
|
const [panelViewModes, setPanelViewModes] = useState(DefaultViewModes)
|
|
63
|
+
const [accessBusy, setAccessBusy] = useState(false)
|
|
64
|
+
const [accessError, setAccessError] = useState(null)
|
|
65
|
+
const [shareOpen, setShareOpen] = useState(false)
|
|
45
66
|
|
|
46
67
|
const { data: resource = {} } = sdk.read(GetResource, { resourceId }, { enabled: !!resourceId })
|
|
47
68
|
const template = useSchema(resource?.type)
|
|
48
69
|
const engine = useSchemaEngine()
|
|
49
70
|
const downloadHref = isDownloadableResource(resource) ? resourceDownloadHref(resource) : null
|
|
71
|
+
const shareable = isShareableResource(resource)
|
|
72
|
+
const access = resourceAccessOf(resource)
|
|
50
73
|
|
|
51
74
|
const form = useForm({ defaultData: resource.content })
|
|
52
75
|
|
|
76
|
+
const onVisibilityChange = (nextAccess) => {
|
|
77
|
+
if (!resourceId || accessBusy || nextAccess === access) return
|
|
78
|
+
setAccessError(null)
|
|
79
|
+
setAccessBusy(true)
|
|
80
|
+
updateResourceAccess(sdk, { id: resourceId, access: nextAccess })
|
|
81
|
+
.catch(() => setAccessError(t('resources.share.accessError')))
|
|
82
|
+
.finally(() => setAccessBusy(false))
|
|
83
|
+
}
|
|
84
|
+
|
|
53
85
|
const onCloseResource = () => {
|
|
54
86
|
if (!_onClose) return
|
|
55
87
|
setPanelViewModes(DefaultViewModes)
|
|
@@ -179,6 +211,43 @@ export const ResourcePanel = memo(function ResourcePanel ({
|
|
|
179
211
|
<Text as="h3" variant="small" style={{ fontWeight: 'bold' }}>{resourceName}</Text>
|
|
180
212
|
</View.Item>
|
|
181
213
|
|
|
214
|
+
{shareable && (
|
|
215
|
+
<Dropdown
|
|
216
|
+
trigger={(
|
|
217
|
+
<Button
|
|
218
|
+
prefix={resourceAccessIcon(resource)}
|
|
219
|
+
variant="command"
|
|
220
|
+
disabled={accessBusy}
|
|
221
|
+
aria-label={t('resources.share.visibility')}
|
|
222
|
+
title={t(`resources.share.access.${access}`)}
|
|
223
|
+
/>
|
|
224
|
+
)}
|
|
225
|
+
>
|
|
226
|
+
<View inset="xs" surface="primary" roundness="s">
|
|
227
|
+
<ContextMenu roundness="s" surface="primary">
|
|
228
|
+
{['public', 'workspace', 'restricted'].map((value) => (
|
|
229
|
+
<ContextMenu.Item
|
|
230
|
+
key={value}
|
|
231
|
+
prefix={value === 'public' ? 'globe' : 'lock'}
|
|
232
|
+
suffix={access === value ? 'check' : undefined}
|
|
233
|
+
disabled={accessBusy}
|
|
234
|
+
onClick={() => onVisibilityChange(value)}
|
|
235
|
+
>
|
|
236
|
+
{t(`resources.share.access.${value}`)}
|
|
237
|
+
</ContextMenu.Item>
|
|
238
|
+
))}
|
|
239
|
+
</ContextMenu>
|
|
240
|
+
</View>
|
|
241
|
+
</Dropdown>
|
|
242
|
+
)}
|
|
243
|
+
{shareable && (
|
|
244
|
+
<Button
|
|
245
|
+
prefix="share"
|
|
246
|
+
variant="command"
|
|
247
|
+
aria-label={t('resources.share.title')}
|
|
248
|
+
onClick={() => setShareOpen(true)}
|
|
249
|
+
/>
|
|
250
|
+
)}
|
|
182
251
|
{downloadHref && (
|
|
183
252
|
<Button
|
|
184
253
|
prefix="software-download"
|
|
@@ -263,6 +332,18 @@ export const ResourcePanel = memo(function ResourcePanel ({
|
|
|
263
332
|
|
|
264
333
|
</View.Item>
|
|
265
334
|
|
|
335
|
+
{accessError && (
|
|
336
|
+
<View.Item>
|
|
337
|
+
<Alert variant="danger">{accessError}</Alert>
|
|
338
|
+
</View.Item>
|
|
339
|
+
)}
|
|
340
|
+
|
|
341
|
+
<ResourceShareDialog
|
|
342
|
+
resourceId={resourceId}
|
|
343
|
+
isVisible={shareOpen}
|
|
344
|
+
onClose={() => setShareOpen(false)}
|
|
345
|
+
/>
|
|
346
|
+
|
|
266
347
|
</View>
|
|
267
348
|
)
|
|
268
349
|
})
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
import React, { useEffect, useState } from 'react'
|
|
3
|
+
import { Overlay, View, Text, Button, Guide, useLocale } from '@ossy/design-system'
|
|
4
|
+
import { AsyncStatus, useSdk } from '@ossy/sdk-react'
|
|
5
|
+
import { metadata as GetResource } from './get.action.js'
|
|
6
|
+
import { updateResourceAccess } from './resource.helpers.js'
|
|
7
|
+
import {
|
|
8
|
+
getResourceShareUrl,
|
|
9
|
+
isPublicResource,
|
|
10
|
+
isShareableResource,
|
|
11
|
+
} from './resource-share.js'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Confirm making a resource public and copy its share link.
|
|
15
|
+
*
|
|
16
|
+
* @param {{
|
|
17
|
+
* resourceId?: string | null,
|
|
18
|
+
* isVisible?: boolean,
|
|
19
|
+
* onClose?: () => void,
|
|
20
|
+
* }} props
|
|
21
|
+
*/
|
|
22
|
+
export function ResourceShareDialog ({
|
|
23
|
+
resourceId,
|
|
24
|
+
isVisible = false,
|
|
25
|
+
onClose = () => {},
|
|
26
|
+
}) {
|
|
27
|
+
const sdk = useSdk()
|
|
28
|
+
const { t } = useLocale()
|
|
29
|
+
const { status, data: resource = {} } = sdk.read(
|
|
30
|
+
GetResource,
|
|
31
|
+
{ resourceId },
|
|
32
|
+
{ enabled: Boolean(resourceId) && isVisible },
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
const [busy, setBusy] = useState(false)
|
|
36
|
+
const [error, setError] = useState(null)
|
|
37
|
+
const [linkCopied, setLinkCopied] = useState(false)
|
|
38
|
+
const [madePublic, setMadePublic] = useState(false)
|
|
39
|
+
|
|
40
|
+
const loading = status !== AsyncStatus.Success && status !== AsyncStatus.Error
|
|
41
|
+
const shareable = status === AsyncStatus.Success && isShareableResource(resource)
|
|
42
|
+
const isPublic = madePublic || isPublicResource(resource)
|
|
43
|
+
const shareUrl = getResourceShareUrl(resourceId || resource)
|
|
44
|
+
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
if (!isVisible) {
|
|
47
|
+
setBusy(false)
|
|
48
|
+
setError(null)
|
|
49
|
+
setLinkCopied(false)
|
|
50
|
+
setMadePublic(false)
|
|
51
|
+
}
|
|
52
|
+
}, [isVisible])
|
|
53
|
+
|
|
54
|
+
const copyLink = async (url) => {
|
|
55
|
+
if (!url) return
|
|
56
|
+
try {
|
|
57
|
+
await navigator.clipboard.writeText(url)
|
|
58
|
+
setLinkCopied(true)
|
|
59
|
+
setTimeout(() => setLinkCopied(false), 2000)
|
|
60
|
+
} catch {
|
|
61
|
+
setError(t('resources.share.copyFailed'))
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const onConfirmShare = async () => {
|
|
66
|
+
if (!resourceId || busy) return
|
|
67
|
+
setError(null)
|
|
68
|
+
setBusy(true)
|
|
69
|
+
try {
|
|
70
|
+
if (!isPublic) {
|
|
71
|
+
await updateResourceAccess(sdk, { id: resourceId, access: 'public' })
|
|
72
|
+
setMadePublic(true)
|
|
73
|
+
}
|
|
74
|
+
const url = getResourceShareUrl(resourceId)
|
|
75
|
+
await copyLink(url)
|
|
76
|
+
} catch {
|
|
77
|
+
setError(t('resources.share.publishError'))
|
|
78
|
+
} finally {
|
|
79
|
+
setBusy(false)
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (!isVisible || !resourceId) return null
|
|
84
|
+
|
|
85
|
+
return (
|
|
86
|
+
<Overlay isVisible={true} onClose={onClose}>
|
|
87
|
+
<View layout="off-center-s" style={{ height: '100%' }}>
|
|
88
|
+
<View data-region="content" style={{ width: 'min(420px, 92vw)' }}>
|
|
89
|
+
<View surface="primary" roundness="s">
|
|
90
|
+
<View surface="primary" inset="l" roundness="s" gap="m">
|
|
91
|
+
{loading ? (
|
|
92
|
+
<Guide
|
|
93
|
+
title={t('resources.share.title')}
|
|
94
|
+
text={t('resources.share.sharing')}
|
|
95
|
+
actions={[
|
|
96
|
+
{
|
|
97
|
+
label: t('resources.share.close'),
|
|
98
|
+
variant: 'command',
|
|
99
|
+
onClick: onClose,
|
|
100
|
+
},
|
|
101
|
+
]}
|
|
102
|
+
/>
|
|
103
|
+
) : !shareable ? (
|
|
104
|
+
<Guide
|
|
105
|
+
title={t('resources.share.title')}
|
|
106
|
+
text={t('resources.share.notShareable')}
|
|
107
|
+
actions={[
|
|
108
|
+
{
|
|
109
|
+
label: t('resources.share.close'),
|
|
110
|
+
variant: 'command',
|
|
111
|
+
onClick: onClose,
|
|
112
|
+
},
|
|
113
|
+
]}
|
|
114
|
+
/>
|
|
115
|
+
) : (
|
|
116
|
+
<>
|
|
117
|
+
<Guide
|
|
118
|
+
title={t('resources.share.title')}
|
|
119
|
+
text={isPublic
|
|
120
|
+
? t('resources.share.alreadyPublic')
|
|
121
|
+
: t('resources.share.confirmPublic')}
|
|
122
|
+
/>
|
|
123
|
+
|
|
124
|
+
{shareUrl && isPublic && (
|
|
125
|
+
<View gap="xs">
|
|
126
|
+
<Text
|
|
127
|
+
variant="small"
|
|
128
|
+
style={{
|
|
129
|
+
wordBreak: 'break-all',
|
|
130
|
+
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
|
|
131
|
+
}}
|
|
132
|
+
>
|
|
133
|
+
{shareUrl}
|
|
134
|
+
</Text>
|
|
135
|
+
</View>
|
|
136
|
+
)}
|
|
137
|
+
|
|
138
|
+
{error && (
|
|
139
|
+
<Text
|
|
140
|
+
variant="small"
|
|
141
|
+
style={{ color: 'var(--palette-danger, #c00)' }}
|
|
142
|
+
>
|
|
143
|
+
{error}
|
|
144
|
+
</Text>
|
|
145
|
+
)}
|
|
146
|
+
|
|
147
|
+
<View layout="row" gap="s" style={{ justifyContent: 'flex-end', flexWrap: 'wrap' }}>
|
|
148
|
+
<Button variant="command" onClick={onClose} disabled={busy}>
|
|
149
|
+
{t('resources.share.close')}
|
|
150
|
+
</Button>
|
|
151
|
+
{isPublic ? (
|
|
152
|
+
<Button
|
|
153
|
+
variant="cta"
|
|
154
|
+
prefix="link"
|
|
155
|
+
disabled={busy || !shareUrl}
|
|
156
|
+
onClick={() => copyLink(shareUrl)}
|
|
157
|
+
>
|
|
158
|
+
{linkCopied
|
|
159
|
+
? t('resources.share.linkCopied')
|
|
160
|
+
: t('resources.share.copyLink')}
|
|
161
|
+
</Button>
|
|
162
|
+
) : (
|
|
163
|
+
<Button
|
|
164
|
+
variant="cta"
|
|
165
|
+
prefix="share"
|
|
166
|
+
disabled={busy || !resource?.id}
|
|
167
|
+
onClick={onConfirmShare}
|
|
168
|
+
>
|
|
169
|
+
{busy
|
|
170
|
+
? t('resources.share.sharing')
|
|
171
|
+
: t('resources.share.makePublic')}
|
|
172
|
+
</Button>
|
|
173
|
+
)}
|
|
174
|
+
</View>
|
|
175
|
+
</>
|
|
176
|
+
)}
|
|
177
|
+
</View>
|
|
178
|
+
</View>
|
|
179
|
+
</View>
|
|
180
|
+
</View>
|
|
181
|
+
</Overlay>
|
|
182
|
+
)
|
|
183
|
+
}
|
package/src/SchemaDetailView.jsx
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import React from 'react'
|
|
2
2
|
import { Text, View, MarkdownViewer, useLocale, hasTranslation } from '@ossy/design-system'
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
formatTemporalFieldValue,
|
|
5
|
+
isTemporalField,
|
|
6
|
+
shouldRenderFieldAsMarkdown,
|
|
7
|
+
} from './schema-field-display.js'
|
|
4
8
|
|
|
5
9
|
const labelStyle = { fontWeight: 600, opacity: 0.65 }
|
|
6
10
|
const valueStyle = { whiteSpace: 'pre-wrap' }
|
|
@@ -12,6 +16,13 @@ function renderPlainFieldValue (value) {
|
|
|
12
16
|
return String(value)
|
|
13
17
|
}
|
|
14
18
|
|
|
19
|
+
function renderFieldValue (field, value, locale) {
|
|
20
|
+
if (isTemporalField(field)) {
|
|
21
|
+
return formatTemporalFieldValue(value, field, locale) ?? '—'
|
|
22
|
+
}
|
|
23
|
+
return renderPlainFieldValue(value)
|
|
24
|
+
}
|
|
25
|
+
|
|
15
26
|
/**
|
|
16
27
|
* Generic read-only detail projection for schema-backed resources.
|
|
17
28
|
*
|
|
@@ -30,7 +41,8 @@ export function SchemaDetailView ({
|
|
|
30
41
|
title,
|
|
31
42
|
emptyMessage = 'No data',
|
|
32
43
|
}) {
|
|
33
|
-
const { messages } = useLocale()
|
|
44
|
+
const { messages, language } = useLocale()
|
|
45
|
+
const locale = language === 'sv' ? 'sv-SE' : 'en-GB'
|
|
34
46
|
const fields = (fieldsProp ?? []).filter((field) => !field.derived)
|
|
35
47
|
const displayTitle = title ?? data.name ?? 'Preview'
|
|
36
48
|
|
|
@@ -86,7 +98,7 @@ export function SchemaDetailView ({
|
|
|
86
98
|
? <MarkdownViewer>{value}</MarkdownViewer>
|
|
87
99
|
: (
|
|
88
100
|
<Text variant="small" style={valueStyle}>
|
|
89
|
-
{
|
|
101
|
+
{renderFieldValue(field, value, locale)}
|
|
90
102
|
</Text>
|
|
91
103
|
)}
|
|
92
104
|
</View>
|
package/src/Upload.page.jsx
CHANGED
|
@@ -8,12 +8,13 @@ export const metadata = {
|
|
|
8
8
|
sv: '/resources/ladda-upp',
|
|
9
9
|
en: '/resources/upload',
|
|
10
10
|
},
|
|
11
|
+
layout: '@ossy/app/layout/blank',
|
|
11
12
|
}
|
|
12
13
|
|
|
13
14
|
|
|
14
15
|
export const UploadPage = () => {
|
|
15
16
|
return (
|
|
16
|
-
<View layout="off-center-s" style={{
|
|
17
|
+
<View surface="base" layout="off-center-s" inset="s" style={{ minHeight: '100dvh' }}>
|
|
17
18
|
<View data-region="content" surface="primary" roundness="m" inset="m">
|
|
18
19
|
<UploadResources/>
|
|
19
20
|
</View>
|
|
@@ -76,3 +76,22 @@ export function safeReturnTo (value) {
|
|
|
76
76
|
if (!trimmed.startsWith('/') || trimmed.startsWith('//')) return null
|
|
77
77
|
return trimmed
|
|
78
78
|
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Path + search from a router href or window URL, if it is a safe returnTo.
|
|
82
|
+
* @param {unknown} href
|
|
83
|
+
* @returns {string | null}
|
|
84
|
+
*/
|
|
85
|
+
export function pathFromHref (href) {
|
|
86
|
+
if (typeof href !== 'string' || !href) return null
|
|
87
|
+
const trimmed = href.trim()
|
|
88
|
+
if (trimmed.startsWith('//')) return null
|
|
89
|
+
try {
|
|
90
|
+
const url = /^[a-z][a-z0-9+.-]*:/i.test(trimmed)
|
|
91
|
+
? new URL(trimmed)
|
|
92
|
+
: new URL(trimmed, 'http://ossy.invalid')
|
|
93
|
+
return safeReturnTo(`${url.pathname}${url.search}`)
|
|
94
|
+
} catch {
|
|
95
|
+
return safeReturnTo(trimmed)
|
|
96
|
+
}
|
|
97
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, it } from '@jest/globals'
|
|
2
|
-
import { isGenericCreateSchema, safeReturnTo, schemaCreateIdentity, schemasForGenericCreate } from './document-create.helpers.js'
|
|
2
|
+
import { isGenericCreateSchema, pathFromHref, safeReturnTo, schemaCreateIdentity, schemasForGenericCreate } from './document-create.helpers.js'
|
|
3
3
|
|
|
4
4
|
describe('document-create helpers', () => {
|
|
5
5
|
it('excludes platform schemas and field-less types', () => {
|
|
@@ -65,7 +65,15 @@ describe('document-create helpers', () => {
|
|
|
65
65
|
|
|
66
66
|
it('only allows same-origin relative returnTo paths', () => {
|
|
67
67
|
expect(safeReturnTo('/forecasts')).toBe('/forecasts')
|
|
68
|
+
expect(safeReturnTo('/en/forecasts?x=1')).toBe('/en/forecasts?x=1')
|
|
68
69
|
expect(safeReturnTo('//evil.example')).toBe(null)
|
|
69
70
|
expect(safeReturnTo('https://evil.example')).toBe(null)
|
|
70
71
|
})
|
|
72
|
+
|
|
73
|
+
it('extracts a safe returnTo from a router href', () => {
|
|
74
|
+
expect(pathFromHref('http://localhost:3006/en/forecasts')).toBe('/en/forecasts')
|
|
75
|
+
expect(pathFromHref('/sv/prognoser')).toBe('/sv/prognoser')
|
|
76
|
+
expect(pathFromHref('https://evil.example/en/forecasts')).toBe('/en/forecasts')
|
|
77
|
+
expect(pathFromHref('//evil.example')).toBe(null)
|
|
78
|
+
})
|
|
71
79
|
})
|
package/src/en.translations.json
CHANGED
|
@@ -50,6 +50,22 @@
|
|
|
50
50
|
"resources.createDirectory.errorEmpty": "Directory name cannot be empty",
|
|
51
51
|
"resources.createDirectory.errorOssyPrefix": "Directory name cannot start with @ossy",
|
|
52
52
|
"resources.panel.download": "Download",
|
|
53
|
+
"resources.share.title": "Share link",
|
|
54
|
+
"resources.share.confirmPublic": "Make this resource public so anyone with the link can open it. You can copy the link after confirming.",
|
|
55
|
+
"resources.share.alreadyPublic": "This resource is public. Copy the link below to share it.",
|
|
56
|
+
"resources.share.makePublic": "Make public & copy link",
|
|
57
|
+
"resources.share.copyLink": "Copy link",
|
|
58
|
+
"resources.share.linkCopied": "Link copied",
|
|
59
|
+
"resources.share.copyFailed": "Could not copy the link",
|
|
60
|
+
"resources.share.publishError": "Could not update sharing settings",
|
|
61
|
+
"resources.share.accessError": "Could not update visibility",
|
|
62
|
+
"resources.share.sharing": "Sharing…",
|
|
63
|
+
"resources.share.close": "Close",
|
|
64
|
+
"resources.share.notShareable": "Folders cannot be shared with a public link.",
|
|
65
|
+
"resources.share.visibility": "Visibility",
|
|
66
|
+
"resources.share.access.public": "Public",
|
|
67
|
+
"resources.share.access.workspace": "Workspace",
|
|
68
|
+
"resources.share.access.restricted": "Private",
|
|
53
69
|
"@ossy/resources/schema/markdown.body.label": "Body",
|
|
54
70
|
"@ossy/resources/schema/markdown.body.description": "Markdown content (headings, lists, links, code)."
|
|
55
71
|
}
|
package/src/index.js
CHANGED
|
@@ -23,6 +23,7 @@ export * from './ResourceContentPage.jsx'
|
|
|
23
23
|
export * from './ResourceDescription.jsx'
|
|
24
24
|
export * from './ResourceDetails.jsx'
|
|
25
25
|
export * from './ResourceDialogMove.jsx'
|
|
26
|
+
export * from './ResourceShareDialog.jsx'
|
|
26
27
|
export * from './ResourceFactory.jsx'
|
|
27
28
|
export * from './GenericResourceDetail.jsx'
|
|
28
29
|
export * from './GenericResourceForm.jsx'
|
|
@@ -4,6 +4,7 @@ import { useSdk } from '@ossy/sdk-react'
|
|
|
4
4
|
import { View, useLocale } from '@ossy/design-system'
|
|
5
5
|
import { useRouter } from '@ossy/router-react'
|
|
6
6
|
import { GenericResourceForm } from './GenericResourceForm.jsx'
|
|
7
|
+
import { safeReturnTo } from './document-create.helpers.js'
|
|
7
8
|
|
|
8
9
|
export const metadata = {
|
|
9
10
|
id: 'resources/create/generic',
|
|
@@ -11,6 +12,7 @@ export const metadata = {
|
|
|
11
12
|
en: '/resources/create/:schemaId',
|
|
12
13
|
sv: '/resources/skapa/:schemaId',
|
|
13
14
|
},
|
|
15
|
+
layout: '@ossy/app/layout/blank',
|
|
14
16
|
}
|
|
15
17
|
|
|
16
18
|
/** Generic resource create page — slot lookup with template Fields fallback. */
|
|
@@ -19,10 +21,15 @@ export default function GenericResourceCreatePage () {
|
|
|
19
21
|
const { t } = useLocale()
|
|
20
22
|
const schemaId = decodeURIComponent(router.params.schemaId || '')
|
|
21
23
|
const location = router.searchParams.location
|
|
24
|
+
const returnTo = safeReturnTo(router.searchParams.returnTo)
|
|
22
25
|
const sdk = useSdk()
|
|
23
26
|
const [error, setError] = useState()
|
|
24
27
|
|
|
25
28
|
const onCancel = () => {
|
|
29
|
+
if (returnTo) {
|
|
30
|
+
router.navigate(returnTo)
|
|
31
|
+
return
|
|
32
|
+
}
|
|
26
33
|
const search = location ? new URLSearchParams({ location }).toString() : ''
|
|
27
34
|
router.navigate(`@storage/home${search ? `?${search}` : ''}`)
|
|
28
35
|
}
|
|
@@ -34,7 +41,7 @@ export default function GenericResourceCreatePage () {
|
|
|
34
41
|
}
|
|
35
42
|
|
|
36
43
|
return (
|
|
37
|
-
<View layout="off-center-m" style={{
|
|
44
|
+
<View surface="base" layout="off-center-m" inset="s" style={{ minHeight: '100dvh' }}>
|
|
38
45
|
<View data-region="content" surface="primary" roundness="m" inset="m">
|
|
39
46
|
<GenericResourceForm
|
|
40
47
|
schemaId={schemaId}
|
|
@@ -16,7 +16,7 @@ export default function ResourceDetailPage () {
|
|
|
16
16
|
const resourceId = router.params.resourceId
|
|
17
17
|
|
|
18
18
|
return (
|
|
19
|
-
<View layout="off-center-
|
|
19
|
+
<View layout="off-center-l" style={{ height: '100%' }}>
|
|
20
20
|
<View data-region="content" surface="primary" roundness="m" inset="m">
|
|
21
21
|
<GenericResourceDetail resourceId={resourceId} />
|
|
22
22
|
</View>
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Share / visibility helpers for workspace resources.
|
|
3
|
+
*
|
|
4
|
+
* Public files are readable at `/r/{id}` without signing in.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export const RESOURCE_ACCESS_VALUES = Object.freeze(['public', 'workspace', 'restricted'])
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @param {object | null | undefined} resource
|
|
11
|
+
* @returns {'public' | 'workspace' | 'restricted'}
|
|
12
|
+
*/
|
|
13
|
+
export function resourceAccessOf (resource) {
|
|
14
|
+
const access = resource?.access
|
|
15
|
+
if (RESOURCE_ACCESS_VALUES.includes(access)) return access
|
|
16
|
+
return 'restricted'
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @param {object | null | undefined} resource
|
|
21
|
+
* @returns {boolean}
|
|
22
|
+
*/
|
|
23
|
+
export function isPublicResource (resource) {
|
|
24
|
+
return resourceAccessOf(resource) === 'public'
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Icon name for list/grid visibility affordance.
|
|
29
|
+
*
|
|
30
|
+
* @param {object | null | undefined} resource
|
|
31
|
+
* @returns {'globe' | 'lock'}
|
|
32
|
+
*/
|
|
33
|
+
export function resourceAccessIcon (resource) {
|
|
34
|
+
return isPublicResource(resource) ? 'globe' : 'lock'
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Absolute (or app-relative) share URL for a resource.
|
|
39
|
+
*
|
|
40
|
+
* @param {string | { id?: string }} resourceOrId
|
|
41
|
+
* @param {{ origin?: string }} [options]
|
|
42
|
+
* @returns {string | null}
|
|
43
|
+
*/
|
|
44
|
+
export function getResourceShareUrl (resourceOrId, { origin } = {}) {
|
|
45
|
+
const id = typeof resourceOrId === 'string' ? resourceOrId : resourceOrId?.id
|
|
46
|
+
if (!id) return null
|
|
47
|
+
const path = `/r/${encodeURIComponent(id)}`
|
|
48
|
+
const resolvedOrigin = origin
|
|
49
|
+
?? (typeof window !== 'undefined' ? window.location?.origin : undefined)
|
|
50
|
+
if (resolvedOrigin) {
|
|
51
|
+
return `${resolvedOrigin}${path}`
|
|
52
|
+
}
|
|
53
|
+
return path
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Whether the resource can expose a public share link (non-directories).
|
|
58
|
+
*
|
|
59
|
+
* @param {object | null | undefined} resource
|
|
60
|
+
* @returns {boolean}
|
|
61
|
+
*/
|
|
62
|
+
export function isShareableResource (resource) {
|
|
63
|
+
if (!resource?.id) return false
|
|
64
|
+
const type = resource.type
|
|
65
|
+
if (type === '@ossy/platform/schema/directory' || type === 'directory') return false
|
|
66
|
+
return true
|
|
67
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { describe, expect, it } from '@jest/globals'
|
|
2
|
+
import {
|
|
3
|
+
getResourceShareUrl,
|
|
4
|
+
isPublicResource,
|
|
5
|
+
isShareableResource,
|
|
6
|
+
resourceAccessIcon,
|
|
7
|
+
resourceAccessOf,
|
|
8
|
+
} from './resource-share.js'
|
|
9
|
+
|
|
10
|
+
describe('resource-share', () => {
|
|
11
|
+
describe('resourceAccessOf', () => {
|
|
12
|
+
it('defaults to restricted', () => {
|
|
13
|
+
expect(resourceAccessOf(undefined)).toBe('restricted')
|
|
14
|
+
expect(resourceAccessOf({})).toBe('restricted')
|
|
15
|
+
expect(resourceAccessOf({ access: 'nope' })).toBe('restricted')
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('returns known access values', () => {
|
|
19
|
+
expect(resourceAccessOf({ access: 'public' })).toBe('public')
|
|
20
|
+
expect(resourceAccessOf({ access: 'workspace' })).toBe('workspace')
|
|
21
|
+
expect(resourceAccessOf({ access: 'restricted' })).toBe('restricted')
|
|
22
|
+
})
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
describe('isPublicResource / resourceAccessIcon', () => {
|
|
26
|
+
it('treats public as public + globe', () => {
|
|
27
|
+
expect(isPublicResource({ access: 'public' })).toBe(true)
|
|
28
|
+
expect(resourceAccessIcon({ access: 'public' })).toBe('globe')
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('treats non-public as private + lock', () => {
|
|
32
|
+
expect(isPublicResource({ access: 'workspace' })).toBe(false)
|
|
33
|
+
expect(isPublicResource({ access: 'restricted' })).toBe(false)
|
|
34
|
+
expect(resourceAccessIcon({ access: 'workspace' })).toBe('lock')
|
|
35
|
+
expect(resourceAccessIcon({})).toBe('lock')
|
|
36
|
+
})
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
describe('getResourceShareUrl', () => {
|
|
40
|
+
it('builds /r/{id} and absolutizes with origin', () => {
|
|
41
|
+
expect(getResourceShareUrl('abc')).toBe('/r/abc')
|
|
42
|
+
expect(getResourceShareUrl({ id: 'res_1' }, { origin: 'https://ossy.se' }))
|
|
43
|
+
.toBe('https://ossy.se/r/res_1')
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('encodes the id', () => {
|
|
47
|
+
expect(getResourceShareUrl('a/b')).toBe('/r/a%2Fb')
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('returns null without an id', () => {
|
|
51
|
+
expect(getResourceShareUrl(null)).toBe(null)
|
|
52
|
+
expect(getResourceShareUrl({})).toBe(null)
|
|
53
|
+
})
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
describe('isShareableResource', () => {
|
|
57
|
+
it('allows files and documents', () => {
|
|
58
|
+
expect(isShareableResource({ id: '1', type: '@ossy/platform/schema/file' })).toBe(true)
|
|
59
|
+
expect(isShareableResource({ id: '1', type: 'image/png' })).toBe(true)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('rejects directories and missing ids', () => {
|
|
63
|
+
expect(isShareableResource({ id: '1', type: 'directory' })).toBe(false)
|
|
64
|
+
expect(isShareableResource({ id: '1', type: '@ossy/platform/schema/directory' })).toBe(false)
|
|
65
|
+
expect(isShareableResource({ type: 'file' })).toBe(false)
|
|
66
|
+
})
|
|
67
|
+
})
|
|
68
|
+
})
|
package/src/resource.helpers.js
CHANGED
|
@@ -19,6 +19,15 @@ export {
|
|
|
19
19
|
triggerResourceDownloads,
|
|
20
20
|
} from './resource-download.js'
|
|
21
21
|
|
|
22
|
+
export {
|
|
23
|
+
getResourceShareUrl,
|
|
24
|
+
isPublicResource,
|
|
25
|
+
isShareableResource,
|
|
26
|
+
resourceAccessIcon,
|
|
27
|
+
resourceAccessOf,
|
|
28
|
+
RESOURCE_ACCESS_VALUES,
|
|
29
|
+
} from './resource-share.js'
|
|
30
|
+
|
|
22
31
|
export {
|
|
23
32
|
applyResourceSelectionGesture,
|
|
24
33
|
idsInRange,
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
const TEMPORAL_FIELD_TYPES = new Set(['date', 'month', 'timestamp'])
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* Whether a schema field value should render as Markdown in detail views.
|
|
3
5
|
*
|
|
@@ -10,3 +12,32 @@ export function shouldRenderFieldAsMarkdown (field, value) {
|
|
|
10
12
|
&& typeof value === 'string'
|
|
11
13
|
&& value.trim().length > 0
|
|
12
14
|
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @param {{ type?: string } | null | undefined} field
|
|
18
|
+
*/
|
|
19
|
+
export function isTemporalField (field) {
|
|
20
|
+
return TEMPORAL_FIELD_TYPES.has(field?.type)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Localized label for date / month / timestamp schema values (Unix ms).
|
|
25
|
+
*
|
|
26
|
+
* @param {unknown} value
|
|
27
|
+
* @param {{ type?: string } | null | undefined} field
|
|
28
|
+
* @param {string} [locale]
|
|
29
|
+
* @returns {string | null}
|
|
30
|
+
*/
|
|
31
|
+
export function formatTemporalFieldValue (value, field, locale = 'en-GB') {
|
|
32
|
+
if (value == null || value === '') return null
|
|
33
|
+
const ms = typeof value === 'number' ? value : Number(value)
|
|
34
|
+
if (!Number.isFinite(ms)) return null
|
|
35
|
+
|
|
36
|
+
const options = field?.type === 'month'
|
|
37
|
+
? { year: 'numeric', month: 'long' }
|
|
38
|
+
: field?.type === 'timestamp'
|
|
39
|
+
? { dateStyle: 'medium', timeStyle: 'short' }
|
|
40
|
+
: { dateStyle: 'medium' }
|
|
41
|
+
|
|
42
|
+
return new Intl.DateTimeFormat(locale, options).format(new Date(ms))
|
|
43
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { describe, expect, it } from '@jest/globals'
|
|
2
|
+
import {
|
|
3
|
+
formatTemporalFieldValue,
|
|
4
|
+
isTemporalField,
|
|
5
|
+
} from './schema-field-display.js'
|
|
6
|
+
|
|
7
|
+
describe('isTemporalField', () => {
|
|
8
|
+
it('matches date, month, and timestamp', () => {
|
|
9
|
+
expect(isTemporalField({ type: 'date' })).toBe(true)
|
|
10
|
+
expect(isTemporalField({ type: 'month' })).toBe(true)
|
|
11
|
+
expect(isTemporalField({ type: 'timestamp' })).toBe(true)
|
|
12
|
+
expect(isTemporalField({ type: 'number' })).toBe(false)
|
|
13
|
+
expect(isTemporalField({})).toBe(false)
|
|
14
|
+
})
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
describe('formatTemporalFieldValue', () => {
|
|
18
|
+
const ms = Date.UTC(2020, 7, 8)
|
|
19
|
+
|
|
20
|
+
it('formats date fields as a calendar day', () => {
|
|
21
|
+
const label = formatTemporalFieldValue(ms, { type: 'date' }, 'en-GB')
|
|
22
|
+
expect(label).toContain('2020')
|
|
23
|
+
expect(label).not.toMatch(/1596844800000/)
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('formats month fields as month and year', () => {
|
|
27
|
+
const label = formatTemporalFieldValue(ms, { type: 'month' }, 'en-GB')
|
|
28
|
+
expect(label.toLowerCase()).toContain('august')
|
|
29
|
+
expect(label).toContain('2020')
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('returns em-dash fallback as null for empty values', () => {
|
|
33
|
+
expect(formatTemporalFieldValue(null, { type: 'date' })).toBe(null)
|
|
34
|
+
expect(formatTemporalFieldValue('', { type: 'date' })).toBe(null)
|
|
35
|
+
expect(formatTemporalFieldValue('not-a-date', { type: 'date' })).toBe(null)
|
|
36
|
+
})
|
|
37
|
+
})
|
package/src/sv.translations.json
CHANGED
|
@@ -50,6 +50,22 @@
|
|
|
50
50
|
"resources.createDirectory.errorEmpty": "Mappnamn får inte vara tomt",
|
|
51
51
|
"resources.createDirectory.errorOssyPrefix": "Mappnamn får inte börja med @ossy",
|
|
52
52
|
"resources.panel.download": "Ladda ner",
|
|
53
|
+
"resources.share.title": "Dela länk",
|
|
54
|
+
"resources.share.confirmPublic": "Gör den här resursen publik så att alla med länken kan öppna den. Du kan kopiera länken efter bekräftelse.",
|
|
55
|
+
"resources.share.alreadyPublic": "Den här resursen är publik. Kopiera länken nedan för att dela den.",
|
|
56
|
+
"resources.share.makePublic": "Gör publik & kopiera länk",
|
|
57
|
+
"resources.share.copyLink": "Kopiera länk",
|
|
58
|
+
"resources.share.linkCopied": "Länk kopierad",
|
|
59
|
+
"resources.share.copyFailed": "Kunde inte kopiera länken",
|
|
60
|
+
"resources.share.publishError": "Kunde inte uppdatera delningsinställningar",
|
|
61
|
+
"resources.share.accessError": "Kunde inte uppdatera synlighet",
|
|
62
|
+
"resources.share.sharing": "Delar…",
|
|
63
|
+
"resources.share.close": "Stäng",
|
|
64
|
+
"resources.share.notShareable": "Mappar kan inte delas med en publik länk.",
|
|
65
|
+
"resources.share.visibility": "Synlighet",
|
|
66
|
+
"resources.share.access.public": "Publik",
|
|
67
|
+
"resources.share.access.workspace": "Workspace",
|
|
68
|
+
"resources.share.access.restricted": "Privat",
|
|
53
69
|
"@ossy/resources/schema/markdown.body.label": "Innehåll",
|
|
54
70
|
"@ossy/resources/schema/markdown.body.description": "Markdown-innehåll (rubriker, listor, länkar, kod)."
|
|
55
71
|
}
|