@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,455 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CUSTOM_FIELD_OBJECT_TYPE_LABELS,
|
|
3
|
+
CUSTOM_FIELD_OBJECT_TYPES,
|
|
4
|
+
CUSTOM_FIELD_TYPE_LABELS,
|
|
5
|
+
CUSTOM_FIELD_TYPES,
|
|
6
|
+
CUSTOM_FIELD_TYPES_WITH_OPTIONS,
|
|
7
|
+
} from '@kelpie/schemas'
|
|
8
|
+
import type {
|
|
9
|
+
CustomFieldDefinition,
|
|
10
|
+
CustomFieldObjectType,
|
|
11
|
+
CustomFieldType,
|
|
12
|
+
} from '@kelpie/schemas'
|
|
13
|
+
import { useMemo, useState } from 'react'
|
|
14
|
+
import type { FormEvent } from 'react'
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
useCreateCustomField,
|
|
18
|
+
useCustomFields,
|
|
19
|
+
useDeleteCustomField,
|
|
20
|
+
useUpdateCustomField,
|
|
21
|
+
} from '../../api/resources/customFields.ts'
|
|
22
|
+
import { useSession } from '../../api/resources/session.ts'
|
|
23
|
+
import { AddButton } from '../../components/SectionHeader.tsx'
|
|
24
|
+
import { PageHeader } from '../../components/PageHeader.tsx'
|
|
25
|
+
import { ErrorPanel, LoadingPanel } from '../../components/QueryState.tsx'
|
|
26
|
+
import { SegmentedControl } from '../../components/SegmentedControl.tsx'
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Admin surface for workspace-defined fields.
|
|
30
|
+
*
|
|
31
|
+
* Admin-only, matching the pattern from `WebhooksPage` — a definition changes
|
|
32
|
+
* the shape of every record of one object type and is team-wide config, not a
|
|
33
|
+
* per-user preference.
|
|
34
|
+
*
|
|
35
|
+
* Reorder in v1 is server-side via `PATCH sort_order`; a drag-and-drop UI is
|
|
36
|
+
* the next tweak and does not change the wire.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
const OBJECT_TYPE_OPTIONS: readonly { id: CustomFieldObjectType; label: string }[] =
|
|
40
|
+
CUSTOM_FIELD_OBJECT_TYPES.map((id) => ({
|
|
41
|
+
id,
|
|
42
|
+
label: CUSTOM_FIELD_OBJECT_TYPE_LABELS[id],
|
|
43
|
+
}))
|
|
44
|
+
|
|
45
|
+
const OPTIONS_TYPES: ReadonlySet<CustomFieldType> = new Set(CUSTOM_FIELD_TYPES_WITH_OPTIONS)
|
|
46
|
+
|
|
47
|
+
export function FieldsPage(): React.JSX.Element {
|
|
48
|
+
const { session } = useSession()
|
|
49
|
+
const isAdmin = session?.role === 'owner' || session?.role === 'admin'
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<div className="animate-slide-in mx-auto max-w-4xl space-y-6">
|
|
53
|
+
<PageHeader
|
|
54
|
+
title="Custom fields"
|
|
55
|
+
description="Add workspace-defined fields to the six main record types. Values on records go through the same API the UI uses."
|
|
56
|
+
/>
|
|
57
|
+
{isAdmin ? <FieldsAdmin /> : <MemberNotice />}
|
|
58
|
+
</div>
|
|
59
|
+
)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function MemberNotice(): React.JSX.Element {
|
|
63
|
+
return (
|
|
64
|
+
<p className="rounded-md border border-border px-4 py-3 text-[13px] text-ink-muted">
|
|
65
|
+
Custom fields are managed by workspace admins.
|
|
66
|
+
</p>
|
|
67
|
+
)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function FieldsAdmin(): React.JSX.Element {
|
|
71
|
+
const [objectType, setObjectType] = useState<CustomFieldObjectType>('deal')
|
|
72
|
+
const [adding, setAdding] = useState(false)
|
|
73
|
+
const definitions = useCustomFields({ objectType, sort: 'sort_order', limit: 200 })
|
|
74
|
+
|
|
75
|
+
return (
|
|
76
|
+
<>
|
|
77
|
+
<div className="flex flex-wrap items-center gap-3">
|
|
78
|
+
<SegmentedControl
|
|
79
|
+
value={objectType}
|
|
80
|
+
onChange={setObjectType}
|
|
81
|
+
options={OBJECT_TYPE_OPTIONS}
|
|
82
|
+
ariaLabel="Record type"
|
|
83
|
+
/>
|
|
84
|
+
<div className="ml-auto">
|
|
85
|
+
<AddButton
|
|
86
|
+
onClick={() => {
|
|
87
|
+
setAdding(true)
|
|
88
|
+
}}
|
|
89
|
+
label="New field"
|
|
90
|
+
/>
|
|
91
|
+
</div>
|
|
92
|
+
</div>
|
|
93
|
+
|
|
94
|
+
{adding && (
|
|
95
|
+
<AddFieldForm
|
|
96
|
+
objectType={objectType}
|
|
97
|
+
onDone={() => {
|
|
98
|
+
setAdding(false)
|
|
99
|
+
}}
|
|
100
|
+
/>
|
|
101
|
+
)}
|
|
102
|
+
|
|
103
|
+
{definitions.error !== null && <ErrorPanel error={definitions.error} />}
|
|
104
|
+
{definitions.isLoading ? (
|
|
105
|
+
<LoadingPanel label="Loading fields…" />
|
|
106
|
+
) : definitions.records.length === 0 ? (
|
|
107
|
+
<p className="text-[13px] text-ink-muted">
|
|
108
|
+
No custom fields on {CUSTOM_FIELD_OBJECT_TYPE_LABELS[objectType].toLowerCase()} records yet.
|
|
109
|
+
</p>
|
|
110
|
+
) : (
|
|
111
|
+
<ul className="space-y-3">
|
|
112
|
+
{definitions.records.map((definition) => (
|
|
113
|
+
<li key={definition.id}>
|
|
114
|
+
<FieldRow definition={definition} />
|
|
115
|
+
</li>
|
|
116
|
+
))}
|
|
117
|
+
</ul>
|
|
118
|
+
)}
|
|
119
|
+
</>
|
|
120
|
+
)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
interface AddFieldFormProps {
|
|
124
|
+
readonly objectType: CustomFieldObjectType
|
|
125
|
+
readonly onDone: () => void
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function AddFieldForm({ objectType, onDone }: AddFieldFormProps): React.JSX.Element {
|
|
129
|
+
const create = useCreateCustomField()
|
|
130
|
+
const [label, setLabel] = useState('')
|
|
131
|
+
const [key, setKey] = useState('')
|
|
132
|
+
const [keyEdited, setKeyEdited] = useState(false)
|
|
133
|
+
const [type, setType] = useState<CustomFieldType>('text')
|
|
134
|
+
const [optionsText, setOptionsText] = useState('')
|
|
135
|
+
const [description, setDescription] = useState('')
|
|
136
|
+
|
|
137
|
+
const derivedKey = useMemo(() => (keyEdited ? key : slugify(label)), [keyEdited, key, label])
|
|
138
|
+
|
|
139
|
+
function submit(event: FormEvent): void {
|
|
140
|
+
event.preventDefault()
|
|
141
|
+
const trimmedLabel = label.trim()
|
|
142
|
+
if (trimmedLabel.length === 0) {
|
|
143
|
+
return
|
|
144
|
+
}
|
|
145
|
+
const options = OPTIONS_TYPES.has(type)
|
|
146
|
+
? optionsText
|
|
147
|
+
.split(/[\n,]/u)
|
|
148
|
+
.map((option) => option.trim())
|
|
149
|
+
.filter((option) => option.length > 0)
|
|
150
|
+
: []
|
|
151
|
+
create.runAsync({
|
|
152
|
+
objectType,
|
|
153
|
+
key: derivedKey,
|
|
154
|
+
label: trimmedLabel,
|
|
155
|
+
type,
|
|
156
|
+
options,
|
|
157
|
+
description: description.trim(),
|
|
158
|
+
})
|
|
159
|
+
.then(() => {
|
|
160
|
+
setLabel('')
|
|
161
|
+
setKey('')
|
|
162
|
+
setKeyEdited(false)
|
|
163
|
+
setType('text')
|
|
164
|
+
setOptionsText('')
|
|
165
|
+
setDescription('')
|
|
166
|
+
onDone()
|
|
167
|
+
})
|
|
168
|
+
.catch(() => {
|
|
169
|
+
// The mutation error is rendered below the form; keep the values.
|
|
170
|
+
})
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return (
|
|
174
|
+
<form
|
|
175
|
+
onSubmit={submit}
|
|
176
|
+
className="space-y-3 rounded-md border border-border bg-surface-raised p-4"
|
|
177
|
+
>
|
|
178
|
+
<div className="grid gap-3 sm:grid-cols-2">
|
|
179
|
+
<Field label="Label">
|
|
180
|
+
<input
|
|
181
|
+
className="w-full rounded-md border border-border bg-transparent px-2 py-1 text-[13px] outline-none focus:border-accent"
|
|
182
|
+
value={label}
|
|
183
|
+
onChange={(event) => {
|
|
184
|
+
setLabel(event.target.value)
|
|
185
|
+
}}
|
|
186
|
+
required
|
|
187
|
+
/>
|
|
188
|
+
</Field>
|
|
189
|
+
<Field label="Key">
|
|
190
|
+
<input
|
|
191
|
+
className="w-full rounded-md border border-border bg-transparent px-2 py-1 font-mono text-[12px] outline-none focus:border-accent"
|
|
192
|
+
value={derivedKey}
|
|
193
|
+
onChange={(event) => {
|
|
194
|
+
setKey(event.target.value)
|
|
195
|
+
setKeyEdited(true)
|
|
196
|
+
}}
|
|
197
|
+
placeholder="lowercase_snake_case"
|
|
198
|
+
pattern="^[a-z][a-z0-9_]*$"
|
|
199
|
+
required
|
|
200
|
+
/>
|
|
201
|
+
</Field>
|
|
202
|
+
<Field label="Type">
|
|
203
|
+
<select
|
|
204
|
+
className="w-full rounded-md border border-border bg-transparent px-2 py-1 text-[13px] outline-none focus:border-accent"
|
|
205
|
+
value={type}
|
|
206
|
+
onChange={(event) => {
|
|
207
|
+
setType(event.target.value as CustomFieldType)
|
|
208
|
+
}}
|
|
209
|
+
>
|
|
210
|
+
{CUSTOM_FIELD_TYPES.map((option) => (
|
|
211
|
+
<option key={option} value={option}>
|
|
212
|
+
{CUSTOM_FIELD_TYPE_LABELS[option]}
|
|
213
|
+
</option>
|
|
214
|
+
))}
|
|
215
|
+
</select>
|
|
216
|
+
</Field>
|
|
217
|
+
{OPTIONS_TYPES.has(type) && (
|
|
218
|
+
<Field label="Options (one per line, or comma-separated)">
|
|
219
|
+
<textarea
|
|
220
|
+
className="min-h-[80px] w-full rounded-md border border-border bg-transparent px-2 py-1 text-[13px] outline-none focus:border-accent"
|
|
221
|
+
value={optionsText}
|
|
222
|
+
onChange={(event) => {
|
|
223
|
+
setOptionsText(event.target.value)
|
|
224
|
+
}}
|
|
225
|
+
required
|
|
226
|
+
/>
|
|
227
|
+
</Field>
|
|
228
|
+
)}
|
|
229
|
+
</div>
|
|
230
|
+
<Field label="Description (agent-facing help text)">
|
|
231
|
+
<textarea
|
|
232
|
+
className="min-h-[60px] w-full rounded-md border border-border bg-transparent px-2 py-1 text-[13px] outline-none focus:border-accent"
|
|
233
|
+
value={description}
|
|
234
|
+
onChange={(event) => {
|
|
235
|
+
setDescription(event.target.value)
|
|
236
|
+
}}
|
|
237
|
+
/>
|
|
238
|
+
</Field>
|
|
239
|
+
|
|
240
|
+
{create.error !== null && <ErrorPanel error={create.error} />}
|
|
241
|
+
|
|
242
|
+
<div className="flex justify-end gap-2">
|
|
243
|
+
<button
|
|
244
|
+
type="button"
|
|
245
|
+
onClick={() => {
|
|
246
|
+
onDone()
|
|
247
|
+
}}
|
|
248
|
+
className="rounded-md border border-border px-3 py-1 text-[12px] text-ink-muted hover:text-ink"
|
|
249
|
+
>
|
|
250
|
+
Cancel
|
|
251
|
+
</button>
|
|
252
|
+
<button
|
|
253
|
+
type="submit"
|
|
254
|
+
disabled={create.isPending}
|
|
255
|
+
className="rounded-md bg-accent px-3 py-1 text-[12px] font-medium text-white hover:bg-accent-hover disabled:opacity-50"
|
|
256
|
+
>
|
|
257
|
+
{create.isPending ? 'Adding…' : 'Add field'}
|
|
258
|
+
</button>
|
|
259
|
+
</div>
|
|
260
|
+
</form>
|
|
261
|
+
)
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
interface FieldRowProps {
|
|
265
|
+
readonly definition: CustomFieldDefinition
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function FieldRow({ definition }: FieldRowProps): React.JSX.Element {
|
|
269
|
+
const update = useUpdateCustomField()
|
|
270
|
+
const remove = useDeleteCustomField()
|
|
271
|
+
const [editing, setEditing] = useState(false)
|
|
272
|
+
const [label, setLabel] = useState(definition.label)
|
|
273
|
+
const [description, setDescription] = useState(definition.description)
|
|
274
|
+
const [optionsText, setOptionsText] = useState(definition.options.join('\n'))
|
|
275
|
+
const [confirmingDelete, setConfirmingDelete] = useState(false)
|
|
276
|
+
|
|
277
|
+
const hasOptions = OPTIONS_TYPES.has(definition.type as CustomFieldType)
|
|
278
|
+
|
|
279
|
+
function save(event: FormEvent): void {
|
|
280
|
+
event.preventDefault()
|
|
281
|
+
const options = hasOptions
|
|
282
|
+
? optionsText
|
|
283
|
+
.split(/[\n,]/u)
|
|
284
|
+
.map((option) => option.trim())
|
|
285
|
+
.filter((option) => option.length > 0)
|
|
286
|
+
: undefined
|
|
287
|
+
update.runAsync({
|
|
288
|
+
id: definition.id,
|
|
289
|
+
changes: {
|
|
290
|
+
label: label.trim(),
|
|
291
|
+
description: description.trim(),
|
|
292
|
+
...(options === undefined ? {} : { options }),
|
|
293
|
+
},
|
|
294
|
+
})
|
|
295
|
+
.then(() => {
|
|
296
|
+
setEditing(false)
|
|
297
|
+
})
|
|
298
|
+
.catch(() => {
|
|
299
|
+
// Error rendered inline.
|
|
300
|
+
})
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
return (
|
|
304
|
+
<div className="rounded-md border border-border p-3">
|
|
305
|
+
<div className="flex items-start justify-between gap-3">
|
|
306
|
+
<div className="min-w-0 flex-1">
|
|
307
|
+
<div className="flex flex-wrap items-baseline gap-2">
|
|
308
|
+
<div className="text-[14px] font-semibold text-ink">{definition.label}</div>
|
|
309
|
+
<code className="rounded bg-surface-raised px-1.5 py-0.5 font-mono text-[11px] text-ink-muted">
|
|
310
|
+
{definition.key}
|
|
311
|
+
</code>
|
|
312
|
+
<span className="text-[11px] text-ink-muted">
|
|
313
|
+
{CUSTOM_FIELD_TYPE_LABELS[definition.type as CustomFieldType]}
|
|
314
|
+
</span>
|
|
315
|
+
</div>
|
|
316
|
+
{definition.description.length > 0 && (
|
|
317
|
+
<p className="mt-1 text-[12px] text-ink-muted">{definition.description}</p>
|
|
318
|
+
)}
|
|
319
|
+
{hasOptions && (
|
|
320
|
+
<p className="mt-1 text-[12px] text-ink-muted">
|
|
321
|
+
Options: {definition.options.join(', ')}
|
|
322
|
+
</p>
|
|
323
|
+
)}
|
|
324
|
+
</div>
|
|
325
|
+
<div className="flex shrink-0 gap-2">
|
|
326
|
+
<button
|
|
327
|
+
type="button"
|
|
328
|
+
onClick={() => {
|
|
329
|
+
setEditing((current) => !current)
|
|
330
|
+
}}
|
|
331
|
+
className="text-[12px] text-ink-muted hover:text-ink"
|
|
332
|
+
>
|
|
333
|
+
{editing ? 'Cancel' : 'Edit'}
|
|
334
|
+
</button>
|
|
335
|
+
<button
|
|
336
|
+
type="button"
|
|
337
|
+
onClick={() => {
|
|
338
|
+
setConfirmingDelete(true)
|
|
339
|
+
}}
|
|
340
|
+
className="text-[12px] text-red-600 hover:text-red-700"
|
|
341
|
+
>
|
|
342
|
+
Delete
|
|
343
|
+
</button>
|
|
344
|
+
</div>
|
|
345
|
+
</div>
|
|
346
|
+
|
|
347
|
+
{editing && (
|
|
348
|
+
<form onSubmit={save} className="mt-3 space-y-2">
|
|
349
|
+
<Field label="Label">
|
|
350
|
+
<input
|
|
351
|
+
className="w-full rounded-md border border-border bg-transparent px-2 py-1 text-[13px] outline-none focus:border-accent"
|
|
352
|
+
value={label}
|
|
353
|
+
onChange={(event) => {
|
|
354
|
+
setLabel(event.target.value)
|
|
355
|
+
}}
|
|
356
|
+
required
|
|
357
|
+
/>
|
|
358
|
+
</Field>
|
|
359
|
+
{hasOptions && (
|
|
360
|
+
<Field label="Options">
|
|
361
|
+
<textarea
|
|
362
|
+
className="min-h-[80px] w-full rounded-md border border-border bg-transparent px-2 py-1 text-[13px] outline-none focus:border-accent"
|
|
363
|
+
value={optionsText}
|
|
364
|
+
onChange={(event) => {
|
|
365
|
+
setOptionsText(event.target.value)
|
|
366
|
+
}}
|
|
367
|
+
/>
|
|
368
|
+
</Field>
|
|
369
|
+
)}
|
|
370
|
+
<Field label="Description">
|
|
371
|
+
<textarea
|
|
372
|
+
className="min-h-[60px] w-full rounded-md border border-border bg-transparent px-2 py-1 text-[13px] outline-none focus:border-accent"
|
|
373
|
+
value={description}
|
|
374
|
+
onChange={(event) => {
|
|
375
|
+
setDescription(event.target.value)
|
|
376
|
+
}}
|
|
377
|
+
/>
|
|
378
|
+
</Field>
|
|
379
|
+
{update.error !== null && <ErrorPanel error={update.error} />}
|
|
380
|
+
<div className="flex justify-end">
|
|
381
|
+
<button
|
|
382
|
+
type="submit"
|
|
383
|
+
disabled={update.isPending}
|
|
384
|
+
className="rounded-md bg-accent px-3 py-1 text-[12px] font-medium text-white hover:bg-accent-hover disabled:opacity-50"
|
|
385
|
+
>
|
|
386
|
+
{update.isPending ? 'Saving…' : 'Save'}
|
|
387
|
+
</button>
|
|
388
|
+
</div>
|
|
389
|
+
</form>
|
|
390
|
+
)}
|
|
391
|
+
|
|
392
|
+
{confirmingDelete && (
|
|
393
|
+
<div className="mt-3 rounded-md border border-red-300 bg-red-50 p-3 text-[13px] text-red-800 dark:border-red-800 dark:bg-red-950/40 dark:text-red-200">
|
|
394
|
+
<p>
|
|
395
|
+
Delete the {definition.label} field? This removes its stored values from every{' '}
|
|
396
|
+
{CUSTOM_FIELD_OBJECT_TYPE_LABELS[
|
|
397
|
+
definition.objectType as CustomFieldObjectType
|
|
398
|
+
].toLowerCase()}{' '}
|
|
399
|
+
record.
|
|
400
|
+
</p>
|
|
401
|
+
{remove.error !== null && <ErrorPanel error={remove.error} />}
|
|
402
|
+
<div className="mt-2 flex justify-end gap-2">
|
|
403
|
+
<button
|
|
404
|
+
type="button"
|
|
405
|
+
onClick={() => {
|
|
406
|
+
setConfirmingDelete(false)
|
|
407
|
+
}}
|
|
408
|
+
className="rounded-md border border-red-300 px-3 py-1 text-[12px] hover:bg-red-100 dark:border-red-800 dark:hover:bg-red-900/40"
|
|
409
|
+
>
|
|
410
|
+
Cancel
|
|
411
|
+
</button>
|
|
412
|
+
<button
|
|
413
|
+
type="button"
|
|
414
|
+
onClick={() => {
|
|
415
|
+
remove.runAsync(definition.id)
|
|
416
|
+
.then(() => {
|
|
417
|
+
setConfirmingDelete(false)
|
|
418
|
+
})
|
|
419
|
+
.catch(() => {
|
|
420
|
+
// Error rendered inline.
|
|
421
|
+
})
|
|
422
|
+
}}
|
|
423
|
+
disabled={remove.isPending}
|
|
424
|
+
className="rounded-md bg-red-600 px-3 py-1 text-[12px] font-medium text-white hover:bg-red-700 disabled:opacity-50"
|
|
425
|
+
>
|
|
426
|
+
{remove.isPending ? 'Deleting…' : 'Delete'}
|
|
427
|
+
</button>
|
|
428
|
+
</div>
|
|
429
|
+
</div>
|
|
430
|
+
)}
|
|
431
|
+
</div>
|
|
432
|
+
)
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
interface FieldProps {
|
|
436
|
+
readonly label: string
|
|
437
|
+
readonly children: React.ReactNode
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function Field({ label, children }: FieldProps): React.JSX.Element {
|
|
441
|
+
return (
|
|
442
|
+
<label className="block text-[12px]">
|
|
443
|
+
<span className="mb-1 block font-medium text-ink-muted">{label}</span>
|
|
444
|
+
{children}
|
|
445
|
+
</label>
|
|
446
|
+
)
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
function slugify(label: string): string {
|
|
450
|
+
return label
|
|
451
|
+
.toLowerCase()
|
|
452
|
+
.replace(/[^a-z0-9]+/gu, '_')
|
|
453
|
+
.replace(/^_+|_+$/gu, '')
|
|
454
|
+
.replace(/^([0-9])/u, '_$1')
|
|
455
|
+
}
|