@ossy/resources 3.4.0 → 3.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/package.json +4 -4
  2. package/src/PlatformFileField.jsx +120 -85
  3. package/src/PlatformReferenceField.jsx +216 -0
  4. package/src/ResourceContentPage.jsx +2 -2
  5. package/src/ResourceGrid.jsx +44 -4
  6. package/src/ResourceList.jsx +38 -4
  7. package/src/ResourcePanel.jsx +19 -2
  8. package/src/ResourceSelectionCheckbox.jsx +68 -0
  9. package/src/SchemaDetailView.jsx +18 -18
  10. package/src/SchemaPresenter.jsx +6 -4
  11. package/src/Upload.jsx +28 -13
  12. package/src/create.task.js +4 -0
  13. package/src/en.translations.json +4 -1
  14. package/src/get-resource.api.js +15 -0
  15. package/src/index.js +2 -0
  16. package/src/markdown.component.jsx +20 -0
  17. package/src/markdown.schema.js +13 -0
  18. package/src/markdown.spec.js +43 -0
  19. package/src/platform-reference-field.component.jsx +5 -0
  20. package/src/reference-field.helpers.js +57 -0
  21. package/src/reference-field.helpers.spec.js +100 -0
  22. package/src/resource-download.js +194 -0
  23. package/src/resource-download.spec.js +93 -0
  24. package/src/resource-read.helpers.js +5 -1
  25. package/src/resource-selection.js +91 -0
  26. package/src/resource-selection.spec.js +77 -0
  27. package/src/resource-upload-access.js +63 -0
  28. package/src/resource-upload-access.spec.js +100 -0
  29. package/src/resource.helpers.js +30 -5
  30. package/src/resources.attach-reference-links.js +100 -0
  31. package/src/resources.attach-reference-links.spec.js +118 -0
  32. package/src/resources.validate-references.js +104 -0
  33. package/src/resources.validate-references.spec.js +182 -0
  34. package/src/schema-field-display.js +12 -0
  35. package/src/server.js +13 -0
  36. package/src/sv.translations.json +4 -1
  37. package/src/update-content.task.js +4 -0
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.0",
4
+ "version": "3.5.0",
5
5
  "private": false,
6
6
  "type": "module",
7
7
  "main": "./src/index.js",
@@ -25,8 +25,8 @@
25
25
  "dependencies": {
26
26
  "@ossy/event-store": "^3.4.0",
27
27
  "@ossy/fold": "^3.4.0",
28
- "@ossy/platform": "^3.4.0",
29
- "@ossy/schema": "^3.4.0"
28
+ "@ossy/platform": "^3.5.0",
29
+ "@ossy/schema": "^3.5.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": "d36b69444268d172bc3e8e1dc77e67afc63f8650"
52
+ "gitHead": "23167600f991596f122765a53f2b9df08314e115"
53
53
  }
@@ -6,7 +6,9 @@ import {
6
6
  Button,
7
7
  UploadInput,
8
8
  LocalFilePreview,
9
+ DropZone,
9
10
  acceptsFile,
11
+ partitionAcceptedFiles,
10
12
  } from '@ossy/design-system'
11
13
  import { uploadFile } from './resource.helpers.js'
12
14
  import { metadata as GetResource } from './get.action.js'
@@ -37,7 +39,7 @@ function resourceIdsFromValue(value) {
37
39
  }
38
40
 
39
41
  /**
40
- * Platform file field — eager upload on pick via SDK create + PUT.
42
+ * Platform file field — eager upload on pick or drop via SDK create + PUT.
41
43
  * Register as `@ossy/design-system/input/file` / `@ossy/design-system/input/image` through app component slots.
42
44
  */
