@byline/admin 2.5.2 → 2.6.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 (260) hide show
  1. package/dist/fields/array/array-field.d.ts +14 -0
  2. package/dist/fields/array/array-field.js +177 -0
  3. package/dist/fields/array/array-field.module.js +11 -0
  4. package/dist/fields/array/array-field_module.css +32 -0
  5. package/dist/fields/blocks/blocks-field.d.ts +13 -0
  6. package/dist/fields/blocks/blocks-field.js +245 -0
  7. package/dist/fields/blocks/blocks-field.module.js +26 -0
  8. package/dist/fields/blocks/blocks-field_module.css +107 -0
  9. package/dist/fields/checkbox/checkbox-field.d.ts +16 -0
  10. package/dist/fields/checkbox/checkbox-field.js +28 -0
  11. package/dist/fields/checkbox/checkbox-field.module.js +6 -0
  12. package/dist/fields/checkbox/checkbox-field_module.css +4 -0
  13. package/dist/fields/column-formatter.d.ts +20 -0
  14. package/dist/fields/column-formatter.js +15 -0
  15. package/dist/fields/date-time-formatter.d.ts +16 -0
  16. package/dist/fields/date-time-formatter.js +8 -0
  17. package/dist/fields/datetime/datetime-field.d.ts +16 -0
  18. package/dist/fields/datetime/datetime-field.js +37 -0
  19. package/dist/fields/datetime/datetime-field.module.js +5 -0
  20. package/dist/fields/datetime/datetime-field_module.css +4 -0
  21. package/dist/fields/draggable-context-menu.d.ts +6 -0
  22. package/dist/fields/draggable-context-menu.js +85 -0
  23. package/dist/fields/draggable-context-menu.module.js +15 -0
  24. package/dist/fields/draggable-context-menu_module.css +91 -0
  25. package/dist/fields/field-helpers.d.ts +26 -0
  26. package/dist/fields/field-helpers.js +50 -0
  27. package/dist/fields/field-renderer.d.ts +37 -0
  28. package/dist/fields/field-renderer.js +206 -0
  29. package/dist/fields/field-renderer.module.js +8 -0
  30. package/dist/fields/field-renderer_module.css +11 -0
  31. package/dist/fields/field-services-context.d.ts +16 -0
  32. package/dist/fields/field-services-context.js +13 -0
  33. package/dist/fields/field-services-types.d.ts +63 -0
  34. package/dist/fields/field-services-types.js +1 -0
  35. package/dist/fields/file/file-field.d.ts +19 -0
  36. package/dist/fields/file/file-field.js +225 -0
  37. package/dist/fields/file/file-field.module.js +18 -0
  38. package/dist/fields/file/file-field_module.css +131 -0
  39. package/dist/fields/file/file-upload-field.d.ts +21 -0
  40. package/dist/fields/file/file-upload-field.js +130 -0
  41. package/dist/fields/file/file-upload-field.module.js +15 -0
  42. package/dist/fields/file/file-upload-field_module.css +74 -0
  43. package/dist/fields/group/group-field.d.ts +15 -0
  44. package/dist/fields/group/group-field.js +59 -0
  45. package/dist/fields/group/group-field.module.js +9 -0
  46. package/dist/fields/group/group-field_module.css +27 -0
  47. package/dist/fields/image/image-field.d.ts +19 -0
  48. package/dist/fields/image/image-field.js +241 -0
  49. package/dist/fields/image/image-field.module.js +22 -0
  50. package/dist/fields/image/image-field_module.css +121 -0
  51. package/dist/fields/image/image-upload-field.d.ts +21 -0
  52. package/dist/fields/image/image-upload-field.js +190 -0
  53. package/dist/fields/image/image-upload-field.module.js +19 -0
  54. package/dist/fields/image/image-upload-field_module.css +92 -0
  55. package/dist/fields/local-date-time.d.ts +27 -0
  56. package/dist/fields/local-date-time.js +49 -0
  57. package/dist/fields/locale-badge.d.ts +18 -0
  58. package/dist/fields/locale-badge.js +10 -0
  59. package/dist/fields/locale-badge.module.js +5 -0
  60. package/dist/fields/locale-badge_module.css +27 -0
  61. package/dist/fields/numerical/numerical-field.d.ts +18 -0
  62. package/dist/fields/numerical/numerical-field.js +74 -0
  63. package/dist/fields/relation/relation-display.d.ts +40 -0
  64. package/dist/fields/relation/relation-display.js +58 -0
  65. package/dist/fields/relation/relation-display.module.js +9 -0
  66. package/dist/fields/relation/relation-display_module.css +21 -0
  67. package/dist/fields/relation/relation-field.d.ts +18 -0
  68. package/dist/fields/relation/relation-field.js +138 -0
  69. package/dist/fields/relation/relation-field.module.js +13 -0
  70. package/dist/fields/relation/relation-field_module.css +62 -0
  71. package/dist/fields/relation/relation-picker.d.ts +49 -0
  72. package/dist/fields/relation/relation-picker.js +236 -0
  73. package/dist/fields/relation/relation-picker.module.js +26 -0
  74. package/dist/fields/relation/relation-picker_module.css +124 -0
  75. package/dist/fields/relation/relation-summary.d.ts +31 -0
  76. package/dist/fields/relation/relation-summary.js +50 -0
  77. package/dist/fields/relation/relation-summary.module.js +11 -0
  78. package/dist/fields/relation/relation-summary_module.css +37 -0
  79. package/dist/fields/select/select-field.d.ts +16 -0
  80. package/dist/fields/select/select-field.js +50 -0
  81. package/dist/fields/select/select-field.module.js +5 -0
  82. package/dist/fields/select/select-field_module.css +4 -0
  83. package/dist/fields/sortable-item.d.ts +15 -0
  84. package/dist/fields/sortable-item.js +81 -0
  85. package/dist/fields/sortable-item.module.js +22 -0
  86. package/dist/fields/sortable-item_module.css +124 -0
  87. package/dist/fields/text/text-field.d.ts +20 -0
  88. package/dist/fields/text/text-field.js +104 -0
  89. package/dist/fields/text/text-field.module.js +6 -0
  90. package/dist/fields/text/text-field_module.css +5 -0
  91. package/dist/fields/text-area/text-area-field.d.ts +20 -0
  92. package/dist/fields/text-area/text-area-field.js +105 -0
  93. package/dist/fields/text-area/text-area-field.module.js +6 -0
  94. package/dist/fields/text-area/text-area-field_module.css +5 -0
  95. package/dist/fields/use-field-change-handler.d.ts +23 -0
  96. package/dist/fields/use-field-change-handler.js +52 -0
  97. package/dist/forms/document-actions.d.ts +48 -0
  98. package/dist/forms/document-actions.js +475 -0
  99. package/dist/forms/document-actions.module.js +34 -0
  100. package/dist/forms/document-actions_module.css +118 -0
  101. package/dist/forms/form-context.d.ts +89 -0
  102. package/dist/forms/form-context.js +466 -0
  103. package/dist/forms/form-renderer.d.ts +98 -0
  104. package/dist/forms/form-renderer.js +597 -0
  105. package/dist/forms/form-renderer.module.js +46 -0
  106. package/dist/forms/form-renderer_module.css +245 -0
  107. package/dist/forms/navigation-guard.d.ts +54 -0
  108. package/dist/forms/navigation-guard.js +22 -0
  109. package/dist/forms/path-widget.d.ts +36 -0
  110. package/dist/forms/path-widget.js +116 -0
  111. package/dist/forms/path-widget.module.js +8 -0
  112. package/dist/forms/path-widget_module.css +29 -0
  113. package/dist/forms/upload-executor.d.ts +57 -0
  114. package/dist/forms/upload-executor.js +94 -0
  115. package/dist/lib/translate-validation-error.d.ts +36 -0
  116. package/dist/lib/translate-validation-error.js +11 -0
  117. package/dist/modules/admin-account/commands.d.ts +2 -1
  118. package/dist/modules/admin-account/commands.js +13 -2
  119. package/dist/modules/admin-account/components/change-password.js +45 -36
  120. package/dist/modules/admin-account/components/container.js +185 -134
  121. package/dist/modules/admin-account/components/preferences.d.ts +8 -0
  122. package/dist/modules/admin-account/components/preferences.js +152 -0
  123. package/dist/modules/admin-account/components/preferences.module.js +11 -0
  124. package/dist/modules/admin-account/components/preferences_module.css +41 -0
  125. package/dist/modules/admin-account/components/update.js +50 -31
  126. package/dist/modules/admin-account/index.d.ts +3 -3
  127. package/dist/modules/admin-account/index.js +2 -2
  128. package/dist/modules/admin-account/schemas.d.ts +4 -0
  129. package/dist/modules/admin-account/schemas.js +4 -1
  130. package/dist/modules/admin-account/service.d.ts +1 -0
  131. package/dist/modules/admin-account/service.js +8 -0
  132. package/dist/modules/admin-permissions/components/inspector.js +31 -41
  133. package/dist/modules/admin-roles/components/create.js +43 -26
  134. package/dist/modules/admin-roles/components/permissions.js +26 -35
  135. package/dist/modules/admin-roles/components/update.js +26 -16
  136. package/dist/modules/admin-users/components/create.js +60 -40
  137. package/dist/modules/admin-users/components/roles.js +9 -15
  138. package/dist/modules/admin-users/components/set-password.js +30 -31
  139. package/dist/modules/admin-users/components/update.js +58 -39
  140. package/dist/modules/admin-users/dto.js +1 -0
  141. package/dist/modules/admin-users/repository.d.ts +17 -0
  142. package/dist/modules/admin-users/schemas.d.ts +4 -0
  143. package/dist/modules/admin-users/schemas.js +6 -2
  144. package/dist/modules/auth/components/sign-in-form.js +10 -8
  145. package/dist/presentation/group.d.ts +27 -0
  146. package/dist/presentation/group.js +14 -0
  147. package/dist/presentation/group.module.js +6 -0
  148. package/dist/presentation/group_module.css +19 -0
  149. package/dist/presentation/row.d.ts +25 -0
  150. package/dist/presentation/row.js +8 -0
  151. package/dist/presentation/row.module.js +5 -0
  152. package/dist/presentation/row_module.css +18 -0
  153. package/dist/presentation/tabs.d.ts +25 -0
  154. package/dist/presentation/tabs.js +39 -0
  155. package/dist/presentation/tabs.module.js +10 -0
  156. package/dist/presentation/tabs_module.css +68 -0
  157. package/dist/react.d.ts +66 -0
  158. package/dist/react.js +36 -0
  159. package/dist/services/admin-services-types.d.ts +16 -0
  160. package/dist/widgets/diff-viewer/diff-modal.d.ts +22 -0
  161. package/dist/widgets/diff-viewer/diff-modal.js +149 -0
  162. package/dist/widgets/diff-viewer/diff-modal.module.js +14 -0
  163. package/dist/widgets/diff-viewer/diff-modal_module.css +56 -0
  164. package/dist/widgets/status-badge/status-badge.d.ts +25 -0
  165. package/dist/widgets/status-badge/status-badge.js +37 -0
  166. package/dist/widgets/status-badge/status-badge.module.js +7 -0
  167. package/dist/widgets/status-badge/status-badge_module.css +20 -0
  168. package/package.json +14 -4
  169. package/src/fields/array/array-field.module.css +48 -0
  170. package/src/fields/array/array-field.tsx +267 -0
  171. package/src/fields/blocks/blocks-field.module.css +148 -0
  172. package/src/fields/blocks/blocks-field.tsx +323 -0
  173. package/src/fields/checkbox/checkbox-field.module.css +4 -0
  174. package/src/fields/checkbox/checkbox-field.tsx +54 -0
  175. package/src/fields/column-formatter.tsx +31 -0
  176. package/src/fields/date-time-formatter.tsx +22 -0
  177. package/src/fields/datetime/datetime-field.module.css +13 -0
  178. package/src/fields/datetime/datetime-field.tsx +54 -0
  179. package/src/fields/draggable-context-menu.module.css +127 -0
  180. package/src/fields/draggable-context-menu.tsx +87 -0
  181. package/src/fields/field-helpers.ts +69 -0
  182. package/src/fields/field-renderer.module.css +22 -0
  183. package/src/fields/field-renderer.tsx +288 -0
  184. package/src/fields/field-services-context.tsx +35 -0
  185. package/src/fields/field-services-types.ts +68 -0
  186. package/src/fields/file/file-field.module.css +153 -0
  187. package/src/fields/file/file-field.tsx +286 -0
  188. package/src/fields/file/file-upload-field.module.css +101 -0
  189. package/src/fields/file/file-upload-field.tsx +187 -0
  190. package/src/fields/group/group-field.module.css +43 -0
  191. package/src/fields/group/group-field.tsx +84 -0
  192. package/src/fields/image/image-field.module.css +155 -0
  193. package/src/fields/image/image-field.tsx +306 -0
  194. package/src/fields/image/image-upload-field.module.css +123 -0
  195. package/src/fields/image/image-upload-field.tsx +276 -0
  196. package/src/fields/local-date-time.tsx +88 -0
  197. package/src/fields/locale-badge.module.css +37 -0
  198. package/src/fields/locale-badge.tsx +32 -0
  199. package/src/fields/numerical/numerical-field.tsx +114 -0
  200. package/src/fields/relation/relation-display.module.css +36 -0
  201. package/src/fields/relation/relation-display.tsx +130 -0
  202. package/src/fields/relation/relation-field.module.css +83 -0
  203. package/src/fields/relation/relation-field.tsx +211 -0
  204. package/src/fields/relation/relation-picker.module.css +168 -0
  205. package/src/fields/relation/relation-picker.tsx +326 -0
  206. package/src/fields/relation/relation-summary.module.css +55 -0
  207. package/src/fields/relation/relation-summary.tsx +123 -0
  208. package/src/fields/select/select-field.module.css +13 -0
  209. package/src/fields/select/select-field.tsx +61 -0
  210. package/src/fields/sortable-item.module.css +167 -0
  211. package/src/fields/sortable-item.tsx +106 -0
  212. package/src/fields/text/text-field.module.css +13 -0
  213. package/src/fields/text/text-field.tsx +146 -0
  214. package/src/fields/text-area/text-area-field.module.css +13 -0
  215. package/src/fields/text-area/text-area-field.tsx +147 -0
  216. package/src/fields/use-field-change-handler.ts +112 -0
  217. package/src/forms/document-actions.module.css +160 -0
  218. package/src/forms/document-actions.tsx +482 -0
  219. package/src/forms/form-context.tsx +704 -0
  220. package/src/forms/form-renderer.module.css +321 -0
  221. package/src/forms/form-renderer.tsx +891 -0
  222. package/src/forms/navigation-guard.tsx +98 -0
  223. package/src/forms/path-widget.module.css +41 -0
  224. package/src/forms/path-widget.test.tsx +217 -0
  225. package/src/forms/path-widget.tsx +183 -0
  226. package/src/forms/upload-executor.ts +192 -0
  227. package/src/lib/translate-validation-error.ts +56 -0
  228. package/src/modules/admin-account/commands.ts +13 -0
  229. package/src/modules/admin-account/components/change-password.tsx +46 -31
  230. package/src/modules/admin-account/components/container.tsx +83 -38
  231. package/src/modules/admin-account/components/preferences.module.css +60 -0
  232. package/src/modules/admin-account/components/preferences.tsx +203 -0
  233. package/src/modules/admin-account/components/update.tsx +53 -27
  234. package/src/modules/admin-account/index.ts +3 -0
  235. package/src/modules/admin-account/schemas.ts +13 -0
  236. package/src/modules/admin-account/service.ts +12 -0
  237. package/src/modules/admin-permissions/components/inspector.tsx +22 -14
  238. package/src/modules/admin-roles/components/create.tsx +51 -23
  239. package/src/modules/admin-roles/components/permissions.tsx +25 -21
  240. package/src/modules/admin-roles/components/update.tsx +37 -19
  241. package/src/modules/admin-users/components/create.tsx +63 -34
  242. package/src/modules/admin-users/components/roles.tsx +9 -8
  243. package/src/modules/admin-users/components/set-password.tsx +34 -28
  244. package/src/modules/admin-users/components/update.tsx +58 -36
  245. package/src/modules/admin-users/dto.ts +1 -0
  246. package/src/modules/admin-users/repository.ts +17 -0
  247. package/src/modules/admin-users/schemas.ts +12 -0
  248. package/src/modules/auth/components/sign-in-form.tsx +14 -8
  249. package/src/presentation/group.module.css +41 -0
  250. package/src/presentation/group.tsx +40 -0
  251. package/src/presentation/row.module.css +32 -0
  252. package/src/presentation/row.tsx +33 -0
  253. package/src/presentation/tabs.module.css +107 -0
  254. package/src/presentation/tabs.tsx +84 -0
  255. package/src/react.ts +84 -0
  256. package/src/services/admin-services-types.ts +18 -0
  257. package/src/widgets/diff-viewer/diff-modal.module.css +79 -0
  258. package/src/widgets/diff-viewer/diff-modal.tsx +186 -0
  259. package/src/widgets/status-badge/status-badge.module.css +31 -0
  260. package/src/widgets/status-badge/status-badge.tsx +71 -0
