@kelpie/ui 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (349) hide show
  1. package/dist/api/resource.d.ts +59 -6
  2. package/dist/api/resource.d.ts.map +1 -1
  3. package/dist/api/resource.js +106 -24
  4. package/dist/api/resource.js.map +1 -1
  5. package/dist/api/resources/companies.d.ts +2 -0
  6. package/dist/api/resources/companies.d.ts.map +1 -1
  7. package/dist/api/resources/companies.js +5 -1
  8. package/dist/api/resources/companies.js.map +1 -1
  9. package/dist/api/resources/consentPurposes.d.ts +13 -0
  10. package/dist/api/resources/consentPurposes.d.ts.map +1 -0
  11. package/dist/api/resources/consentPurposes.js +39 -0
  12. package/dist/api/resources/consentPurposes.js.map +1 -0
  13. package/dist/api/resources/customFields.d.ts +15 -0
  14. package/dist/api/resources/customFields.d.ts.map +1 -0
  15. package/dist/api/resources/customFields.js +41 -0
  16. package/dist/api/resources/customFields.js.map +1 -0
  17. package/dist/api/resources/decisions.d.ts +2 -0
  18. package/dist/api/resources/decisions.d.ts.map +1 -1
  19. package/dist/api/resources/decisions.js +1 -0
  20. package/dist/api/resources/decisions.js.map +1 -1
  21. package/dist/api/resources/emailDomainLinker.d.ts +16 -0
  22. package/dist/api/resources/emailDomainLinker.d.ts.map +1 -0
  23. package/dist/api/resources/emailDomainLinker.js +16 -0
  24. package/dist/api/resources/emailDomainLinker.js.map +1 -0
  25. package/dist/api/resources/enquiries.d.ts +39 -0
  26. package/dist/api/resources/enquiries.d.ts.map +1 -0
  27. package/dist/api/resources/enquiries.js +68 -0
  28. package/dist/api/resources/enquiries.js.map +1 -0
  29. package/dist/api/resources/forms.d.ts +21 -1
  30. package/dist/api/resources/forms.d.ts.map +1 -1
  31. package/dist/api/resources/forms.js +44 -17
  32. package/dist/api/resources/forms.js.map +1 -1
  33. package/dist/api/resources/handbookPages.d.ts +1 -1
  34. package/dist/api/resources/handbookPages.js +1 -1
  35. package/dist/api/resources/importJobs.d.ts +5 -0
  36. package/dist/api/resources/importJobs.d.ts.map +1 -1
  37. package/dist/api/resources/importJobs.js +3 -0
  38. package/dist/api/resources/importJobs.js.map +1 -1
  39. package/dist/api/resources/listMembers.d.ts +27 -0
  40. package/dist/api/resources/listMembers.d.ts.map +1 -0
  41. package/dist/api/resources/listMembers.js +108 -0
  42. package/dist/api/resources/listMembers.js.map +1 -0
  43. package/dist/api/resources/lists.d.ts +14 -0
  44. package/dist/api/resources/lists.d.ts.map +1 -0
  45. package/dist/api/resources/lists.js +34 -0
  46. package/dist/api/resources/lists.js.map +1 -0
  47. package/dist/api/resources/opportunities.d.ts +2 -0
  48. package/dist/api/resources/opportunities.d.ts.map +1 -1
  49. package/dist/api/resources/opportunities.js +1 -0
  50. package/dist/api/resources/opportunities.js.map +1 -1
  51. package/dist/api/resources/people.d.ts.map +1 -1
  52. package/dist/api/resources/people.js +4 -1
  53. package/dist/api/resources/people.js.map +1 -1
  54. package/dist/api/resources/roles.d.ts +2 -0
  55. package/dist/api/resources/roles.d.ts.map +1 -1
  56. package/dist/api/resources/roles.js +1 -1
  57. package/dist/api/resources/roles.js.map +1 -1
  58. package/dist/api/resources/sampleData.d.ts +17 -0
  59. package/dist/api/resources/sampleData.d.ts.map +1 -0
  60. package/dist/api/resources/sampleData.js +31 -0
  61. package/dist/api/resources/sampleData.js.map +1 -0
  62. package/dist/api/resources/webhooks.d.ts.map +1 -1
  63. package/dist/api/resources/webhooks.js +7 -22
  64. package/dist/api/resources/webhooks.js.map +1 -1
  65. package/dist/app/KelpieApp.d.ts.map +1 -1
  66. package/dist/app/KelpieApp.js +9 -1
  67. package/dist/app/KelpieApp.js.map +1 -1
  68. package/dist/components/ActivitiesPanel.d.ts.map +1 -1
  69. package/dist/components/ActivitiesPanel.js +3 -1
  70. package/dist/components/ActivitiesPanel.js.map +1 -1
  71. package/dist/components/ColumnPicker.d.ts +24 -0
  72. package/dist/components/ColumnPicker.d.ts.map +1 -0
  73. package/dist/components/ColumnPicker.js +56 -0
  74. package/dist/components/ColumnPicker.js.map +1 -0
  75. package/dist/components/CustomFieldsPanel.d.ts +23 -0
  76. package/dist/components/CustomFieldsPanel.d.ts.map +1 -0
  77. package/dist/components/CustomFieldsPanel.js +116 -0
  78. package/dist/components/CustomFieldsPanel.js.map +1 -0
  79. package/dist/components/DataTable.d.ts +16 -1
  80. package/dist/components/DataTable.d.ts.map +1 -1
  81. package/dist/components/DataTable.js +51 -10
  82. package/dist/components/DataTable.js.map +1 -1
  83. package/dist/components/DecisionsPanel.d.ts.map +1 -1
  84. package/dist/components/DecisionsPanel.js +4 -3
  85. package/dist/components/DecisionsPanel.js.map +1 -1
  86. package/dist/components/EntitySearch.d.ts.map +1 -1
  87. package/dist/components/EntitySearch.js +11 -5
  88. package/dist/components/EntitySearch.js.map +1 -1
  89. package/dist/components/FormsPanel.d.ts +18 -0
  90. package/dist/components/FormsPanel.d.ts.map +1 -0
  91. package/dist/components/FormsPanel.js +37 -0
  92. package/dist/components/FormsPanel.js.map +1 -0
  93. package/dist/components/ListsPanel.d.ts +14 -0
  94. package/dist/components/ListsPanel.d.ts.map +1 -0
  95. package/dist/components/ListsPanel.js +56 -0
  96. package/dist/components/ListsPanel.js.map +1 -0
  97. package/dist/components/NotesPanel.d.ts.map +1 -1
  98. package/dist/components/NotesPanel.js +6 -5
  99. package/dist/components/NotesPanel.js.map +1 -1
  100. package/dist/components/Paginator.d.ts +26 -0
  101. package/dist/components/Paginator.d.ts.map +1 -0
  102. package/dist/components/Paginator.js +16 -0
  103. package/dist/components/Paginator.js.map +1 -0
  104. package/dist/components/PlanAttention.d.ts +3 -2
  105. package/dist/components/PlanAttention.d.ts.map +1 -1
  106. package/dist/components/PlanAttention.js +7 -3
  107. package/dist/components/PlanAttention.js.map +1 -1
  108. package/dist/components/PlanPanel.d.ts.map +1 -1
  109. package/dist/components/PlanPanel.js +2 -1
  110. package/dist/components/PlanPanel.js.map +1 -1
  111. package/dist/components/Shell.d.ts.map +1 -1
  112. package/dist/components/Shell.js +4 -0
  113. package/dist/components/Shell.js.map +1 -1
  114. package/dist/components/useHasCustomFields.d.ts +13 -0
  115. package/dist/components/useHasCustomFields.d.ts.map +1 -0
  116. package/dist/components/useHasCustomFields.js +16 -0
  117. package/dist/components/useHasCustomFields.js.map +1 -0
  118. package/dist/index.d.ts +8 -2
  119. package/dist/index.d.ts.map +1 -1
  120. package/dist/index.js +4 -1
  121. package/dist/index.js.map +1 -1
  122. package/dist/lib/listView.d.ts +15 -0
  123. package/dist/lib/listView.d.ts.map +1 -0
  124. package/dist/lib/listView.js +66 -0
  125. package/dist/lib/listView.js.map +1 -0
  126. package/dist/lib/sort.d.ts +43 -0
  127. package/dist/lib/sort.d.ts.map +1 -0
  128. package/dist/lib/sort.js +102 -0
  129. package/dist/lib/sort.js.map +1 -0
  130. package/dist/pages/CompaniesPage.d.ts.map +1 -1
  131. package/dist/pages/CompaniesPage.js +88 -3
  132. package/dist/pages/CompaniesPage.js.map +1 -1
  133. package/dist/pages/CompanyDetail.d.ts.map +1 -1
  134. package/dist/pages/CompanyDetail.js +40 -15
  135. package/dist/pages/CompanyDetail.js.map +1 -1
  136. package/dist/pages/DealDetail.d.ts.map +1 -1
  137. package/dist/pages/DealDetail.js +20 -1
  138. package/dist/pages/DealDetail.js.map +1 -1
  139. package/dist/pages/DealsPage.d.ts.map +1 -1
  140. package/dist/pages/DealsPage.js +134 -14
  141. package/dist/pages/DealsPage.js.map +1 -1
  142. package/dist/pages/DecisionsPage.d.ts.map +1 -1
  143. package/dist/pages/DecisionsPage.js +52 -8
  144. package/dist/pages/DecisionsPage.js.map +1 -1
  145. package/dist/pages/EnquiriesPage.d.ts +2 -0
  146. package/dist/pages/EnquiriesPage.d.ts.map +1 -0
  147. package/dist/pages/EnquiriesPage.js +251 -0
  148. package/dist/pages/EnquiriesPage.js.map +1 -0
  149. package/dist/pages/EnquiryDetail.d.ts +2 -0
  150. package/dist/pages/EnquiryDetail.d.ts.map +1 -0
  151. package/dist/pages/EnquiryDetail.js +207 -0
  152. package/dist/pages/EnquiryDetail.js.map +1 -0
  153. package/dist/pages/EnquiryStageSettingsPage.d.ts +3 -0
  154. package/dist/pages/EnquiryStageSettingsPage.d.ts.map +1 -0
  155. package/dist/pages/EnquiryStageSettingsPage.js +34 -0
  156. package/dist/pages/EnquiryStageSettingsPage.js.map +1 -0
  157. package/dist/pages/FormDetail.d.ts.map +1 -1
  158. package/dist/pages/FormDetail.js +19 -1
  159. package/dist/pages/FormDetail.js.map +1 -1
  160. package/dist/pages/FormsPage.d.ts.map +1 -1
  161. package/dist/pages/FormsPage.js +53 -2
  162. package/dist/pages/FormsPage.js.map +1 -1
  163. package/dist/pages/FundraisingPage.d.ts.map +1 -1
  164. package/dist/pages/FundraisingPage.js +116 -14
  165. package/dist/pages/FundraisingPage.js.map +1 -1
  166. package/dist/pages/HiringPage.d.ts.map +1 -1
  167. package/dist/pages/HiringPage.js +27 -4
  168. package/dist/pages/HiringPage.js.map +1 -1
  169. package/dist/pages/ListDetail.d.ts +11 -0
  170. package/dist/pages/ListDetail.d.ts.map +1 -0
  171. package/dist/pages/ListDetail.js +214 -0
  172. package/dist/pages/ListDetail.js.map +1 -0
  173. package/dist/pages/ListsPage.d.ts +9 -0
  174. package/dist/pages/ListsPage.d.ts.map +1 -0
  175. package/dist/pages/ListsPage.js +125 -0
  176. package/dist/pages/ListsPage.js.map +1 -0
  177. package/dist/pages/OpportunitiesPage.d.ts.map +1 -1
  178. package/dist/pages/OpportunitiesPage.js +97 -14
  179. package/dist/pages/OpportunitiesPage.js.map +1 -1
  180. package/dist/pages/OpportunityDetail.d.ts.map +1 -1
  181. package/dist/pages/OpportunityDetail.js +64 -3
  182. package/dist/pages/OpportunityDetail.js.map +1 -1
  183. package/dist/pages/PartnershipDetail.d.ts.map +1 -1
  184. package/dist/pages/PartnershipDetail.js +19 -1
  185. package/dist/pages/PartnershipDetail.js.map +1 -1
  186. package/dist/pages/PartnershipsPage.d.ts.map +1 -1
  187. package/dist/pages/PartnershipsPage.js +111 -14
  188. package/dist/pages/PartnershipsPage.js.map +1 -1
  189. package/dist/pages/PeoplePage.d.ts.map +1 -1
  190. package/dist/pages/PeoplePage.js +119 -3
  191. package/dist/pages/PeoplePage.js.map +1 -1
  192. package/dist/pages/PersonDetail.d.ts.map +1 -1
  193. package/dist/pages/PersonDetail.js +67 -5
  194. package/dist/pages/PersonDetail.js.map +1 -1
  195. package/dist/pages/PlanningPage.d.ts.map +1 -1
  196. package/dist/pages/PlanningPage.js +88 -17
  197. package/dist/pages/PlanningPage.js.map +1 -1
  198. package/dist/pages/RaiseDetail.d.ts.map +1 -1
  199. package/dist/pages/RaiseDetail.js +13 -1
  200. package/dist/pages/RaiseDetail.js.map +1 -1
  201. package/dist/pages/RoleDetail.d.ts.map +1 -1
  202. package/dist/pages/RoleDetail.js +5 -4
  203. package/dist/pages/RoleDetail.js.map +1 -1
  204. package/dist/pages/SearchPage.d.ts.map +1 -1
  205. package/dist/pages/SearchPage.js +1 -0
  206. package/dist/pages/SearchPage.js.map +1 -1
  207. package/dist/pages/admin/DataPage.d.ts.map +1 -1
  208. package/dist/pages/admin/DataPage.js +126 -13
  209. package/dist/pages/admin/DataPage.js.map +1 -1
  210. package/dist/pages/admin/FieldsPage.d.ts +2 -0
  211. package/dist/pages/admin/FieldsPage.d.ts.map +1 -0
  212. package/dist/pages/admin/FieldsPage.js +164 -0
  213. package/dist/pages/admin/FieldsPage.js.map +1 -0
  214. package/dist/pages/admin/PrivacyPage.d.ts +9 -0
  215. package/dist/pages/admin/PrivacyPage.d.ts.map +1 -0
  216. package/dist/pages/admin/PrivacyPage.js +170 -0
  217. package/dist/pages/admin/PrivacyPage.js.map +1 -0
  218. package/dist/pages/admin/WebhookDeliveries.d.ts.map +1 -1
  219. package/dist/pages/admin/WebhookDeliveries.js +3 -2
  220. package/dist/pages/admin/WebhookDeliveries.js.map +1 -1
  221. package/dist/pages/admin/WebhooksPage.d.ts.map +1 -1
  222. package/dist/pages/admin/WebhooksPage.js +3 -2
  223. package/dist/pages/admin/WebhooksPage.js.map +1 -1
  224. package/dist/pages/admin/WorkspaceSettingsPage.d.ts.map +1 -1
  225. package/dist/pages/admin/WorkspaceSettingsPage.js +45 -2
  226. package/dist/pages/admin/WorkspaceSettingsPage.js.map +1 -1
  227. package/dist/pages/dashboard/attention.d.ts.map +1 -1
  228. package/dist/pages/dashboard/attention.js +3 -0
  229. package/dist/pages/dashboard/attention.js.map +1 -1
  230. package/dist/pages/forms/AddFieldMenu.d.ts +17 -0
  231. package/dist/pages/forms/AddFieldMenu.d.ts.map +1 -0
  232. package/dist/pages/forms/AddFieldMenu.js +43 -0
  233. package/dist/pages/forms/AddFieldMenu.js.map +1 -0
  234. package/dist/pages/forms/EmbedPanel.d.ts +4 -0
  235. package/dist/pages/forms/EmbedPanel.d.ts.map +1 -1
  236. package/dist/pages/forms/EmbedPanel.js +62 -3
  237. package/dist/pages/forms/EmbedPanel.js.map +1 -1
  238. package/dist/pages/forms/FieldsEditor.d.ts.map +1 -1
  239. package/dist/pages/forms/FieldsEditor.js +179 -36
  240. package/dist/pages/forms/FieldsEditor.js.map +1 -1
  241. package/dist/pages/forms/FormActions.d.ts +6 -0
  242. package/dist/pages/forms/FormActions.d.ts.map +1 -0
  243. package/dist/pages/forms/FormActions.js +149 -0
  244. package/dist/pages/forms/FormActions.js.map +1 -0
  245. package/dist/pages/forms/FormSettings.d.ts.map +1 -1
  246. package/dist/pages/forms/FormSettings.js +15 -19
  247. package/dist/pages/forms/FormSettings.js.map +1 -1
  248. package/dist/pages/forms/SubmissionDetail.d.ts +2 -0
  249. package/dist/pages/forms/SubmissionDetail.d.ts.map +1 -0
  250. package/dist/pages/forms/SubmissionDetail.js +76 -0
  251. package/dist/pages/forms/SubmissionDetail.js.map +1 -0
  252. package/dist/pages/forms/SubmissionsTable.d.ts.map +1 -1
  253. package/dist/pages/forms/SubmissionsTable.js +57 -8
  254. package/dist/pages/forms/SubmissionsTable.js.map +1 -1
  255. package/dist/pages/forms/fieldList.d.ts +11 -5
  256. package/dist/pages/forms/fieldList.d.ts.map +1 -1
  257. package/dist/pages/forms/fieldList.js +66 -6
  258. package/dist/pages/forms/fieldList.js.map +1 -1
  259. package/dist/pages/forms/template.d.ts +17 -0
  260. package/dist/pages/forms/template.d.ts.map +1 -1
  261. package/dist/pages/forms/template.js +93 -0
  262. package/dist/pages/forms/template.js.map +1 -1
  263. package/dist/pages/handbook/HandbookPage.js +1 -1
  264. package/dist/pages/hiringDirectory.js +2 -2
  265. package/dist/pages/onboarding/WorkspaceStepPage.d.ts.map +1 -1
  266. package/dist/pages/onboarding/WorkspaceStepPage.js +15 -2
  267. package/dist/pages/onboarding/WorkspaceStepPage.js.map +1 -1
  268. package/dist/pages/positionDirectory.js +2 -2
  269. package/package.json +2 -2
  270. package/src/api/resource.ts +155 -32
  271. package/src/api/resources/companies.ts +7 -1
  272. package/src/api/resources/consentPurposes.ts +83 -0
  273. package/src/api/resources/customFields.ts +88 -0
  274. package/src/api/resources/decisions.ts +3 -0
  275. package/src/api/resources/emailDomainLinker.ts +43 -0
  276. package/src/api/resources/enquiries.ts +118 -0
  277. package/src/api/resources/forms.ts +59 -19
  278. package/src/api/resources/handbookPages.ts +1 -1
  279. package/src/api/resources/importJobs.ts +9 -0
  280. package/src/api/resources/listMembers.ts +173 -0
  281. package/src/api/resources/lists.ts +61 -0
  282. package/src/api/resources/opportunities.ts +3 -0
  283. package/src/api/resources/people.ts +4 -1
  284. package/src/api/resources/roles.ts +3 -1
  285. package/src/api/resources/sampleData.ts +57 -0
  286. package/src/api/resources/webhooks.ts +8 -24
  287. package/src/app/KelpieApp.tsx +16 -0
  288. package/src/components/ActivitiesPanel.tsx +3 -10
  289. package/src/components/ColumnPicker.tsx +124 -0
  290. package/src/components/CustomFieldsPanel.tsx +330 -0
  291. package/src/components/DataTable.tsx +89 -10
  292. package/src/components/DecisionsPanel.tsx +4 -12
  293. package/src/components/EntitySearch.tsx +12 -5
  294. package/src/components/FormsPanel.tsx +86 -0
  295. package/src/components/ListsPanel.tsx +176 -0
  296. package/src/components/NotesPanel.tsx +6 -14
  297. package/src/components/Paginator.tsx +78 -0
  298. package/src/components/PlanAttention.tsx +20 -3
  299. package/src/components/PlanPanel.tsx +2 -10
  300. package/src/components/Shell.tsx +4 -0
  301. package/src/components/useHasCustomFields.ts +18 -0
  302. package/src/index.ts +10 -1
  303. package/src/lib/listView.ts +149 -0
  304. package/src/lib/sort.ts +143 -0
  305. package/src/pages/CompaniesPage.tsx +125 -11
  306. package/src/pages/CompanyDetail.tsx +98 -63
  307. package/src/pages/DealDetail.tsx +34 -0
  308. package/src/pages/DealsPage.tsx +178 -24
  309. package/src/pages/DecisionsPage.tsx +75 -18
  310. package/src/pages/EnquiriesPage.tsx +434 -0
  311. package/src/pages/EnquiryDetail.tsx +554 -0
  312. package/src/pages/EnquiryStageSettingsPage.tsx +63 -0
  313. package/src/pages/FormDetail.tsx +23 -1
  314. package/src/pages/FormsPage.tsx +81 -17
  315. package/src/pages/FundraisingPage.tsx +152 -24
  316. package/src/pages/HiringPage.tsx +39 -11
  317. package/src/pages/ListDetail.tsx +470 -0
  318. package/src/pages/ListsPage.tsx +281 -0
  319. package/src/pages/OpportunitiesPage.tsx +130 -24
  320. package/src/pages/OpportunityDetail.tsx +168 -2
  321. package/src/pages/PartnershipDetail.tsx +35 -0
  322. package/src/pages/PartnershipsPage.tsx +149 -24
  323. package/src/pages/PeoplePage.tsx +157 -11
  324. package/src/pages/PersonDetail.tsx +157 -6
  325. package/src/pages/PlanningPage.tsx +259 -29
  326. package/src/pages/RaiseDetail.tsx +24 -0
  327. package/src/pages/RoleDetail.tsx +21 -26
  328. package/src/pages/SearchPage.tsx +1 -0
  329. package/src/pages/admin/DataPage.tsx +324 -13
  330. package/src/pages/admin/FieldsPage.tsx +455 -0
  331. package/src/pages/admin/PrivacyPage.tsx +461 -0
  332. package/src/pages/admin/WebhookDeliveries.tsx +10 -15
  333. package/src/pages/admin/WebhooksPage.tsx +14 -19
  334. package/src/pages/admin/WorkspaceSettingsPage.tsx +134 -2
  335. package/src/pages/dashboard/attention.ts +3 -0
  336. package/src/pages/forms/AddFieldMenu.tsx +121 -0
  337. package/src/pages/forms/EmbedPanel.tsx +163 -2
  338. package/src/pages/forms/FieldsEditor.tsx +539 -165
  339. package/src/pages/forms/FormActions.tsx +552 -0
  340. package/src/pages/forms/FormSettings.tsx +29 -70
  341. package/src/pages/forms/SubmissionDetail.tsx +249 -0
  342. package/src/pages/forms/SubmissionsTable.tsx +112 -26
  343. package/src/pages/forms/fieldList.ts +68 -5
  344. package/src/pages/forms/template.ts +103 -0
  345. package/src/pages/handbook/HandbookPage.tsx +1 -1
  346. package/src/pages/hiringDirectory.ts +2 -2
  347. package/src/pages/onboarding/WorkspaceStepPage.tsx +31 -3
  348. package/src/pages/positionDirectory.ts +2 -2
  349. package/src/styles.css +3 -3
