@ossy/resources 3.7.0 → 3.8.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 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.7.0",
4
+ "version": "3.8.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.7.0",
26
+ "@ossy/event-store": "^3.8.0",
27
27
  "@ossy/fold": "^3.4.0",
28
- "@ossy/platform": "^3.7.0",
29
- "@ossy/schema": "^3.6.1"
28
+ "@ossy/platform": "^3.8.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": "622d97535abad08415963405ace2f5aaaecd7ca4"
52
+ "gitHead": "14548dfc2019e7258e8c75db527acbf66fe829bd"
53
53
  }
@@ -1,36 +1,104 @@
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 onCancel = () => {
17
- const search = new URLSearchParams({ location }).toString()
18
- router.navigate(`@storage/home?${search}`)
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(() => onCancel())
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
  <>
29
97
  <GenericResourceForm
30
98
  schemaId={schemaId}
31
99
  mode="create"
32
100
  onSubmit={onSubmit}
33
- onCancel={onCancel}
101
+ onCancel={onDone}
34
102
  />
35
103
  {error && <Alert>{error}</Alert>}
36
104
  </>
@@ -201,7 +201,7 @@ const ResourceGridItem = memo(function ResourceGridItem({
201
201
  position: 'relative',
202
202
  }}
203
203
  >
204
- {(showSelectionControls || selected) && (
204
+ {showSelectionControls && (
205
205
  <View
206
206
  style={{
207
207
  position: 'absolute',
@@ -160,7 +160,7 @@ const ResourceListItem = memo(function ResourceListItem({
160
160
 
161
161
  const leading = (
162
162
  <View layout="row" alignItems="center" gap="s" style={{ flexShrink: 0 }}>
163
- {(showSelectionControls || selected) && (
163
+ {showSelectionControls && (
164
164
  <ResourceSelectionCheckbox
165
165
  selected={selected}
166
166
  onToggle={handleToggleSelect}
@@ -176,7 +176,7 @@ export const ResourcePanel = memo(function ResourcePanel ({
176
176
  <Switch.Case match={[ViewMode.View]}>
177
177
 
178
178
  <View.Item fill surface="primary" style={{ padding: '4px 8px' }}>
179
- <Text as="h3" variant="breadcrumb">{resourceName}</Text>
179
+ <Text as="h3" variant="small" style={{ fontWeight: 'bold' }}>{resourceName}</Text>
180
180
  </View.Item>
181
181
 
182
182
  {downloadHref && (
@@ -1,7 +1,6 @@
1
- import React from '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
- <View stack bordered>
14
- <View.Item fill style={{ padding: '16px 8px' }}>
15
-
16
- <div style={{ display: 'flex', justifyContent: 'center' }}>
17
- <video
18
- controls
19
- src={resource?.content?.src}
20
- style={{ width: 'auto', height: '400px', margin: 'var(--space-l) auto' }}
21
- />
22
- </div>
23
- </View.Item>
24
- </View>
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,78 @@
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
+ }
@@ -0,0 +1,71 @@
1
+ import { describe, expect, it } from '@jest/globals'
2
+ import { isGenericCreateSchema, 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('//evil.example')).toBe(null)
69
+ expect(safeReturnTo('https://evil.example')).toBe(null)
70
+ })
71
+ })
@@ -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,6 +42,8 @@
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",
package/src/index.js CHANGED
@@ -52,3 +52,4 @@ export * from './useSchemaEngine.js'
52
52
  export * from './useDocumentValidator.js'
53
53
  export * from './useForm.js'
54
54
  export * from './resource.helpers.js'
55
+ export * from './document-create.helpers.js'
@@ -1,4 +1,7 @@
1
1
  import { attachResourceMediaUrls } from './resources.attach-media-urls.js'
2
+ import { createLogger } from '@ossy/observability'
3
+
4
+ const log = createLogger('resources')
2
5
 
3
6
  export function mediaContextFromRun({ payload, req }) {
4
7
  return {
@@ -7,12 +10,24 @@ export function mediaContextFromRun({ payload, req }) {
7
10
  }
8
11
  }
9
12
 
13
+ async function attachOrKeep (resource, context) {
14
+ try {
15
+ return await attachResourceMediaUrls(resource, context)
16
+ } catch (error) {
17
+ log.warn('[withResourceMedia] skipping media urls', {
18
+ resourceId: resource?.id,
19
+ error: error?.message,
20
+ })
21
+ return resource
22
+ }
23
+ }
24
+
10
25
  export async function withResourceMedia(result, context) {
11
26
  if (Array.isArray(result)) {
12
- return Promise.all(result.map(r => attachResourceMediaUrls(r, context)))
27
+ return Promise.all(result.map(r => attachOrKeep(r, context)))
13
28
  }
14
29
  if (result && typeof result === 'object' && result.id) {
15
- return attachResourceMediaUrls(result, context)
30
+ return attachOrKeep(result, context)
16
31
  }
17
32
  return result
18
33
  }
@@ -1,6 +1,10 @@
1
1
  import { StorageClient } from '@ossy/platform'
2
2
  import { Aggregate } from '@ossy/event-store'
3
+ import { coerceStorageKey } from '@ossy/platform/storage-keys'
3
4
  import { Workspace } from '@ossy/workspaces/server'
5
+ import { createLogger } from '@ossy/observability'
6
+
7
+ const log = createLogger('resources')
4
8
 
5
9
  /**
6
10
  * Adds `content.src` (and `content.sizes[*]` URLs) for file resources, respecting
@@ -39,11 +43,19 @@ export async function attachResourceMediaUrls(resource, { userId, workspaceId }
39
43
  return { ...resource, content: nextContent }
40
44
  }
41
45
 
42
- if (Key) {
46
+ const originalKey = coerceStorageKey(Key, resource.id)
47
+ if (Key && !originalKey) {
48
+ log.warn('[attachResourceMediaUrls] skipping invalid storage key', {
49
+ resourceId: resource.id,
50
+ key: Key,
51
+ })
52
+ }
53
+
54
+ if (originalKey) {
43
55
  nextContent.src =
44
56
  access === 'public'
45
- ? StorageClient.createDownloadUrl(Key)
46
- : await StorageClient.createPresignedDownloadUrl(Key)
57
+ ? StorageClient.createDownloadUrl(originalKey)
58
+ : await StorageClient.createPresignedDownloadUrl(originalKey)
47
59
  }
48
60
 
49
61
  if (hasSizes) {
@@ -54,10 +66,15 @@ export async function attachResourceMediaUrls(resource, { userId, workspaceId }
54
66
  nextSizes[name] = value
55
67
  continue
56
68
  }
69
+ const sizeFallback = resource.id && /^[A-Za-z0-9_-]+$/.test(name)
70
+ ? `${resource.id}:${name}`
71
+ : resource.id
72
+ const logical = coerceStorageKey(value, sizeFallback)
73
+ if (!logical) continue
57
74
  nextSizes[name] =
58
75
  access === 'public'
59
- ? StorageClient.createDownloadUrl(value)
60
- : await StorageClient.createPresignedDownloadUrl(value)
76
+ ? StorageClient.createDownloadUrl(logical)
77
+ : await StorageClient.createPresignedDownloadUrl(logical)
61
78
  }
62
79
  nextContent.sizes = nextSizes
63
80
  }
@@ -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,6 +42,8 @@
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",