portal-mojo 0.1.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 (369) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +55 -0
  3. package/docs/DetailView.md +173 -0
  4. package/docs/ModelTable.md +128 -0
  5. package/docs/README.md +98 -0
  6. package/docs/admin-assistant.md +72 -0
  7. package/docs/admin-bouncer.md +120 -0
  8. package/docs/admin-cloudwatch.md +38 -0
  9. package/docs/admin-credentials.md +116 -0
  10. package/docs/admin-dashboard.md +15 -0
  11. package/docs/admin-devices-geoip.md +284 -0
  12. package/docs/admin-dns-certificates.md +122 -0
  13. package/docs/admin-dns-records.md +65 -0
  14. package/docs/admin-dns-registrar.md +33 -0
  15. package/docs/admin-dns.md +80 -0
  16. package/docs/admin-identity-users.md +103 -0
  17. package/docs/admin-incidents.md +71 -0
  18. package/docs/admin-jobs.md +426 -0
  19. package/docs/admin-members.md +112 -0
  20. package/docs/admin-messaging.md +56 -0
  21. package/docs/admin-metrics-explorer.md +63 -0
  22. package/docs/admin-monitoring.md +99 -0
  23. package/docs/admin-network-security.md +393 -0
  24. package/docs/admin-phonehub.md +27 -0
  25. package/docs/admin-push.md +36 -0
  26. package/docs/admin-rules.md +87 -0
  27. package/docs/admin-security-tickets.md +40 -0
  28. package/docs/admin-settings.md +78 -0
  29. package/docs/admin-shortlinks.md +32 -0
  30. package/docs/admin-storage.md +141 -0
  31. package/docs/auth-pages.md +212 -0
  32. package/docs/auth.md +58 -0
  33. package/docs/calendar.md +122 -0
  34. package/docs/charts.md +303 -0
  35. package/docs/client.md +206 -0
  36. package/docs/collection-multiselect.md +117 -0
  37. package/docs/collection-select.md +132 -0
  38. package/docs/combobox.md +105 -0
  39. package/docs/dataview.md +226 -0
  40. package/docs/datepicker.md +151 -0
  41. package/docs/daterange.md +170 -0
  42. package/docs/datetimepicker.md +249 -0
  43. package/docs/defineModel.md +72 -0
  44. package/docs/detail-primitives.md +174 -0
  45. package/docs/feedback.md +85 -0
  46. package/docs/file-upload-ux.md +146 -0
  47. package/docs/forms.md +435 -0
  48. package/docs/grouping-and-fmt.md +98 -0
  49. package/docs/idioms.md +112 -0
  50. package/docs/image-editor.md +101 -0
  51. package/docs/loading.md +242 -0
  52. package/docs/location-address.md +76 -0
  53. package/docs/markdown.md +203 -0
  54. package/docs/menus-and-access.md +89 -0
  55. package/docs/multiselect-dropdown.md +153 -0
  56. package/docs/params.md +73 -0
  57. package/docs/popover.md +130 -0
  58. package/docs/realtime.md +61 -0
  59. package/docs/record-feed.md +238 -0
  60. package/docs/right-panel.md +42 -0
  61. package/docs/tabs-and-form-wizard.md +60 -0
  62. package/docs/taginput.md +120 -0
  63. package/docs/timepicker.md +173 -0
  64. package/docs/timezone-select.md +147 -0
  65. package/docs/worldmap.md +302 -0
  66. package/package.json +55 -0
  67. package/src/admin/assistant/AssistantFeed.tsx +29 -0
  68. package/src/admin/assistant/AssistantPanel.tsx +65 -0
  69. package/src/admin/assistant/api.ts +17 -0
  70. package/src/admin/assistant/data.ts +63 -0
  71. package/src/admin/assistant/index.ts +21 -0
  72. package/src/admin/assistant/launchers.tsx +33 -0
  73. package/src/admin/assistant/pages.tsx +78 -0
  74. package/src/admin/assistant/streaming.ts +238 -0
  75. package/src/admin/assistant/types.ts +30 -0
  76. package/src/admin/bouncer/devices.tsx +230 -0
  77. package/src/admin/bouncer/index.ts +34 -0
  78. package/src/admin/bouncer/models.ts +199 -0
  79. package/src/admin/bouncer/signals.tsx +182 -0
  80. package/src/admin/bouncer/signatures.tsx +220 -0
  81. package/src/admin/cloudwatch/CloudWatchChart.tsx +61 -0
  82. package/src/admin/cloudwatch/CloudWatchDashboardPage.tsx +52 -0
  83. package/src/admin/cloudwatch/CloudWatchResourceDetail.tsx +33 -0
  84. package/src/admin/cloudwatch/data.ts +163 -0
  85. package/src/admin/cloudwatch/index.ts +16 -0
  86. package/src/admin/credentials/group-api-keys.tsx +433 -0
  87. package/src/admin/credentials/index.ts +4 -0
  88. package/src/admin/credentials/models.ts +244 -0
  89. package/src/admin/credentials/secret-dialog.tsx +77 -0
  90. package/src/admin/credentials/webhook-subscriptions.tsx +423 -0
  91. package/src/admin/dashboard/AdminDashboardPage.tsx +101 -0
  92. package/src/admin/dashboard/data.ts +60 -0
  93. package/src/admin/dashboard/index.ts +2 -0
  94. package/src/admin/dns/CertificateDetail.tsx +115 -0
  95. package/src/admin/dns/CertificateLifecyclePoller.tsx +141 -0
  96. package/src/admin/dns/CertificateRequestDialog.tsx +103 -0
  97. package/src/admin/dns/CertificatesPage.tsx +85 -0
  98. package/src/admin/dns/DnsRecordEditor.tsx +110 -0
  99. package/src/admin/dns/DnsRecordsPage.tsx +36 -0
  100. package/src/admin/dns/DnsRecordsPanel.tsx +71 -0
  101. package/src/admin/dns/DomainCertificatesSection.tsx +40 -0
  102. package/src/admin/dns/DomainDetail.tsx +41 -0
  103. package/src/admin/dns/DomainOnboardingDialog.tsx +27 -0
  104. package/src/admin/dns/DomainPurchaseDetail.tsx +11 -0
  105. package/src/admin/dns/DomainPurchaseWizard.tsx +112 -0
  106. package/src/admin/dns/DomainPurchasesPage.tsx +16 -0
  107. package/src/admin/dns/DomainsPage.tsx +27 -0
  108. package/src/admin/dns/ProviderCredentialsPage.tsx +304 -0
  109. package/src/admin/dns/RegistrantContactPage.tsx +42 -0
  110. package/src/admin/dns/api.ts +515 -0
  111. package/src/admin/dns/certificate-data.ts +221 -0
  112. package/src/admin/dns/data.ts +32 -0
  113. package/src/admin/dns/dns-data.ts +266 -0
  114. package/src/admin/dns/dns-integration.ts +24 -0
  115. package/src/admin/dns/domain-links.ts +37 -0
  116. package/src/admin/dns/index.ts +86 -0
  117. package/src/admin/dns/models.ts +418 -0
  118. package/src/admin/dns/purchase-data.ts +67 -0
  119. package/src/admin/dns/registrant-data.ts +52 -0
  120. package/src/admin/identity/index.ts +5 -0
  121. package/src/admin/identity/members/GroupMembersPanel.tsx +146 -0
  122. package/src/admin/identity/members/MemberDetail.tsx +226 -0
  123. package/src/admin/identity/members/MembersPage.tsx +144 -0
  124. package/src/admin/identity/members/index.ts +24 -0
  125. package/src/admin/identity/members/member-flows.tsx +132 -0
  126. package/src/admin/identity/members/models.ts +204 -0
  127. package/src/admin/identity/users/UserDetail.tsx +257 -0
  128. package/src/admin/identity/users/UsersPage.tsx +163 -0
  129. package/src/admin/identity/users/index.ts +27 -0
  130. package/src/admin/identity/users/models.ts +232 -0
  131. package/src/admin/identity/users/sections/ApiKeysSection.tsx +119 -0
  132. package/src/admin/identity/users/sections/AuditSection.tsx +190 -0
  133. package/src/admin/identity/users/sections/DevicesSection.tsx +140 -0
  134. package/src/admin/identity/users/sections/GroupsSection.tsx +64 -0
  135. package/src/admin/identity/users/sections/LoginsSection.tsx +106 -0
  136. package/src/admin/identity/users/sections/MetadataPanel.tsx +36 -0
  137. package/src/admin/identity/users/sections/NotificationsSection.tsx +117 -0
  138. package/src/admin/identity/users/sections/OAuthSection.tsx +72 -0
  139. package/src/admin/identity/users/sections/OverviewSection.tsx +109 -0
  140. package/src/admin/identity/users/sections/PermissionsSection.tsx +35 -0
  141. package/src/admin/identity/users/sections/PersonalSection.tsx +73 -0
  142. package/src/admin/identity/users/sections/ProfileSection.tsx +175 -0
  143. package/src/admin/identity/users/sections/SecuritySection.tsx +123 -0
  144. package/src/admin/identity/users/sections/actions.tsx +752 -0
  145. package/src/admin/identity/users/sections/permission-catalog.ts +138 -0
  146. package/src/admin/identity/users/sections/queries.ts +74 -0
  147. package/src/admin/identity/users/sections/shared.tsx +299 -0
  148. package/src/admin/incidents/EventDetail.tsx +43 -0
  149. package/src/admin/incidents/EventsPage.tsx +52 -0
  150. package/src/admin/incidents/IncidentDetail.tsx +68 -0
  151. package/src/admin/incidents/IncidentsPage.tsx +102 -0
  152. package/src/admin/incidents/forensics.tsx +73 -0
  153. package/src/admin/incidents/index.ts +16 -0
  154. package/src/admin/incidents/models.ts +113 -0
  155. package/src/admin/incidents/sanitize.ts +71 -0
  156. package/src/admin/index.ts +383 -0
  157. package/src/admin/jobs/JobDashboardPage.tsx +111 -0
  158. package/src/admin/jobs/JobDetail.tsx +721 -0
  159. package/src/admin/jobs/JobRunnersPage.tsx +216 -0
  160. package/src/admin/jobs/JobsTablePage.tsx +122 -0
  161. package/src/admin/jobs/RunnerDetail.tsx +576 -0
  162. package/src/admin/jobs/ScheduledTaskDetail.tsx +421 -0
  163. package/src/admin/jobs/ScheduledTasksPage.tsx +167 -0
  164. package/src/admin/jobs/columns.tsx +297 -0
  165. package/src/admin/jobs/control.ts +488 -0
  166. package/src/admin/jobs/index.ts +85 -0
  167. package/src/admin/jobs/models.ts +599 -0
  168. package/src/admin/jobs/queries.ts +272 -0
  169. package/src/admin/jobs/scheduled-task-form.tsx +493 -0
  170. package/src/admin/jobs/sections/JobChannelsPanel.tsx +142 -0
  171. package/src/admin/jobs/sections/JobOperationsSection.tsx +423 -0
  172. package/src/admin/jobs/sections/JobRunnersStrip.tsx +82 -0
  173. package/src/admin/jobs/sections/JobThroughputSection.tsx +118 -0
  174. package/src/admin/messaging/EmailDomainWizard.tsx +24 -0
  175. package/src/admin/messaging/EmailDomainsPage.tsx +17 -0
  176. package/src/admin/messaging/EmailTemplatesPage.tsx +13 -0
  177. package/src/admin/messaging/MailboxesPage.tsx +15 -0
  178. package/src/admin/messaging/PublicMessagesPage.tsx +13 -0
  179. package/src/admin/messaging/SandboxedEmailPreview.tsx +23 -0
  180. package/src/admin/messaging/SentMessagesPage.tsx +11 -0
  181. package/src/admin/messaging/api.ts +17 -0
  182. package/src/admin/messaging/data.ts +10 -0
  183. package/src/admin/messaging/index.ts +23 -0
  184. package/src/admin/messaging/models.ts +76 -0
  185. package/src/admin/messaging/push/PushPage.tsx +44 -0
  186. package/src/admin/messaging/push/api.ts +17 -0
  187. package/src/admin/messaging/push/index.ts +9 -0
  188. package/src/admin/messaging/push/models.ts +43 -0
  189. package/src/admin/messaging/sanitize.ts +44 -0
  190. package/src/admin/monitoring/LogInspector.tsx +160 -0
  191. package/src/admin/monitoring/LogsPage.tsx +135 -0
  192. package/src/admin/monitoring/MetricsExplorerPage.tsx +360 -0
  193. package/src/admin/monitoring/MetricsPermissionsPage.tsx +166 -0
  194. package/src/admin/monitoring/MetricsSourcePicker.tsx +136 -0
  195. package/src/admin/monitoring/index.ts +55 -0
  196. package/src/admin/monitoring/metrics-explorer-client.ts +229 -0
  197. package/src/admin/monitoring/metrics-explorer-data.ts +158 -0
  198. package/src/admin/monitoring/models.ts +155 -0
  199. package/src/admin/network/BlockedIPsPage.tsx +194 -0
  200. package/src/admin/network/BlockedIpDetail.tsx +262 -0
  201. package/src/admin/network/FirewallLogPage.tsx +115 -0
  202. package/src/admin/network/IPSetDetail.tsx +280 -0
  203. package/src/admin/network/IPSetEditor.tsx +191 -0
  204. package/src/admin/network/IPSetsPage.tsx +177 -0
  205. package/src/admin/network/geofence/BlocksTab.tsx +289 -0
  206. package/src/admin/network/geofence/ExemptionsTab.tsx +349 -0
  207. package/src/admin/network/geofence/GeofencingPage.tsx +119 -0
  208. package/src/admin/network/geofence/PostureHeader.tsx +184 -0
  209. package/src/admin/network/geofence/RuleEditor.tsx +176 -0
  210. package/src/admin/network/geofence/RulesTab.tsx +254 -0
  211. package/src/admin/network/geofence/SimulatorTab.tsx +281 -0
  212. package/src/admin/network/geofence/geofence-data.ts +674 -0
  213. package/src/admin/network/index.ts +68 -0
  214. package/src/admin/network/models.ts +607 -0
  215. package/src/admin/phonehub/PhoneHubPage.tsx +57 -0
  216. package/src/admin/phonehub/api.ts +47 -0
  217. package/src/admin/phonehub/data.ts +6 -0
  218. package/src/admin/phonehub/index.ts +10 -0
  219. package/src/admin/phonehub/models.ts +68 -0
  220. package/src/admin/rules/HandlerChainBuilder.tsx +51 -0
  221. package/src/admin/rules/RuleSetDetailPage.tsx +33 -0
  222. package/src/admin/rules/RuleSetsPage.tsx +33 -0
  223. package/src/admin/rules/editors.tsx +54 -0
  224. package/src/admin/rules/handler-dsl.ts +213 -0
  225. package/src/admin/rules/index.ts +13 -0
  226. package/src/admin/rules/models.ts +112 -0
  227. package/src/admin/security/devices/LoginEventDetail.tsx +123 -0
  228. package/src/admin/security/devices/LoginLocationMap.tsx +321 -0
  229. package/src/admin/security/devices/LoginLocationsPage.tsx +162 -0
  230. package/src/admin/security/devices/UserDeviceDetail.tsx +546 -0
  231. package/src/admin/security/devices/UserDeviceLocationDetail.tsx +272 -0
  232. package/src/admin/security/devices/UserDevicesPage.tsx +84 -0
  233. package/src/admin/security/devices/index.ts +40 -0
  234. package/src/admin/security/devices/models.ts +470 -0
  235. package/src/admin/security/geoip/GeoIpCachePage.tsx +135 -0
  236. package/src/admin/security/geoip/GeoIpDossier.tsx +804 -0
  237. package/src/admin/security/geoip/geoip-forms.ts +102 -0
  238. package/src/admin/security/geoip/index.ts +27 -0
  239. package/src/admin/security/geoip/models.ts +397 -0
  240. package/src/admin/security/index.ts +31 -0
  241. package/src/admin/security/models.ts +137 -0
  242. package/src/admin/security/tickets.tsx +562 -0
  243. package/src/admin/security-permissions.ts +5 -0
  244. package/src/admin/settings/SettingDetail.tsx +57 -0
  245. package/src/admin/settings/SettingEditor.tsx +157 -0
  246. package/src/admin/settings/SettingsPage.tsx +72 -0
  247. package/src/admin/settings/index.ts +19 -0
  248. package/src/admin/settings/model.ts +143 -0
  249. package/src/admin/shortlinks/ShortlinkDetail.tsx +55 -0
  250. package/src/admin/shortlinks/ShortlinkHistoryPage.tsx +18 -0
  251. package/src/admin/shortlinks/ShortlinksPage.tsx +59 -0
  252. package/src/admin/shortlinks/index.ts +18 -0
  253. package/src/admin/shortlinks/models.ts +246 -0
  254. package/src/admin/storage/BackendsPage.tsx +43 -0
  255. package/src/admin/storage/BucketDetail.tsx +77 -0
  256. package/src/admin/storage/BucketsPage.tsx +64 -0
  257. package/src/admin/storage/FileManagerDetail.tsx +36 -0
  258. package/src/admin/storage/FilePreview.tsx +54 -0
  259. package/src/admin/storage/FileUploadSurface.tsx +250 -0
  260. package/src/admin/storage/FileView.tsx +109 -0
  261. package/src/admin/storage/FilesPage.tsx +53 -0
  262. package/src/admin/storage/file-renditions.ts +99 -0
  263. package/src/admin/storage/index.ts +31 -0
  264. package/src/admin/storage/models.ts +585 -0
  265. package/src/admin/storage/storage-dialogs.tsx +80 -0
  266. package/src/charts/CircularProgress.tsx +281 -0
  267. package/src/charts/KPIStrip.tsx +162 -0
  268. package/src/charts/KPITile.tsx +125 -0
  269. package/src/charts/MetricsChart.tsx +459 -0
  270. package/src/charts/MetricsMiniWidget.tsx +588 -0
  271. package/src/charts/MiniChart.tsx +283 -0
  272. package/src/charts/PieChart.tsx +354 -0
  273. package/src/charts/SeriesChart.tsx +378 -0
  274. package/src/charts/chart-dialogs.tsx +149 -0
  275. package/src/charts/exportChart.ts +144 -0
  276. package/src/charts/index.ts +28 -0
  277. package/src/charts/pie-math.ts +127 -0
  278. package/src/charts/stats.ts +265 -0
  279. package/src/charts/worldmap/WorldMap.tsx +649 -0
  280. package/src/charts/worldmap/countryCentroids.ts +304 -0
  281. package/src/charts/worldmap/geo.ts +293 -0
  282. package/src/charts/worldmap/use-world-land.ts +47 -0
  283. package/src/charts/worldmap/world-land.ts +18 -0
  284. package/src/charts/worldmap/worldmap-data.ts +285 -0
  285. package/src/client/auth.ts +737 -0
  286. package/src/client/client.ts +268 -0
  287. package/src/client/duid.ts +31 -0
  288. package/src/client/errors.ts +33 -0
  289. package/src/client/group-context.ts +28 -0
  290. package/src/client/group.tsx +126 -0
  291. package/src/client/hooks.ts +50 -0
  292. package/src/client/index.ts +37 -0
  293. package/src/client/jwt.ts +75 -0
  294. package/src/client/location.ts +285 -0
  295. package/src/client/lookups.ts +41 -0
  296. package/src/client/markdown-fallback.tsx +115 -0
  297. package/src/client/markdown-parse.ts +386 -0
  298. package/src/client/markdown-sanitize.ts +217 -0
  299. package/src/client/markdown.ts +122 -0
  300. package/src/client/me.ts +188 -0
  301. package/src/client/mock.ts +9984 -0
  302. package/src/client/model.ts +227 -0
  303. package/src/client/params.ts +292 -0
  304. package/src/client/realtime-mock.ts +221 -0
  305. package/src/client/realtime.ts +561 -0
  306. package/src/client/record-feed.ts +329 -0
  307. package/src/client/safe-export.ts +71 -0
  308. package/src/client/types.ts +278 -0
  309. package/src/client/upload.ts +421 -0
  310. package/src/ui/AddressField.tsx +273 -0
  311. package/src/ui/AttachmentQueue.tsx +92 -0
  312. package/src/ui/CollectionMultiSelect.tsx +622 -0
  313. package/src/ui/CollectionSelect.tsx +517 -0
  314. package/src/ui/ComboBox.tsx +336 -0
  315. package/src/ui/DetailView.tsx +438 -0
  316. package/src/ui/FileDrop.tsx +194 -0
  317. package/src/ui/FileField.tsx +369 -0
  318. package/src/ui/FilterBar.tsx +269 -0
  319. package/src/ui/FormFields.tsx +77 -0
  320. package/src/ui/FormView.tsx +360 -0
  321. package/src/ui/FormWizard.tsx +336 -0
  322. package/src/ui/GroupSwitcher.tsx +202 -0
  323. package/src/ui/Guarded.tsx +16 -0
  324. package/src/ui/MarkdownView.tsx +89 -0
  325. package/src/ui/ModelTable.tsx +848 -0
  326. package/src/ui/MultiSelectDropdown.tsx +390 -0
  327. package/src/ui/Popover.tsx +204 -0
  328. package/src/ui/RecordFeed.tsx +627 -0
  329. package/src/ui/RequiresGroup.tsx +32 -0
  330. package/src/ui/RightPanel.tsx +107 -0
  331. package/src/ui/SidebarNav.tsx +330 -0
  332. package/src/ui/Tabs.tsx +161 -0
  333. package/src/ui/TagInput.tsx +381 -0
  334. package/src/ui/ThemeProvider.tsx +52 -0
  335. package/src/ui/UploadQueue.tsx +603 -0
  336. package/src/ui/armed-button.tsx +91 -0
  337. package/src/ui/date/Calendar.tsx +498 -0
  338. package/src/ui/date/DatePicker.tsx +289 -0
  339. package/src/ui/date/DateRangePicker.tsx +280 -0
  340. package/src/ui/date/DateTimePicker.tsx +587 -0
  341. package/src/ui/date/PresetRail.tsx +165 -0
  342. package/src/ui/date/TimePicker.tsx +624 -0
  343. package/src/ui/date/TimezoneSelect.tsx +267 -0
  344. package/src/ui/date/fns.ts +679 -0
  345. package/src/ui/detail/DataView.tsx +692 -0
  346. package/src/ui/detail/FlowStrip.tsx +80 -0
  347. package/src/ui/detail/KnownFieldsCard.tsx +121 -0
  348. package/src/ui/detail/MetadataSection.tsx +219 -0
  349. package/src/ui/detail/StackTraceView.tsx +150 -0
  350. package/src/ui/detail/StatusPanel.tsx +73 -0
  351. package/src/ui/detail/Timeline.tsx +52 -0
  352. package/src/ui/detail/index.ts +12 -0
  353. package/src/ui/field-registry.tsx +428 -0
  354. package/src/ui/field-wire.ts +322 -0
  355. package/src/ui/form-autosave.ts +583 -0
  356. package/src/ui/format.ts +444 -0
  357. package/src/ui/grouping.ts +150 -0
  358. package/src/ui/image-editor/ImageEditor.tsx +484 -0
  359. package/src/ui/image-editor/canvas.ts +254 -0
  360. package/src/ui/image-editor/index.ts +3 -0
  361. package/src/ui/image-editor/math.ts +416 -0
  362. package/src/ui/index.ts +58 -0
  363. package/src/ui/loading.tsx +604 -0
  364. package/src/ui/menu-registry.ts +213 -0
  365. package/src/ui/modal.tsx +297 -0
  366. package/src/ui/password.tsx +302 -0
  367. package/src/ui/schema-form-core.tsx +315 -0
  368. package/src/ui/toast.tsx +219 -0
  369. package/src/ui/ui.tsx +45 -0
