@kelpie/ui 0.10.0 → 0.11.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 (104) hide show
  1. package/dist/api/resources/activities.d.ts +2 -1
  2. package/dist/api/resources/activities.d.ts.map +1 -1
  3. package/dist/api/resources/activities.js +6 -2
  4. package/dist/api/resources/activities.js.map +1 -1
  5. package/dist/api/resources/conversions.d.ts +16 -0
  6. package/dist/api/resources/conversions.d.ts.map +1 -0
  7. package/dist/api/resources/conversions.js +81 -0
  8. package/dist/api/resources/conversions.js.map +1 -0
  9. package/dist/api/resources/enquiries.d.ts +2 -7
  10. package/dist/api/resources/enquiries.d.ts.map +1 -1
  11. package/dist/api/resources/enquiries.js +2 -30
  12. package/dist/api/resources/enquiries.js.map +1 -1
  13. package/dist/api/resources/importJobs.d.ts +2 -2
  14. package/dist/api/resources/importJobs.d.ts.map +1 -1
  15. package/dist/api/resources/importJobs.js.map +1 -1
  16. package/dist/app/KelpieApp.js +1 -1
  17. package/dist/app/KelpieApp.js.map +1 -1
  18. package/dist/components/ActivitiesPanel.d.ts +2 -2
  19. package/dist/components/ActivitiesPanel.d.ts.map +1 -1
  20. package/dist/components/ActivitiesPanel.js +7 -5
  21. package/dist/components/ActivitiesPanel.js.map +1 -1
  22. package/dist/components/ApiKeysPanel.d.ts +0 -7
  23. package/dist/components/ApiKeysPanel.d.ts.map +1 -1
  24. package/dist/components/ApiKeysPanel.js +83 -16
  25. package/dist/components/ApiKeysPanel.js.map +1 -1
  26. package/dist/components/ConvertRecordDialog.d.ts +30 -0
  27. package/dist/components/ConvertRecordDialog.d.ts.map +1 -0
  28. package/dist/components/ConvertRecordDialog.js +50 -0
  29. package/dist/components/ConvertRecordDialog.js.map +1 -0
  30. package/dist/components/Shell.d.ts.map +1 -1
  31. package/dist/components/Shell.js +19 -7
  32. package/dist/components/Shell.js.map +1 -1
  33. package/dist/pages/DealDetail.d.ts.map +1 -1
  34. package/dist/pages/DealDetail.js +15 -2
  35. package/dist/pages/DealDetail.js.map +1 -1
  36. package/dist/pages/DecisionsPage.d.ts.map +1 -1
  37. package/dist/pages/DecisionsPage.js +1 -7
  38. package/dist/pages/DecisionsPage.js.map +1 -1
  39. package/dist/pages/EnquiryDetail.d.ts.map +1 -1
  40. package/dist/pages/EnquiryDetail.js +17 -25
  41. package/dist/pages/EnquiryDetail.js.map +1 -1
  42. package/dist/pages/ListDetail.d.ts.map +1 -1
  43. package/dist/pages/ListDetail.js +1 -14
  44. package/dist/pages/ListDetail.js.map +1 -1
  45. package/dist/pages/OpportunityDetail.d.ts.map +1 -1
  46. package/dist/pages/OpportunityDetail.js +15 -2
  47. package/dist/pages/OpportunityDetail.js.map +1 -1
  48. package/dist/pages/PartnershipDetail.d.ts.map +1 -1
  49. package/dist/pages/PartnershipDetail.js +15 -2
  50. package/dist/pages/PartnershipDetail.js.map +1 -1
  51. package/dist/pages/RaiseDetail.d.ts.map +1 -1
  52. package/dist/pages/RaiseDetail.js +15 -2
  53. package/dist/pages/RaiseDetail.js.map +1 -1
  54. package/dist/pages/admin/DataPage.d.ts.map +1 -1
  55. package/dist/pages/admin/DataPage.js +3 -3
  56. package/dist/pages/admin/DataPage.js.map +1 -1
  57. package/dist/pages/admin/PrivacyPage.js +3 -3
  58. package/dist/pages/admin/PrivacyPage.js.map +1 -1
  59. package/dist/pages/admin/TeamPage.js +1 -1
  60. package/dist/pages/admin/TeamPage.js.map +1 -1
  61. package/dist/pages/admin/WorkspaceSettingsPage.js +2 -12
  62. package/dist/pages/admin/WorkspaceSettingsPage.js.map +1 -1
  63. package/dist/pages/forms/AddFieldMenu.js +2 -2
  64. package/dist/pages/forms/AddFieldMenu.js.map +1 -1
  65. package/dist/pages/forms/FieldsEditor.d.ts.map +1 -1
  66. package/dist/pages/forms/FieldsEditor.js +22 -10
  67. package/dist/pages/forms/FieldsEditor.js.map +1 -1
  68. package/dist/pages/forms/MapTargetSearch.d.ts +7 -0
  69. package/dist/pages/forms/MapTargetSearch.d.ts.map +1 -0
  70. package/dist/pages/forms/MapTargetSearch.js +32 -0
  71. package/dist/pages/forms/MapTargetSearch.js.map +1 -0
  72. package/dist/pages/forms/fieldList.d.ts +6 -7
  73. package/dist/pages/forms/fieldList.d.ts.map +1 -1
  74. package/dist/pages/forms/fieldList.js +14 -16
  75. package/dist/pages/forms/fieldList.js.map +1 -1
  76. package/dist/pages/forms/template.d.ts.map +1 -1
  77. package/dist/pages/forms/template.js +7 -0
  78. package/dist/pages/forms/template.js.map +1 -1
  79. package/package.json +2 -2
  80. package/src/api/resources/activities.ts +10 -2
  81. package/src/api/resources/conversions.ts +125 -0
  82. package/src/api/resources/enquiries.ts +9 -35
  83. package/src/api/resources/importJobs.ts +3 -2
  84. package/src/app/KelpieApp.tsx +1 -0
  85. package/src/components/ActivitiesPanel.tsx +20 -14
  86. package/src/components/ApiKeysPanel.tsx +206 -21
  87. package/src/components/ConvertRecordDialog.tsx +210 -0
  88. package/src/components/Shell.tsx +91 -36
  89. package/src/pages/DealDetail.tsx +35 -0
  90. package/src/pages/DecisionsPage.tsx +0 -14
  91. package/src/pages/EnquiryDetail.tsx +37 -90
  92. package/src/pages/ListDetail.tsx +0 -47
  93. package/src/pages/OpportunityDetail.tsx +35 -0
  94. package/src/pages/PartnershipDetail.tsx +35 -0
  95. package/src/pages/RaiseDetail.tsx +35 -0
  96. package/src/pages/admin/DataPage.tsx +14 -9
  97. package/src/pages/admin/PrivacyPage.tsx +4 -4
  98. package/src/pages/admin/TeamPage.tsx +1 -2
  99. package/src/pages/admin/WorkspaceSettingsPage.tsx +1 -31
  100. package/src/pages/forms/AddFieldMenu.tsx +2 -2
  101. package/src/pages/forms/FieldsEditor.tsx +45 -20
  102. package/src/pages/forms/MapTargetSearch.tsx +69 -0
  103. package/src/pages/forms/fieldList.ts +34 -16
  104. package/src/pages/forms/template.ts +7 -0