@@ -10,12 +10,20 @@ import type { DragEndEvent } from '@dnd-kit/core'
10
10
  import { SortableContext, useSortable, verticalListSortingStrategy } from '@dnd-kit/sortable'
11
11
  import { CSS } from '@dnd-kit/utilities'
12
12
  import { FORM_FIELD_MAP_TARGET_LABELS, FORM_OPTION_VALUE_TYPES } from '@kelpie/schemas'
13
- import type { Form, FormFieldMapTarget, FormFieldOptionInput, FormFieldType } from '@kelpie/schemas'
14
- import { useEffect, useState } from 'react'
15
-
13
+ import type {
14
+ Form,
15
+ FormFieldInput,
16
+ FormFieldMapTarget,
17
+ FormFieldOptionInput,
18
+ FormFieldType,
19
+ } from '@kelpie/schemas'
20
+ import { useEffect, useRef, useState } from 'react'
21
+
22
+ import { useConsentPurposes } from '../../api/resources/consentPurposes.ts'
23
+ import type { ConsentPurpose } from '@kelpie/schemas'
16
24
  import { useUpdateFormFields } from '../../api/resources/forms.ts'
17
25
  import { ErrorPanel } from '../../components/QueryState.tsx'
18
- import { SectionHeader } from '../../components/SectionHeader.tsx'
26
+ import { AddFieldMenu } from './AddFieldMenu.tsx'
19
27
  import {
20
28
  FIELD_TYPE_OPTIONS,
21
29
  MAP_TARGET_OPTIONS,
@@ -31,10 +39,14 @@ import {
31
39
  typeForTarget,
32
40
  } from './fieldList.ts'
33
41
  import type { EditableField } from './fieldList.ts'
34
- import { NEW_FIELD } from './template.ts'
35
42
 
36
43
  /**
37
- * The field builder.
44
+ * The field builder: the form as a visitor will see it, edited in place.
45
+ *
46
+ * The left column renders the fields the way the embed does — labels, required
47
+ * markers, placeholders, the Submit button. Clicking a field selects it and the
48
+ * panel beside the preview edits its settings; the anchor that appears on hover
49
+ * drags it up and down; "Add field" offers ready-made fields to append.
38
50
  *
39
51
  * Explicitly saved rather than saved per keystroke, unlike the inline editing
40
52
  * everywhere else in the app. A write replaces the whole list, so an
@@ -64,20 +76,37 @@ export interface FieldsEditorProps {
64
76
 
65
77
  export function FieldsEditor({ form }: FieldsEditorProps): React.JSX.Element {
66
78
  const [fields, setFields] = useState<EditableField[]>(() => toEditableFields(form))
79
+ const [selectedId, setSelectedId] = useState<string | null>(null)
67
80
  const updateFields = useUpdateFormFields()
81
+ const fieldsRef = useRef<readonly EditableField[]>(fields)
82
+ fieldsRef.current = fields
68
83
  const sensors = useSensors(
69
84
  useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
70
85
  useSensor(KeyboardSensor),
71
86
  )
72
87
 
73
88
  // A save answers with the stored list, ids and all. Re-reading it here is what
74
- // turns the local ids of newly added fields into the real ones.
89
+ // turns the local ids of newly added fields into the real ones. The selection
90
+ // survives by position: a just-added field comes back at the same index under
91
+ // the id the server gave it.
75
92
  useEffect(() => {
76
- setFields(toEditableFields(form))
93
+ const next = toEditableFields(form)
94
+
95
+ setSelectedId((current) => {
96
+ if (current === null || next.some((field) => field.id === current)) {
97
+ return current
98
+ }
99
+
100
+ const index = fieldsRef.current.findIndex((field) => field.id === current)
101
+
102
+ return index >= 0 ? (next[index]?.id ?? null) : null
103
+ })
104
+ setFields(next)
77
105
  }, [form])
78
106
 
79
107
  const problems = findProblems(fields, form.createDeal)
80
108
  const changed = fieldsChanged(form, fields)
109
+ const selected = fields.find((field) => field.id === selectedId)
81
110
 
82
111
  function onDragEnd(event: DragEndEvent): void {
83
112
  const overId = event.over?.id
@@ -87,20 +116,29 @@ export function FieldsEditor({ form }: FieldsEditorProps): React.JSX.Element {
87
116
  }
88
117
  }
89
118
 
119
+ function addField(input: FormFieldInput): void {
120
+ const added: EditableField = { ...input, id: localFieldId() }
121
+
122
+ setFields((current) => insertField(current, added, selectedId))
123
+ setSelectedId(added.id)
124
+ }
125
+
90
126
  function save(): void {
91
127
  updateFields.run({ id: form.id, fields: toFieldInputs(fields) })
92
128
  }
93
129
 
94
130
  return (
95
- <div className="max-w-3xl">
96
- <SectionHeader
97
- title="Fields"
98
- description="Drag to reorder. What a field maps to decides what a submission writes."
99
- onAdd={() => {
100
- setFields((current) => insertField(current, { ...NEW_FIELD, id: localFieldId() }, null))
101
- }}
102
- addLabel="Add field"
103
- />
131
+ <div>
132
+ <div className="mb-3 flex items-start justify-between gap-2">
133
+ <div className="min-w-0">
134
+ <h2 className="text-[13px] font-semibold text-ink">Fields</h2>
135
+ <p className="mt-0.5 text-[11px] text-ink-faint">
136
+ Click a field to edit it. Drag the anchor to reorder. What a field maps to decides what
137
+ a submission writes.
138
+ </p>
139
+ </div>
140
+ <AddFieldMenu fields={fields} onAdd={addField} />
141
+ </div>
104
142
 
105
143
  {problems.list.map((problem) => (
106
144
  <p key={problem} className="mb-2 text-[12px] text-danger">
@@ -108,36 +146,64 @@ export function FieldsEditor({ form }: FieldsEditorProps): React.JSX.Element {
108
146
  </p>
109
147
  ))}
110
148
 
111
- <DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={onDragEnd}>
112
- <SortableContext items={fields.map((field) => field.id)} strategy={verticalListSortingStrategy}>
113
- <ul className="flex flex-col gap-2">
114
- {fields.map((field) => (
115
- <SortableField
116
- key={field.id}
117
- field={field}
118
- problem={problems.byField.get(field.id)}
119
- onChange={(change) => {
120
- setFields((current) => editField(current, field.id, change))
121
- }}
122
- onRemove={() => {
123
- setFields((current) => removeField(current, field.id))
124
- }}
125
- onAddBelow={() => {
126
- setFields((current) =>
127
- insertField(current, { ...NEW_FIELD, id: localFieldId() }, field.id),
128
- )
129
- }}
130
- />
131
- ))}
132
- </ul>
133
- </SortableContext>
134
- </DndContext>
149
+ <div className="grid items-start gap-6 lg:grid-cols-[minmax(0,1fr)_300px]">
150
+ <div className="rounded-lg border border-border bg-surface-raised p-4 sm:p-6">
151
+ {fields.length === 0 ? (
152
+ <p className="rounded-md border border-dashed border-border px-6 py-10 text-center text-[13px] text-ink-muted">
153
+ No fields yet. A form needs at least an email field.
154
+ </p>
155
+ ) : (
156
+ <DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={onDragEnd}>
157
+ <SortableContext
158
+ items={fields.map((field) => field.id)}
159
+ strategy={verticalListSortingStrategy}
160
+ >
161
+ <ul className="flex flex-col gap-1">
162
+ {fields.map((field) => (
163
+ <PreviewField
164
+ key={field.id}
165
+ field={field}
166
+ selected={field.id === selectedId}
167
+ problem={problems.byField.get(field.id)}
168
+ onSelect={() => {
169
+ setSelectedId(field.id)
170
+ }}
171
+ />
172
+ ))}
173
+ </ul>
174
+ </SortableContext>
175
+ </DndContext>
176
+ )}
177
+
178
+ <div className="mt-4 pl-7" aria-hidden="true">
179
+ <span className="pointer-events-none inline-flex rounded-md bg-accent px-4 py-2 text-[13px] font-semibold text-accent-fg">
180
+ Submit
181
+ </span>
182
+ </div>
183
+ </div>
135
184
 
136
- {fields.length === 0 && (
137
- <p className="rounded-md border border-dashed border-border px-6 py-10 text-center text-[13px] text-ink-muted">
138
- No fields yet. A form needs at least an email field.
139
- </p>
140
- )}
185
+ {selected === undefined ? (
186
+ <aside className="rounded-lg border border-dashed border-border p-4 text-[13px] text-ink-muted lg:sticky lg:top-4">
187
+ Select a field in the preview to edit its label, mapping, and type. "Add field" appends
188
+ a new one.
189
+ </aside>
190
+ ) : (
191
+ <FieldSettings
192
+ field={selected}
193
+ problem={problems.byField.get(selected.id)}
194
+ onChange={(change) => {
195
+ setFields((current) => editField(current, selected.id, change))
196
+ }}
197
+ onRemove={() => {
198
+ setFields((current) => removeField(current, selected.id))
199
+ setSelectedId(null)
200
+ }}
201
+ onClose={() => {
202
+ setSelectedId(null)
203
+ }}
204
+ />
205
+ )}
206
+ </div>
141
207
 
142
208
  {updateFields.error !== null && (
143
209
  <div className="mt-3">
@@ -159,6 +225,7 @@ export function FieldsEditor({ form }: FieldsEditorProps): React.JSX.Element {
159
225
  type="button"
160
226
  onClick={() => {
161
227
  setFields(toEditableFields(form))
228
+ setSelectedId(null)
162
229
  }}
163
230
  className="text-[12px] font-medium text-ink-muted transition hover:text-ink"
164
231
  >
@@ -171,21 +238,20 @@ export function FieldsEditor({ form }: FieldsEditorProps): React.JSX.Element {
171
238
  )
172
239
  }
173
240
 
174
- interface SortableFieldProps {
241
+ interface PreviewFieldProps {
175
242
  readonly field: EditableField
243
+ readonly selected: boolean
176
244
  readonly problem: string | undefined
177
- readonly onChange: (change: Partial<EditableField>) => void
178
- readonly onRemove: () => void
179
- readonly onAddBelow: () => void
245
+ readonly onSelect: () => void
180
246
  }
181
247
 
182
- function SortableField({
183
- field,
184
- problem,
185
- onChange,
186
- onRemove,
187
- onAddBelow,
188
- }: SortableFieldProps): React.JSX.Element {
248
+ /**
249
+ * One field, rendered the way the embed renders it and inert: the control never
250
+ * takes input here. A full-row button on top is what selects the field, so the
251
+ * click target is the whole thing a visitor would see, and the drag anchor
252
+ * beside the row is the one place a press means "move" instead of "edit".
253
+ */
254
+ function PreviewField({ field, selected, problem, onSelect }: PreviewFieldProps): React.JSX.Element {
189
255
  const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
190
256
  id: field.id,
191
257
  })