43
45
  export function PlatformFileField({
@@ -63,6 +65,7 @@ export function PlatformFileField({
63
65
 
64
66
  const resourceId = multi ? null : resourceIdFromValue(value)
65
67
  const resourceIds = multi ? resourceIdsFromValue(value) : []
68
+ const busy = disabled || uploading
66
69
 
67
70
  useEffect(() => {
68
71
  if (!resourceId || localFile) {
@@ -105,6 +108,29 @@ export function PlatformFileField({
105
108
  [name, onChange, sdk, uploadLocation],
106
109
  )
107
110
 
111
+ const uploadMany = useCallback(
112
+ async files => {
113
+ const remaining = max - resourceIds.length
114
+ const batch = files.slice(0, remaining)
115
+ if (!batch.length) return
116
+ setUploading(true)
117
+ setError(null)
118
+ try {
119
+ const uploaded = []
120
+ for (const file of batch) {
121
+ const resource = await uploadFile(sdk, uploadLocation, file)
122
+ uploaded.push({ resourceId: resource.id })
123
+ }
124
+ commitResourceValue(name, [...resourceIds.map(id => ({ resourceId: id })), ...uploaded], onChange)
125
+ } catch (err) {
126
+ setError(err?.message || 'Upload failed')
127
+ } finally {
128
+ setUploading(false)
129
+ }
130
+ },
131
+ [max, name, onChange, resourceIds, sdk, uploadLocation],
132
+ )
133
+
108
134
  const onSinglePick = useCallback(
109
135
  e => {
110
136
  const file = e.target.files?.[0]
@@ -120,33 +146,38 @@ export function PlatformFileField({
120
146
  )
121
147
 
122
148
  const onMultiPick = useCallback(
123
- async e => {
149
+ e => {
124
150
  const picked = Array.from(e.target.files || [])
125
151
  e.target.value = ''
126
152
  if (!picked.length || uploading) return
127
- const valid = picked.filter(f => acceptsFile(f, accept))
128
- if (valid.length !== picked.length) {
153
+ const { accepted, rejected } = partitionAcceptedFiles(picked, accept)
154
+ if (rejected.length) {
129
155
  setError('Some files were rejected by accept filter')
130
156
  return
131
157
  }
132
- const remaining = max - resourceIds.length
133
- const batch = valid.slice(0, remaining)
134
- setUploading(true)
135
- setError(null)
136
- try {
137
- const uploaded = []
138
- for (const file of batch) {
139
- const resource = await uploadFile(sdk, uploadLocation, file)
140
- uploaded.push({ resourceId: resource.id })
141
- }
142
- commitResourceValue(name, [...resourceIds.map(id => ({ resourceId: id })), ...uploaded], onChange)
143
- } catch (err) {
144
- setError(err?.message || 'Upload failed')
145
- } finally {
146
- setUploading(false)
158
+ uploadMany(accepted)
159
+ },
160
+ [accept, uploadMany, uploading],
161
+ )
162
+
163
+ const onFilesDrop = useCallback(
164
+ files => {
165
+ if (busy) return
166
+ const { accepted, rejected } = partitionAcceptedFiles(files, accept)
167
+ if (!accepted.length) {
168
+ if (rejected.length) setError('File type not accepted')
169
+ return
147
170
  }
171
+ if (rejected.length) {
172
+ setError('Some files were rejected by accept filter')
173
+ }
174
+ if (multi) {
175
+ uploadMany(accepted)
176
+ return
177
+ }
178
+ uploadSingle(accepted[0])
148
179
  },
149
- [accept, max, name, onChange, resourceIds, sdk, uploadLocation, uploading],
180
+ [accept, busy, multi, uploadMany, uploadSingle],
150
181
  )
151
182
 
152
183
  const removeMulti = useCallback(
@@ -163,77 +194,81 @@ export function PlatformFileField({
163
194
  if (multi) {
164
195
  const canAdd = resourceIds.length < max
165
196
  return (
166
- <View gap="s" style={style} {...rest}>
167
- {resourceIds.map(id => (
168
- <View key={id} layout="row" gap="s" alignItems="center">
169
- <View
170
- as="img"
171
- src={`/r/${id}`}
172
- alt=""
173
- width="32px"
174
- height="32px"
175
- style={{ objectFit: 'cover', borderRadius: 'var(--space-s)' }}
197
+ <DropZone onFilesDrop={canAdd ? onFilesDrop : undefined}>
198
+ <View gap="s" style={style} data-ossy-file-dropzone {...rest}>
199
+ {resourceIds.map(id => (
200
+ <View key={id} layout="row" gap="s" alignItems="center">
201
+ <View
202
+ as="img"
203
+ src={`/r/${id}`}
204
+ alt=""
205
+ width="32px"
206
+ height="32px"
207
+ style={{ objectFit: 'cover', borderRadius: 'var(--space-s)' }}
208
+ />
209
+ <Text style={{ flex: 1, fontSize: 14 }}>{id}</Text>
210
+ <Button
211
+ type="button"
212
+ variant="command-danger"
213
+ prefix="close"
214
+ size="s"
215
+ disabled={busy}
216
+ onClick={() => removeMulti(id)}
217
+ />
218
+ </View>
219
+ ))}
220
+ {canAdd && (
221
+ <UploadInput
222
+ id={name}
223
+ name={name}
224
+ accept={accept}
225
+ disabled={busy}
226
+ required={required && resourceIds.length === 0}
227
+ multiple
228
+ onChange={onMultiPick}
176
229
  />
177
- <Text style={{ flex: 1, fontSize: 14 }}>{id}</Text>
178
- <Button
179
- type="button"
180
- variant="command-danger"
181
- prefix="close"
182
- size="s"
183
- disabled={disabled || uploading}
184
- onClick={() => removeMulti(id)}
185
- />
186
- </View>
187
- ))}
188
- {canAdd && (
189
- <UploadInput
190
- id={name}
191
- name={name}
192
- accept={accept}
193
- disabled={disabled || uploading}
194
- required={required && resourceIds.length === 0}
195
- multiple
196
- onChange={onMultiPick}
197
- />
198
- )}
199
- {uploading && <Text style={{ fontSize: 12 }}>Uploading…</Text>}
200
- {error && <Text style={{ fontSize: 12, color: 'var(--color-danger, crimson)' }}>{error}</Text>}
201
- </View>
230
+ )}
231
+ {uploading && <Text style={{ fontSize: 12 }}>Uploading…</Text>}
232
+ {error && <Text style={{ fontSize: 12, color: 'var(--color-danger, crimson)' }}>{error}</Text>}
233
+ </View>
234
+ </DropZone>
202
235
  )
203
236
  }
204
237
 
205
238
  const previewSrc = localFile ? null : previewUrl
206
239
 
207
240
  return (
208
- <View gap="s" style={style} {...rest}>
209
- {(localFile || previewSrc || resourceId) && (
210
- <View layout="row" gap="s" alignItems="center">
211
- {localFile && <LocalFilePreview file={localFile} size="48px" />}
212
- {!localFile && previewSrc && (
213
- <View
214
- as="img"
215
- src={previewSrc}
216
- alt={label || name}
217
- width="48px"
218
- height="48px"
219
- style={{ objectFit: 'cover', borderRadius: 'var(--space-s)' }}
220
- />
221
- )}
222
- {localFile && (
223
- <Text style={{ flex: 1, fontSize: 14 }}>{localFile.name}</Text>
224
- )}
225
- {uploading && <Text style={{ fontSize: 12 }}>Uploading…</Text>}
226
- </View>
227
- )}
228
- <UploadInput
229
- id={name}
230
- name={name}
231
- accept={accept}
232
- disabled={disabled || uploading}
233
- required={required && !resourceId}
234
- onChange={onSinglePick}
235
- />
236
- {error && <Text style={{ fontSize: 12, color: 'var(--color-danger, crimson)' }}>{error}</Text>}
237
- </View>
241
+ <DropZone onFilesDrop={onFilesDrop}>
242
+ <View gap="s" style={style} data-ossy-file-dropzone {...rest}>
243
+ {(localFile || previewSrc || resourceId) && (
244
+ <View layout="row" gap="s" alignItems="center">
245
+ {localFile && <LocalFilePreview file={localFile} size="48px" />}
246
+ {!localFile && previewSrc && (
247
+ <View
248
+ as="img"
249
+ src={previewSrc}
250
+ alt={label || name}
251
+ width="48px"
252
+ height="48px"
253
+ style={{ objectFit: 'cover', borderRadius: 'var(--space-s)' }}
254
+ />
255
+ )}
256
+ {localFile && (
257
+ <Text style={{ flex: 1, fontSize: 14 }}>{localFile.name}</Text>
258
+ )}
259
+ {uploading && <Text style={{ fontSize: 12 }}>Uploading…</Text>}
260
+ </View>
261
+ )}
262
+ <UploadInput
263
+ id={name}
264
+ name={name}
265
+ accept={accept}
266
+ disabled={busy}
267
+ required={required && !resourceId}
268
+ onChange={onSinglePick}
269
+ />
270
+ {error && <Text style={{ fontSize: 12, color: 'var(--color-danger, crimson)' }}>{error}</Text>}
271
+ </View>
272
+ </DropZone>
238
273
  )
239
274
  }
@@ -0,0 +1,216 @@
1
+ import React, { useCallback, useMemo, useState } from 'react'
2
+ import { View, Text, Button, Select } from '@ossy/design-system'
3
+ import { useSdk, AsyncStatus } from '@ossy/sdk-react'
4
+ import { metadata as SearchResources } from './search.action.js'
5
+ import {
6
+ commitResourceValue,
7
+ resourceIdFromValue,
8
+ resourceIdsFromValue,
9
+ resourcePickerLabel,
10
+ sortResourcesForPicker,
11
+ toReferenceArray,
12
+ } from './reference-field.helpers.js'
13
+ import { useSchema } from './useSchemas.js'
14
+
15
+ /**
16
+ * Platform document-link field — stores `{ resourceId }` (or `{ resourceId }[]` when
17
+ * `max > 1`) pointing at other documents. When `of` is set, lists matching workspace
18
+ * resources via SearchResources for picking.
19
+ * Register as `@ossy/design-system/input/reference` through app component slots.
20
+ *
21
+ * @param {object} props
22
+ * @param {string} [props.of] Target schema id (e.g. `@ossy/profile/schema/profile`)
23
+ */
24
+ export function PlatformReferenceField({
25
+ name,
26
+ label,
27
+ value,
28
+ onChange,
29
+ of: targetSchemaId,
30
+ max = 1,
31
+ disabled,
32
+ required,
33
+ style = {},
34
+ ...rest
35
+ }) {
36
+ const sdk = useSdk()
37
+ const targetSchema = useSchema(targetSchemaId)
38
+ const targetLabel = targetSchema?.name || targetSchemaId
39
+ const multi = max > 1
40
+ const resourceId = multi ? null : resourceIdFromValue(value)
41
+ const resourceIds = multi ? resourceIdsFromValue(value) : []
42
+
43
+ const searchPayload = useMemo(
44
+ () => (targetSchemaId ? { type: targetSchemaId } : null),
45
+ [targetSchemaId],
46
+ )
47
+
48
+ const { status, data: resources = [] } = searchPayload
49
+ ? sdk.read(SearchResources, searchPayload)
50
+ : { status: AsyncStatus.Success, data: [] }
51
+
52
+ const options = useMemo(() => sortResourcesForPicker(resources), [resources])
53
+
54
+ const labelForId = useCallback(
55
+ id => {
56
+ const match = options.find(resource => resource.id === id)
57
+ return match ? resourcePickerLabel(match) : id
58
+ },
59
+ [options],
60
+ )
61
+
62
+ const selectResourceSingle = useCallback(
63
+ event => {
64
+ const next = event.target.value
65
+ if (!next) {
66
+ commitResourceValue(name, null, onChange)
67
+ return
68
+ }
69
+ commitResourceValue(name, { resourceId: next }, onChange)
70
+ },
71
+ [name, onChange],
72
+ )
73
+
74
+ const clearSingle = useCallback(() => {
75
+ commitResourceValue(name, null, onChange)
76
+ }, [name, onChange])
77
+
78
+ const addMulti = useCallback(
79
+ nextId => {
80
+ const id = typeof nextId === 'string' ? nextId.trim() : ''
81
+ if (!id || resourceIds.includes(id) || resourceIds.length >= max) return
82
+ commitResourceValue(name, toReferenceArray([...resourceIds, id]), onChange)
83
+ },
84
+ [max, name, onChange, resourceIds],
85
+ )
86
+
87
+ const selectResourceMulti = useCallback(
88
+ event => {
89
+ const next = event.target.value
90
+ event.target.value = ''
91
+ addMulti(next)
92
+ },
93
+ [addMulti],
94
+ )
95
+
96
+ const removeMulti = useCallback(
97
+ id => {
98
+ commitResourceValue(
99
+ name,
100
+ toReferenceArray(resourceIds.filter(rid => rid !== id)),
101
+ onChange,
102
+ )
103
+ },
104
+ [name, onChange, resourceIds],
105
+ )
106
+
107
+ if (!targetSchemaId) {
108
+ return (
109
+ <Text variant="small" color="neutral-600">
110
+ Reference fields require a target schema (`of`).
111
+ </Text>
112
+ )
113
+ }
114
+
115
+ const loading =
116
+ status === AsyncStatus.Loading || status === AsyncStatus.NotInitialized
117
+
118
+ if (multi) {
119
+ const canAdd = resourceIds.length < max
120
+ const availableOptions = options.filter(resource => !resourceIds.includes(resource.id))
121
+
122
+ return (
123
+ <View gap="s" style={style} data-ossy-reference-field={name} data-ossy-reference-multi="">
124
+ <Text variant="small" color="neutral-600">
125
+ Links to {targetLabel} (up to {max})
126
+ </Text>
127
+
128
+ {resourceIds.map(id => (
129
+ <View
130
+ key={id}
131
+ layout="row"
132
+ gap="s"
133
+ style={{ alignItems: 'center' }}
134
+ data-ossy-reference-selected={id}
135
+ >
136
+ <Text variant="small">{labelForId(id)}</Text>
137
+ <Button
138
+ type="button"
139
+ variant="command"
140
+ prefix="remove"
141
+ aria-label="Remove"
142
+ disabled={disabled}
143
+ onClick={() => removeMulti(id)}
144
+ data-ossy-reference-remove={id}
145
+ />
146
+ </View>
147
+ ))}
148
+
149
+ {canAdd ? (
150
+ <Select
151
+ {...rest}
152
+ id={name}
153
+ name={name}
154
+ value=""
155
+ disabled={disabled || loading}
156
+ required={required && resourceIds.length === 0}
157
+ aria-label={label || name}
158
+ onChange={selectResourceMulti}
159
+ data-ossy-reference-picker={name}
160
+ style={{ minWidth: '50%' }}
161
+ >
162
+ <option value="">
163
+ {loading ? 'Loading…' : 'Add a document…'}
164
+ </option>
165
+ {availableOptions.map(resource => (
166
+ <option key={resource.id} value={resource.id}>
167
+ {resourcePickerLabel(resource)}
168
+ </option>
169
+ ))}
170
+ </Select>
171
+ ) : null}
172
+ </View>
173
+ )
174
+ }
175
+
176
+ const selectedInOptions = options.some(resource => resource.id === resourceId)
177
+
178
+ return (
179
+ <View gap="s" style={style} data-ossy-reference-field={name}>
180
+ <Text variant="small" color="neutral-600">
181
+ Links to {targetLabel}
182
+ </Text>
183
+
184
+ <Select
185
+ {...rest}
186
+ id={name}
187
+ name={name}
188
+ value={selectedInOptions ? resourceId : ''}
189
+ disabled={disabled || loading}
190
+ required={required && !resourceId}
191
+ aria-label={label || name}
192
+ onChange={selectResourceSingle}
193
+ data-ossy-reference-picker={name}
194
+ style={{ minWidth: '50%' }}
195
+ >
196
+ <option value="">
197
+ {loading ? 'Loading…' : 'Select a document…'}
198
+ </option>
199
+ {options.map(resource => (
200
+ <option key={resource.id} value={resource.id}>
201
+ {resourcePickerLabel(resource)}
202
+ </option>
203
+ ))}
204
+ {resourceId && !selectedInOptions ? (
205
+ <option value={resourceId}>{resourceId}</option>
206
+ ) : null}
207
+ </Select>
208
+
209
+ {resourceId ? (
210
+ <Button type="button" variant="neutral" disabled={disabled} onClick={clearSingle}>
211
+ Clear
212
+ </Button>
213
+ ) : null}
214
+ </View>
215
+ )
216
+ }
@@ -1,5 +1,6 @@
1
1
  import React from 'react'
2
2
  import { metadata as GetResource } from './get.action.js'
3
+ import { resourceDownloadHref } from './resource-download.js'
3
4
  import { AsyncStatus, useSdk } from '@ossy/sdk-react'
4
5
  import { Switch, View, DelayedRender, Button, Icon, PageSection, Text, Tags, ImageCard } from '@ossy/design-system'
5
6
  import { useRouter } from '@ossy/router-react'
@@ -94,8 +95,7 @@ export const ResourceContentPage = (props) => {
94
95
  <View layout="row" gap="m">
95
96
  <Button
96
97
  variant="cta"
97
- target="_blank"
98
- href={resource?.content?.src}
98
+ href={resourceDownloadHref(resource)}
99
99
  download={resource?.name}
100
100
  suffix="software-download"
101
101
  >Download
@@ -12,6 +12,7 @@ import {
12
12
  ContextMenu,
13
13
  } from '@ossy/design-system'
14
14
  import { formatBytes } from './utils/format-bytes.js'
15
+ import { ResourceSelectionCheckbox } from './ResourceSelectionCheckbox.jsx'
15
16
 
16
17
  const TILE_WIDTH = 160
17
18
 
@@ -26,9 +27,16 @@ function useSchemaMap() {
26
27
  export const ResourceGrid = ({
27
28
  resources = [],
28
29
  onClick = () => {},
30
+ onToggleSelect,
29
31
  inlineFolder = null,
32
+ selectedIds,
33
+ showSelectionControls = false,
30
34
  }) => {
31
35
  const templateMap = useSchemaMap()
36
+ const selectedSet = useMemo(() => {
37
+ if (selectedIds instanceof Set) return selectedIds
38
+ return new Set(selectedIds || [])
39
+ }, [selectedIds])
32
40
 
33
41
  return (
34
42
  <View
@@ -45,6 +53,9 @@ export const ResourceGrid = ({
45
53
  resource={resource}
46
54
  templateMap={templateMap}
47
55
  onItemClick={onClick}
56
+ onToggleSelect={onToggleSelect}
57
+ showSelectionControls={showSelectionControls}
58
+ selected={selectedSet.has(resource.id) || Boolean(resource.selected)}
48
59
  />
49
60
  ))}
50
61
  </View>
@@ -120,16 +131,26 @@ const ResourceGridItem = memo(function ResourceGridItem({
120
131
  resource,
121
132
  templateMap,
122
133
  onItemClick,
134
+ onToggleSelect,
135
+ showSelectionControls = false,
136
+ selected = false,
123
137
  }) {
124
138
  const { onClick, href, onDrop, onFilesDrop, dragData, actions, name, type, content } = resource
125
139
  const [menuOpen, setMenuOpen] = useState(false)
126
140
  const [menuPosition, setMenuPosition] = useState(null)
127
141
 
128
- const handleClick = useCallback(() => {
129
- onItemClick(resource)
130
- onClick?.(resource)
142
+ const handleClick = useCallback((event) => {
143
+ if (event?.metaKey || event?.ctrlKey || event?.shiftKey) {
144
+ event.preventDefault?.()
145
+ }
146
+ onItemClick?.(resource, event)
147
+ onClick?.(resource, event)
131
148
  }, [onItemClick, onClick, resource])
132
149
 
150
+ const handleToggleSelect = useCallback((event) => {
151
+ onToggleSelect?.(resource, event)
152
+ }, [onToggleSelect, resource])
153
+
133
154
  const handleContextMenu = useCallback((event) => {
134
155
  if (!actions) return
135
156
  event.preventDefault()
@@ -162,6 +183,7 @@ const ResourceGridItem = memo(function ResourceGridItem({
162
183
  onClick={handleClick}
163
184
  onContextMenu={handleContextMenu}
164
185
  selectable
186
+ data-selected={selected || undefined}
165
187
  surface="primary"
166
188
  roundness="m"
167
189
  inset="m"
@@ -172,11 +194,29 @@ const ResourceGridItem = memo(function ResourceGridItem({
172
194
  textDecoration: 'none',
173
195
  color: 'inherit',
174
196
  cursor: 'pointer',
175
- border: '1px solid var(--separator-primary)',
197
+ border: selected
198
+ ? '1px solid var(--foreground-active, var(--separator-primary))'
199
+ : '1px solid var(--separator-primary)',
176
200
  boxSizing: 'border-box',
177
201
  position: 'relative',
178
202
  }}
179
203
  >
204
+ {(showSelectionControls || selected) && (
205
+ <View
206
+ style={{
207
+ position: 'absolute',
208
+ top: 'var(--space-xs, 4px)',
209
+ left: 'var(--space-xs, 4px)',
210
+ zIndex: 1,
211
+ }}
212
+ onClick={event => event.stopPropagation()}
213
+ >
214
+ <ResourceSelectionCheckbox
215
+ selected={selected}
216
+ onToggle={handleToggleSelect}
217
+ />
218
+ </View>
219
+ )}
180
220
  {actions ? (
181
221
  <View
182
222
  style={{