@@ -6,6 +6,10 @@ import { Link, useNavigate, useParams } from 'react-router'
6
6
  import { usePatch } from '../api/resource.ts'
7
7
  import type { PatchResult } from '../api/resource.ts'
8
8
  import { useCompanies, useCompany } from '../api/resources/companies.ts'
9
+ import {
10
+ detailPathForPipelineKind,
11
+ useConvertPipelineRecord,
12
+ } from '../api/resources/conversions.ts'
9
13
  import { useFormSubmissionsForRecord } from '../api/resources/forms.ts'
10
14
  import { useMembers } from '../api/resources/members.ts'
11
15
  import {
@@ -20,6 +24,7 @@ import { ActivitiesPanel, LatestActivity } from '../components/ActivitiesPanel.t
20
24
  import { AgentTasks } from '../components/AgentTasks.tsx'
21
25
  import { Chip } from '../components/Chip.tsx'
22
26
  import type { ChipTone } from '../components/Chip.tsx'
27
+ import { ConvertRecordAction, ConvertRecordButton } from '../components/ConvertRecordDialog.tsx'
23
28
  import { DecisionsPanel } from '../components/DecisionsPanel.tsx'
24
29
  import { DeleteRecord } from '../components/DeleteRecord.tsx'
25
30
  import { EntitySearch } from '../components/EntitySearch.tsx'
@@ -61,10 +66,12 @@ export function PartnershipDetail(): React.JSX.Element {
61
66
  const { id } = useParams()
62
67
  const navigate = useNavigate()
63
68
  const { record, isLoading, isNotFound, error } = usePartnership(id)
69
+ const convertRecord = useConvertPipelineRecord('partnership')
64
70
  const deletePartnership = useDeletePartnership()
65
71
  const moduleTabs = inSlotOrder(useRecordTabs('partnership'))
66
72
  const hasCustomFields = useHasCustomFields('partnership')
67
73
  const [activeTab, setActiveTab] = useState('overview')
74
+ const [showConvert, setShowConvert] = useState(false)
68
75
  const formSubmissions = useFormSubmissionsForRecord('partnership', id)
69
76
 
70
77
  if (isNotFound) {
@@ -94,6 +101,7 @@ export function PartnershipDetail(): React.JSX.Element {
94
101
  ]
95
102
  const active = tabs.some((tab) => tab.id === activeTab) ? activeTab : 'overview'
96
103
  const moduleTab = moduleTabs.find((tab) => tab.id === active)
104
+ const convertedTo = record.convertedTo
97
105
 
98
106
  return (
99
107
  <div className="animate-fade-in mx-auto max-w-6xl">
@@ -109,6 +117,13 @@ export function PartnershipDetail(): React.JSX.Element {
109
117
  <PartnershipHeading partnership={record} />
110
118
 
111
119
  <div className="flex justify-end gap-2">
120
+ <ConvertRecordButton
121
+ convertedTo={convertedTo}
122
+ convert={convertRecord}
123
+ onOpenDialog={() => {
124
+ setShowConvert(true)
125
+ }}
126
+ />
112
127
  <AgentTasks targetType="partnership" targetId={record.id} targetLabel={record.name} />
113
128
  <DeleteRecord
114
129
  recordLabel="Partnership"
@@ -124,6 +139,26 @@ export function PartnershipDetail(): React.JSX.Element {
124
139
  />
125
140
  </div>
126
141
 
142
+ {convertRecord.error !== null && <ErrorPanel error={convertRecord.error} />}
143
+ <ConvertRecordAction
144
+ sourceKind="partnership"
145
+ recordId={record.id}
146
+ recordName={record.name}
147
+ companyId={record.companyId}
148
+ convertedTo={convertedTo}
149
+ convert={convertRecord}
150
+ showDialog={showConvert}
151
+ onOpenDialog={() => {
152
+ setShowConvert(true)
153
+ }}
154
+ onCloseDialog={() => {
155
+ setShowConvert(false)
156
+ }}
157
+ onConverted={(created, targetType) => {
158
+ navigate(detailPathForPipelineKind(targetType, created.id))
159
+ }}
160
+ />
161
+
127
162
  <RecordTabs
128
163
  tabs={tabs}
129
164
  active={active}
@@ -6,6 +6,10 @@ import { Link, useNavigate, useParams } from 'react-router'
6
6
  import { usePatch } from '../api/resource.ts'
7
7
  import type { PatchResult } from '../api/resource.ts'
8
8
  import { useCompanies, useCompany } from '../api/resources/companies.ts'
9
+ import {
10
+ detailPathForPipelineKind,
11
+ useConvertPipelineRecord,
12
+ } from '../api/resources/conversions.ts'
9
13
  import { useMembers } from '../api/resources/members.ts'
10
14
  import { usePeople } from '../api/resources/people.ts'
11
15
  import { usePipelineStages } from '../api/resources/pipelineStages.ts'
@@ -15,6 +19,7 @@ import { ActivitiesPanel, LatestActivity } from '../components/ActivitiesPanel.t
15
19
  import { AgentTasks } from '../components/AgentTasks.tsx'
16
20
  import { Chip } from '../components/Chip.tsx'
17
21
  import type { ChipTone } from '../components/Chip.tsx'
22
+ import { ConvertRecordAction, ConvertRecordButton } from '../components/ConvertRecordDialog.tsx'
18
23
  import { DecisionsPanel } from '../components/DecisionsPanel.tsx'
19
24
  import { DeleteRecord } from '../components/DeleteRecord.tsx'
20
25
  import { EntitySearch } from '../components/EntitySearch.tsx'
@@ -60,10 +65,12 @@ export function RaiseDetail(): React.JSX.Element {
60
65
  const { id } = useParams()
61
66
  const navigate = useNavigate()
62
67
  const { record, isLoading, isNotFound, error } = useRaise(id)
68
+ const convertRecord = useConvertPipelineRecord('raise')
63
69
  const deleteRaise = useDeleteRaise()
64
70
  const moduleTabs = inSlotOrder(useRecordTabs('raise'))
65
71
  const hasCustomFields = useHasCustomFields('raise')
66
72
  const [activeTab, setActiveTab] = useState('overview')
73
+ const [showConvert, setShowConvert] = useState(false)
67
74
 
68
75
  if (isNotFound) {
69
76
  return <NotFoundPanel label="Raise" backTo="/fundraising" />
@@ -89,6 +96,7 @@ export function RaiseDetail(): React.JSX.Element {
89
96
  ]
90
97
  const active = tabs.some((tab) => tab.id === activeTab) ? activeTab : 'overview'
91
98
  const moduleTab = moduleTabs.find((tab) => tab.id === active)
99
+ const convertedTo = record.convertedTo
92
100
 
93
101
  return (
94
102
  <div className="animate-fade-in mx-auto max-w-6xl">
@@ -104,6 +112,13 @@ export function RaiseDetail(): React.JSX.Element {
104
112
  <RaiseHeading raise={record} />
105
113
 
106
114
  <div className="flex justify-end gap-2">
115
+ <ConvertRecordButton
116
+ convertedTo={convertedTo}
117
+ convert={convertRecord}
118
+ onOpenDialog={() => {
119
+ setShowConvert(true)
120
+ }}
121
+ />
107
122
  <AgentTasks targetType="raise" targetId={record.id} targetLabel={record.name} />
108
123
  <DeleteRecord
109
124
  recordLabel="Raise"
@@ -119,6 +134,26 @@ export function RaiseDetail(): React.JSX.Element {
119
134
  />
120
135
  </div>
121
136
 
137
+ {convertRecord.error !== null && <ErrorPanel error={convertRecord.error} />}
138
+ <ConvertRecordAction
139
+ sourceKind="raise"
140
+ recordId={record.id}
141
+ recordName={record.name}
142
+ companyId={record.companyId}
143
+ convertedTo={convertedTo}
144
+ convert={convertRecord}
145
+ showDialog={showConvert}
146
+ onOpenDialog={() => {
147
+ setShowConvert(true)
148
+ }}
149
+ onCloseDialog={() => {
150
+ setShowConvert(false)
151
+ }}
152
+ onConverted={(created, targetType) => {
153
+ navigate(detailPathForPipelineKind(targetType, created.id))
154
+ }}
155
+ />
156
+
122
157
  <RecordTabs tabs={tabs} active={active} onChange={setActiveTab} ariaLabel="Raise sections">
123
158
  {active === 'overview' && <RaiseOverview raise={record} />}
124
159
  {active === 'fields' && <RaiseFields raise={record} />}
@@ -1,5 +1,7 @@
1
1
  import {
2
2
  CONFLICT_MODE_LABELS,
3
+ EXPORT_OBJECT_LABELS,
4
+ EXPORT_OBJECTS,
3
5
  IMPORT_OBJECTS,
4
6
  IMPORT_SOURCES,
5
7
  MATCH_KEYS,
@@ -13,6 +15,7 @@ import {
13
15
  requiredColumns,
14
16
  } from '@kelpie/schemas'
15
17
  import type {
18
+ ExportObject,
16
19
  ImportColumnMap,
17
20
  ImportConflictMode,
18
21
  ImportJob,
@@ -65,7 +68,7 @@ const STEPS: readonly { readonly id: WizardStep; readonly label: string }[] = [
65
68
  ]
66
69
 
67
70
  export function DataPage(): ReactNode {
68
- const [exportObject, setExportObject] = useState<ImportObject>('companies')
71
+ const [exportObject, setExportObject] = useState<ExportObject>('companies')
69
72
 
70
73
  const [step, setStep] = useState<WizardStep>('source')
71
74
  const [source, setSource] = useState<ImportSource>('custom')
@@ -133,7 +136,7 @@ export function DataPage(): ReactNode {
133
136
  setProblem(null)
134
137
  }
135
138
 
136
- async function onDownload(target: ImportObject, template: boolean): Promise<void> {
139
+ async function onDownload(target: ExportObject, template: boolean): Promise<void> {
137
140
  setProblem(null)
138
141
  saveCsv(await exportCsv.runAsync({ object: target, template }))
139
142
  }
@@ -255,7 +258,7 @@ export function DataPage(): ReactNode {
255
258
  <div className="animate-slide-in space-y-10">
256
259
  <PageHeader
257
260
  title="Import & export"
258
- description="CSV for People, Companies, Positions, and Deals. Custom files or HubSpot / Salesforce packs."
261
+ description="CSV for People, Companies, Positions, Deals, and other CRM objects. Custom field values export with each record; definitions export separately."
259
262
  />
260
263
 
261
264
  {failure === null ? null : (
@@ -272,20 +275,20 @@ export function DataPage(): ReactNode {
272
275
  <div>
273
276
  <h2 className="text-[15px] font-semibold text-ink">Export</h2>
274
277
  <p className="mt-1 text-[13px] text-ink-muted">
275
- Download this workspace as CSV. A Kelpie export reads straight back in with no column
276
- mapping.
278
+ Download this workspace as CSV. Importable objects round-trip through the wizard; other
279
+ objects and custom field definitions export for backup and analysis.
277
280
  </p>
278
281
  </div>
279
282
  <div className="flex flex-wrap items-end gap-3">
280
283
  <Field label="Object">
281
284
  <select
282
285
  value={exportObject}
283
- onChange={(event) => setExportObject(event.target.value as ImportObject)}
286
+ onChange={(event) => setExportObject(event.target.value as ExportObject)}
284
287
  className="w-48 rounded-md border border-border bg-surface-raised px-3 py-2 text-[13px] outline-none focus:border-accent"
285
288
  >
286
- {IMPORT_OBJECTS.map((item) => (
289
+ {EXPORT_OBJECTS.map((item) => (
287
290
  <option key={item} value={item}>
288
- {OBJECT_LABELS[item]}
291
+ {EXPORT_OBJECT_LABELS[item]}
289
292
  </option>
290
293
  ))}
291
294
  </select>
@@ -377,7 +380,9 @@ export function DataPage(): ReactNode {
377
380
  </button>
378
381
  </div>
379
382
  <p className="text-[12px] text-ink-muted">
380
- Import order for linked data: Companies → People → Positions → Deals.
383
+ Import order for linked data: Companies → People → Positions → pipeline records (Deals,
384
+ Opportunities, Enquiries, Partnerships, Fundraising). Custom field definitions can be
385
+ imported before the records that use them.
381
386
  </p>
382
387
  <button
383
388
  type="submit"
@@ -132,14 +132,14 @@ function EmptyState({ onAdd, onSeedStarters, isSeeding }: EmptyStateProps): Reac
132
132
  <p className="mt-1 text-ink-muted">
133
133
  A purpose is what a person is agreeing to — being contacted at all, marketing, research.
134
134
  Every workspace usually has at least a general <span className="font-medium">Contact</span>{' '}
135
- purpose. Forms, lists, and imports that don&rsquo;t need consent don&rsquo;t need one.
135
+ purpose. Forms and imports that don&rsquo;t need consent don&rsquo;t need one.
136
136
  </p>
137
137
  <div className="mt-4 flex flex-wrap items-center gap-2">
138
138
  <button
139
139
  type="button"
140
140
  onClick={onSeedStarters}
141
141
  disabled={isSeeding}
142
- className="rounded-md bg-accent px-3 py-1.5 text-[12px] font-medium text-white hover:bg-accent-hover disabled:opacity-50"
142
+ className="rounded-md bg-accent px-3 py-1.5 text-[12px] font-semibold text-accent-fg transition hover:bg-accent-hover disabled:opacity-50"
143
143
  >
144
144
  {isSeeding ? 'Adding…' : 'Add Contact & Marketing'}
145
145
  </button>
@@ -268,7 +268,7 @@ function AddPurposeForm({ onDone }: AddPurposeFormProps): React.JSX.Element {
268
268
  <button
269
269
  type="submit"
270
270
  disabled={create.isPending}
271
- className="rounded-md bg-accent px-3 py-1 text-[12px] font-medium text-white hover:bg-accent-hover disabled:opacity-50"
271
+ className="rounded-md bg-accent px-3 py-1 text-[12px] font-semibold text-accent-fg transition hover:bg-accent-hover disabled:opacity-50"
272
272
  >
273
273
  {create.isPending ? 'Adding…' : 'Add purpose'}
274
274
  </button>
@@ -389,7 +389,7 @@ function PurposeRow({ purpose }: PurposeRowProps): React.JSX.Element {
389
389
  <button
390
390
  type="submit"
391
391
  disabled={update.isPending}
392
- className="rounded-md bg-accent px-3 py-1 text-[12px] font-medium text-white hover:bg-accent-hover disabled:opacity-50"
392
+ className="rounded-md bg-accent px-3 py-1 text-[12px] font-semibold text-accent-fg transition hover:bg-accent-hover disabled:opacity-50"
393
393
  >
394
394
  {update.isPending ? 'Saving…' : 'Save'}
395
395
  </button>
@@ -114,8 +114,7 @@ function InviteForm(): React.JSX.Element {
114
114
  </div>
115
115
  {sendInvite.error !== null && <ErrorPanel error={sendInvite.error} />}
116
116
  <p className="text-[11px] text-ink-faint">
117
- The invitation is emailed and lasts seven days. Ownership is not invited; it is given to a
118
- member who has already joined.
117
+ The invitation is emailed and lasts seven days.
119
118
  </p>
120
119
  </form>
121
120
  )
@@ -74,8 +74,6 @@ function WorkspaceSettingsForm({
74
74
  const [name, setName] = useState(workspace.name)
75
75
  const [slug, setSlug] = useState(workspace.slug)
76
76
  const [timezone, setTimezone] = useState(workspace.timezone)
77
- const [tagline, setTagline] = useState(workspace.tagline ?? '')
78
- const [oneLiner, setOneLiner] = useState(workspace.oneLiner ?? '')
79
77
  const [saved, setSaved] = useState(false)
80
78
 
81
79
  const canEdit = session?.role === 'owner' || session?.role === 'admin'
@@ -92,10 +90,6 @@ function WorkspaceSettingsForm({
92
90
  name: name.trim(),
93
91
  slug: slug.trim(),
94
92
  timezone,
95
- // Empty is not blank text, it is no tagline. `null` clears the column;
96
- // an empty string would store one and hand agents an empty identity.
97
- tagline: tagline.trim().length === 0 ? null : tagline.trim(),
98
- oneLiner: oneLiner.trim().length === 0 ? null : oneLiner.trim(),
99
93
  })
100
94
  .then(() => {
101
95
  setSaved(true)
@@ -107,7 +101,7 @@ function WorkspaceSettingsForm({
107
101
  <div className="animate-slide-in mx-auto max-w-4xl space-y-8">
108
102
  <PageHeader
109
103
  title="Workspace"
110
- description="Name, address, timezone, and how the company introduces itself to an agent."
104
+ description="Name, address, and timezone."
111
105
  />
112
106
 
113
107
  <form onSubmit={save} className="space-y-4">
@@ -135,30 +129,6 @@ function WorkspaceSettingsForm({
135
129
  />
136
130
  </Field>
137
131
 
138
- <Field label="Tagline" hint="The short identity an agent loads first.">
139
- <input
140
- value={tagline}
141
- onChange={(event) => {
142
- setTagline(event.target.value)
143
- }}
144
- placeholder="CRM and company brain for agent-native startups"
145
- disabled={!canEdit}
146
- className="w-full max-w-xl rounded-md border border-border bg-surface-raised px-3 py-2 text-[13px] outline-none focus:border-accent focus:ring-2 focus:ring-accent/20 disabled:opacity-60"
147
- />
148
- </Field>
149
-
150
- <Field label="One-liner" hint="What the company does, in a sentence.">
151
- <textarea
152
- value={oneLiner}
153
- onChange={(event) => {
154
- setOneLiner(event.target.value)
155
- }}
156
- rows={2}
157
- disabled={!canEdit}
158
- className="w-full max-w-xl rounded-md border border-border bg-surface-raised px-3 py-2 text-[13px] outline-none focus:border-accent focus:ring-2 focus:ring-accent/20 disabled:opacity-60"
159
- />
160
- </Field>
161
-
162
132
  <Field label="Timezone">
163
133
  <select
164
134
  value={timezone}
@@ -1,4 +1,4 @@
1
- import { FORM_FIELD_MAP_TARGET_LABELS } from '@kelpie/schemas'
1
+ import { labelForMapTarget } from '@kelpie/schemas'
2
2
  import type { FormFieldInput } from '@kelpie/schemas'
3
3
  import { useEffect, useRef, useState } from 'react'
4
4
 
@@ -92,7 +92,7 @@ export function AddFieldMenu({ fields, onAdd }: AddFieldMenuProps): React.JSX.El
92
92
  >
93
93
  <span>{preset.label}</span>
94
94
  <span className="shrink-0 text-[11px] text-ink-faint">
95
- {FORM_FIELD_MAP_TARGET_LABELS[preset.mapTo]}
95
+ {labelForMapTarget(preset.mapTo)}
96
96
  </span>
97
97
  </button>
98
98
  ))}
@@ -9,24 +9,24 @@ import {
9
9
  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
- import { FORM_FIELD_MAP_TARGET_LABELS, FORM_OPTION_VALUE_TYPES } from '@kelpie/schemas'
12
+ import { FORM_OPTION_VALUE_TYPES } from '@kelpie/schemas'
13
13
  import type {
14
14
  Form,
15
15
  FormFieldInput,
16
- FormFieldMapTarget,
17
16
  FormFieldOptionInput,
18
17
  FormFieldType,
19
18
  } from '@kelpie/schemas'
20
- import { useEffect, useRef, useState } from 'react'
19
+ import { useEffect, useMemo, useRef, useState } from 'react'
21
20
 
22
21
  import { useConsentPurposes } from '../../api/resources/consentPurposes.ts'
22
+ import { useCustomFields } from '../../api/resources/customFields.ts'
23
23
  import type { ConsentPurpose } from '@kelpie/schemas'
24
24
  import { useUpdateFormFields } from '../../api/resources/forms.ts'
25
25
  import { ErrorPanel } from '../../components/QueryState.tsx'
26
26
  import { AddFieldMenu } from './AddFieldMenu.tsx'
27
+ import { MapTargetSearch } from './MapTargetSearch.tsx'
27
28
  import {
28
29
  FIELD_TYPE_OPTIONS,
29
- MAP_TARGET_OPTIONS,
30
30
  editField,
31
31
  fieldsChanged,
32
32
  findProblems,
@@ -78,6 +78,17 @@ export function FieldsEditor({ form }: FieldsEditorProps): React.JSX.Element {
78
78
  const [fields, setFields] = useState<EditableField[]>(() => toEditableFields(form))
79
79
  const [selectedId, setSelectedId] = useState<string | null>(null)
80
80
  const updateFields = useUpdateFormFields()
81
+ const customFields = useCustomFields()
82
+ const customFieldDefinitions = useMemo(
83
+ () =>
84
+ customFields.records.map((definition) => ({
85
+ objectType: definition.objectType,
86
+ key: definition.key,
87
+ label: definition.label,
88
+ type: definition.type,
89
+ })),
90
+ [customFields.records],
91
+ )
81
92
  const fieldsRef = useRef<readonly EditableField[]>(fields)
82
93
  fieldsRef.current = fields
83
94
  const sensors = useSensors(
@@ -104,7 +115,7 @@ export function FieldsEditor({ form }: FieldsEditorProps): React.JSX.Element {
104
115
  setFields(next)
105
116
  }, [form])
106
117
 
107
- const problems = findProblems(fields, form.createDeal)
118
+ const problems = findProblems(fields, form.createDeal, { customFieldDefinitions })
108
119
  const changed = fieldsChanged(form, fields)
109
120
  const selected = fields.find((field) => field.id === selectedId)
110
121
 
@@ -190,6 +201,8 @@ export function FieldsEditor({ form }: FieldsEditorProps): React.JSX.Element {
190
201
  ) : (
191
202
  <FieldSettings
192
203
  field={selected}
204
+ fields={fields}
205
+ customFieldDefinitions={customFieldDefinitions}
193
206
  problem={problems.byField.get(selected.id)}
194
207
  onChange={(change) => {
195
208
  setFields((current) => editField(current, selected.id, change))
@@ -424,6 +437,13 @@ function ConsentPreview({ field }: { readonly field: EditableField }): React.JSX
424
437
 
425
438
  interface FieldSettingsProps {
426
439
  readonly field: EditableField
440
+ readonly fields: readonly EditableField[]
441
+ readonly customFieldDefinitions: readonly {
442
+ readonly objectType: import('@kelpie/schemas').CustomFieldObjectType
443
+ readonly key: string
444
+ readonly label: string
445
+ readonly type: import('@kelpie/schemas').CustomFieldType
446
+ }[]
427
447
  readonly problem: string | undefined
428
448
  readonly onChange: (change: Partial<FormFieldInput>) => void
429
449
  readonly onRemove: () => void
@@ -433,11 +453,21 @@ interface FieldSettingsProps {
433
453
  /** The panel a selected field's settings edit in. */
434
454
  function FieldSettings({
435
455
  field,
456
+ fields,
457
+ customFieldDefinitions,
436
458
  problem,
437
459
  onChange,
438
460
  onRemove,
439
461
  onClose,
440
462
  }: FieldSettingsProps): React.JSX.Element {
463
+ const usedTargets = useMemo(
464
+ () =>
465
+ new Set(
466
+ fields.filter((entry) => entry.id !== field.id).map((entry) => entry.mapTo),
467
+ ),
468
+ [fields, field.id],
469
+ )
470
+
441
471
  return (
442
472
  <aside className="rounded-lg border border-border bg-surface-raised p-4 lg:sticky lg:top-4">
443
473
  <div className="mb-3 flex items-center justify-between gap-2">
@@ -464,24 +494,19 @@ function FieldSettings({
464
494
  />
465
495
  </label>
466
496
 
467
- <label className="block">
497
+ <div className="block">
468
498
  <span className="mb-1 block text-[11px] font-medium text-ink-faint">Maps to</span>
469
- <select
470
- className={inputClass}
499
+ <MapTargetSearch
471
500
  value={field.mapTo}
472
- onChange={(event) => {
473
- const mapTo = event.target.value as FormFieldMapTarget
474
-
475
- onChange({ mapTo, type: typeForTarget(mapTo, field.type) })
501
+ usedTargets={usedTargets}
502
+ onChange={(mapTo) => {
503
+ onChange({
504
+ mapTo,
505
+ type: typeForTarget(mapTo, field.type, customFieldDefinitions),
506
+ })
476
507
  }}
477
- >
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>
508
+ />
509
+ </div>
485
510
 
486
511
  <label className="block">
487
512
  <span className="mb-1 block text-[11px] font-medium text-ink-faint">Type</span>
@@ -0,0 +1,69 @@
1
+ import {
2
+ isRepeatableMapTarget,
3
+ labelForMapTarget,
4
+ listMapTargetEntries,
5
+ metaForMapTargetEntry,
6
+ } from '@kelpie/schemas'
7
+ import type { CustomFieldDefinition } from '@kelpie/schemas'
8
+
9
+ import { useCustomFields } from '../../api/resources/customFields.ts'
10
+ import { EntitySearch } from '../../components/EntitySearch.tsx'
11
+ import type { SearchOption } from '../../components/EntitySearch.tsx'
12
+
13
+ export interface MapTargetSearchProps {
14
+ readonly value: string
15
+ readonly usedTargets: ReadonlySet<string>
16
+ readonly onChange: (target: string) => void
17
+ }
18
+
19
+ function toCustomRefs(
20
+ definitions: readonly CustomFieldDefinition[],
21
+ ): readonly { objectType: CustomFieldDefinition['objectType']; key: string; label: string; type: CustomFieldDefinition['type'] }[] {
22
+ return definitions.map((definition) => ({
23
+ objectType: definition.objectType,
24
+ key: definition.key,
25
+ label: definition.label,
26
+ type: definition.type,
27
+ }))
28
+ }
29
+
30
+ export function MapTargetSearch({
31
+ value,
32
+ usedTargets,
33
+ onChange,
34
+ }: MapTargetSearchProps): React.JSX.Element {
35
+ const customFields = useCustomFields()
36
+ const customRefs = toCustomRefs(customFields.records)
37
+ const entries = listMapTargetEntries(customRefs)
38
+
39
+ const options: readonly SearchOption[] = entries
40
+ .filter((entry) => entry.target === value || !usedTargets.has(entry.target) || isRepeatableMapTarget(entry.target))
41
+ .map((entry) => {
42
+ const customType =
43
+ entry.fieldKind === 'custom'
44
+ ? customRefs.find(
45
+ (definition) =>
46
+ entry.target.endsWith(`.custom_fields.${definition.key}`) &&
47
+ entry.target.startsWith(`${definition.objectType}.`),
48
+ )?.type
49
+ : undefined
50
+
51
+ return {
52
+ id: entry.target,
53
+ label: labelForMapTarget(entry.target, customRefs),
54
+ meta: metaForMapTargetEntry(entry, customType),
55
+ }
56
+ })
57
+
58
+ return (
59
+ <EntitySearch
60
+ options={options}
61
+ value={value}
62
+ onChange={onChange}
63
+ placeholder="Search object and field…"
64
+ emptyMessage="No matching fields"
65
+ limit={12}
66
+ size="md"
67
+ />
68
+ )
69
+ }
@@ -1,10 +1,19 @@
1
1
  import {
2
- FORM_FIELD_MAP_TARGETS,
3
2
  FORM_FIELD_TYPES,
3
+ isCompatibleFormFieldType,
4
+ isKnownMapTarget,
5
+ isRepeatableMapTarget,
4
6
  PERSON_CONSENT_TARGET,
5
7
  PERSON_EMAIL_TARGET,
8
+ suggestedFormFieldType,
9
+ } from '@kelpie/schemas'
10
+ import type {
11
+ CustomFieldDefinitionRef,
12
+ Form,
13
+ FormFieldInput,
14
+ FormFieldMapTarget,
15
+ FormFieldType,
6
16
  } from '@kelpie/schemas'
7
- import type { Form, FormFieldInput, FormFieldMapTarget, FormFieldType } from '@kelpie/schemas'
8
17
 
9
18
  import { CRM_FIELD_PRESETS, NEW_SELECT_OPTIONS } from './template.ts'
10
19
 
@@ -28,10 +37,9 @@ export interface EditableField extends FormFieldInput {
28
37
 
29
38
  export const FIELD_TYPE_OPTIONS = FORM_FIELD_TYPES.map((type) => ({ value: type, label: type }))
30
39
 
31
- export const MAP_TARGET_OPTIONS = FORM_FIELD_MAP_TARGETS.map((target) => ({
32
- value: target,
33
- label: target,
34
- }))
40
+ export interface FindProblemsOptions {
41
+ readonly customFieldDefinitions?: readonly CustomFieldDefinitionRef[]
42
+ }
35
43
 
36
44
  /** Reads a saved form into the shape the builder edits. */
37
45
  export function toEditableFields(form: Form): EditableField[] {
@@ -92,8 +100,7 @@ export function editField(
92
100
  // that may repeat — consent's uniqueness is per purpose, not per
93
101
  // target, and is enforced when the purpose is picked.
94
102
  return change.mapTo !== undefined &&
95
- change.mapTo !== 'submission' &&
96
- change.mapTo !== PERSON_CONSENT_TARGET &&
103
+ !isRepeatableMapTarget(change.mapTo) &&
97
104
  field.mapTo === change.mapTo
98
105
  ? { ...field, mapTo: 'submission' }
99
106
  : field
@@ -182,11 +189,13 @@ export interface FieldListProblems {
182
189
  export function findProblems(
183
190
  fields: readonly EditableField[],
184
191
  createsDeal: boolean,
192
+ options: FindProblemsOptions = {},
185
193
  ): FieldListProblems {
186
194
  const list: string[] = []
187
195
  const byField = new Map<string, string>()
188
- const seen = new Set<FormFieldMapTarget>()
196
+ const seen = new Set<string>()
189
197
  const usedConsentPurposes = new Set<string>()
198
+ const customFieldDefinitions = options.customFieldDefinitions ?? []
190
199
 
191
200
  if (!fields.some((field) => field.mapTo === PERSON_EMAIL_TARGET)) {
192
201
  list.push(`One field must map to ${PERSON_EMAIL_TARGET}, or no submission can be processed.`)
@@ -197,6 +206,15 @@ export function findProblems(
197
206
  }
198
207
 
199
208
  for (const field of fields) {
209
+ if (!isKnownMapTarget(field.mapTo, customFieldDefinitions)) {
210
+ byField.set(field.id, `Unknown map target ${field.mapTo}.`)
211
+ } else if (
212
+ field.mapTo !== PERSON_CONSENT_TARGET &&
213
+ !isCompatibleFormFieldType(field.type, field.mapTo, customFieldDefinitions)
214
+ ) {
215
+ byField.set(field.id, `A ${field.type} field cannot map to ${field.mapTo}.`)
216
+ }
217
+
200
218
  if (field.mapTo === PERSON_CONSENT_TARGET) {
201
219
  const purposes = field.consentPurposeIds ?? []
202
220
  if (field.type === 'notice' && (field.statement ?? '').trim().length === 0) {
@@ -224,7 +242,7 @@ export function findProblems(
224
242
  }
225
243
  }
226
244
  }
227
- } else if (field.mapTo !== 'submission') {
245
+ } else if (!isRepeatableMapTarget(field.mapTo)) {
228
246
  if (seen.has(field.mapTo)) {
229
247
  byField.set(field.id, `Another field already maps to ${field.mapTo}.`)
230
248
  }
@@ -252,12 +270,12 @@ export function isUsable(problems: FieldListProblems): boolean {
252
270
  }
253
271
 
254
272
  /** The types a mapping can sensibly render as. */
255
- export function typeForTarget(target: FormFieldMapTarget, current: FormFieldType): FormFieldType {
256
- if (target === PERSON_EMAIL_TARGET) return 'email'
257
- if (target === PERSON_CONSENT_TARGET) {
258
- return current === 'notice' ? 'notice' : 'consent'
259
- }
260
- return current === 'consent' || current === 'notice' ? 'text' : current
273
+ export function typeForTarget(
274
+ target: FormFieldMapTarget,
275
+ current: FormFieldType,
276
+ customFieldDefinitions: readonly CustomFieldDefinitionRef[] = [],
277
+ ): FormFieldType {
278
+ return suggestedFormFieldType(target, customFieldDefinitions, current)
261
279
  }
262
280
 
263
281
  /**