@@ -198,124 +264,318 @@ function SortableField({
198
264
  transition,
199
265
  opacity: isDragging ? 0.35 : 1,
200
266
  }}
201
- className={[
202
- 'rounded-md border bg-surface-raised p-3',
203
- problem === undefined ? 'border-border' : 'border-danger/50',
204
- ].join(' ')}
267
+ className="group flex items-start gap-0.5"
205
268
  >
206
- <div className="flex items-start gap-2">
269
+ <button
270
+ type="button"
271
+ className="mt-8 shrink-0 cursor-grab touch-none rounded-md px-1 py-1 text-[10px] leading-none text-ink-faint opacity-0 transition hover:text-ink focus-visible:opacity-100 active:cursor-grabbing group-hover:opacity-100"
272
+ aria-label={`Drag ${field.label} to reorder`}
273
+ {...attributes}
274
+ {...listeners}
275
+ >
276
+ ⋮⋮
277
+ </button>
278
+
279
+ <div
280
+ className={[
281
+ 'relative min-w-0 flex-1 rounded-md p-2.5 transition',
282
+ selected
283
+ ? 'ring-2 ring-accent'
284
+ : problem === undefined
285
+ ? 'ring-1 ring-transparent group-hover:ring-border-strong'
286
+ : 'ring-1 ring-danger/50',
287
+ ].join(' ')}
288
+ >
289
+ <button
290
+ type="button"
291
+ onClick={onSelect}
292
+ aria-label={`Edit ${field.label}`}
293
+ aria-pressed={selected}
294
+ className="absolute inset-0 z-10 cursor-pointer rounded-md"
295
+ />
296
+
297
+ <div className="pointer-events-none">
298
+ <span className="mb-1 block text-[13px] font-medium text-ink">
299
+ {field.label}
300
+ {(field.required ?? false) && <span className="ml-0.5 text-danger">*</span>}
301
+ </span>
302
+ <PreviewControl field={field} />
303
+ {problem !== undefined && <p className="mt-1.5 text-[12px] text-danger">{problem}</p>}
304
+ </div>
305
+ </div>
306
+ </li>
307
+ )
308
+ }
309
+
310
+ /** The control itself, matching what the embed serves for each field type. */
311
+ function PreviewControl({ field }: { readonly field: EditableField }): React.JSX.Element {
312
+ const controlClass =
313
+ 'w-full rounded-md border border-border bg-surface px-3 py-2 text-[13px] text-ink placeholder:text-ink-faint'
314
+
315
+ if (field.type === 'textarea') {
316
+ return (
317
+ <textarea
318
+ readOnly
319
+ tabIndex={-1}
320
+ rows={3}
321
+ placeholder={field.placeholder ?? undefined}
322
+ className={controlClass}
323
+ />
324
+ )
325
+ }
326
+
327
+ if (field.type === 'select') {
328
+ const options = field.options ?? []
329
+
330
+ // Remounted whenever the choices change, so the uncontrolled select cannot
331
+ // show a choice that no longer exists. The embed gives a non-required
332
+ // select a blank "—" first, and the preview shows the same.
333
+ return (
334
+ <select
335
+ key={options.map((option) => option.key).join('\n')}
336
+ tabIndex={-1}
337
+ defaultValue=""
338
+ className={controlClass}
339
+ >
340
+ {(field.required ?? false) === false && <option value="">—</option>}
341
+ {options.map((option) => (
342
+ <option key={option.key} value={option.key}>
343
+ {option.value}
344
+ </option>
345
+ ))}
346
+ </select>
347
+ )
348
+ }
349
+
350
+ if (field.type === 'consent') {
351
+ return <ConsentPreview field={field} />
352
+ }
353
+
354
+ if (field.type === 'notice') {
355
+ return <NoticePreview field={field} />
356
+ }
357
+
358
+ return (
359
+ <input
360
+ readOnly
361
+ tabIndex={-1}
362
+ type={field.type === 'email' ? 'email' : 'text'}
363
+ placeholder={field.placeholder ?? undefined}
364
+ className={controlClass}
365
+ />
366
+ )
367
+ }
368
+
369
+ /**
370
+ * The consent preview — matches what the visitor sees: a statement above,
371
+ * one checkbox per selected purpose below, each labelled by the purpose. The
372
+ * label above (in FieldRow) still shows the field heading.
373
+ */
374
+ /**
375
+ * The notice preview — prose only, styled like the hosted embed. There is no
376
+ * checkbox because a notice is granted implicitly by submission.
377
+ */
378
+ function NoticePreview({ field }: { readonly field: EditableField }): React.JSX.Element {
379
+ const statement = (field.statement ?? '').trim()
380
+ return (
381
+ <div>
382
+ {statement.length === 0 ? (
383
+ <p className="text-[11px] italic text-ink-faint">
384
+ Add a statement in the settings panel — the notice text the visitor reads.
385
+ </p>
386
+ ) : (
387
+ <p className="rounded-md border border-border bg-surface px-3 py-2 text-[12px] leading-snug text-ink-muted whitespace-pre-line">
388
+ {statement}
389
+ </p>
390
+ )}
391
+ </div>
392
+ )
393
+ }
394
+
395
+ function ConsentPreview({ field }: { readonly field: EditableField }): React.JSX.Element {
396
+ const purposes = useConsentPurposes({ sort: 'sort_order', limit: 200 })
397
+ const labelById = new Map<string, string>(
398
+ purposes.records.map((purpose: ConsentPurpose) => [purpose.id, purpose.label]),
399
+ )
400
+ const statement = field.statement ?? field.label
401
+ const ids = field.consentPurposeIds ?? []
402
+ const overrides = field.consentPurposeLabels ?? {}
403
+
404
+ return (
405
+ <div className="space-y-1.5">
406
+ <p className="text-[12px] text-ink-muted">{statement}</p>
407
+ {ids.length === 0 ? (
408
+ <p className="text-[11px] italic text-ink-faint">
409
+ Pick at least one purpose in the settings panel.
410
+ </p>
411
+ ) : (
412
+ ids.map((id) => (
413
+ <div key={id} className="flex items-start gap-2">
414
+ <input readOnly tabIndex={-1} type="checkbox" className="mt-0.5" />
415
+ <span className="text-[13px] text-ink">
416
+ {overrides[id] ?? labelById.get(id) ?? id}
417
+ </span>
418
+ </div>
419
+ ))
420
+ )}
421
+ </div>
422
+ )
423
+ }
424
+
425
+ interface FieldSettingsProps {
426
+ readonly field: EditableField
427
+ readonly problem: string | undefined
428
+ readonly onChange: (change: Partial<FormFieldInput>) => void
429
+ readonly onRemove: () => void
430
+ readonly onClose: () => void
431
+ }
432
+
433
+ /** The panel a selected field's settings edit in. */
434
+ function FieldSettings({
435
+ field,
436
+ problem,
437
+ onChange,
438
+ onRemove,
439
+ onClose,
440
+ }: FieldSettingsProps): React.JSX.Element {
441
+ return (
442
+ <aside className="rounded-lg border border-border bg-surface-raised p-4 lg:sticky lg:top-4">
443
+ <div className="mb-3 flex items-center justify-between gap-2">
444
+ <h3 className="text-[12px] font-semibold text-ink">Field settings</h3>
207
445
  <button
208
446
  type="button"
209
- className="cursor-grab touch-none px-1 py-2 text-[10px] text-ink-faint active:cursor-grabbing"
210
- aria-label={`Drag ${field.label} to reorder`}
211
- {...attributes}
212
- {...listeners}
447
+ onClick={onClose}
448
+ aria-label="Close field settings"
449
+ className="rounded-md px-1.5 py-1 text-[12px] text-ink-faint transition hover:text-ink"
213
450
  >
214
- ⋮⋮
451
+
215
452
  </button>
453
+ </div>
216
454
 
217
- <div className="grid flex-1 gap-2 sm:grid-cols-2">
218
- <label className="block">
219
- <span className="mb-1 block text-[11px] font-medium text-ink-faint">Label</span>
220
- <input
221
- className={inputClass}
222
- value={field.label}
223
- onChange={(event) => {
224
- onChange({ label: event.target.value })
225
- }}
226
- />
227
- </label>
228
-
229
- <label className="block">
230
- <span className="mb-1 block text-[11px] font-medium text-ink-faint">Maps to</span>
231
- <select
232
- className={inputClass}
233
- value={field.mapTo}
234
- onChange={(event) => {
235
- const mapTo = event.target.value as FormFieldMapTarget
455
+ <div className="flex flex-col gap-3">
456
+ <label className="block">
457
+ <span className="mb-1 block text-[11px] font-medium text-ink-faint">Label</span>
458
+ <input
459
+ className={inputClass}
460
+ value={field.label}
461
+ onChange={(event) => {
462
+ onChange({ label: event.target.value })
463
+ }}
464
+ />
465
+ </label>
236
466
 
237
- onChange({ mapTo, type: typeForTarget(mapTo, field.type) })
238
- }}
239
- >
240
- {MAP_TARGET_OPTIONS.map((option) => (
241
- <option key={option.value} value={option.value}>
242
- {FORM_FIELD_MAP_TARGET_LABELS[option.value]}
243
- </option>
244
- ))}
245
- </select>
246
- </label>
247
-
248
- <label className="block">
249
- <span className="mb-1 block text-[11px] font-medium text-ink-faint">Type</span>
250
- <select
251
- className={inputClass}
252
- value={field.type}
253
- onChange={(event) => {
254
- onChange({ type: event.target.value as FormFieldType })
255
- }}
256
- >
257
- {FIELD_TYPE_OPTIONS.map((option) => (
258
- <option key={option.value} value={option.value}>
259
- {option.label}
260
- </option>
261
- ))}
262
- </select>
263
- </label>
264
-
265
- <label className="block">
266
- <span className="mb-1 block text-[11px] font-medium text-ink-faint">Placeholder</span>
267
- <input
268
- className={inputClass}
269
- value={field.placeholder ?? ''}
270
- onChange={(event) => {
271
- onChange({ placeholder: event.target.value.length === 0 ? null : event.target.value })
272
- }}
273
- />
274
- </label>
275
- </div>
467
+ <label className="block">
468
+ <span className="mb-1 block text-[11px] font-medium text-ink-faint">Maps to</span>
469
+ <select
470
+ className={inputClass}
471
+ value={field.mapTo}
472
+ onChange={(event) => {
473
+ const mapTo = event.target.value as FormFieldMapTarget
276
474
 
277
- <div className="flex shrink-0 flex-col items-end gap-1">
278
- <button
279
- type="button"
280
- onClick={onRemove}
281
- aria-label={`Remove ${field.label}`}
282
- className="rounded-md px-1.5 py-1 text-[12px] text-ink-faint transition hover:text-danger"
475
+ onChange({ mapTo, type: typeForTarget(mapTo, field.type) })
476
+ }}
283
477
  >
284
-
285
- </button>
286
- <button
287
- type="button"
288
- onClick={onAddBelow}
289
- aria-label={`Add a field below ${field.label}`}
290
- className="rounded-md px-1.5 py-1 text-[12px] text-ink-faint transition hover:text-accent"
478
+ {MAP_TARGET_OPTIONS.map((option) => (
479
+ <option key={option.value} value={option.value}>
480
+ {FORM_FIELD_MAP_TARGET_LABELS[option.value]}
481
+ </option>
482
+ ))}
483
+ </select>
484
+ </label>
485
+
486
+ <label className="block">
487
+ <span className="mb-1 block text-[11px] font-medium text-ink-faint">Type</span>
488
+ <select
489
+ className={inputClass}
490
+ value={field.type}
491
+ onChange={(event) => {
492
+ onChange({ type: event.target.value as FormFieldType })
493
+ }}
291
494
  >
292
- +
293
- </button>
294
- </div>
295
- </div>
495
+ {FIELD_TYPE_OPTIONS.map((option) => (
496
+ <option key={option.value} value={option.value}>
497
+ {option.label}
498
+ </option>
499
+ ))}
500
+ </select>
501
+ </label>
296
502
 
297
- <label className="mt-2 flex items-center gap-2 pl-7 text-[12px] text-ink-muted">
298
- <input
299
- type="checkbox"
300
- checked={field.required ?? false}
301
- onChange={(event) => {
302
- onChange({ required: event.target.checked })
303
- }}
304
- />
305
- Required
306
- </label>
307
-
308
- {field.type === 'select' && (
309
- <OptionsEditor
310
- options={field.options ?? []}
311
- onChange={(options) => {
312
- onChange({ options })
313
- }}
314
- />
315
- )}
503
+ <label className="block">
504
+ <span className="mb-1 block text-[11px] font-medium text-ink-faint">Placeholder</span>
505
+ <input
506
+ className={inputClass}
507
+ value={field.placeholder ?? ''}
508
+ onChange={(event) => {
509
+ onChange({ placeholder: event.target.value.length === 0 ? null : event.target.value })
510
+ }}
511
+ />
512
+ </label>
316
513
 
317
- {problem !== undefined && <p className="mt-2 pl-7 text-[12px] text-danger">{problem}</p>}
318
- </li>
514
+ <label className="flex items-center gap-2 text-[12px] text-ink-muted">
515
+ <input
516
+ type="checkbox"
517
+ checked={field.required ?? false}
518
+ onChange={(event) => {
519
+ onChange({ required: event.target.checked })
520
+ }}
521
+ />
522
+ Required
523
+ </label>
524
+
525
+ {field.type === 'select' && (
526
+ <OptionsEditor
527
+ options={field.options ?? []}
528
+ onChange={(options) => {
529
+ onChange({ options })
530
+ }}
531
+ />
532
+ )}
533
+
534
+ {(field.type === 'consent' || field.type === 'notice') && (
535
+ <>
536
+ <label className="block">
537
+ <span className="mb-1 block text-[11px] font-medium text-ink-faint">
538
+ {field.type === 'notice'
539
+ ? 'Notice (the text the visitor reads)'
540
+ : 'Statement (intro sentence above the checkboxes)'}
541
+ </span>
542
+ <textarea
543
+ className={inputClass}
544
+ rows={field.type === 'notice' ? 5 : 3}
545
+ value={field.statement ?? ''}
546
+ onChange={(event) => {
547
+ const value = event.target.value
548
+ onChange({ statement: value.length === 0 ? null : value })
549
+ }}
550
+ placeholder={
551
+ field.type === 'notice'
552
+ ? 'By submitting this form you agree to us storing your information and using it to contact you about your enquiry.'
553
+ : 'Please tell us how we can contact you.'
554
+ }
555
+ />
556
+ </label>
557
+ <ConsentPurposePicker
558
+ mode={field.type === 'notice' ? 'notice' : 'checkbox'}
559
+ value={field.consentPurposeIds ?? []}
560
+ labels={field.consentPurposeLabels ?? {}}
561
+ onChange={(consentPurposeIds, consentPurposeLabels) => {
562
+ onChange({ consentPurposeIds, consentPurposeLabels })
563
+ }}
564
+ />
565
+ </>
566
+ )}
567
+
568
+ {problem !== undefined && <p className="text-[12px] text-danger">{problem}</p>}
569
+
570
+ <button
571
+ type="button"
572
+ onClick={onRemove}
573
+ className="self-start rounded-md border border-border px-2.5 py-1.5 text-[12px] font-medium text-danger transition hover:border-danger/50 hover:bg-danger-soft"
574
+ >
575
+ Remove field
576
+ </button>
577
+ </div>
578
+ </aside>
319
579
  )
320
580
  }
