@kelpie/ui 0.8.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.
- package/dist/api/resource.d.ts +1 -1
- package/dist/api/resource.d.ts.map +1 -1
- package/dist/api/resource.js.map +1 -1
- package/dist/api/resources/consentPurposes.d.ts +13 -0
- package/dist/api/resources/consentPurposes.d.ts.map +1 -0
- package/dist/api/resources/consentPurposes.js +39 -0
- package/dist/api/resources/consentPurposes.js.map +1 -0
- package/dist/api/resources/customFields.d.ts +15 -0
- package/dist/api/resources/customFields.d.ts.map +1 -0
- package/dist/api/resources/customFields.js +41 -0
- package/dist/api/resources/customFields.js.map +1 -0
- package/dist/api/resources/enquiries.d.ts +39 -0
- package/dist/api/resources/enquiries.d.ts.map +1 -0
- package/dist/api/resources/enquiries.js +68 -0
- package/dist/api/resources/enquiries.js.map +1 -0
- package/dist/api/resources/forms.d.ts +21 -1
- package/dist/api/resources/forms.d.ts.map +1 -1
- package/dist/api/resources/forms.js +41 -0
- package/dist/api/resources/forms.js.map +1 -1
- package/dist/api/resources/importJobs.d.ts +5 -0
- package/dist/api/resources/importJobs.d.ts.map +1 -1
- package/dist/api/resources/importJobs.js +3 -0
- package/dist/api/resources/importJobs.js.map +1 -1
- package/dist/api/resources/opportunities.d.ts +2 -0
- package/dist/api/resources/opportunities.d.ts.map +1 -1
- package/dist/api/resources/opportunities.js +1 -0
- package/dist/api/resources/opportunities.js.map +1 -1
- package/dist/api/resources/sampleData.d.ts.map +1 -1
- package/dist/api/resources/sampleData.js +1 -0
- package/dist/api/resources/sampleData.js.map +1 -1
- package/dist/app/KelpieApp.d.ts.map +1 -1
- package/dist/app/KelpieApp.js +7 -1
- package/dist/app/KelpieApp.js.map +1 -1
- package/dist/components/ActivitiesPanel.d.ts.map +1 -1
- package/dist/components/ActivitiesPanel.js +1 -0
- package/dist/components/ActivitiesPanel.js.map +1 -1
- package/dist/components/CustomFieldsPanel.d.ts +23 -0
- package/dist/components/CustomFieldsPanel.d.ts.map +1 -0
- package/dist/components/CustomFieldsPanel.js +116 -0
- package/dist/components/CustomFieldsPanel.js.map +1 -0
- package/dist/components/FormsPanel.d.ts +18 -0
- package/dist/components/FormsPanel.d.ts.map +1 -0
- package/dist/components/FormsPanel.js +37 -0
- package/dist/components/FormsPanel.js.map +1 -0
- package/dist/components/PlanAttention.d.ts +3 -2
- package/dist/components/PlanAttention.d.ts.map +1 -1
- package/dist/components/PlanAttention.js +7 -3
- package/dist/components/PlanAttention.js.map +1 -1
- package/dist/components/Shell.d.ts.map +1 -1
- package/dist/components/Shell.js +3 -0
- package/dist/components/Shell.js.map +1 -1
- package/dist/components/useHasCustomFields.d.ts +13 -0
- package/dist/components/useHasCustomFields.d.ts.map +1 -0
- package/dist/components/useHasCustomFields.js +16 -0
- package/dist/components/useHasCustomFields.js.map +1 -0
- package/dist/pages/CompanyDetail.d.ts.map +1 -1
- package/dist/pages/CompanyDetail.js +24 -2
- package/dist/pages/CompanyDetail.js.map +1 -1
- package/dist/pages/DealDetail.d.ts.map +1 -1
- package/dist/pages/DealDetail.js +18 -1
- package/dist/pages/DealDetail.js.map +1 -1
- package/dist/pages/DecisionsPage.d.ts.map +1 -1
- package/dist/pages/DecisionsPage.js +2 -0
- package/dist/pages/DecisionsPage.js.map +1 -1
- package/dist/pages/EnquiriesPage.d.ts +2 -0
- package/dist/pages/EnquiriesPage.d.ts.map +1 -0
- package/dist/pages/EnquiriesPage.js +251 -0
- package/dist/pages/EnquiriesPage.js.map +1 -0
- package/dist/pages/EnquiryDetail.d.ts +2 -0
- package/dist/pages/EnquiryDetail.d.ts.map +1 -0
- package/dist/pages/EnquiryDetail.js +207 -0
- package/dist/pages/EnquiryDetail.js.map +1 -0
- package/dist/pages/EnquiryStageSettingsPage.d.ts +3 -0
- package/dist/pages/EnquiryStageSettingsPage.d.ts.map +1 -0
- package/dist/pages/EnquiryStageSettingsPage.js +34 -0
- package/dist/pages/EnquiryStageSettingsPage.js.map +1 -0
- package/dist/pages/FormDetail.d.ts.map +1 -1
- package/dist/pages/FormDetail.js +19 -1
- package/dist/pages/FormDetail.js.map +1 -1
- package/dist/pages/ListDetail.d.ts.map +1 -1
- package/dist/pages/ListDetail.js +27 -1
- package/dist/pages/ListDetail.js.map +1 -1
- package/dist/pages/OpportunityDetail.d.ts.map +1 -1
- package/dist/pages/OpportunityDetail.js +62 -3
- package/dist/pages/OpportunityDetail.js.map +1 -1
- package/dist/pages/PartnershipDetail.d.ts.map +1 -1
- package/dist/pages/PartnershipDetail.js +17 -1
- package/dist/pages/PartnershipDetail.js.map +1 -1
- package/dist/pages/PeoplePage.d.ts.map +1 -1
- package/dist/pages/PeoplePage.js +17 -0
- package/dist/pages/PeoplePage.js.map +1 -1
- package/dist/pages/PersonDetail.d.ts.map +1 -1
- package/dist/pages/PersonDetail.js +63 -3
- package/dist/pages/PersonDetail.js.map +1 -1
- package/dist/pages/PlanningPage.d.ts.map +1 -1
- package/dist/pages/PlanningPage.js +3 -0
- package/dist/pages/PlanningPage.js.map +1 -1
- package/dist/pages/RaiseDetail.d.ts.map +1 -1
- package/dist/pages/RaiseDetail.js +11 -1
- package/dist/pages/RaiseDetail.js.map +1 -1
- package/dist/pages/RoleDetail.js +1 -1
- package/dist/pages/RoleDetail.js.map +1 -1
- package/dist/pages/SearchPage.d.ts.map +1 -1
- package/dist/pages/SearchPage.js +1 -0
- package/dist/pages/SearchPage.js.map +1 -1
- package/dist/pages/admin/DataPage.d.ts.map +1 -1
- package/dist/pages/admin/DataPage.js +18 -3
- package/dist/pages/admin/DataPage.js.map +1 -1
- package/dist/pages/admin/FieldsPage.d.ts +2 -0
- package/dist/pages/admin/FieldsPage.d.ts.map +1 -0
- package/dist/pages/admin/FieldsPage.js +164 -0
- package/dist/pages/admin/FieldsPage.js.map +1 -0
- package/dist/pages/admin/PrivacyPage.d.ts +9 -0
- package/dist/pages/admin/PrivacyPage.d.ts.map +1 -0
- package/dist/pages/admin/PrivacyPage.js +170 -0
- package/dist/pages/admin/PrivacyPage.js.map +1 -0
- package/dist/pages/dashboard/attention.d.ts.map +1 -1
- package/dist/pages/dashboard/attention.js +3 -0
- package/dist/pages/dashboard/attention.js.map +1 -1
- package/dist/pages/forms/AddFieldMenu.d.ts +17 -0
- package/dist/pages/forms/AddFieldMenu.d.ts.map +1 -0
- package/dist/pages/forms/AddFieldMenu.js +43 -0
- package/dist/pages/forms/AddFieldMenu.js.map +1 -0
- package/dist/pages/forms/EmbedPanel.d.ts +4 -0
- package/dist/pages/forms/EmbedPanel.d.ts.map +1 -1
- package/dist/pages/forms/EmbedPanel.js +62 -3
- package/dist/pages/forms/EmbedPanel.js.map +1 -1
- package/dist/pages/forms/FieldsEditor.d.ts.map +1 -1
- package/dist/pages/forms/FieldsEditor.js +179 -36
- package/dist/pages/forms/FieldsEditor.js.map +1 -1
- package/dist/pages/forms/FormActions.d.ts +6 -0
- package/dist/pages/forms/FormActions.d.ts.map +1 -0
- package/dist/pages/forms/FormActions.js +149 -0
- package/dist/pages/forms/FormActions.js.map +1 -0
- package/dist/pages/forms/FormSettings.d.ts.map +1 -1
- package/dist/pages/forms/FormSettings.js +15 -19
- package/dist/pages/forms/FormSettings.js.map +1 -1
- package/dist/pages/forms/SubmissionDetail.d.ts +2 -0
- package/dist/pages/forms/SubmissionDetail.d.ts.map +1 -0
- package/dist/pages/forms/SubmissionDetail.js +76 -0
- package/dist/pages/forms/SubmissionDetail.js.map +1 -0
- package/dist/pages/forms/SubmissionsTable.d.ts.map +1 -1
- package/dist/pages/forms/SubmissionsTable.js +56 -8
- package/dist/pages/forms/SubmissionsTable.js.map +1 -1
- package/dist/pages/forms/fieldList.d.ts +11 -5
- package/dist/pages/forms/fieldList.d.ts.map +1 -1
- package/dist/pages/forms/fieldList.js +66 -6
- package/dist/pages/forms/fieldList.js.map +1 -1
- package/dist/pages/forms/template.d.ts +17 -0
- package/dist/pages/forms/template.d.ts.map +1 -1
- package/dist/pages/forms/template.js +93 -0
- package/dist/pages/forms/template.js.map +1 -1
- package/dist/pages/onboarding/WorkspaceStepPage.js +1 -1
- package/dist/pages/onboarding/WorkspaceStepPage.js.map +1 -1
- package/package.json +2 -2
- package/src/api/resource.ts +6 -1
- package/src/api/resources/consentPurposes.ts +83 -0
- package/src/api/resources/customFields.ts +88 -0
- package/src/api/resources/enquiries.ts +118 -0
- package/src/api/resources/forms.ts +58 -0
- package/src/api/resources/importJobs.ts +9 -0
- package/src/api/resources/opportunities.ts +3 -0
- package/src/api/resources/sampleData.ts +1 -0
- package/src/app/KelpieApp.tsx +12 -0
- package/src/components/ActivitiesPanel.tsx +1 -0
- package/src/components/CustomFieldsPanel.tsx +330 -0
- package/src/components/FormsPanel.tsx +86 -0
- package/src/components/PlanAttention.tsx +20 -3
- package/src/components/Shell.tsx +3 -0
- package/src/components/useHasCustomFields.ts +18 -0
- package/src/pages/CompanyDetail.tsx +36 -1
- package/src/pages/DealDetail.tsx +31 -0
- package/src/pages/DecisionsPage.tsx +2 -0
- package/src/pages/EnquiriesPage.tsx +434 -0
- package/src/pages/EnquiryDetail.tsx +554 -0
- package/src/pages/EnquiryStageSettingsPage.tsx +63 -0
- package/src/pages/FormDetail.tsx +23 -1
- package/src/pages/ListDetail.tsx +62 -0
- package/src/pages/OpportunityDetail.tsx +165 -2
- package/src/pages/PartnershipDetail.tsx +32 -0
- package/src/pages/PeoplePage.tsx +20 -0
- package/src/pages/PersonDetail.tsx +152 -3
- package/src/pages/PlanningPage.tsx +3 -0
- package/src/pages/RaiseDetail.tsx +21 -0
- package/src/pages/RoleDetail.tsx +1 -1
- package/src/pages/SearchPage.tsx +1 -0
- package/src/pages/admin/DataPage.tsx +51 -3
- package/src/pages/admin/FieldsPage.tsx +455 -0
- package/src/pages/admin/PrivacyPage.tsx +461 -0
- package/src/pages/dashboard/attention.ts +3 -0
- package/src/pages/forms/AddFieldMenu.tsx +121 -0
- package/src/pages/forms/EmbedPanel.tsx +163 -2
- package/src/pages/forms/FieldsEditor.tsx +539 -165
- package/src/pages/forms/FormActions.tsx +552 -0
- package/src/pages/forms/FormSettings.tsx +29 -70
- package/src/pages/forms/SubmissionDetail.tsx +249 -0
- package/src/pages/forms/SubmissionsTable.tsx +109 -16
- package/src/pages/forms/fieldList.ts +68 -5
- package/src/pages/forms/template.ts +103 -0
- package/src/pages/onboarding/WorkspaceStepPage.tsx +1 -1
|
@@ -0,0 +1,552 @@
|
|
|
1
|
+
import { PIPELINE_KINDS } from '@kelpie/schemas'
|
|
2
|
+
import type { Form, FormAttachTarget, PipelineKind } from '@kelpie/schemas'
|
|
3
|
+
import { useMemo, useState } from 'react'
|
|
4
|
+
|
|
5
|
+
import { useDeals } from '../../api/resources/deals.ts'
|
|
6
|
+
import { useEnquiries } from '../../api/resources/enquiries.ts'
|
|
7
|
+
import { useUpdateForm } from '../../api/resources/forms.ts'
|
|
8
|
+
import { useLists } from '../../api/resources/lists.ts'
|
|
9
|
+
import { useMembers } from '../../api/resources/members.ts'
|
|
10
|
+
import { useOpportunities } from '../../api/resources/opportunities.ts'
|
|
11
|
+
import { usePartnerships } from '../../api/resources/partnerships.ts'
|
|
12
|
+
import { usePipelineStages } from '../../api/resources/pipelineStages.ts'
|
|
13
|
+
import { useRaises } from '../../api/resources/raises.ts'
|
|
14
|
+
import { Chip } from '../../components/Chip.tsx'
|
|
15
|
+
import { ErrorPanel } from '../../components/QueryState.tsx'
|
|
16
|
+
import { SectionHeader } from '../../components/SectionHeader.tsx'
|
|
17
|
+
import { toTags } from '../fields.ts'
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* What the form does with a submission, once the answers are captured.
|
|
21
|
+
*
|
|
22
|
+
* The three create-triggers sit side by side here. Deal moved off the
|
|
23
|
+
* Settings tab in the forms post-submit actions release: the form's identity
|
|
24
|
+
* (name, status, thank-you copy) is one thing, and what the form *produces*
|
|
25
|
+
* — a deal, an opportunity, a partnership, tagged records, list memberships,
|
|
26
|
+
* pipeline attachments — is another. Actions is where a builder answers
|
|
27
|
+
* "what happens next?".
|
|
28
|
+
*
|
|
29
|
+
* Every control commits on change; the optimistic update in
|
|
30
|
+
* `createResourceHooks` puts the old value back if the PATCH is refused.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
const inputClass =
|
|
34
|
+
'w-full 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'
|
|
35
|
+
|
|
36
|
+
export interface FormActionsProps {
|
|
37
|
+
readonly form: Form
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function FormActions({ form }: FormActionsProps): React.JSX.Element {
|
|
41
|
+
const updateForm = useUpdateForm()
|
|
42
|
+
const patch = (changes: Parameters<typeof updateForm.run>[0]['changes']): void => {
|
|
43
|
+
updateForm.run({ id: form.id, changes })
|
|
44
|
+
}
|
|
45
|
+
const hasCompanyField = form.fields.some((field) => field.mapTo.startsWith('company.'))
|
|
46
|
+
|
|
47
|
+
return (
|
|
48
|
+
<div className="max-w-2xl space-y-6">
|
|
49
|
+
<SectionHeader
|
|
50
|
+
title="Actions"
|
|
51
|
+
description="What the form creates, tags, lists, and links every submitter to."
|
|
52
|
+
/>
|
|
53
|
+
|
|
54
|
+
{updateForm.error !== null && <ErrorPanel error={updateForm.error} />}
|
|
55
|
+
|
|
56
|
+
<TriggerBlock
|
|
57
|
+
form={form}
|
|
58
|
+
kind="deal"
|
|
59
|
+
toggle={form.createDeal}
|
|
60
|
+
kindValue={null}
|
|
61
|
+
stageId={form.dealStageId}
|
|
62
|
+
nameTemplate={form.dealNameTemplate}
|
|
63
|
+
ownerId={null}
|
|
64
|
+
requiresCompany
|
|
65
|
+
hasCompanyField={hasCompanyField}
|
|
66
|
+
onToggle={(next) => patch({ createDeal: next })}
|
|
67
|
+
onStage={(next) => patch({ dealStageId: next })}
|
|
68
|
+
onTemplate={(next) => patch({ dealNameTemplate: next })}
|
|
69
|
+
/>
|
|
70
|
+
|
|
71
|
+
<TriggerBlock
|
|
72
|
+
form={form}
|
|
73
|
+
kind="opportunity"
|
|
74
|
+
toggle={form.createOpportunity}
|
|
75
|
+
kindValue={form.opportunityKind}
|
|
76
|
+
stageId={form.opportunityStageId}
|
|
77
|
+
nameTemplate={form.opportunityNameTemplate}
|
|
78
|
+
ownerId={form.opportunityOwnerId}
|
|
79
|
+
onToggle={(next) => patch({ createOpportunity: next })}
|
|
80
|
+
onKind={(next) => patch({ opportunityKind: next })}
|
|
81
|
+
onStage={(next) => patch({ opportunityStageId: next })}
|
|
82
|
+
onTemplate={(next) => patch({ opportunityNameTemplate: next })}
|
|
83
|
+
onOwner={(next) => patch({ opportunityOwnerId: next })}
|
|
84
|
+
/>
|
|
85
|
+
|
|
86
|
+
<TriggerBlock
|
|
87
|
+
form={form}
|
|
88
|
+
kind="partnership"
|
|
89
|
+
toggle={form.createPartnership}
|
|
90
|
+
kindValue={form.partnershipKind}
|
|
91
|
+
stageId={form.partnershipStageId}
|
|
92
|
+
nameTemplate={form.partnershipNameTemplate}
|
|
93
|
+
ownerId={form.partnershipOwnerId}
|
|
94
|
+
requiresCompany
|
|
95
|
+
hasCompanyField={hasCompanyField}
|
|
96
|
+
onToggle={(next) => patch({ createPartnership: next })}
|
|
97
|
+
onKind={(next) => patch({ partnershipKind: next })}
|
|
98
|
+
onStage={(next) => patch({ partnershipStageId: next })}
|
|
99
|
+
onTemplate={(next) => patch({ partnershipNameTemplate: next })}
|
|
100
|
+
onOwner={(next) => patch({ partnershipOwnerId: next })}
|
|
101
|
+
/>
|
|
102
|
+
|
|
103
|
+
<TriggerBlock
|
|
104
|
+
form={form}
|
|
105
|
+
kind="enquiry"
|
|
106
|
+
toggle={form.createEnquiry}
|
|
107
|
+
kindValue={null}
|
|
108
|
+
stageId={form.enquiryStageId}
|
|
109
|
+
nameTemplate={form.enquiryNameTemplate}
|
|
110
|
+
ownerId={form.enquiryOwnerId}
|
|
111
|
+
onToggle={(next) => patch({ createEnquiry: next })}
|
|
112
|
+
onStage={(next) => patch({ enquiryStageId: next })}
|
|
113
|
+
onTemplate={(next) => patch({ enquiryNameTemplate: next })}
|
|
114
|
+
onOwner={(next) => patch({ enquiryOwnerId: next })}
|
|
115
|
+
/>
|
|
116
|
+
|
|
117
|
+
<TagsBlock
|
|
118
|
+
title="Tag the person"
|
|
119
|
+
hint="Merged into the submitter's tags. Never removes a tag someone set by hand."
|
|
120
|
+
value={form.personTags}
|
|
121
|
+
onChange={(next) => patch({ personTags: next })}
|
|
122
|
+
/>
|
|
123
|
+
<TagsBlock
|
|
124
|
+
title="Tag the company"
|
|
125
|
+
hint="Merged into the resolved company's tags. Skipped when no company is resolved."
|
|
126
|
+
value={form.companyTags}
|
|
127
|
+
onChange={(next) => patch({ companyTags: next })}
|
|
128
|
+
/>
|
|
129
|
+
|
|
130
|
+
<ListsBlock form={form} onChange={(next) => patch({ listIds: next })} />
|
|
131
|
+
|
|
132
|
+
<AttachTargetsBlock form={form} onChange={(next) => patch({ attachTargets: next })} />
|
|
133
|
+
</div>
|
|
134
|
+
)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// -------- Create-trigger block --------
|
|
138
|
+
|
|
139
|
+
interface TriggerBlockProps {
|
|
140
|
+
readonly form: Form
|
|
141
|
+
readonly kind: PipelineKind
|
|
142
|
+
readonly toggle: boolean
|
|
143
|
+
readonly kindValue: string | null
|
|
144
|
+
readonly stageId: string | null
|
|
145
|
+
readonly nameTemplate: string | null
|
|
146
|
+
readonly ownerId: string | null
|
|
147
|
+
readonly requiresCompany?: boolean
|
|
148
|
+
readonly hasCompanyField?: boolean
|
|
149
|
+
readonly onToggle: (next: boolean) => void
|
|
150
|
+
readonly onKind?: (next: string | null) => void
|
|
151
|
+
readonly onStage: (next: string | null) => void
|
|
152
|
+
readonly onTemplate: (next: string | null) => void
|
|
153
|
+
readonly onOwner?: (next: string | null) => void
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const TRIGGER_LABEL: Readonly<Record<PipelineKind, string>> = {
|
|
157
|
+
enquiry: 'Create an Enquiry',
|
|
158
|
+
deal: 'Create a Deal',
|
|
159
|
+
opportunity: 'Create an Opportunity',
|
|
160
|
+
raise: 'Create a Raise',
|
|
161
|
+
partnership: 'Create a Partnership',
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function TriggerBlock(props: TriggerBlockProps): React.JSX.Element {
|
|
165
|
+
const stages = usePipelineStages(props.kind)
|
|
166
|
+
const members = useMembers()
|
|
167
|
+
const disabled = props.requiresCompany === true && props.hasCompanyField !== true && !props.toggle
|
|
168
|
+
const showsKind = props.onKind !== undefined
|
|
169
|
+
const showsOwner = props.onOwner !== undefined
|
|
170
|
+
|
|
171
|
+
return (
|
|
172
|
+
<div className="space-y-3 rounded-md border border-border p-4">
|
|
173
|
+
<label className="flex items-center gap-2 text-[13px] font-medium text-ink">
|
|
174
|
+
<input
|
|
175
|
+
type="checkbox"
|
|
176
|
+
checked={props.toggle}
|
|
177
|
+
disabled={disabled}
|
|
178
|
+
onChange={(event) => props.onToggle(event.target.checked)}
|
|
179
|
+
/>
|
|
180
|
+
{TRIGGER_LABEL[props.kind]}
|
|
181
|
+
</label>
|
|
182
|
+
|
|
183
|
+
{props.requiresCompany === true && props.hasCompanyField !== true && (
|
|
184
|
+
<p className="text-[12px] text-danger">
|
|
185
|
+
Add a field mapped to Company · name or Company · domain first. A {props.kind} belongs to
|
|
186
|
+
a company, and this form collects neither.
|
|
187
|
+
</p>
|
|
188
|
+
)}
|
|
189
|
+
|
|
190
|
+
{props.toggle && (
|
|
191
|
+
<>
|
|
192
|
+
{showsKind && (
|
|
193
|
+
<Labelled
|
|
194
|
+
label="Kind"
|
|
195
|
+
hint={`Optional. Free text: "Grant", "Accelerator", …`}
|
|
196
|
+
>
|
|
197
|
+
<input
|
|
198
|
+
className={inputClass}
|
|
199
|
+
defaultValue={props.kindValue ?? ''}
|
|
200
|
+
onBlur={(event) => {
|
|
201
|
+
const next = event.target.value.length === 0 ? null : event.target.value
|
|
202
|
+
|
|
203
|
+
if (next !== props.kindValue) {
|
|
204
|
+
props.onKind?.(next)
|
|
205
|
+
}
|
|
206
|
+
}}
|
|
207
|
+
/>
|
|
208
|
+
</Labelled>
|
|
209
|
+
)}
|
|
210
|
+
|
|
211
|
+
<Labelled
|
|
212
|
+
label="Stage"
|
|
213
|
+
hint="Leaving this on the first open stage follows the board, so reordering it does not strand old forms on a column that moved."
|
|
214
|
+
>
|
|
215
|
+
<select
|
|
216
|
+
className={inputClass}
|
|
217
|
+
value={props.stageId ?? ''}
|
|
218
|
+
onChange={(event) => {
|
|
219
|
+
props.onStage(event.target.value.length === 0 ? null : event.target.value)
|
|
220
|
+
}}
|
|
221
|
+
>
|
|
222
|
+
<option value="">First open stage</option>
|
|
223
|
+
{stages.records.map((stage) => (
|
|
224
|
+
<option key={stage.id} value={stage.id}>
|
|
225
|
+
{stage.label}
|
|
226
|
+
</option>
|
|
227
|
+
))}
|
|
228
|
+
</select>
|
|
229
|
+
</Labelled>
|
|
230
|
+
|
|
231
|
+
<Labelled
|
|
232
|
+
label="Name template"
|
|
233
|
+
hint="Tokens: {{company.name}} and {{person.name}}. A field mapped to the record's name wins over this."
|
|
234
|
+
>
|
|
235
|
+
<input
|
|
236
|
+
className={inputClass}
|
|
237
|
+
defaultValue={props.nameTemplate ?? ''}
|
|
238
|
+
onBlur={(event) => {
|
|
239
|
+
const next = event.target.value.length === 0 ? null : event.target.value
|
|
240
|
+
|
|
241
|
+
if (next !== props.nameTemplate) {
|
|
242
|
+
props.onTemplate(next)
|
|
243
|
+
}
|
|
244
|
+
}}
|
|
245
|
+
/>
|
|
246
|
+
</Labelled>
|
|
247
|
+
|
|
248
|
+
{showsOwner && (
|
|
249
|
+
<Labelled label="Owner" hint="Empty falls back to the workspace default member.">
|
|
250
|
+
<select
|
|
251
|
+
className={inputClass}
|
|
252
|
+
value={props.ownerId ?? ''}
|
|
253
|
+
onChange={(event) => {
|
|
254
|
+
props.onOwner?.(event.target.value.length === 0 ? null : event.target.value)
|
|
255
|
+
}}
|
|
256
|
+
>
|
|
257
|
+
<option value="">Workspace default</option>
|
|
258
|
+
{members.members.map((member) => (
|
|
259
|
+
<option key={member.id} value={member.id}>
|
|
260
|
+
{member.name}
|
|
261
|
+
</option>
|
|
262
|
+
))}
|
|
263
|
+
</select>
|
|
264
|
+
</Labelled>
|
|
265
|
+
)}
|
|
266
|
+
</>
|
|
267
|
+
)}
|
|
268
|
+
</div>
|
|
269
|
+
)
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// -------- Tags --------
|
|
273
|
+
|
|
274
|
+
function TagsBlock({
|
|
275
|
+
title,
|
|
276
|
+
hint,
|
|
277
|
+
value,
|
|
278
|
+
onChange,
|
|
279
|
+
}: {
|
|
280
|
+
readonly title: string
|
|
281
|
+
readonly hint: string
|
|
282
|
+
readonly value: readonly string[]
|
|
283
|
+
readonly onChange: (next: readonly string[]) => void
|
|
284
|
+
}): React.JSX.Element {
|
|
285
|
+
return (
|
|
286
|
+
<div className="space-y-2 rounded-md border border-border p-4">
|
|
287
|
+
<div className="text-[13px] font-medium text-ink">{title}</div>
|
|
288
|
+
<input
|
|
289
|
+
className={inputClass}
|
|
290
|
+
defaultValue={value.join(', ')}
|
|
291
|
+
onBlur={(event) => {
|
|
292
|
+
const next = toTags(event.target.value)
|
|
293
|
+
const changed =
|
|
294
|
+
next.length !== value.length || next.some((tag, index) => tag !== value[index])
|
|
295
|
+
|
|
296
|
+
if (changed) {
|
|
297
|
+
onChange(next)
|
|
298
|
+
}
|
|
299
|
+
}}
|
|
300
|
+
/>
|
|
301
|
+
<div className="flex flex-wrap gap-1">
|
|
302
|
+
{value.map((tag) => (
|
|
303
|
+
<Chip key={tag}>{tag}</Chip>
|
|
304
|
+
))}
|
|
305
|
+
</div>
|
|
306
|
+
<p className="text-[11px] text-ink-faint">{hint}</p>
|
|
307
|
+
</div>
|
|
308
|
+
)
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// -------- Lists --------
|
|
312
|
+
|
|
313
|
+
function ListsBlock({
|
|
314
|
+
form,
|
|
315
|
+
onChange,
|
|
316
|
+
}: {
|
|
317
|
+
readonly form: Form
|
|
318
|
+
readonly onChange: (next: readonly string[]) => void
|
|
319
|
+
}): React.JSX.Element {
|
|
320
|
+
const personLists = useLists({ targetType: 'person' })
|
|
321
|
+
const companyLists = useLists({ targetType: 'company' })
|
|
322
|
+
const eligible = useMemo(
|
|
323
|
+
() => [...personLists.records, ...companyLists.records],
|
|
324
|
+
[personLists.records, companyLists.records],
|
|
325
|
+
)
|
|
326
|
+
const eligibleById = new Map(eligible.map((list) => [list.id, list]))
|
|
327
|
+
const [pick, setPick] = useState('')
|
|
328
|
+
const chosen = form.listIds
|
|
329
|
+
const remaining = eligible.filter((list) => !chosen.includes(list.id))
|
|
330
|
+
|
|
331
|
+
function add(): void {
|
|
332
|
+
if (pick.length === 0 || chosen.includes(pick)) {
|
|
333
|
+
return
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
onChange([...chosen, pick])
|
|
337
|
+
setPick('')
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
return (
|
|
341
|
+
<div className="space-y-2 rounded-md border border-border p-4">
|
|
342
|
+
<div className="text-[13px] font-medium text-ink">Add to a list</div>
|
|
343
|
+
<p className="text-[11px] text-ink-faint">
|
|
344
|
+
Person lists get the submitter. Company lists get the resolved company, or are skipped when
|
|
345
|
+
no company is resolved.
|
|
346
|
+
</p>
|
|
347
|
+
<ul className="divide-y divide-border">
|
|
348
|
+
{chosen.length === 0 && (
|
|
349
|
+
<li className="py-2 text-[12px] text-ink-faint">No lists yet.</li>
|
|
350
|
+
)}
|
|
351
|
+
{chosen.map((listId) => {
|
|
352
|
+
const list = eligibleById.get(listId)
|
|
353
|
+
|
|
354
|
+
return (
|
|
355
|
+
<li key={listId} className="flex items-center justify-between gap-2 py-2">
|
|
356
|
+
<span className="text-[13px] text-ink">
|
|
357
|
+
{list === undefined ? listId : `${list.name} (${list.targetType})`}
|
|
358
|
+
</span>
|
|
359
|
+
<button
|
|
360
|
+
type="button"
|
|
361
|
+
onClick={() => onChange(chosen.filter((id) => id !== listId))}
|
|
362
|
+
className="text-[11px] font-medium text-danger hover:underline"
|
|
363
|
+
>
|
|
364
|
+
Remove
|
|
365
|
+
</button>
|
|
366
|
+
</li>
|
|
367
|
+
)
|
|
368
|
+
})}
|
|
369
|
+
</ul>
|
|
370
|
+
<div className="flex items-center gap-2">
|
|
371
|
+
<select
|
|
372
|
+
className={`${inputClass} min-w-0 flex-1`}
|
|
373
|
+
value={pick}
|
|
374
|
+
onChange={(event) => setPick(event.target.value)}
|
|
375
|
+
>
|
|
376
|
+
<option value="">Pick a list…</option>
|
|
377
|
+
{remaining.map((list) => (
|
|
378
|
+
<option key={list.id} value={list.id}>
|
|
379
|
+
{list.name} ({list.targetType})
|
|
380
|
+
</option>
|
|
381
|
+
))}
|
|
382
|
+
</select>
|
|
383
|
+
<button
|
|
384
|
+
type="button"
|
|
385
|
+
onClick={add}
|
|
386
|
+
className="shrink-0 rounded-md bg-accent px-3 py-2 text-[12px] font-semibold text-accent-fg hover:bg-accent-hover disabled:opacity-50"
|
|
387
|
+
disabled={pick.length === 0}
|
|
388
|
+
>
|
|
389
|
+
Add
|
|
390
|
+
</button>
|
|
391
|
+
</div>
|
|
392
|
+
</div>
|
|
393
|
+
)
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// -------- Attach targets --------
|
|
397
|
+
|
|
398
|
+
interface AttachRecord {
|
|
399
|
+
readonly id: string
|
|
400
|
+
readonly name: string
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function useAttachOptions(kind: PipelineKind): readonly AttachRecord[] {
|
|
404
|
+
const deals = useDeals({}, { enabled: kind === 'deal' })
|
|
405
|
+
const opportunities = useOpportunities({}, { enabled: kind === 'opportunity' })
|
|
406
|
+
const raises = useRaises({}, { enabled: kind === 'raise' })
|
|
407
|
+
const partnerships = usePartnerships({}, { enabled: kind === 'partnership' })
|
|
408
|
+
const enquiries = useEnquiries({}, { enabled: kind === 'enquiry' })
|
|
409
|
+
|
|
410
|
+
switch (kind) {
|
|
411
|
+
case 'deal':
|
|
412
|
+
return deals.records.map((deal) => ({ id: deal.id, name: deal.name }))
|
|
413
|
+
case 'opportunity':
|
|
414
|
+
return opportunities.records.map((opp) => ({ id: opp.id, name: opp.name }))
|
|
415
|
+
case 'raise':
|
|
416
|
+
return raises.records.map((raise) => ({ id: raise.id, name: raise.name }))
|
|
417
|
+
case 'partnership':
|
|
418
|
+
return partnerships.records.map((partnership) => ({
|
|
419
|
+
id: partnership.id,
|
|
420
|
+
name: partnership.name,
|
|
421
|
+
}))
|
|
422
|
+
case 'enquiry':
|
|
423
|
+
return enquiries.records.map((enquiry) => ({ id: enquiry.id, name: enquiry.name }))
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function AttachTargetsBlock({
|
|
428
|
+
form,
|
|
429
|
+
onChange,
|
|
430
|
+
}: {
|
|
431
|
+
readonly form: Form
|
|
432
|
+
readonly onChange: (next: readonly FormAttachTarget[]) => void
|
|
433
|
+
}): React.JSX.Element {
|
|
434
|
+
const [kind, setKind] = useState<PipelineKind>('deal')
|
|
435
|
+
const [pick, setPick] = useState('')
|
|
436
|
+
const options = useAttachOptions(kind)
|
|
437
|
+
const chosen = form.attachTargets
|
|
438
|
+
const chosenKey = new Set(chosen.map((target) => `${target.targetType}:${target.targetId}`))
|
|
439
|
+
const remaining = options.filter(
|
|
440
|
+
(record) => !chosenKey.has(`${kind}:${record.id}`),
|
|
441
|
+
)
|
|
442
|
+
|
|
443
|
+
function add(): void {
|
|
444
|
+
if (pick.length === 0) {
|
|
445
|
+
return
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
const key = `${kind}:${pick}`
|
|
449
|
+
|
|
450
|
+
if (chosenKey.has(key)) {
|
|
451
|
+
return
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
onChange([...chosen, { targetType: kind, targetId: pick }])
|
|
455
|
+
setPick('')
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
return (
|
|
459
|
+
<div className="space-y-2 rounded-md border border-border p-4">
|
|
460
|
+
<div className="text-[13px] font-medium text-ink">Attach the submitter to a record</div>
|
|
461
|
+
<p className="text-[11px] text-ink-faint">
|
|
462
|
+
Every submitter is linked into these records through <code>person_links</code>.
|
|
463
|
+
</p>
|
|
464
|
+
<ul className="divide-y divide-border">
|
|
465
|
+
{chosen.length === 0 && (
|
|
466
|
+
<li className="py-2 text-[12px] text-ink-faint">No records yet.</li>
|
|
467
|
+
)}
|
|
468
|
+
{chosen.map((target) => (
|
|
469
|
+
<li
|
|
470
|
+
key={`${target.targetType}:${target.targetId}`}
|
|
471
|
+
className="flex items-center justify-between gap-2 py-2"
|
|
472
|
+
>
|
|
473
|
+
<span className="text-[13px] text-ink">
|
|
474
|
+
{target.targetType} · {target.targetId}
|
|
475
|
+
</span>
|
|
476
|
+
<button
|
|
477
|
+
type="button"
|
|
478
|
+
onClick={() =>
|
|
479
|
+
onChange(
|
|
480
|
+
chosen.filter(
|
|
481
|
+
(candidate) =>
|
|
482
|
+
candidate.targetType !== target.targetType ||
|
|
483
|
+
candidate.targetId !== target.targetId,
|
|
484
|
+
),
|
|
485
|
+
)
|
|
486
|
+
}
|
|
487
|
+
className="text-[11px] font-medium text-danger hover:underline"
|
|
488
|
+
>
|
|
489
|
+
Remove
|
|
490
|
+
</button>
|
|
491
|
+
</li>
|
|
492
|
+
))}
|
|
493
|
+
</ul>
|
|
494
|
+
<div className="grid grid-cols-[auto_1fr_auto] items-center gap-2">
|
|
495
|
+
<select
|
|
496
|
+
className={inputClass}
|
|
497
|
+
value={kind}
|
|
498
|
+
onChange={(event) => {
|
|
499
|
+
setKind(event.target.value as PipelineKind)
|
|
500
|
+
setPick('')
|
|
501
|
+
}}
|
|
502
|
+
>
|
|
503
|
+
{PIPELINE_KINDS.map((option) => (
|
|
504
|
+
<option key={option} value={option}>
|
|
505
|
+
{option}
|
|
506
|
+
</option>
|
|
507
|
+
))}
|
|
508
|
+
</select>
|
|
509
|
+
<select
|
|
510
|
+
className={inputClass}
|
|
511
|
+
value={pick}
|
|
512
|
+
onChange={(event) => setPick(event.target.value)}
|
|
513
|
+
>
|
|
514
|
+
<option value="">Pick a record…</option>
|
|
515
|
+
{remaining.map((record) => (
|
|
516
|
+
<option key={record.id} value={record.id}>
|
|
517
|
+
{record.name}
|
|
518
|
+
</option>
|
|
519
|
+
))}
|
|
520
|
+
</select>
|
|
521
|
+
<button
|
|
522
|
+
type="button"
|
|
523
|
+
onClick={add}
|
|
524
|
+
className="rounded-md bg-accent px-3 py-2 text-[12px] font-semibold text-accent-fg hover:bg-accent-hover disabled:opacity-50"
|
|
525
|
+
disabled={pick.length === 0}
|
|
526
|
+
>
|
|
527
|
+
Add
|
|
528
|
+
</button>
|
|
529
|
+
</div>
|
|
530
|
+
</div>
|
|
531
|
+
)
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// -------- Shared bits --------
|
|
535
|
+
|
|
536
|
+
function Labelled({
|
|
537
|
+
label,
|
|
538
|
+
hint,
|
|
539
|
+
children,
|
|
540
|
+
}: {
|
|
541
|
+
readonly label: string
|
|
542
|
+
readonly hint?: string
|
|
543
|
+
readonly children: React.ReactNode
|
|
544
|
+
}): React.JSX.Element {
|
|
545
|
+
return (
|
|
546
|
+
<label className="block">
|
|
547
|
+
<span className="mb-1.5 block text-[12px] font-medium text-ink">{label}</span>
|
|
548
|
+
{children}
|
|
549
|
+
{hint !== undefined && <p className="mt-1 text-[11px] text-ink-faint">{hint}</p>}
|
|
550
|
+
</label>
|
|
551
|
+
)
|
|
552
|
+
}
|
|
@@ -1,18 +1,18 @@
|
|
|
1
1
|
import { FORM_STATUSES } from '@kelpie/schemas'
|
|
2
2
|
import type { Form, FormStatus } from '@kelpie/schemas'
|
|
3
3
|
|
|
4
|
-
import { usePipelineStages } from '../../api/resources/pipelineStages.ts'
|
|
5
4
|
import { useUpdateForm } from '../../api/resources/forms.ts'
|
|
6
5
|
import { ErrorPanel } from '../../components/QueryState.tsx'
|
|
7
6
|
import { SectionHeader } from '../../components/SectionHeader.tsx'
|
|
8
7
|
|
|
9
8
|
/**
|
|
10
|
-
*
|
|
9
|
+
* The form's identity: status, public heading, thank-you copy, and the public key.
|
|
11
10
|
*
|
|
12
11
|
* Every control commits on change, because each one is a single field and the
|
|
13
12
|
* optimistic update in `createResourceHooks` puts the old value back if the
|
|
14
13
|
* PATCH is refused. The field list is the exception and has its own Save; see
|
|
15
|
-
* `FieldsEditor`.
|
|
14
|
+
* `FieldsEditor`. What the form *does* on submit — create records, tag them,
|
|
15
|
+
* add to lists, attach the submitter — lives on the Actions tab.
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
18
|
const inputClass =
|
|
@@ -24,17 +24,16 @@ export interface FormSettingsProps {
|
|
|
24
24
|
|
|
25
25
|
export function FormSettings({ form }: FormSettingsProps): React.JSX.Element {
|
|
26
26
|
const updateForm = useUpdateForm()
|
|
27
|
-
const stages = usePipelineStages('deal')
|
|
28
27
|
const patch = (changes: Parameters<typeof updateForm.run>[0]['changes']): void => {
|
|
29
28
|
updateForm.run({ id: form.id, changes })
|
|
30
29
|
}
|
|
31
30
|
|
|
32
|
-
/** The server refuses `create_deal` without a company mapping, so say so before it does. */
|
|
33
|
-
const hasCompanyField = form.fields.some((field) => field.mapTo.startsWith('company.'))
|
|
34
|
-
|
|
35
31
|
return (
|
|
36
32
|
<div className="max-w-xl space-y-4">
|
|
37
|
-
<SectionHeader
|
|
33
|
+
<SectionHeader
|
|
34
|
+
title="Settings"
|
|
35
|
+
description="Status, the public form title, thank-you copy, and the public key."
|
|
36
|
+
/>
|
|
38
37
|
|
|
39
38
|
{updateForm.error !== null && <ErrorPanel error={updateForm.error} />}
|
|
40
39
|
|
|
@@ -55,6 +54,24 @@ export function FormSettings({ form }: FormSettingsProps): React.JSX.Element {
|
|
|
55
54
|
<Hint>A paused form still renders where it is embedded, and says it is closed.</Hint>
|
|
56
55
|
</Labelled>
|
|
57
56
|
|
|
57
|
+
<Labelled label="Form title">
|
|
58
|
+
<input
|
|
59
|
+
className={inputClass}
|
|
60
|
+
defaultValue={form.title}
|
|
61
|
+
onBlur={(event) => {
|
|
62
|
+
const next = event.target.value.trim()
|
|
63
|
+
if (next.length > 0 && next !== form.title) {
|
|
64
|
+
patch({ title: next })
|
|
65
|
+
} else if (next.length === 0) {
|
|
66
|
+
event.target.value = form.title
|
|
67
|
+
}
|
|
68
|
+
}}
|
|
69
|
+
/>
|
|
70
|
+
<Hint>
|
|
71
|
+
Shown as the heading on the hosted and embedded form. Defaults to the form name.
|
|
72
|
+
</Hint>
|
|
73
|
+
</Labelled>
|
|
74
|
+
|
|
58
75
|
<Labelled label="Thank-you message">
|
|
59
76
|
<textarea
|
|
60
77
|
className={`${inputClass} min-h-[72px]`}
|
|
@@ -68,68 +85,10 @@ export function FormSettings({ form }: FormSettingsProps): React.JSX.Element {
|
|
|
68
85
|
<Hint>Shown in place of the form once a submission lands.</Hint>
|
|
69
86
|
</Labelled>
|
|
70
87
|
|
|
71
|
-
<
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
checked={form.createDeal}
|
|
76
|
-
disabled={!hasCompanyField && !form.createDeal}
|
|
77
|
-
onChange={(event) => {
|
|
78
|
-
patch({ createDeal: event.target.checked })
|
|
79
|
-
}}
|
|
80
|
-
/>
|
|
81
|
-
Create a Deal on submit
|
|
82
|
-
</label>
|
|
83
|
-
|
|
84
|
-
{!hasCompanyField && (
|
|
85
|
-
<p className="text-[12px] text-danger">
|
|
86
|
-
Add a field mapped to Company · name or Company · domain first. A deal belongs to a
|
|
87
|
-
company, and this form collects neither.
|
|
88
|
-
</p>
|
|
89
|
-
)}
|
|
90
|
-
|
|
91
|
-
{form.createDeal && (
|
|
92
|
-
<>
|
|
93
|
-
<Labelled label="Deal stage">
|
|
94
|
-
<select
|
|
95
|
-
className={inputClass}
|
|
96
|
-
value={form.dealStageId ?? ''}
|
|
97
|
-
onChange={(event) => {
|
|
98
|
-
patch({ dealStageId: event.target.value.length === 0 ? null : event.target.value })
|
|
99
|
-
}}
|
|
100
|
-
>
|
|
101
|
-
<option value="">First open stage</option>
|
|
102
|
-
{stages.records.map((stage) => (
|
|
103
|
-
<option key={stage.id} value={stage.id}>
|
|
104
|
-
{stage.label}
|
|
105
|
-
</option>
|
|
106
|
-
))}
|
|
107
|
-
</select>
|
|
108
|
-
<Hint>
|
|
109
|
-
Leaving this on the first open stage follows the board, so reordering it does not
|
|
110
|
-
strand old forms on a column that moved.
|
|
111
|
-
</Hint>
|
|
112
|
-
</Labelled>
|
|
113
|
-
|
|
114
|
-
<Labelled label="Deal name template">
|
|
115
|
-
<input
|
|
116
|
-
className={inputClass}
|
|
117
|
-
defaultValue={form.dealNameTemplate ?? ''}
|
|
118
|
-
onBlur={(event) => {
|
|
119
|
-
const next = event.target.value.length === 0 ? null : event.target.value
|
|
120
|
-
|
|
121
|
-
if (next !== form.dealNameTemplate) {
|
|
122
|
-
patch({ dealNameTemplate: next })
|
|
123
|
-
}
|
|
124
|
-
}}
|
|
125
|
-
/>
|
|
126
|
-
<Hint>
|
|
127
|
-
{'Tokens: {{company.name}} and {{person.name}}. A field mapped to Deal · name wins over this.'}
|
|
128
|
-
</Hint>
|
|
129
|
-
</Labelled>
|
|
130
|
-
</>
|
|
131
|
-
)}
|
|
132
|
-
</div>
|
|
88
|
+
<p className="rounded-md border border-border bg-surface px-3 py-2 text-[12px] text-ink-muted">
|
|
89
|
+
Deal, opportunity, partnership, tags, lists, and attached records live on the{' '}
|
|
90
|
+
<strong className="font-medium text-ink">Actions</strong> tab.
|
|
91
|
+
</p>
|
|
133
92
|
|
|
134
93
|
<Labelled label="Public key">
|
|
135
94
|
<input className={`${inputClass} font-mono text-[12px]`} value={form.publicKey} readOnly />
|