@@ -0,0 +1,517 @@
1
+ // CollectionSelect — the single-record server picker: search-as-you-type
2
+ // against any model/endpoint, pick one row, and hydrate a bare stored id
3
+ // into its display label. Ported from web-mojo
4
+ // src/core/forms/inputs/CollectionSelect.js (669 lines: CollectionSelectView
5
+ // + CollectionDropdownView) into the one-cache world: list data flows through
6
+ // useModelList's query keys ([endpoint, params]) and id→label hydration
7
+ // through the generic one-record key ([endpoint, 'one', id]), so this picker,
8
+ // ModelTable, DetailView and every model hook share one cache and one
9
+ // invalidation root — never a parallel fetch path (the #1275 epic contract).
10
+ // Sibling of CollectionMultiSelect (shared prop contract: model/endpoint,
11
+ // labelField/valueField, defaultParams, requiresActiveGroup).
12
+ //
13
+ // Deliberate departures from the source, all bug-class kills:
14
+ // · The value is CONTROLLED (`value` + `onChange`) — no hidden input, no
15
+ // setValue/setFormValue imperative surface. The input can never display
16
+ // text the committed value + draft state don't hold (rule 4): closing
17
+ // by outside click or Escape DROPS the in-progress search text and the
18
+ // display reverts to the committed label (the source left typed text in
19
+ // the DOM input while its hidden value said "no selection").
20
+ // · web-mojo's '0' no-selection sentinel stops at this boundary: 0/'0'
21
+ // coming IN normalize to "no selection", and the empty value going OUT
22
+ // is null — '0' never reaches the API or the parent.
23
+ // · Typing that diverges from the committed label clears the selection
24
+ // exactly once (onChange(null)) — the source re-emitted the clear on
25
+ // every subsequent keystroke (searchValue !== '' ≠ selectedLabel='').
26
+ // · Ids compare NORMALIZED (String()) everywhere; numeric-string ids
27
+ // hydrate under the NUMERIC one-record key so DetailView/useOne share
28
+ // the cached record (django-mojo pks are ints; uuid/slug ids pass
29
+ // through untouched).
30
+ // · Re-selecting the already-committed row does not re-fire onChange
31
+ // (commit fires only when the value actually changes — ComboBox idiom).
32
+ // · The dropdown mounts in the shared <Popover> (top layer) instead of an
33
+ // absolutely-positioned div, so the picker works inside native-<dialog>
34
+ // modals and never clips in overflow containers.
35
+ // · "Start typing to search…" is actually reachable — in the source it
36
+ // was dead code (its showNoResults guard required hasSearched).
37
+ import { useContext, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from 'react';
38
+ import { useQueryClient, type QueryClient } from '@tanstack/react-query';
39
+ import { useModel, useModelList } from '../client/hooks';
40
+ import type { ModelDef } from '../client/model';
41
+ import type { Params } from '../client/types';
42
+ import { GroupContext } from '../client/group-context';
43
+ import { Popover } from './Popover';
44
+
45
+ /** Every id comparison in this component goes through here — string and
46
+ * number ids (and mixes of the two) work by construction. */
47
+ const keyOf = (v: string | number): string => String(v);
48
+
49
+ /** MOJOUtils.getNestedValue heritage: dot-notation nested access. */
50
+ function getPath(obj: unknown, path: string): unknown {
51
+ let cur: unknown = obj;
52
+ for (const part of path.split('.')) {
53
+ if (cur == null || typeof cur !== 'object') return undefined;
54
+ cur = (cur as Record<string, unknown>)[part];
55
+ }
56
+ return cur;
57
+ }
58
+
59
+ // One warn per (endpoint, field) — a bad field name is a config bug, not a
60
+ // render event (unknown-value rule: fall back WITH a warn, never to nothing).
61
+ const warnedFields = new Set<string>();
62
+ function warnFieldOnce(endpoint: string, kind: 'labelField' | 'valueField', field: string, note: string): void {
63
+ const key = `${endpoint}:${kind}:${field}`;
64
+ if (warnedFields.has(key)) return;
65
+ warnedFields.add(key);
66
+ console.warn(`CollectionSelect: ${kind} "${field}" ${note} (endpoint ${endpoint})`);
67
+ }
68
+
69
+ /** django-mojo pks are ints — a numeric-string id fetches under the NUMBER
70
+ * one-record key ([endpoint,'one',42]) so DetailView/useOne share the cache.
71
+ * Non-numeric ids (uuid, slug) pass through unchanged. */
72
+ const coerceFetchId = (id: string | number): string | number =>
73
+ typeof id === 'string' && /^[1-9]\d*$/.test(id) ? Number(id) : id;
74
+
75
+ /** "Label known already?" — scan every cached LIST under this endpoint's
76
+ * root for the row (ModelTable pages, other pickers, this picker's own
77
+ * fetches all share the [endpoint, params] keys). Non-reactive by design:
78
+ * a miss falls through to the reactive one-record fetch. */
79
+ function findRowInListCaches(qc: QueryClient, endpoint: string, key: string, valueField: string): unknown {
80
+ for (const [, data] of qc.getQueriesData<unknown>({ queryKey: [endpoint] })) {
81
+ if (data == null || typeof data !== 'object') continue;
82
+ const rows = (data as { rows?: unknown }).rows;
83
+ if (!Array.isArray(rows)) continue; // skips [endpoint,'one',id] entries
84
+ for (const row of rows) {
85
+ const raw = getPath(row, valueField);
86
+ if (raw != null && String(raw) === key) return row;
87
+ }
88
+ }
89
+ return undefined;
90
+ }
91
+
92
+ interface Item<T> {
93
+ /** Normalized identity — the one string every comparison uses. */
94
+ key: string;
95
+ /** The row's raw id, emitted through onChange when the row is picked. */
96
+ id: string | number;
97
+ label: string;
98
+ row: T;
99
+ }
100
+
101
+ /**
102
+ * The controlled value: an id, null for "no selection", or a full row-like
103
+ * OBJECT (web-mojo setFormValue heritage — a saved record with the relation
104
+ * expanded) from which id + label extract via the valueField/labelField dot
105
+ * paths. 0 and '0' (web-mojo's no-selection sentinel) normalize to null.
106
+ */
107
+ export type CollectionSelectValue = string | number | Record<string, unknown> | null;
108
+
109
+ export interface CollectionSelectProps<T extends { id: number | string }> {
110
+ /** A defineModel definition — supplies the endpoint (and the row type). */
111
+ model?: ModelDef<T>;
112
+ /** Bare endpoint, for lists without a model definition. */
113
+ endpoint?: string;
114
+ /** Selected id — CONTROLLED. Accepts id | null | row-object (see CollectionSelectValue). */
115
+ value: CollectionSelectValue;
116
+ /**
117
+ * Fires on COMMIT only: picking a row → (id, row); clearing (✕, or typing
118
+ * away from the committed label) → (null). Never per keystroke; never '0'.
119
+ */
120
+ onChange: (id: string | number | null, row?: T) => void;
121
+ /** Row field shown as the label; dot notation reaches nested fields. Default 'name'. */
122
+ labelField?: string;
123
+ /** Row field used as the id; dot notation reaches nested fields. Default 'id'. */
124
+ valueField?: string;
125
+ /** Rows fetched per search — the `size` wire param. Default 10. */
126
+ maxItems?: number;
127
+ placeholder?: string;
128
+ /** Debounce from keystroke to the `search` wire param. Default 400ms. */
129
+ debounceMs?: number;
130
+ /**
131
+ * Fetch the initial (unsearched) page when the dropdown opens, so options
132
+ * exist before typing. Default true; false = "Start typing to search…".
133
+ */
134
+ emptyFetch?: boolean;
135
+ /**
136
+ * Extra wire params merged into every fetch — a dict, or a callback
137
+ * re-evaluated per render (each fetch uses its freshest result; a changed
138
+ * result is a new query key and refetches). Overrides the built-in
139
+ * `size`; `group` (requiresActiveGroup) and `search` win over it.
140
+ */
141
+ defaultParams?: Params | (() => Params | null | undefined);
142
+ /**
143
+ * Fold the active group in as the `group` wire param. Without an active
144
+ * group the fetch is HELD (never an unscoped list where a scoped one was
145
+ * demanded — the sibling's decision) and the dropdown says so.
146
+ */
147
+ requiresActiveGroup?: boolean;
148
+ label?: string;
149
+ required?: boolean;
150
+ help?: string;
151
+ /** Error text below the field (replaces help); the input gets invalid styling. */
152
+ error?: string;
153
+ disabled?: boolean;
154
+ /** Display-only: the committed label shows, but no opening/typing/clearing. */
155
+ readOnly?: boolean;
156
+ }
157
+
158
+ export function CollectionSelect<T extends { id: number | string }>({
159
+ model, endpoint, value, onChange,
160
+ labelField = 'name', valueField = 'id',
161
+ maxItems = 10, placeholder = 'Search…', debounceMs = 400,
162
+ emptyFetch = true, defaultParams, requiresActiveGroup = false,
163
+ label, required = false, help, error,
164
+ disabled = false, readOnly = false,
165
+ }: CollectionSelectProps<T>) {
166
+ const resolvedEndpoint = model?.endpoint ?? endpoint;
167
+ if (!resolvedEndpoint) throw new Error('CollectionSelect: pass `model` or `endpoint`');
168
+
169
+ const qc = useQueryClient();
170
+ const uid = useId();
171
+ const inputDomId = `colsel-input-${uid}`;
172
+ const listboxId = `colsel-list-${uid}`;
173
+ const optionId = (i: number) => `colsel-opt-${uid}-${i}`;
174
+
175
+ const wrapRef = useRef<HTMLDivElement>(null); // the Popover anchor: input + clear
176
+ const inputRef = useRef<HTMLInputElement>(null);
177
+ const popRef = useRef<HTMLDivElement>(null); // dropdown content (blur containment)
178
+ /** Label learned at selection time — keeps the display stable even after
179
+ * the list cache that held the row is garbage-collected. */
180
+ const lastPickedRef = useRef<{ key: string; label: string } | null>(null);
181
+ const warnedIdsRef = useRef<Set<string>>(new Set());
182
+
183
+ // ── The controlled value, normalized ────────────────────────────────
184
+ // 0/'0'/'' → no selection (the web-mojo sentinel ends here); an object
185
+ // value carries its own id + label via the dot paths.
186
+ let selId: string | number | null = null;
187
+ let seedLabel: string | null = null;
188
+ if (value != null && value !== 0 && value !== '0' && value !== '') {
189
+ if (typeof value === 'object') {
190
+ const rawId = getPath(value, valueField);
191
+ if (rawId == null || rawId === 0 || rawId === '0') {
192
+ warnFieldOnce(resolvedEndpoint, 'valueField', valueField, 'is missing on the object value — treating it as no selection');
193
+ } else {
194
+ selId = typeof rawId === 'number' || typeof rawId === 'string' ? rawId : String(rawId);
195
+ const rawLabel = getPath(value, labelField);
196
+ seedLabel = rawLabel == null ? null : String(rawLabel);
197
+ }
198
+ } else {
199
+ selId = value;
200
+ }
201
+ }
202
+ const selKey = selId == null ? null : keyOf(selId);
203
+
204
+ // ── Draft: the ONE value pipeline ───────────────────────────────────
205
+ // null = not searching → the input displays the committed label; a string
206
+ // = the in-progress search text. Committing/reverting nulls the draft.
207
+ const [draft, setDraft] = useState<string | null>(null);
208
+ const [term, setTerm] = useState('');
209
+ const [open, setOpen] = useState(false);
210
+ const [focusIdx, setFocusIdx] = useState(-1);
211
+ useEffect(() => {
212
+ const next = (draft ?? '').trim();
213
+ const t = setTimeout(() => setTerm(next), debounceMs);
214
+ return () => clearTimeout(t);
215
+ }, [draft, debounceMs]);
216
+
217
+ // ── Active group (requiresActiveGroup) ──────────────────────────────
218
+ // Read the context nullable-safely: consumers without a GroupProvider
219
+ // only misconfigure when they ASK for group scoping.
220
+ const groupCtx = useContext(GroupContext);
221
+ const activeGroupId = groupCtx?.group?.id ?? null;
222
+ const waitingForGroup = requiresActiveGroup && activeGroupId == null;
223
+ useEffect(() => {
224
+ if (requiresActiveGroup && groupCtx === null) {
225
+ console.warn('CollectionSelect: requiresActiveGroup is set but there is no <GroupProvider> above — the fetch stays held');
226
+ }
227
+ }, [requiresActiveGroup, groupCtx]);
228
+
229
+ // ── The list fetch — SHARED cache keys ([endpoint, params]) ─────────
230
+ // defaultParams may be a callback: evaluated every render, so each fetch
231
+ // carries its freshest result (the source evaluated it once at setup).
232
+ const extra = typeof defaultParams === 'function' ? (defaultParams() ?? {}) : (defaultParams ?? {});
233
+ const wire: Params = {
234
+ size: maxItems,
235
+ ...extra,
236
+ ...(requiresActiveGroup && activeGroupId != null ? { group: activeGroupId } : {}),
237
+ ...(term ? { search: term } : {}), // trimmed; dropped when empty
238
+ };
239
+ // Fetch only while the dropdown is open (the source fetched on focus);
240
+ // with emptyFetch off, additionally only once there is a search term.
241
+ const listEnabled = open && !waitingForGroup && (emptyFetch || term !== '');
242
+ const query = useModelList<T>(resolvedEndpoint, wire, { enabled: listEnabled });
243
+ // A held query (emptyFetch=false with no term, or no active group) must
244
+ // show its empty state — keepPreviousData would otherwise keep serving
245
+ // the previous key's rows through the placeholder.
246
+ const rows = listEnabled ? query.data?.rows : undefined;
247
+
248
+ const items = useMemo<Item<T>[]>(() => {
249
+ const out: Item<T>[] = [];
250
+ for (const row of rows ?? []) {
251
+ const rawId = getPath(row, valueField);
252
+ if (rawId == null) {
253
+ warnFieldOnce(resolvedEndpoint, 'valueField', valueField, 'is missing on a row — row dropped');
254
+ continue;
255
+ }
256
+ const id: string | number = typeof rawId === 'number' || typeof rawId === 'string' ? rawId : String(rawId);
257
+ const rawLabel = getPath(row, labelField);
258
+ if (rawLabel == null) {
259
+ warnFieldOnce(resolvedEndpoint, 'labelField', labelField, 'is missing on a row — showing the id');
260
+ }
261
+ out.push({ key: keyOf(id), id, label: rawLabel == null ? String(id) : String(rawLabel), row });
262
+ }
263
+ return out;
264
+ }, [rows, valueField, labelField, resolvedEndpoint]);
265
+
266
+ // A new row set invalidates the walk position (source parity: reset on
267
+ // input; stronger here — stale indices into replaced data are meaningless).
268
+ useEffect(() => { setFocusIdx(-1); }, [rows]);
269
+
270
+ // ── id → label hydration ────────────────────────────────────────────
271
+ // Resolution order: object-value label → label learned at pick time →
272
+ // any cached LIST row → fetch the one record through the generic
273
+ // [endpoint,'one',id] key (shared with DetailView/useOne — one cache).
274
+ const picked = lastPickedRef.current;
275
+ const pickedLabel = selKey != null && picked?.key === selKey ? picked.label : null;
276
+ let scanLabel: string | null = null;
277
+ if (selKey != null && seedLabel == null && pickedLabel == null) {
278
+ const row = findRowInListCaches(qc, resolvedEndpoint, selKey, valueField);
279
+ if (row != null) {
280
+ const raw = getPath(row, labelField);
281
+ // A row without the label field stays unresolved — the one-record
282
+ // graph can carry fields the list graph omits.
283
+ if (raw != null) scanLabel = String(raw);
284
+ }
285
+ }
286
+ const needFetch = selKey != null && seedLabel == null && pickedLabel == null && scanLabel == null;
287
+ const oneQuery = useModel<T>(resolvedEndpoint, needFetch && selId != null ? coerceFetchId(selId) : null);
288
+ let oneLabel: string | null = null;
289
+ if (needFetch && oneQuery.data != null) {
290
+ const raw = getPath(oneQuery.data, labelField);
291
+ if (raw == null) {
292
+ warnFieldOnce(resolvedEndpoint, 'labelField', labelField, 'is missing on the fetched record — showing the id');
293
+ } else {
294
+ oneLabel = String(raw);
295
+ }
296
+ }
297
+ useEffect(() => {
298
+ if (!oneQuery.isError || selKey == null) return;
299
+ if (warnedIdsRef.current.has(selKey)) return;
300
+ warnedIdsRef.current.add(selKey);
301
+ console.warn(`CollectionSelect: could not hydrate ${resolvedEndpoint} id ${selKey} — displaying "#${selKey}"`, oneQuery.error);
302
+ }, [oneQuery.isError, oneQuery.error, selKey, resolvedEndpoint]);
303
+
304
+ const selLabel = seedLabel ?? pickedLabel ?? scanLabel ?? oneLabel;
305
+ // Interim "#<id>" while the label resolves (or when it never can).
306
+ const committedText = selKey != null ? (selLabel ?? `#${selKey}`) : '';
307
+ const displayText = draft ?? committedText;
308
+
309
+ // ── Commit / revert (the only paths that touch the pipeline) ────────
310
+ const canOpen = !disabled && !readOnly;
311
+ const openList = () => { if (canOpen) setOpen(true); };
312
+ /** Close + drop the draft — the display reverts to the committed label. */
313
+ const closeAndRevert = () => {
314
+ setOpen(false);
315
+ setDraft(null);
316
+ setTerm('');
317
+ setFocusIdx(-1);
318
+ };
319
+ const selectItem = (item: Item<T>) => {
320
+ lastPickedRef.current = { key: item.key, label: item.label };
321
+ closeAndRevert();
322
+ if (item.key !== selKey) onChange(item.id, item.row);
323
+ };
324
+ const clearSelection = () => {
325
+ lastPickedRef.current = null;
326
+ setDraft(null);
327
+ setTerm('');
328
+ setFocusIdx(-1);
329
+ // Clear refocuses AND reopens on the base page — the source's
330
+ // clear-then-refocus behavior, made deterministic (the ✕ renders
331
+ // only while canOpen, so opening here is always legal).
332
+ setOpen(true);
333
+ if (selKey != null) onChange(null);
334
+ inputRef.current?.focus();
335
+ };
336
+
337
+ const onInput = (e: React.ChangeEvent<HTMLInputElement>) => {
338
+ const text = e.target.value;
339
+ setDraft(text);
340
+ setFocusIdx(-1);
341
+ if (canOpen) setOpen(true);
342
+ // Typing that diverges from the committed label IS the clear commit
343
+ // (source semantics) — fired once; the parent's null comes back as
344
+ // selKey null, so further keystrokes are draft-only.
345
+ if (selKey != null && text !== committedText) onChange(null);
346
+ };
347
+
348
+ const onBlur = (e: React.FocusEvent<HTMLInputElement>) => {
349
+ const next = e.relatedTarget as Node | null;
350
+ if (next && (wrapRef.current?.contains(next) || popRef.current?.contains(next))) return;
351
+ closeAndRevert(); // Tab-away settles like outside click
352
+ };
353
+
354
+ // While open, Escape is owned by the Popover (capture phase; first Escape
355
+ // closes only the dropdown even inside a modal) → onClose('escape') →
356
+ // closeAndRevert. Keyboard here covers the walk + commit.
357
+ const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
358
+ switch (e.key) {
359
+ case 'ArrowDown':
360
+ if (!canOpen) break;
361
+ e.preventDefault();
362
+ if (!open) setOpen(true);
363
+ else setFocusIdx((i) => Math.min(i + 1, items.length - 1)); // clamp, no wrap (source)
364
+ break;
365
+ case 'ArrowUp':
366
+ if (!open) break;
367
+ e.preventDefault();
368
+ setFocusIdx((i) => Math.max(i - 1, 0));
369
+ break;
370
+ case 'Enter': {
371
+ if (!open) break;
372
+ e.preventDefault(); // never submit an enclosing form while picking
373
+ const it = focusIdx >= 0 ? items[focusIdx] : undefined;
374
+ if (it) selectItem(it);
375
+ break;
376
+ }
377
+ }
378
+ };
379
+
380
+ // Keep the keyboard-focused option in view.
381
+ useEffect(() => {
382
+ if (focusIdx < 0) return;
383
+ document.getElementById(`colsel-opt-${uid}-${focusIdx}`)?.scrollIntoView({ block: 'nearest' });
384
+ }, [focusIdx, uid]);
385
+
386
+ // The dropdown matches the field's width (source: w-100) — measured, since
387
+ // the Popover shell is width:max-content in the top layer.
388
+ const [menuWidth, setMenuWidth] = useState<number | undefined>(undefined);
389
+ useLayoutEffect(() => {
390
+ if (!open) return;
391
+ const el = wrapRef.current;
392
+ if (!el) return;
393
+ const sync = () => setMenuWidth(el.offsetWidth);
394
+ sync();
395
+ const ro = new ResizeObserver(sync);
396
+ ro.observe(el);
397
+ return () => ro.disconnect();
398
+ }, [open]);
399
+
400
+ // Spinner whenever the CURRENT key's rows aren't on screen yet (cold load
401
+ // or a search/params change still holding the previous key's data).
402
+ const pendingView = listEnabled && (query.isPending || query.isPlaceholderData);
403
+ const showClear = selKey != null && !disabled && !readOnly;
404
+
405
+ return (
406
+ <div className={`collection-select${disabled ? ' collection-select-disabled' : ''}`}>
407
+ {label && (
408
+ <label className="field-label" htmlFor={inputDomId}>
409
+ {label}
410
+ {required && <em> *</em>}
411
+ </label>
412
+ )}
413
+
414
+ <div ref={wrapRef} className={`collection-select-field${showClear ? ' has-clear' : ''}`}>
415
+ <input
416
+ ref={inputRef}
417
+ id={inputDomId}
418
+ type="text"
419
+ className={`input collection-select-input${error ? ' input-invalid' : ''}`}
420
+ role="combobox"
421
+ aria-expanded={open}
422
+ aria-autocomplete="list"
423
+ aria-controls={listboxId}
424
+ aria-activedescendant={open && focusIdx >= 0 ? optionId(focusIdx) : undefined}
425
+ aria-invalid={error ? true : undefined}
426
+ autoComplete="off"
427
+ placeholder={placeholder}
428
+ value={displayText}
429
+ disabled={disabled}
430
+ readOnly={readOnly}
431
+ required={required}
432
+ onFocus={openList}
433
+ onClick={openList}
434
+ onBlur={onBlur}
435
+ onChange={onInput}
436
+ onKeyDown={onKeyDown}
437
+ />
438
+ {showClear && (
439
+ <button
440
+ type="button"
441
+ className="collection-select-clear"
442
+ aria-label="Clear selection"
443
+ title="Clear selection"
444
+ onMouseDown={(e) => e.preventDefault() /* keep input focus */}
445
+ onClick={clearSelection}
446
+ >
447
+ <i className="bi bi-x-circle" aria-hidden="true" />
448
+ </button>
449
+ )}
450
+ </div>
451
+
452
+ <Popover anchorRef={wrapRef} open={open} onClose={closeAndRevert} role="presentation">
453
+ <div
454
+ ref={popRef}
455
+ id={listboxId}
456
+ role="listbox"
457
+ aria-label={label ?? 'Options'}
458
+ className="collection-select-pop"
459
+ style={{ width: menuWidth }}
460
+ >
461
+ {waitingForGroup ? (
462
+ <div className="collection-select-state">
463
+ <i className="bi bi-people" aria-hidden="true" />
464
+ Select an active group first
465
+ </div>
466
+ ) : query.isError ? (
467
+ // Error beats spinner: a failed fetch must be unmissable,
468
+ // never masked by placeholder-data edge states.
469
+ <div className="collection-select-state collection-select-state-error" role="alert">
470
+ <i className="bi bi-exclamation-triangle" aria-hidden="true" />
471
+ <span>{query.error instanceof Error ? query.error.message : 'Request failed'}</span>
472
+ <button type="button" className="btn btn-compact" onMouseDown={(e) => e.preventDefault()} onClick={() => void query.refetch()}>
473
+ Retry
474
+ </button>
475
+ </div>
476
+ ) : pendingView ? (
477
+ <div className="collection-select-state" aria-live="polite">
478
+ <i className="bi bi-arrow-repeat spin" aria-hidden="true" />
479
+ Loading…
480
+ </div>
481
+ ) : items.length === 0 ? (
482
+ <div className="collection-select-state">
483
+ <i className={`bi ${term ? 'bi-inbox' : 'bi-search'}`} aria-hidden="true" />
484
+ {term ? 'No results found' : 'Start typing to search…'}
485
+ </div>
486
+ ) : (
487
+ items.map((item, i) => {
488
+ const isSelected = item.key === selKey;
489
+ return (
490
+ <button
491
+ type="button"
492
+ key={item.key}
493
+ id={optionId(i)}
494
+ role="option"
495
+ aria-selected={isSelected}
496
+ tabIndex={-1}
497
+ className={`collection-select-option${i === focusIdx ? ' is-focused' : ''}${isSelected ? ' is-selected' : ''}`}
498
+ onMouseDown={(e) => e.preventDefault() /* option click must not blur the input */}
499
+ onClick={() => selectItem(item)}
500
+ >
501
+ <span className="collection-select-option-label">{item.label}</span>
502
+ {isSelected && <i className="bi bi-check collection-select-check" aria-hidden="true" />}
503
+ </button>
504
+ );
505
+ })
506
+ )}
507
+ </div>
508
+ </Popover>
509
+
510
+ {error ? (
511
+ <div className="field-error">{error}</div>
512
+ ) : help ? (
513
+ <div className="field-help">{help}</div>
514
+ ) : null}
515
+ </div>
516
+ );
517
+ }