321
581
 
@@ -335,7 +595,7 @@ function OptionsEditor({
335
595
  readonly onChange: (options: readonly FormFieldOptionInput[]) => void
336
596
  }): React.JSX.Element {
337
597
  return (
338
- <div className="mt-3 space-y-2 pl-7">
598
+ <div className="space-y-2">
339
599
  <span className="block text-[11px] font-medium text-ink-faint">Options</span>
340
600
  {options.map((option, index) => (
341
601
  <div key={`${option.key}-${String(index)}`} className="flex items-center gap-2">
@@ -364,7 +624,7 @@ function OptionsEditor({
364
624
  }}
365
625
  />
366
626
  <select
367
- className={`${inputClass} w-28`}
627
+ className={`${inputClass} w-24`}
368
628
  aria-label={`Option ${String(index + 1)} value type`}
369
629
  value={option.valueType ?? 'string'}
370
630
  onChange={(event) => {
@@ -410,3 +670,117 @@ function OptionsEditor({
410
670
  </div>
411
671
  )
412
672
  }
673
+
674
+ /**
675
+ * Picks the workspace consent purposes this field offers, with a text input
676
+ * beside each selected purpose so the admin can override the checkbox text.
677
+ * The override is bound to the same purpose the checkbox grants — the wording
678
+ * changes, the mapping never does. An empty override falls back to the
679
+ * workspace purpose's own label at render time.
680
+ */
681
+ function ConsentPurposePicker({
682
+ mode,
683
+ value,
684
+ labels,
685
+ onChange,
686
+ }: {
687
+ /**
688
+ * `checkbox` shows a text override per selected purpose (each purpose has
689
+ * its own checkbox in the embed). `notice` hides the overrides — the
690
+ * consent is granted implicitly, without a per-purpose checkbox.
691
+ */
692
+ readonly mode: 'checkbox' | 'notice'
693
+ readonly value: readonly string[]
694
+ readonly labels: Readonly<Record<string, string>>
695
+ readonly onChange: (
696
+ ids: readonly string[],
697
+ labels: Readonly<Record<string, string>>,
698
+ ) => void
699
+ }): React.JSX.Element {
700
+ const purposes = useConsentPurposes({ sort: 'sort_order', limit: 200 })
701
+
702
+ if (purposes.isLoading) {
703
+ return <p className="text-[12px] text-ink-muted">Loading consent purposes…</p>
704
+ }
705
+
706
+ if (purposes.records.length === 0) {
707
+ return (
708
+ <p className="text-[12px] text-ink-muted">
709
+ No consent purposes yet. Add one on the Privacy admin page, then pick it here.
710
+ </p>
711
+ )
712
+ }
713
+
714
+ const chosen = new Set(value)
715
+
716
+ function toggle(id: string, checked: boolean): void {
717
+ // Preserve the workspace's purpose order so the visitor sees the checkboxes
718
+ // in a consistent order regardless of which one the admin ticked first.
719
+ const order = purposes.records.map((purpose) => purpose.id)
720
+ const nextIds = order.filter((purposeId) =>
721
+ purposeId === id ? checked : chosen.has(purposeId),
722
+ )
723
+ // A deselected purpose drops its override too — silent orphan overrides
724
+ // would resurface if the admin ticked it again later.
725
+ const nextLabels: Record<string, string> = {}
726
+ for (const [key, val] of Object.entries(labels)) {
727
+ if (nextIds.includes(key)) nextLabels[key] = val
728
+ }
729
+ onChange(nextIds, nextLabels)
730
+ }
731
+
732
+ function setLabel(id: string, text: string): void {
733
+ const nextLabels: Record<string, string> = { ...labels }
734
+ if (text.length === 0) {
735
+ delete nextLabels[id]
736
+ } else {
737
+ nextLabels[id] = text
738
+ }
739
+ onChange(value, nextLabels)
740
+ }
741
+
742
+ const legend =
743
+ mode === 'notice'
744
+ ? 'Purposes granted when the visitor submits (implicit consent)'
745
+ : 'Consent purposes (each selected one becomes a checkbox)'
746
+
747
+ return (
748
+ <fieldset className="block">
749
+ <legend className="mb-1 block text-[11px] font-medium text-ink-faint">{legend}</legend>
750
+ <div className="flex flex-col gap-2">
751
+ {purposes.records.map((purpose) => {
752
+ const isChosen = chosen.has(purpose.id)
753
+ return (
754
+ <div key={purpose.id} className="rounded-md border border-border p-2">
755
+ <label className="flex items-center gap-2 text-[13px] text-ink">
756
+ <input
757
+ type="checkbox"
758
+ checked={isChosen}
759
+ onChange={(event) => {
760
+ toggle(purpose.id, event.target.checked)
761
+ }}
762
+ />
763
+ {purpose.label}
764
+ </label>
765
+ {isChosen && mode === 'checkbox' && (
766
+ <label className="mt-1.5 block">
767
+ <span className="mb-1 block text-[11px] font-medium text-ink-faint">
768
+ Checkbox text (defaults to the purpose label)
769
+ </span>
770
+ <input
771
+ className={inputClass}
772
+ value={labels[purpose.id] ?? ''}
773
+ onChange={(event) => {
774
+ setLabel(purpose.id, event.target.value)
775
+ }}
776
+ placeholder={purpose.label}
777
+ />
778
+ </label>
779
+ )}
780
+ </div>
781
+ )
782
+ })}
783
+ </div>
784
+ </fieldset>
785
+ )
786
+ }