@@ -0,0 +1,211 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+
9
+ import { useState } from 'react'
10
+
11
+ import type {
12
+ CollectionAdminConfig,
13
+ CollectionDefinition,
14
+ RelationField as FieldType,
15
+ RelatedDocumentValue,
16
+ } from '@byline/core'
17
+ import { getCollectionAdminConfig, getCollectionDefinition } from '@byline/core'
18
+ import { useTranslation } from '@byline/i18n/react'
19
+ import { Button, CloseIcon, EditIcon, ErrorText, IconButton, Label } from '@byline/ui/react'
20
+ import cx from 'classnames'
21
+
22
+ import { useFieldError, useFieldValue } from '../../forms/form-context'
23
+ import styles from './relation-field.module.css'
24
+ import { RelationPicker } from './relation-picker'
25
+ import { RelationSummary } from './relation-summary'
26
+
27
+ // The raw form value for a relation field is `RelatedDocumentValue`, but
28
+ // when the edit loader runs server-side populate the value arrives as a
29
+ // `PopulatedRelationValue` (same base shape, plus `_resolved` / `document`
30
+ // discriminator keys). We accept both here and let `RelationSummary`
31
+ // narrow internally.
32
+ type IncomingRelationValue = RelatedDocumentValue & {
33
+ _resolved?: boolean
34
+ _cycle?: boolean
35
+ document?: Record<string, any>
36
+ }
37
+
38
+ // ---------------------------------------------------------------------------
39
+ // RelationField — widget for `type: 'relation'` fields
40
+ // ---------------------------------------------------------------------------
41
+
42
+ interface RelationFieldProps {
43
+ field: FieldType
44
+ value?: RelatedDocumentValue | null
45
+ defaultValue?: RelatedDocumentValue | null
46
+ onChange?: (value: RelatedDocumentValue | null) => void
47
+ id?: string
48
+ path?: string
49
+ }
50
+
51
+ export const RelationField = ({
52
+ field,
53
+ value,
54
+ defaultValue,
55
+ onChange,
56
+ id,
57
+ path,
58
+ }: RelationFieldProps) => {
59
+ const fieldPath = path ?? field.name
60
+ const htmlId = id ?? fieldPath
61
+ const fieldError = useFieldError(fieldPath)
62
+ const fieldValue = useFieldValue<IncomingRelationValue | null | undefined>(fieldPath)
63
+
64
+ const incomingValue: IncomingRelationValue | null =
65
+ fieldValue !== undefined
66
+ ? ((fieldValue as IncomingRelationValue | null) ?? null)
67
+ : ((value as IncomingRelationValue | null) ??
68
+ (defaultValue as IncomingRelationValue | null) ??
69
+ null)
70
+
71
+ // Resolve the target collection definition + admin config. The admin
72
+ // config drives the picker-column rendering inside RelationSummary so
73
+ // the selected tile matches the picker row exactly. Missing target →
74
+ // render an inline error and disable the picker.
75
+ const targetDef: CollectionDefinition | null = getCollectionDefinition(field.targetCollection)
76
+ const targetAdminConfig: CollectionAdminConfig | null = getCollectionAdminConfig(
77
+ field.targetCollection
78
+ )
79
+
80
+ const { t } = useTranslation('byline-admin')
81
+ const [pickerOpen, setPickerOpen] = useState(false)
82
+ // Cached target document from the most recent picker selection. Lets the
83
+ // tile render real display data (name, thumbnail) immediately after a
84
+ // pick without a round trip. Cleared via the `targetDocumentId`
85
+ // comparison in the render path.
86
+ const [pickedRecord, setPickedRecord] = useState<{
87
+ id: string
88
+ record: Record<string, any>
89
+ } | null>(null)
90
+
91
+ const handleSelect = (selection: {
92
+ targetDocumentId: string
93
+ targetCollectionId: string
94
+ record?: Record<string, any>
95
+ }) => {
96
+ setPickerOpen(false)
97
+ if (selection.record) {
98
+ setPickedRecord({ id: selection.targetDocumentId, record: selection.record })
99
+ } else {
100
+ setPickedRecord(null)
101
+ }
102
+ onChange?.({
103
+ targetDocumentId: selection.targetDocumentId,
104
+ targetCollectionId: selection.targetCollectionId,
105
+ })
106
+ }
107
+
108
+ const handleRemove = () => {
109
+ setPickedRecord(null)
110
+ onChange?.(null)
111
+ }
112
+
113
+ // Only carry the cached picker record through to the summary when it
114
+ // still matches the current value — guards against a stale cache after
115
+ // an external value change (e.g. patch rollback).
116
+ const cachedRecord =
117
+ pickedRecord && incomingValue && pickedRecord.id === incomingValue.targetDocumentId
118
+ ? pickedRecord.record
119
+ : null
120
+
121
+ const isUnknown = targetDef == null
122
+
123
+ return (
124
+ <div className={`byline-field-relation ${field.name}`}>
125
+ <div className={cx('byline-field-relation-header', styles.header)}>
126
+ <Label
127
+ id={`${htmlId}-label`}
128
+ htmlFor={htmlId}
129
+ label={field.label ?? field.name}
130
+ required={!field.optional}
131
+ />
132
+ </div>
133
+ {field.helpText && (
134
+ <div className={cx('byline-field-relation-help', styles.help)}>{field.helpText}</div>
135
+ )}
136
+
137
+ {isUnknown ? (
138
+ <div className={cx('byline-field-relation-error-tile', styles['error-tile'])}>
139
+ <span>
140
+ {t('fields.relation.unknownError', {
141
+ name: field.name,
142
+ target: field.targetCollection,
143
+ })}
144
+ </span>
145
+ <span className={cx('byline-field-relation-error-text', styles['error-text'])}>
146
+ {t('fields.relation.unknownHint')}
147
+ </span>
148
+ </div>
149
+ ) : incomingValue ? (
150
+ <div className={cx('byline-field-relation-tile', styles.tile)}>
151
+ <RelationSummary
152
+ targetDefinition={targetDef}
153
+ targetAdminConfig={targetAdminConfig}
154
+ displayField={field.displayField}
155
+ value={incomingValue}
156
+ cachedRecord={cachedRecord}
157
+ />
158
+ <div className={cx('byline-field-relation-actions', styles.actions)}>
159
+ <IconButton
160
+ id={htmlId}
161
+ type="button"
162
+ intent="noeffect"
163
+ size="xs"
164
+ aria-label={t('fields.relation.changeAriaLabel', {
165
+ label: targetDef.labels.singular,
166
+ })}
167
+ onClick={() => setPickerOpen(true)}
168
+ >
169
+ <EditIcon width="15px" height="15px" />
170
+ </IconButton>
171
+ <IconButton
172
+ type="button"
173
+ intent="noeffect"
174
+ size="xs"
175
+ aria-label={t('fields.relation.removeAriaLabel', {
176
+ label: targetDef.labels.singular,
177
+ })}
178
+ onClick={handleRemove}
179
+ >
180
+ <CloseIcon width="15px" height="15px" />
181
+ </IconButton>
182
+ </div>
183
+ </div>
184
+ ) : (
185
+ <Button
186
+ id={htmlId}
187
+ size="xs"
188
+ variant="outlined"
189
+ intent="noeffect"
190
+ type="button"
191
+ onClick={() => setPickerOpen(true)}
192
+ >
193
+ {t('fields.relation.selectButton', { label: targetDef.labels.singular })}
194
+ </Button>
195
+ )}
196
+
197
+ {fieldError && <ErrorText id={`${field.name}-error`} text={fieldError} />}
198
+
199
+ {!isUnknown && (
200
+ <RelationPicker
201
+ targetCollectionPath={field.targetCollection}
202
+ targetDefinition={targetDef}
203
+ displayField={field.displayField}
204
+ isOpen={pickerOpen}
205
+ onSelect={handleSelect}
206
+ onDismiss={() => setPickerOpen(false)}
207
+ />
208
+ )}
209
+ </div>
210
+ )
211
+ }
@@ -0,0 +1,168 @@
1
+ /**
2
+ * RelationPicker — modal listing for selecting a target document.
3
+ *
4
+ * Override handles:
5
+ * .byline-field-relation-picker — picker chrome wrapper
6
+ * .byline-field-relation-picker-header — modal header (title)
7
+ * .byline-field-relation-picker-title — title text
8
+ * .byline-field-relation-picker-body — vertical stack of search + list + pager
9
+ * .byline-field-relation-picker-list — scrollable list container
10
+ * .byline-field-relation-picker-loading — centered spinner row
11
+ * .byline-field-relation-picker-error — error message row
12
+ * .byline-field-relation-picker-empty — "no results" message row
13
+ * .byline-field-relation-picker-rows — <ul> of rows
14
+ * .byline-field-relation-picker-row-button — clickable row button
15
+ * .byline-field-relation-picker-row-selected — added when row is selected
16
+ * .byline-field-relation-picker-row-cells — picker-columns layout inside a row
17
+ * .byline-field-relation-picker-row-stack — fallback label/path stack
18
+ * .byline-field-relation-picker-row-label — fallback row label text
19
+ * .byline-field-relation-picker-row-path — fallback row secondary path
20
+ * .byline-field-relation-picker-pager — pager row at the bottom
21
+ * .byline-field-relation-picker-action — Cancel / Select buttons
22
+ */
23
+
24
+ .header,
25
+ :global(.byline-field-relation-picker-header) {
26
+ padding-top: 1rem;
27
+ margin-bottom: var(--spacing-8);
28
+ }
29
+
30
+ .title,
31
+ :global(.byline-field-relation-picker-title) {
32
+ margin: 0 0 var(--spacing-8) 0;
33
+ font-size: var(--font-size-xl);
34
+ }
35
+
36
+ .body,
37
+ :global(.byline-field-relation-picker-body) {
38
+ display: flex;
39
+ flex-direction: column;
40
+ gap: 0.75rem;
41
+ }
42
+
43
+ .list,
44
+ :global(.byline-field-relation-picker-list) {
45
+ min-height: 320px;
46
+ max-height: 420px;
47
+ overflow-y: auto;
48
+ border: var(--border-width-thin) var(--border-style-solid) var(--gray-700);
49
+ border-radius: var(--border-radius-md);
50
+ }
51
+
52
+ .loading,
53
+ :global(.byline-field-relation-picker-loading) {
54
+ display: flex;
55
+ align-items: center;
56
+ justify-content: center;
57
+ padding: 2.5rem 0;
58
+ }
59
+
60
+ .error,
61
+ :global(.byline-field-relation-picker-error) {
62
+ padding: 2.5rem 1rem;
63
+ color: var(--red-500);
64
+ font-size: var(--font-size-sm);
65
+ text-align: center;
66
+ }
67
+
68
+ .empty,
69
+ :global(.byline-field-relation-picker-empty) {
70
+ padding: 2.5rem 1rem;
71
+ color: var(--gray-400);
72
+ font-size: var(--font-size-sm);
73
+ text-align: center;
74
+ }
75
+
76
+ .rows,
77
+ :global(.byline-field-relation-picker-rows) {
78
+ margin: 0;
79
+ padding: 0;
80
+ list-style: none;
81
+ }
82
+
83
+ .rows > li + li,
84
+ :global(.byline-field-relation-picker-rows) > li + li {
85
+ border-top: var(--border-width-thin) var(--border-style-solid) var(--gray-700);
86
+ }
87
+
88
+ .row-button,
89
+ :global(.byline-field-relation-picker-row-button) {
90
+ width: 100%;
91
+ padding: var(--spacing-8) 0.75rem;
92
+ border: none;
93
+ background: none;
94
+ text-align: left;
95
+ color: inherit;
96
+ cursor: pointer;
97
+ transition: background-color 150ms ease;
98
+ }
99
+
100
+ .row-button:hover,
101
+ :global(.byline-field-relation-picker-row-button):hover {
102
+ background-color: var(--gray-25);
103
+ }
104
+
105
+ .row-selected,
106
+ :global(.byline-field-relation-picker-row-selected) {
107
+ background-color: oklch(from var(--primary-200) l c h / 0.3);
108
+ border-left: 2px solid var(--primary-200);
109
+ }
110
+
111
+ .row-cells,
112
+ :global(.byline-field-relation-picker-row-cells) {
113
+ display: flex;
114
+ align-items: center;
115
+ gap: 0.75rem;
116
+ }
117
+
118
+ .row-stack,
119
+ :global(.byline-field-relation-picker-row-stack) {
120
+ display: flex;
121
+ flex-direction: column;
122
+ gap: 0.125rem;
123
+ }
124
+
125
+ .row-label,
126
+ :global(.byline-field-relation-picker-row-label) {
127
+ color: var(--gray-100);
128
+ font-size: var(--font-size-sm);
129
+ overflow: hidden;
130
+ text-overflow: ellipsis;
131
+ white-space: nowrap;
132
+ }
133
+
134
+ .row-path,
135
+ :global(.byline-field-relation-picker-row-path) {
136
+ color: var(--gray-500);
137
+ font-size: var(--font-size-xs);
138
+ overflow: hidden;
139
+ text-overflow: ellipsis;
140
+ white-space: nowrap;
141
+ }
142
+
143
+ .pager,
144
+ :global(.byline-field-relation-picker-pager) {
145
+ display: flex;
146
+ align-items: center;
147
+ justify-content: space-between;
148
+ color: var(--gray-400);
149
+ font-size: var(--font-size-xs);
150
+ }
151
+
152
+ .action,
153
+ :global(.byline-field-relation-picker-action) {
154
+ min-width: 70px;
155
+ }
156
+
157
+ :is([data-theme="dark"], :global(.dark)) {
158
+ .row-button:hover,
159
+ :global(.byline-field-relation-picker-row-button):hover {
160
+ background-color: var(--gray-900);
161
+ }
162
+
163
+ .row-selected,
164
+ :global(.byline-field-relation-picker-row-selected) {
165
+ background-color: oklch(from var(--primary-900) l c h / 0.3);
166
+ border-left: 2px solid var(--primary-400);
167
+ }
168
+ }
@@ -0,0 +1,326 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+
9
+ import { useCallback, useEffect, useState } from 'react'
10
+
11
+ import type { CollectionAdminConfig, CollectionDefinition } from '@byline/core'
12
+ import { getCollectionAdminConfig } from '@byline/core'
13
+ import { useTranslation } from '@byline/i18n/react'
14
+ import { Button, LoaderRing, Modal, Search } from '@byline/ui/react'
15
+ import cx from 'classnames'
16
+
17
+ import { useBylineFieldServices } from '../field-services-context'
18
+ import {
19
+ PickerCell,
20
+ resolveFallbackDisplayField,
21
+ resolveRowLabel,
22
+ resolveSelectFields,
23
+ } from './relation-display'
24
+ import styles from './relation-picker.module.css'
25
+
26
+ // ---------------------------------------------------------------------------
27
+ // RelationPicker — modal listing for selecting a target document
28
+ // ---------------------------------------------------------------------------
29
+
30
+ /**
31
+ * Row rendering strategy, in priority order:
32
+ * 1. `CollectionAdminConfig.picker` — a ColumnDefinition[] from the target
33
+ * admin config. Each row renders the declared columns side-by-side,
34
+ * reusing any column formatters (thumbnail, date, etc).
35
+ * 2. Explicit `displayField` prop on this component (forwarded from
36
+ * `RelationField.displayField`).
37
+ * 3. `CollectionDefinition.useAsTitle` on the target.
38
+ * 4. First top-level `text` field on the target.
39
+ *
40
+ * Paths 2–4 render a single-line label (primary) + `path` (secondary).
41
+ */
42
+ interface RelationPickerProps {
43
+ /** The target collection path (e.g. `'media'`). */
44
+ targetCollectionPath: string
45
+ /** The target collection definition (used for labels + displayField fallback). */
46
+ targetDefinition?: CollectionDefinition | null
47
+ /** Explicit display field to render as row label. */
48
+ displayField?: string
49
+ /** Modal open/close state. */
50
+ isOpen: boolean
51
+ /**
52
+ * Called with the picked selection when the user confirms.
53
+ *
54
+ * `record` is the raw document the picker row rendered — the caller can
55
+ * use it to show the selected value in its own tile without a refetch.
56
+ * The fields available on `record` are whatever `resolveSelectFields`
57
+ * asked the listing endpoint for (picker columns + `useAsTitle` +
58
+ * `displayField`), so any display surface downstream of the picker that
59
+ * also renders from those same columns will find the data it needs.
60
+ */
61
+ onSelect: (selection: {
62
+ targetDocumentId: string
63
+ targetCollectionId: string
64
+ record?: Record<string, any>
65
+ }) => void
66
+ /** Called when the user dismisses the modal. */
67
+ onDismiss: () => void
68
+ }
69
+
70
+ const PAGE_SIZE = 15
71
+
72
+ export const RelationPicker = ({
73
+ targetCollectionPath,
74
+ targetDefinition,
75
+ displayField,
76
+ isOpen,
77
+ onSelect,
78
+ onDismiss,
79
+ }: RelationPickerProps) => {
80
+ const [query, setQuery] = useState<string>('')
81
+ const [page, setPage] = useState<number>(1)
82
+ const { t } = useTranslation('byline-admin')
83
+ const [selectedDocumentId, setSelectedDocumentId] = useState<string | null>(null)
84
+ const [loading, setLoading] = useState<boolean>(false)
85
+ const [error, setError] = useState<string | null>(null)
86
+ const [documents, setDocuments] = useState<any[]>([])
87
+ const [totalPages, setTotalPages] = useState<number>(1)
88
+ const [collectionId, setCollectionId] = useState<string | null>(null)
89
+
90
+ const { getCollectionDocuments } = useBylineFieldServices()
91
+
92
+ const targetAdminConfig: CollectionAdminConfig | null =
93
+ getCollectionAdminConfig(targetCollectionPath)
94
+ const pickerColumns = targetAdminConfig?.picker
95
+
96
+ // Reset local state each time the modal opens so prior queries don't leak.
97
+ useEffect(() => {
98
+ if (isOpen) {
99
+ setQuery('')
100
+ setPage(1)
101
+ setSelectedDocumentId(null)
102
+ setError(null)
103
+ }
104
+ }, [isOpen])
105
+
106
+ // Fetch whenever the modal is open and the query / page changes.
107
+ useEffect(() => {
108
+ if (!isOpen) return
109
+ let cancelled = false
110
+
111
+ const selectFields = resolveSelectFields(targetDefinition, displayField, pickerColumns)
112
+
113
+ setLoading(true)
114
+ setError(null)
115
+ getCollectionDocuments({
116
+ collection: targetCollectionPath,
117
+ params: {
118
+ page,
119
+ page_size: PAGE_SIZE,
120
+ query: query.length > 0 ? query : undefined,
121
+ fields: selectFields,
122
+ },
123
+ })
124
+ .then((response: any) => {
125
+ if (cancelled) return
126
+ setDocuments(response.docs)
127
+ setTotalPages(response.meta.totalPages ?? 1)
128
+ setCollectionId(response.included.collection.id as string)
129
+ })
130
+ .catch((err: any) => {
131
+ if (cancelled) return
132
+ setError(err instanceof Error ? err.message : t('fields.relation.picker.loadFailed'))
133
+ })
134
+ .finally(() => {
135
+ if (!cancelled) setLoading(false)
136
+ })
137
+
138
+ return () => {
139
+ cancelled = true
140
+ }
141
+ }, [
142
+ isOpen,
143
+ targetCollectionPath,
144
+ query,
145
+ page,
146
+ displayField,
147
+ targetDefinition,
148
+ pickerColumns,
149
+ getCollectionDocuments,
150
+ t,
151
+ ])
152
+
153
+ const resolvedDisplayField =
154
+ displayField ??
155
+ targetDefinition?.useAsTitle ??
156
+ resolveFallbackDisplayField(targetDefinition) ??
157
+ null
158
+
159
+ const handleSelect = useCallback(() => {
160
+ if (!selectedDocumentId || !collectionId) return
161
+ const record = documents.find((d) => d?.id === selectedDocumentId)
162
+ onSelect({
163
+ targetDocumentId: selectedDocumentId,
164
+ targetCollectionId: collectionId,
165
+ record,
166
+ })
167
+ }, [selectedDocumentId, collectionId, documents, onSelect])
168
+
169
+ const title = t('fields.relation.selectPickerTitle', {
170
+ label: targetDefinition?.labels.singular ?? targetCollectionPath,
171
+ })
172
+
173
+ return (
174
+ <Modal isOpen={isOpen} onDismiss={onDismiss}>
175
+ <Modal.Container style={{ maxWidth: '600px', width: '100%' }}>
176
+ <Modal.Header className={cx('byline-field-relation-picker-header', styles.header)}>
177
+ <h3 className={cx('byline-field-relation-picker-title', styles.title)}>{title}</h3>
178
+ </Modal.Header>
179
+ <Modal.Content>
180
+ <div className={cx('byline-field-relation-picker-body', styles.body)}>
181
+ <Search
182
+ onSearch={(q) => {
183
+ setPage(1)
184
+ setQuery(q ?? '')
185
+ }}
186
+ onClear={() => {
187
+ setPage(1)
188
+ setQuery('')
189
+ }}
190
+ inputSize="sm"
191
+ placeholder={t('fields.relation.picker.searchPlaceholder')}
192
+ />
193
+
194
+ <div className={cx('byline-field-relation-picker-list', styles.list)}>
195
+ {loading && documents.length === 0 && (
196
+ <div className={cx('byline-field-relation-picker-loading', styles.loading)}>
197
+ <LoaderRing size={24} color="#888888" />
198
+ </div>
199
+ )}
200
+ {!loading && error && (
201
+ <div className={cx('byline-field-relation-picker-error', styles.error)}>
202
+ {error}
203
+ </div>
204
+ )}
205
+ {!loading && !error && documents.length === 0 && (
206
+ <div className={cx('byline-field-relation-picker-empty', styles.empty)}>
207
+ {t('fields.relation.picker.empty')}
208
+ </div>
209
+ )}
210
+ {documents.length > 0 && (
211
+ <ul className={cx('byline-field-relation-picker-rows', styles.rows)}>
212
+ {documents.map((doc) => {
213
+ const id = doc.id as string
214
+ const selected = selectedDocumentId === id
215
+ return (
216
+ <li key={id}>
217
+ <button
218
+ type="button"
219
+ className={cx(
220
+ 'byline-field-relation-picker-row-button',
221
+ styles['row-button'],
222
+ selected && [
223
+ 'byline-field-relation-picker-row-selected',
224
+ styles['row-selected'],
225
+ ]
226
+ )}
227
+ onClick={() => setSelectedDocumentId(id)}
228
+ >
229
+ {pickerColumns && pickerColumns.length > 0 ? (
230
+ <div
231
+ className={cx(
232
+ 'byline-field-relation-picker-row-cells',
233
+ styles['row-cells']
234
+ )}
235
+ >
236
+ {pickerColumns.map((col) => (
237
+ <PickerCell key={String(col.fieldName)} column={col} record={doc} />
238
+ ))}
239
+ </div>
240
+ ) : (
241
+ <div
242
+ className={cx(
243
+ 'byline-field-relation-picker-row-stack',
244
+ styles['row-stack']
245
+ )}
246
+ >
247
+ <span
248
+ className={cx(
249
+ 'byline-field-relation-picker-row-label',
250
+ styles['row-label']
251
+ )}
252
+ >
253
+ {resolveRowLabel(doc, resolvedDisplayField) || id}
254
+ </span>
255
+ {typeof doc.path === 'string' && doc.path.length > 0 && (
256
+ <span
257
+ className={cx(
258
+ 'byline-field-relation-picker-row-path',
259
+ styles['row-path']
260
+ )}
261
+ >
262
+ {doc.path}
263
+ </span>
264
+ )}
265
+ </div>
266
+ )}
267
+ </button>
268
+ </li>
269
+ )
270
+ })}
271
+ </ul>
272
+ )}
273
+ </div>
274
+
275
+ {totalPages > 1 && (
276
+ <div className={cx('byline-field-relation-picker-pager', styles.pager)}>
277
+ <Button
278
+ size="xs"
279
+ variant="outlined"
280
+ intent="noeffect"
281
+ type="button"
282
+ disabled={page <= 1 || loading}
283
+ onClick={() => setPage((p) => Math.max(1, p - 1))}
284
+ >
285
+ {t('common.pager.previous')}
286
+ </Button>
287
+ <span>{t('common.pager.pageOf', { page, total: totalPages })}</span>
288
+ <Button
289
+ size="xs"
290
+ variant="outlined"
291
+ intent="noeffect"
292
+ type="button"
293
+ disabled={page >= totalPages || loading}
294
+ onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
295
+ >
296
+ {t('common.pager.next')}
297
+ </Button>
298
+ </div>
299
+ )}
300
+ </div>
301
+ </Modal.Content>
302
+ <Modal.Actions>
303
+ <Button
304
+ size="sm"
305
+ intent="noeffect"
306
+ type="button"
307
+ onClick={onDismiss}
308
+ className={cx('byline-field-relation-picker-action', styles.action)}
309
+ >
310
+ {t('common.actions.cancel')}
311
+ </Button>
312
+ <Button
313
+ size="sm"
314
+ className={cx('byline-field-relation-picker-action', styles.action)}
315
+ intent="primary"
316
+ type="button"
317
+ disabled={!selectedDocumentId}
318
+ onClick={handleSelect}
319
+ >
320
+ {t('common.actions.select')}
321
+ </Button>
322
+ </Modal.Actions>
323
+ </Modal.Container>
324
+ </Modal>
325
+ )
326
+ }