@kelpie/ui 0.9.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.
- package/dist/api/resources/activities.d.ts +2 -1
- package/dist/api/resources/activities.d.ts.map +1 -1
- package/dist/api/resources/activities.js +6 -2
- package/dist/api/resources/activities.js.map +1 -1
- package/dist/api/resources/conversions.d.ts +16 -0
- package/dist/api/resources/conversions.d.ts.map +1 -0
- package/dist/api/resources/conversions.js +81 -0
- package/dist/api/resources/conversions.js.map +1 -0
- package/dist/api/resources/enquiries.d.ts +2 -7
- package/dist/api/resources/enquiries.d.ts.map +1 -1
- package/dist/api/resources/enquiries.js +2 -30
- package/dist/api/resources/enquiries.js.map +1 -1
- package/dist/api/resources/importJobs.d.ts +2 -2
- package/dist/api/resources/importJobs.d.ts.map +1 -1
- package/dist/api/resources/importJobs.js.map +1 -1
- package/dist/app/KelpieApp.js +1 -1
- package/dist/app/KelpieApp.js.map +1 -1
- package/dist/components/ActivitiesPanel.d.ts +2 -2
- package/dist/components/ActivitiesPanel.d.ts.map +1 -1
- package/dist/components/ActivitiesPanel.js +7 -5
- package/dist/components/ActivitiesPanel.js.map +1 -1
- package/dist/components/ApiKeysPanel.d.ts +0 -7
- package/dist/components/ApiKeysPanel.d.ts.map +1 -1
- package/dist/components/ApiKeysPanel.js +83 -16
- package/dist/components/ApiKeysPanel.js.map +1 -1
- package/dist/components/ConvertRecordDialog.d.ts +30 -0
- package/dist/components/ConvertRecordDialog.d.ts.map +1 -0
- package/dist/components/ConvertRecordDialog.js +50 -0
- package/dist/components/ConvertRecordDialog.js.map +1 -0
- package/dist/components/Shell.d.ts.map +1 -1
- package/dist/components/Shell.js +19 -7
- package/dist/components/Shell.js.map +1 -1
- package/dist/pages/DealDetail.d.ts.map +1 -1
- package/dist/pages/DealDetail.js +15 -2
- package/dist/pages/DealDetail.js.map +1 -1
- package/dist/pages/DecisionsPage.d.ts.map +1 -1
- package/dist/pages/DecisionsPage.js +1 -7
- package/dist/pages/DecisionsPage.js.map +1 -1
- package/dist/pages/EnquiryDetail.d.ts.map +1 -1
- package/dist/pages/EnquiryDetail.js +17 -25
- package/dist/pages/EnquiryDetail.js.map +1 -1
- package/dist/pages/ListDetail.d.ts.map +1 -1
- package/dist/pages/ListDetail.js +1 -14
- package/dist/pages/ListDetail.js.map +1 -1
- package/dist/pages/OpportunityDetail.d.ts.map +1 -1
- package/dist/pages/OpportunityDetail.js +15 -2
- package/dist/pages/OpportunityDetail.js.map +1 -1
- package/dist/pages/PartnershipDetail.d.ts.map +1 -1
- package/dist/pages/PartnershipDetail.js +15 -2
- package/dist/pages/PartnershipDetail.js.map +1 -1
- package/dist/pages/RaiseDetail.d.ts.map +1 -1
- package/dist/pages/RaiseDetail.js +15 -2
- package/dist/pages/RaiseDetail.js.map +1 -1
- package/dist/pages/admin/DataPage.d.ts.map +1 -1
- package/dist/pages/admin/DataPage.js +3 -3
- package/dist/pages/admin/DataPage.js.map +1 -1
- package/dist/pages/admin/PrivacyPage.js +3 -3
- package/dist/pages/admin/PrivacyPage.js.map +1 -1
- package/dist/pages/admin/TeamPage.js +1 -1
- package/dist/pages/admin/TeamPage.js.map +1 -1
- package/dist/pages/admin/WorkspaceSettingsPage.js +2 -12
- package/dist/pages/admin/WorkspaceSettingsPage.js.map +1 -1
- package/dist/pages/forms/AddFieldMenu.js +2 -2
- package/dist/pages/forms/AddFieldMenu.js.map +1 -1
- package/dist/pages/forms/FieldsEditor.d.ts.map +1 -1
- package/dist/pages/forms/FieldsEditor.js +22 -10
- package/dist/pages/forms/FieldsEditor.js.map +1 -1
- package/dist/pages/forms/MapTargetSearch.d.ts +7 -0
- package/dist/pages/forms/MapTargetSearch.d.ts.map +1 -0
- package/dist/pages/forms/MapTargetSearch.js +32 -0
- package/dist/pages/forms/MapTargetSearch.js.map +1 -0
- package/dist/pages/forms/fieldList.d.ts +6 -7
- package/dist/pages/forms/fieldList.d.ts.map +1 -1
- package/dist/pages/forms/fieldList.js +14 -16
- package/dist/pages/forms/fieldList.js.map +1 -1
- package/dist/pages/forms/template.d.ts.map +1 -1
- package/dist/pages/forms/template.js +7 -0
- package/dist/pages/forms/template.js.map +1 -1
- package/package.json +2 -2
- package/src/api/resources/activities.ts +10 -2
- package/src/api/resources/conversions.ts +125 -0
- package/src/api/resources/enquiries.ts +9 -35
- package/src/api/resources/importJobs.ts +3 -2
- package/src/app/KelpieApp.tsx +1 -0
- package/src/components/ActivitiesPanel.tsx +20 -14
- package/src/components/ApiKeysPanel.tsx +206 -21
- package/src/components/ConvertRecordDialog.tsx +210 -0
- package/src/components/Shell.tsx +91 -36
- package/src/pages/DealDetail.tsx +35 -0
- package/src/pages/DecisionsPage.tsx +0 -14
- package/src/pages/EnquiryDetail.tsx +37 -90
- package/src/pages/ListDetail.tsx +0 -47
- package/src/pages/OpportunityDetail.tsx +35 -0
- package/src/pages/PartnershipDetail.tsx +35 -0
- package/src/pages/RaiseDetail.tsx +35 -0
- package/src/pages/admin/DataPage.tsx +14 -9
- package/src/pages/admin/PrivacyPage.tsx +4 -4
- package/src/pages/admin/TeamPage.tsx +1 -2
- package/src/pages/admin/WorkspaceSettingsPage.tsx +1 -31
- package/src/pages/forms/AddFieldMenu.tsx +2 -2
- package/src/pages/forms/FieldsEditor.tsx +45 -20
- package/src/pages/forms/MapTargetSearch.tsx +69 -0
- package/src/pages/forms/fieldList.ts +34 -16
- 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<
|
|
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:
|
|
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
|
|
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.
|
|
276
|
-
|
|
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
|
|
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
|
-
{
|
|
289
|
+
{EXPORT_OBJECTS.map((item) => (
|
|
287
290
|
<option key={item} value={item}>
|
|
288
|
-
{
|
|
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
|
|
135
|
+
purpose. Forms and imports that don’t need consent don’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-
|
|
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-
|
|
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-
|
|
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.
|
|
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,
|
|
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 {
|
|
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
|
-
{
|
|
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 {
|
|
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
|
-
<
|
|
497
|
+
<div className="block">
|
|
468
498
|
<span className="mb-1 block text-[11px] font-medium text-ink-faint">Maps to</span>
|
|
469
|
-
<
|
|
470
|
-
className={inputClass}
|
|
499
|
+
<MapTargetSearch
|
|
471
500
|
value={field.mapTo}
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
501
|
+
usedTargets={usedTargets}
|
|
502
|
+
onChange={(mapTo) => {
|
|
503
|
+
onChange({
|
|
504
|
+
mapTo,
|
|
505
|
+
type: typeForTarget(mapTo, field.type, customFieldDefinitions),
|
|
506
|
+
})
|
|
476
507
|
}}
|
|
477
|
-
|
|
478
|
-
|
|
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
|
|
32
|
-
|
|
33
|
-
|
|
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
|
|
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<
|
|
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
|
|
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(
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
return
|
|
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
|
/**
|