@ossy/resources 3.7.1 → 3.9.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 +5 -5
- package/src/CreateDirectory.page.jsx +2 -1
- package/src/CreateDocument.jsx +76 -8
- package/src/CreateDocument.page.jsx +2 -1
- package/src/GenericResourceForm.jsx +18 -12
- package/src/ResourceDetails.jsx +1 -43
- package/src/ResourceGrid.jsx +20 -1
- package/src/ResourceList.jsx +16 -2
- package/src/ResourcePanel.jsx +83 -2
- package/src/ResourceShareDialog.jsx +183 -0
- package/src/Upload.page.jsx +2 -1
- package/src/VideoResource.jsx +13 -14
- package/src/document-create.helpers.js +97 -0
- package/src/document-create.helpers.spec.js +79 -0
- package/src/en.translations.json +19 -0
- package/src/index.js +2 -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/sv.translations.json +19 -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.9.0",
|
|
5
5
|
"private": false,
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "./src/index.js",
|
|
@@ -23,10 +23,10 @@
|
|
|
23
23
|
"src": "./src"
|
|
24
24
|
},
|
|
25
25
|
"dependencies": {
|
|
26
|
-
"@ossy/event-store": "^3.
|
|
26
|
+
"@ossy/event-store": "^3.8.0",
|
|
27
27
|
"@ossy/fold": "^3.4.0",
|
|
28
|
-
"@ossy/platform": "^3.
|
|
29
|
-
"@ossy/schema": "^3.
|
|
28
|
+
"@ossy/platform": "^3.9.0",
|
|
29
|
+
"@ossy/schema": "^3.8.0"
|
|
30
30
|
},
|
|
31
31
|
"peerDependencies": {
|
|
32
32
|
"@ossy/design-system": ">=1.0.0",
|
|
@@ -49,5 +49,5 @@
|
|
|
49
49
|
"/src",
|
|
50
50
|
"README.md"
|
|
51
51
|
],
|
|
52
|
-
"gitHead": "
|
|
52
|
+
"gitHead": "f404be69becb27a1fd853a6ff1903554e76e7d17"
|
|
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
|
@@ -1,38 +1,106 @@
|
|
|
1
|
-
import React, { useState } from 'react'
|
|
1
|
+
import React, { useMemo, useState } from 'react'
|
|
2
2
|
import { createDocument } from './resource.helpers.js'
|
|
3
3
|
import { useSdk } from '@ossy/sdk-react'
|
|
4
|
-
import { Alert, useLocale } from '@ossy/design-system'
|
|
4
|
+
import { Alert, Button, Text, View, useLocale } from '@ossy/design-system'
|
|
5
5
|
import { useRouter } from '@ossy/router-react'
|
|
6
6
|
import { GenericResourceForm } from './GenericResourceForm.jsx'
|
|
7
|
+
import { useSchemas } from './useSchemas.js'
|
|
8
|
+
import { safeReturnTo, schemasForGenericCreate } from './document-create.helpers.js'
|
|
7
9
|
|
|
8
10
|
export const CreateDocument = () => {
|
|
9
11
|
const { t } = useLocale()
|
|
10
12
|
const router = useRouter()
|
|
11
13
|
const schemaId = router.searchParams.schemaId
|
|
12
14
|
const location = router.searchParams.location
|
|
15
|
+
const category = router.searchParams.category
|
|
13
16
|
const sdk = useSdk()
|
|
17
|
+
const schemas = useSchemas()
|
|
14
18
|
const [error, setError] = useState()
|
|
15
19
|
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
-
|
|
20
|
+
const returnTo = safeReturnTo(router.searchParams.returnTo)
|
|
21
|
+
|
|
22
|
+
const creatable = useMemo(
|
|
23
|
+
() => schemasForGenericCreate(schemas, { category: category || undefined }),
|
|
24
|
+
[schemas, category],
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
const grouped = useMemo(() => {
|
|
28
|
+
const groups = new Map()
|
|
29
|
+
for (const schema of creatable) {
|
|
30
|
+
const name = schema.categoryName || t('resources.form.otherCategory')
|
|
31
|
+
const list = groups.get(name) || []
|
|
32
|
+
list.push(schema)
|
|
33
|
+
groups.set(name, list)
|
|
34
|
+
}
|
|
35
|
+
return [...groups.entries()].sort(([a], [b]) => a.localeCompare(b))
|
|
36
|
+
}, [creatable, t])
|
|
37
|
+
|
|
38
|
+
const onDone = () => {
|
|
39
|
+
if (returnTo) {
|
|
40
|
+
router.navigate(returnTo)
|
|
41
|
+
return
|
|
42
|
+
}
|
|
43
|
+
const search = location ? new URLSearchParams({ location }).toString() : ''
|
|
44
|
+
router.navigate(`@storage/home${search ? `?${search}` : ''}`)
|
|
19
45
|
}
|
|
20
46
|
|
|
21
47
|
const onSubmit = ({ name, content, type }) => {
|
|
22
48
|
createDocument(sdk, { type, location, name, content })
|
|
23
|
-
.then(() =>
|
|
49
|
+
.then(() => onDone())
|
|
24
50
|
.catch((err) => setError(err?.message || t('resources.form.createFailed')))
|
|
25
51
|
}
|
|
26
52
|
|
|
53
|
+
const selectSchema = (id) => {
|
|
54
|
+
const params = new URLSearchParams()
|
|
55
|
+
if (location) params.set('location', location)
|
|
56
|
+
if (category) params.set('category', category)
|
|
57
|
+
if (returnTo) params.set('returnTo', returnTo)
|
|
58
|
+
params.set('schemaId', id)
|
|
59
|
+
router.navigate(`@create-document?${params.toString()}`)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (!schemaId) {
|
|
63
|
+
return (
|
|
64
|
+
<View gap="m" style={{ minWidth: 'min(28rem, calc(100vw - 2rem))' }}>
|
|
65
|
+
<View gap="xs">
|
|
66
|
+
<Text variant="heading-tertiary" as="h3" text="resources.createDocument.title" />
|
|
67
|
+
<Text color="secondary" text="resources.createDocument.pickType" />
|
|
68
|
+
</View>
|
|
69
|
+
{grouped.length === 0 ? (
|
|
70
|
+
<Text color="secondary" text="resources.createDocument.noTypes" />
|
|
71
|
+
) : grouped.map(([categoryName, items]) => (
|
|
72
|
+
<View key={categoryName} gap="s">
|
|
73
|
+
<Text variant="small" weight="medium">{categoryName}</Text>
|
|
74
|
+
<View layout="row" gap="s" style={{ flexWrap: 'wrap' }}>
|
|
75
|
+
{items.map((schema) => (
|
|
76
|
+
<Button
|
|
77
|
+
key={schema.id}
|
|
78
|
+
variant="neutral"
|
|
79
|
+
prefix={schema.icon || 'add'}
|
|
80
|
+
onClick={() => selectSchema(schema.id)}
|
|
81
|
+
>
|
|
82
|
+
{schema.name || schema.id}
|
|
83
|
+
</Button>
|
|
84
|
+
))}
|
|
85
|
+
</View>
|
|
86
|
+
</View>
|
|
87
|
+
))}
|
|
88
|
+
<View layout="row" justifyContent="flex-end">
|
|
89
|
+
<Button variant="link" onClick={onDone} label="design-system.cancel" />
|
|
90
|
+
</View>
|
|
91
|
+
</View>
|
|
92
|
+
)
|
|
93
|
+
}
|
|
94
|
+
|
|
27
95
|
return (
|
|
28
96
|
<>
|
|
97
|
+
{error && <Alert variant="danger">{error}</Alert>}
|
|
29
98
|
<GenericResourceForm
|
|
30
99
|
schemaId={schemaId}
|
|
31
100
|
mode="create"
|
|
32
101
|
onSubmit={onSubmit}
|
|
33
|
-
onCancel={
|
|
102
|
+
onCancel={onDone}
|
|
34
103
|
/>
|
|
35
|
-
{error && <Alert>{error}</Alert>}
|
|
36
104
|
</>
|
|
37
105
|
)
|
|
38
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',
|
|
@@ -201,7 +203,7 @@ const ResourceGridItem = memo(function ResourceGridItem({
|
|
|
201
203
|
position: 'relative',
|
|
202
204
|
}}
|
|
203
205
|
>
|
|
204
|
-
{
|
|
206
|
+
{showSelectionControls && (
|
|
205
207
|
<View
|
|
206
208
|
style={{
|
|
207
209
|
position: 'absolute',
|
|
@@ -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()
|
|
@@ -160,7 +161,7 @@ const ResourceListItem = memo(function ResourceListItem({
|
|
|
160
161
|
|
|
161
162
|
const leading = (
|
|
162
163
|
<View layout="row" alignItems="center" gap="s" style={{ flexShrink: 0 }}>
|
|
163
|
-
{
|
|
164
|
+
{showSelectionControls && (
|
|
164
165
|
<ResourceSelectionCheckbox
|
|
165
166
|
selected={selected}
|
|
166
167
|
onToggle={handleToggleSelect}
|
|
@@ -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)
|
|
@@ -176,9 +208,46 @@ export const ResourcePanel = memo(function ResourcePanel ({
|
|
|
176
208
|
<Switch.Case match={[ViewMode.View]}>
|
|
177
209
|
|
|
178
210
|
<View.Item fill surface="primary" style={{ padding: '4px 8px' }}>
|
|
179
|
-
<Text as="h3" variant="
|
|
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/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>
|
package/src/VideoResource.jsx
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
|
-
import React
|
|
1
|
+
import React from 'react'
|
|
2
2
|
import { metadata as GetResource } from './get.action.js'
|
|
3
3
|
import { useSdk } from '@ossy/sdk-react'
|
|
4
|
-
import { View } from '@ossy/design-system'
|
|
5
4
|
|
|
6
5
|
export const VideoResource = ({
|
|
7
6
|
resourceId
|
|
@@ -10,17 +9,17 @@ export const VideoResource = ({
|
|
|
10
9
|
const { data: resource } = sdk.read(GetResource, { resourceId }, { enabled: !!resourceId })
|
|
11
10
|
|
|
12
11
|
return (
|
|
13
|
-
<
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
</
|
|
12
|
+
<div style={{ display: 'flex', justifyContent: 'center', padding: 'var(--space-m) var(--space-s) var(--space-xl)' }}>
|
|
13
|
+
<video
|
|
14
|
+
controls
|
|
15
|
+
src={resource?.content?.src}
|
|
16
|
+
style={{
|
|
17
|
+
width: '100%',
|
|
18
|
+
maxWidth: '960px',
|
|
19
|
+
height: 'auto',
|
|
20
|
+
margin: 'var(--space-m) auto var(--space-l)',
|
|
21
|
+
}}
|
|
22
|
+
/>
|
|
23
|
+
</div>
|
|
25
24
|
)
|
|
26
25
|
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { parseSchemaId } from '@ossy/schema'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Document types that can be created from the generic create-document GUI.
|
|
5
|
+
* Platform internals and capability schemas are excluded.
|
|
6
|
+
*
|
|
7
|
+
* @param {object} schema
|
|
8
|
+
* @returns {boolean}
|
|
9
|
+
*/
|
|
10
|
+
export function isGenericCreateSchema (schema) {
|
|
11
|
+
if (!schema?.id || !Array.isArray(schema.fields) || schema.fields.length === 0) return false
|
|
12
|
+
const category = schema.categoryName ?? ''
|
|
13
|
+
if (/platform/i.test(category)) return false
|
|
14
|
+
return true
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function kebabConcept (value) {
|
|
18
|
+
return String(value)
|
|
19
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1-$2')
|
|
20
|
+
.replace(/_/g, '-')
|
|
21
|
+
.toLowerCase()
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Treat canonical ADR 0006 ids and pre-migration `@provider/feature/concept` ids
|
|
26
|
+
* as the same create-document type (e.g. employee vs schema/employee).
|
|
27
|
+
*
|
|
28
|
+
* @param {string} [schemaId]
|
|
29
|
+
* @returns {string | undefined}
|
|
30
|
+
*/
|
|
31
|
+
export function schemaCreateIdentity (schemaId) {
|
|
32
|
+
if (typeof schemaId !== 'string' || !schemaId) return schemaId
|
|
33
|
+
const parsed = parseSchemaId(schemaId)
|
|
34
|
+
if (parsed) return `${parsed.provider}/${parsed.feature}/${kebabConcept(parsed.concept)}`
|
|
35
|
+
const legacy = /^@([^/]+)\/([^/]+)\/([^/]+)$/.exec(schemaId)
|
|
36
|
+
if (legacy) return `${legacy[1]}/${legacy[2]}/${kebabConcept(legacy[3])}`
|
|
37
|
+
return schemaId
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function schemaNameKey (schema) {
|
|
41
|
+
const name = schema?.name
|
|
42
|
+
if (!name) return null
|
|
43
|
+
return `name:${schema.categoryName ?? ''}:${name}`
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* @param {object[]} schemas
|
|
48
|
+
* @param {{ category?: string }} [options]
|
|
49
|
+
* @returns {object[]}
|
|
50
|
+
*/
|
|
51
|
+
export function schemasForGenericCreate (schemas, { category } = {}) {
|
|
52
|
+
const list = Array.isArray(schemas) ? schemas : []
|
|
53
|
+
const seen = new Set()
|
|
54
|
+
const result = []
|
|
55
|
+
for (const schema of list) {
|
|
56
|
+
if (!isGenericCreateSchema(schema)) continue
|
|
57
|
+
if (category && schema.categoryName !== category) continue
|
|
58
|
+
const identity = schemaCreateIdentity(schema.id)
|
|
59
|
+
const nameKey = schemaNameKey(schema)
|
|
60
|
+
if (seen.has(identity) || (nameKey && seen.has(nameKey))) continue
|
|
61
|
+
seen.add(identity)
|
|
62
|
+
if (nameKey) seen.add(nameKey)
|
|
63
|
+
result.push(schema)
|
|
64
|
+
}
|
|
65
|
+
return result
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Same-origin relative path only.
|
|
70
|
+
* @param {unknown} value
|
|
71
|
+
* @returns {string | null}
|
|
72
|
+
*/
|
|
73
|
+
export function safeReturnTo (value) {
|
|
74
|
+
if (typeof value !== 'string') return null
|
|
75
|
+
const trimmed = value.trim()
|
|
76
|
+
if (!trimmed.startsWith('/') || trimmed.startsWith('//')) return null
|
|
77
|
+
return trimmed
|
|
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
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { describe, expect, it } from '@jest/globals'
|
|
2
|
+
import { isGenericCreateSchema, pathFromHref, safeReturnTo, schemaCreateIdentity, schemasForGenericCreate } from './document-create.helpers.js'
|
|
3
|
+
|
|
4
|
+
describe('document-create helpers', () => {
|
|
5
|
+
it('excludes platform schemas and field-less types', () => {
|
|
6
|
+
const schemas = [
|
|
7
|
+
{ id: '@ossy/consultancy/schema/employee', categoryName: 'Consultancy', fields: [{ name: 'name', type: 'text' }] },
|
|
8
|
+
{ id: '@ossy/platform/schema/file', categoryName: 'Platform', fields: [{ name: 'name', type: 'text' }] },
|
|
9
|
+
{ id: '@ossy/empty/schema/x', categoryName: 'Other', fields: [] },
|
|
10
|
+
]
|
|
11
|
+
expect(schemasForGenericCreate(schemas).map((s) => s.id)).toEqual([
|
|
12
|
+
'@ossy/consultancy/schema/employee',
|
|
13
|
+
])
|
|
14
|
+
expect(isGenericCreateSchema(schemas[1])).toBe(false)
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('filters by category when asked', () => {
|
|
18
|
+
const schemas = [
|
|
19
|
+
{ id: 'a', categoryName: 'Consultancy', fields: [{ name: 'n', type: 'text' }] },
|
|
20
|
+
{ id: 'b', categoryName: 'Web', fields: [{ name: 'n', type: 'text' }] },
|
|
21
|
+
]
|
|
22
|
+
expect(schemasForGenericCreate(schemas, { category: 'Consultancy' }).map((s) => s.id)).toEqual(['a'])
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('aliases canonical and pre-migration consultancy ids', () => {
|
|
26
|
+
expect(schemaCreateIdentity('@ossy/consultancy/schema/employee')).toBe('ossy/consultancy/employee')
|
|
27
|
+
expect(schemaCreateIdentity('@ossy/consultancy/employee')).toBe('ossy/consultancy/employee')
|
|
28
|
+
expect(schemaCreateIdentity('@ossy/consultancy/companyBasicInfo')).toBe('ossy/consultancy/company-basic-info')
|
|
29
|
+
expect(schemaCreateIdentity('@ossy/consultancy/schema/company-basic-info')).toBe('ossy/consultancy/company-basic-info')
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('hides workspace copies of system schemas on the create picker', () => {
|
|
33
|
+
const schemas = [
|
|
34
|
+
{ id: '@ossy/consultancy/schema/company-basic-info', name: 'Company Basic Info', categoryName: 'Consultancy', icon: 'organisation', fields: [{ name: 'name', type: 'text' }] },
|
|
35
|
+
{ id: '@ossy/consultancy/schema/contract', name: 'Contract', categoryName: 'Consultancy', icon: 'file', fields: [{ name: 'name', type: 'text' }] },
|
|
36
|
+
{ id: '@ossy/consultancy/schema/employee', name: 'Employee', categoryName: 'Consultancy', icon: 'user-add', fields: [{ name: 'name', type: 'text' }] },
|
|
37
|
+
{ id: '@ossy/consultancy/schema/opportunity', name: 'Opportunity', categoryName: 'Consultancy', fields: [{ name: 'name', type: 'text' }] },
|
|
38
|
+
{ id: '@ossy/consultancy/schema/proposal', name: 'Proposal', categoryName: 'Consultancy', icon: 'file', fields: [{ name: 'name', type: 'text' }] },
|
|
39
|
+
{ id: '@ossy/consultancy/schema/vacation', name: 'Vacation', categoryName: 'Consultancy', icon: 'calendar', fields: [{ name: 'name', type: 'text' }] },
|
|
40
|
+
{ id: '@ossy/consultancy/vacation', name: 'Vacation', categoryName: 'Consultancy', fields: [{ name: 'name', type: 'text' }] },
|
|
41
|
+
{ id: '@ossy/consultancy/employee', name: 'Employee', categoryName: 'Consultancy', fields: [{ name: 'name', type: 'text' }] },
|
|
42
|
+
{ id: '@ossy/consultancy/companyBasicInfo', name: 'Company Basic Info', categoryName: 'Consultancy', fields: [{ name: 'name', type: 'text' }] },
|
|
43
|
+
{ id: '@ossy/consultancy/contract', name: 'Contract', categoryName: 'Consultancy', fields: [{ name: 'name', type: 'text' }] },
|
|
44
|
+
]
|
|
45
|
+
expect(schemasForGenericCreate(schemas).map((s) => s.id)).toEqual([
|
|
46
|
+
'@ossy/consultancy/schema/company-basic-info',
|
|
47
|
+
'@ossy/consultancy/schema/contract',
|
|
48
|
+
'@ossy/consultancy/schema/employee',
|
|
49
|
+
'@ossy/consultancy/schema/opportunity',
|
|
50
|
+
'@ossy/consultancy/schema/proposal',
|
|
51
|
+
'@ossy/consultancy/schema/vacation',
|
|
52
|
+
])
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('keeps workspace-only custom types', () => {
|
|
56
|
+
const schemas = [
|
|
57
|
+
{ id: '@ossy/consultancy/schema/employee', name: 'Employee', categoryName: 'Consultancy', fields: [{ name: 'name', type: 'text' }] },
|
|
58
|
+
{ id: 'workspace/custom-invoice', name: 'Invoice', categoryName: 'Consultancy', fields: [{ name: 'name', type: 'text' }] },
|
|
59
|
+
]
|
|
60
|
+
expect(schemasForGenericCreate(schemas).map((s) => s.id)).toEqual([
|
|
61
|
+
'@ossy/consultancy/schema/employee',
|
|
62
|
+
'workspace/custom-invoice',
|
|
63
|
+
])
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('only allows same-origin relative returnTo paths', () => {
|
|
67
|
+
expect(safeReturnTo('/forecasts')).toBe('/forecasts')
|
|
68
|
+
expect(safeReturnTo('/en/forecasts?x=1')).toBe('/en/forecasts?x=1')
|
|
69
|
+
expect(safeReturnTo('//evil.example')).toBe(null)
|
|
70
|
+
expect(safeReturnTo('https://evil.example')).toBe(null)
|
|
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
|
+
})
|
|
79
|
+
})
|
package/src/en.translations.json
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
"resources.form.createFailed": "Create failed",
|
|
9
9
|
"resources.form.createResource": "Create resource",
|
|
10
10
|
"resources.form.typeRemoved": "This document type has been removed and can't be edited.",
|
|
11
|
+
"resources.form.otherCategory": "Other",
|
|
11
12
|
"@ossy/resources/actions/create.label": "Create resource",
|
|
12
13
|
"@ossy/resources/actions/create.description": "Create a new resource in the workspace",
|
|
13
14
|
"@ossy/resources/actions/list.label": "List resources",
|
|
@@ -41,12 +42,30 @@
|
|
|
41
42
|
"resources.upload.location": "Location: {location}",
|
|
42
43
|
"resources.createDirectory.title": "Create directory",
|
|
43
44
|
"resources.createDocument.title": "Create document",
|
|
45
|
+
"resources.createDocument.pickType": "Choose a document type to create in this location.",
|
|
46
|
+
"resources.createDocument.noTypes": "No document types are available to create here.",
|
|
44
47
|
"resources.createDirectory.description": "Create a new directory to organize your resources. The new directory will be created in the location: {location}",
|
|
45
48
|
"resources.createDirectory.namePlaceholder": "Directory name",
|
|
46
49
|
"resources.createDirectory.submit": "Create directory",
|
|
47
50
|
"resources.createDirectory.errorEmpty": "Directory name cannot be empty",
|
|
48
51
|
"resources.createDirectory.errorOssyPrefix": "Directory name cannot start with @ossy",
|
|
49
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",
|
|
50
69
|
"@ossy/resources/schema/markdown.body.label": "Body",
|
|
51
70
|
"@ossy/resources/schema/markdown.body.description": "Markdown content (headings, lists, links, code)."
|
|
52
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'
|
|
@@ -52,3 +53,4 @@ export * from './useSchemaEngine.js'
|
|
|
52
53
|
export * from './useDocumentValidator.js'
|
|
53
54
|
export * from './useForm.js'
|
|
54
55
|
export * from './resource.helpers.js'
|
|
56
|
+
export * from './document-create.helpers.js'
|
|
@@ -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,
|
package/src/sv.translations.json
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
"resources.form.createFailed": "Skapandet misslyckades",
|
|
9
9
|
"resources.form.createResource": "Skapa resurs",
|
|
10
10
|
"resources.form.typeRemoved": "Den här dokumenttypen har tagits bort och kan inte redigeras.",
|
|
11
|
+
"resources.form.otherCategory": "Övrigt",
|
|
11
12
|
"@ossy/resources/actions/create.label": "Skapa resurs",
|
|
12
13
|
"@ossy/resources/actions/create.description": "Skapa en ny resurs i workspace",
|
|
13
14
|
"@ossy/resources/actions/list.label": "Lista resurser",
|
|
@@ -41,12 +42,30 @@
|
|
|
41
42
|
"resources.upload.location": "Plats: {location}",
|
|
42
43
|
"resources.createDirectory.title": "Skapa mapp",
|
|
43
44
|
"resources.createDocument.title": "Skapa dokument",
|
|
45
|
+
"resources.createDocument.pickType": "Välj en dokumenttyp att skapa på den här platsen.",
|
|
46
|
+
"resources.createDocument.noTypes": "Inga dokumenttyper kan skapas här.",
|
|
44
47
|
"resources.createDirectory.description": "Skapa en ny mapp för att organisera dina resurser. Den nya mappen skapas på platsen: {location}",
|
|
45
48
|
"resources.createDirectory.namePlaceholder": "Mappnamn",
|
|
46
49
|
"resources.createDirectory.submit": "Skapa mapp",
|
|
47
50
|
"resources.createDirectory.errorEmpty": "Mappnamn får inte vara tomt",
|
|
48
51
|
"resources.createDirectory.errorOssyPrefix": "Mappnamn får inte börja med @ossy",
|
|
49
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",
|
|
50
69
|
"@ossy/resources/schema/markdown.body.label": "Innehåll",
|
|
51
70
|
"@ossy/resources/schema/markdown.body.description": "Markdown-innehåll (rubriker, listor, länkar, kod)."
|
|
52
71
|
}
|