@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,249 @@
|
|
|
1
|
+
import type { Form, FormField, FormSubmission, FormSubmissionActionEntry } from '@kelpie/schemas'
|
|
2
|
+
import { useMemo } from 'react'
|
|
3
|
+
import { Link, useParams } from 'react-router'
|
|
4
|
+
|
|
5
|
+
import { useTimezone } from '../../api/resources/account.ts'
|
|
6
|
+
import { useCompanies } from '../../api/resources/companies.ts'
|
|
7
|
+
import { useForm, useFormSubmission } from '../../api/resources/forms.ts'
|
|
8
|
+
import { usePeople } from '../../api/resources/people.ts'
|
|
9
|
+
import { ErrorPanel, LoadingPanel, NotFoundPanel } from '../../components/QueryState.tsx'
|
|
10
|
+
import { SectionHeader } from '../../components/SectionHeader.tsx'
|
|
11
|
+
import { formatDateTime } from '../../lib/dates.ts'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* One submission: every answer, the records it linked, and the action log.
|
|
15
|
+
*
|
|
16
|
+
* Nested under the form so the field labels (which live on the form, not the
|
|
17
|
+
* submission) are at hand, and so "back" returns to that form's Submissions tab
|
|
18
|
+
* rather than the forms list.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** `api.md`: `?limit=` maxes out at 200. */
|
|
22
|
+
const MAX_PAGE = 200
|
|
23
|
+
|
|
24
|
+
export function SubmissionDetail(): React.JSX.Element {
|
|
25
|
+
const { id: formId, submissionId } = useParams()
|
|
26
|
+
const form = useForm(formId)
|
|
27
|
+
const submission = useFormSubmission(formId, submissionId)
|
|
28
|
+
const people = usePeople({ limit: MAX_PAGE })
|
|
29
|
+
const companies = useCompanies({ limit: MAX_PAGE })
|
|
30
|
+
const timezone = useTimezone()
|
|
31
|
+
|
|
32
|
+
const nameById = useMemo(
|
|
33
|
+
() => new Map([...people.records, ...companies.records].map((record) => [record.id, record.name])),
|
|
34
|
+
[people.records, companies.records],
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
if (form.isNotFound || submission.isNotFound) {
|
|
38
|
+
return (
|
|
39
|
+
<NotFoundPanel
|
|
40
|
+
label="Submission"
|
|
41
|
+
backTo={formId === undefined ? '/forms' : `/forms/${formId}`}
|
|
42
|
+
/>
|
|
43
|
+
)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (form.error !== null) {
|
|
47
|
+
return <ErrorPanel error={form.error} />
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (submission.error !== null) {
|
|
51
|
+
return <ErrorPanel error={submission.error} />
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (
|
|
55
|
+
form.isLoading ||
|
|
56
|
+
submission.isLoading ||
|
|
57
|
+
form.record === undefined ||
|
|
58
|
+
submission.record === undefined
|
|
59
|
+
) {
|
|
60
|
+
return <LoadingPanel label="Loading submission…" />
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return (
|
|
64
|
+
<SubmissionDetailView
|
|
65
|
+
form={form.record}
|
|
66
|
+
submission={submission.record}
|
|
67
|
+
nameById={nameById}
|
|
68
|
+
timezone={timezone}
|
|
69
|
+
/>
|
|
70
|
+
)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function SubmissionDetailView({
|
|
74
|
+
form,
|
|
75
|
+
submission,
|
|
76
|
+
nameById,
|
|
77
|
+
timezone,
|
|
78
|
+
}: {
|
|
79
|
+
readonly form: Form
|
|
80
|
+
readonly submission: FormSubmission
|
|
81
|
+
readonly nameById: ReadonlyMap<string, string>
|
|
82
|
+
readonly timezone: string
|
|
83
|
+
}): React.JSX.Element {
|
|
84
|
+
const answers = form.fields.map((field) => ({
|
|
85
|
+
field,
|
|
86
|
+
value: displayAnswer(field, submission.answers[field.id]),
|
|
87
|
+
}))
|
|
88
|
+
|
|
89
|
+
return (
|
|
90
|
+
<div className="animate-fade-in mx-auto max-w-2xl">
|
|
91
|
+
<Link
|
|
92
|
+
to={`/forms/${form.id}`}
|
|
93
|
+
className="mb-4 inline-flex text-[12px] font-medium text-ink-muted transition hover:text-accent"
|
|
94
|
+
>
|
|
95
|
+
← {form.name}
|
|
96
|
+
</Link>
|
|
97
|
+
|
|
98
|
+
<SectionHeader
|
|
99
|
+
title="Submission"
|
|
100
|
+
description={`Received ${formatDateTime(submission.submittedAt, timezone)}.`}
|
|
101
|
+
/>
|
|
102
|
+
|
|
103
|
+
<div className="mt-6 space-y-6">
|
|
104
|
+
<section>
|
|
105
|
+
<h3 className="mb-2 text-[11px] font-semibold uppercase tracking-wide text-ink-faint">
|
|
106
|
+
Answers
|
|
107
|
+
</h3>
|
|
108
|
+
<dl className="divide-y divide-border rounded-md border border-border">
|
|
109
|
+
{answers.map(({ field, value }) => (
|
|
110
|
+
<div
|
|
111
|
+
key={field.id}
|
|
112
|
+
className="grid gap-0.5 px-3 py-2.5 sm:grid-cols-[10rem_1fr] sm:gap-3"
|
|
113
|
+
>
|
|
114
|
+
<dt className="text-[12px] font-medium text-ink-muted">
|
|
115
|
+
{field.label}
|
|
116
|
+
{field.required && <span className="ml-0.5 text-danger">*</span>}
|
|
117
|
+
</dt>
|
|
118
|
+
<dd className="whitespace-pre-wrap text-[13px] text-ink">
|
|
119
|
+
{value === null ? <span className="text-ink-faint">—</span> : value}
|
|
120
|
+
</dd>
|
|
121
|
+
</div>
|
|
122
|
+
))}
|
|
123
|
+
</dl>
|
|
124
|
+
</section>
|
|
125
|
+
|
|
126
|
+
<section>
|
|
127
|
+
<h3 className="mb-2 text-[11px] font-semibold uppercase tracking-wide text-ink-faint">
|
|
128
|
+
Records
|
|
129
|
+
</h3>
|
|
130
|
+
<ul className="space-y-1.5 rounded-md border border-border px-3 py-2.5 text-[13px]">
|
|
131
|
+
<RecordRow
|
|
132
|
+
label="Person"
|
|
133
|
+
to={submission.personId === null ? null : `/people/${submission.personId}`}
|
|
134
|
+
text={
|
|
135
|
+
submission.personId === null
|
|
136
|
+
? null
|
|
137
|
+
: (nameById.get(submission.personId) ?? 'Person')
|
|
138
|
+
}
|
|
139
|
+
/>
|
|
140
|
+
<RecordRow
|
|
141
|
+
label="Company"
|
|
142
|
+
to={submission.companyId === null ? null : `/companies/${submission.companyId}`}
|
|
143
|
+
text={
|
|
144
|
+
submission.companyId === null
|
|
145
|
+
? null
|
|
146
|
+
: (nameById.get(submission.companyId) ?? 'Company')
|
|
147
|
+
}
|
|
148
|
+
/>
|
|
149
|
+
<RecordRow
|
|
150
|
+
label="Deal"
|
|
151
|
+
to={submission.dealId === null ? null : `/deals/${submission.dealId}`}
|
|
152
|
+
text={submission.dealId === null ? null : 'Open deal'}
|
|
153
|
+
/>
|
|
154
|
+
<RecordRow
|
|
155
|
+
label="Opportunity"
|
|
156
|
+
to={
|
|
157
|
+
submission.opportunityId === null
|
|
158
|
+
? null
|
|
159
|
+
: `/opportunities/${submission.opportunityId}`
|
|
160
|
+
}
|
|
161
|
+
text={submission.opportunityId === null ? null : 'Open opportunity'}
|
|
162
|
+
/>
|
|
163
|
+
<RecordRow
|
|
164
|
+
label="Partnership"
|
|
165
|
+
to={
|
|
166
|
+
submission.partnershipId === null
|
|
167
|
+
? null
|
|
168
|
+
: `/partnerships/${submission.partnershipId}`
|
|
169
|
+
}
|
|
170
|
+
text={submission.partnershipId === null ? null : 'Open partnership'}
|
|
171
|
+
/>
|
|
172
|
+
<RecordRow
|
|
173
|
+
label="Enquiry"
|
|
174
|
+
to={submission.enquiryId === null ? null : `/enquiries/${submission.enquiryId}`}
|
|
175
|
+
text={submission.enquiryId === null ? null : 'Open enquiry'}
|
|
176
|
+
/>
|
|
177
|
+
</ul>
|
|
178
|
+
</section>
|
|
179
|
+
|
|
180
|
+
{submission.actionLog.length > 0 && (
|
|
181
|
+
<section>
|
|
182
|
+
<h3 className="mb-2 text-[11px] font-semibold uppercase tracking-wide text-ink-faint">
|
|
183
|
+
Action log
|
|
184
|
+
</h3>
|
|
185
|
+
<ul className="divide-y divide-border rounded-md border border-border">
|
|
186
|
+
{submission.actionLog.map((entry, index) => (
|
|
187
|
+
<li key={`${entry.action}-${String(index)}`} className="px-3 py-2.5">
|
|
188
|
+
<div className="flex items-baseline justify-between gap-2">
|
|
189
|
+
<span className="font-mono text-[12px] text-ink">{entry.action}</span>
|
|
190
|
+
<ActionStatus status={entry.status} />
|
|
191
|
+
</div>
|
|
192
|
+
{entry.detail.length > 0 && (
|
|
193
|
+
<p className="mt-1 text-[12px] text-ink-muted">{entry.detail}</p>
|
|
194
|
+
)}
|
|
195
|
+
</li>
|
|
196
|
+
))}
|
|
197
|
+
</ul>
|
|
198
|
+
</section>
|
|
199
|
+
)}
|
|
200
|
+
</div>
|
|
201
|
+
</div>
|
|
202
|
+
)
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function RecordRow({
|
|
206
|
+
label,
|
|
207
|
+
to,
|
|
208
|
+
text,
|
|
209
|
+
}: {
|
|
210
|
+
readonly label: string
|
|
211
|
+
readonly to: string | null
|
|
212
|
+
readonly text: string | null
|
|
213
|
+
}): React.JSX.Element {
|
|
214
|
+
return (
|
|
215
|
+
<li className="flex items-baseline gap-2 py-0.5">
|
|
216
|
+
<span className="w-28 shrink-0 text-[12px] font-medium text-ink-muted">{label}</span>
|
|
217
|
+
{to === null || text === null ? (
|
|
218
|
+
<span className="text-ink-faint">—</span>
|
|
219
|
+
) : (
|
|
220
|
+
<Link to={to} className="text-accent hover:underline">
|
|
221
|
+
{text}
|
|
222
|
+
</Link>
|
|
223
|
+
)}
|
|
224
|
+
</li>
|
|
225
|
+
)
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function ActionStatus({
|
|
229
|
+
status,
|
|
230
|
+
}: {
|
|
231
|
+
readonly status: FormSubmissionActionEntry['status']
|
|
232
|
+
}): React.JSX.Element {
|
|
233
|
+
const className =
|
|
234
|
+
status === 'ok' ? 'text-success' : status === 'error' ? 'text-danger' : 'text-ink-faint'
|
|
235
|
+
|
|
236
|
+
return <span className={`text-[11px] font-medium uppercase ${className}`}>{status}</span>
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function displayAnswer(field: FormField, answer: string | undefined): string | null {
|
|
240
|
+
if (answer === undefined || answer.trim().length === 0) {
|
|
241
|
+
return null
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if (field.type === 'select') {
|
|
245
|
+
return field.options.find((option) => option.key === answer)?.value ?? answer
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
return answer
|
|
249
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import type { Form, FormSubmission } from '@kelpie/schemas'
|
|
1
|
+
import type { Form, FormSubmission, FormSubmissionActionEntry } from '@kelpie/schemas'
|
|
2
2
|
import { useMemo } from 'react'
|
|
3
|
-
import { Link } from 'react-router'
|
|
3
|
+
import { Link, useNavigate } from 'react-router'
|
|
4
4
|
|
|
5
5
|
import type { RecordListResult } from '../../api/resource.ts'
|
|
6
6
|
import { useTimezone } from '../../api/resources/account.ts'
|
|
@@ -20,6 +20,10 @@ import { formatDateTime } from '../../lib/dates.ts'
|
|
|
20
20
|
* same way the Decisions page does it: `api.md` has no include-expansion, and
|
|
21
21
|
* neither list takes a set of bare ids. A record past those pages is still
|
|
22
22
|
* linked, by its type rather than its name, which beats a raw id.
|
|
23
|
+
*
|
|
24
|
+
* A row opens `/forms/:id/submissions/:submissionId`. Inline person/company
|
|
25
|
+
* links still navigate, and stop the row click so opening a person does not
|
|
26
|
+
* also open the submission.
|
|
23
27
|
*/
|
|
24
28
|
|
|
25
29
|
/** `api.md`: `?limit=` maxes out at 200. */
|
|
@@ -31,6 +35,7 @@ export interface SubmissionsTableProps {
|
|
|
31
35
|
}
|
|
32
36
|
|
|
33
37
|
export function SubmissionsTable({ form, submissions }: SubmissionsTableProps): React.JSX.Element {
|
|
38
|
+
const navigate = useNavigate()
|
|
34
39
|
const people = usePeople({ limit: MAX_PAGE })
|
|
35
40
|
const companies = useCompanies({ limit: MAX_PAGE })
|
|
36
41
|
const timezone = useTimezone()
|
|
@@ -66,17 +71,33 @@ export function SubmissionsTable({ form, submissions }: SubmissionsTableProps):
|
|
|
66
71
|
),
|
|
67
72
|
},
|
|
68
73
|
{
|
|
69
|
-
key: '
|
|
70
|
-
header: '
|
|
71
|
-
className: 'w-
|
|
72
|
-
render: (submission) =>
|
|
73
|
-
|
|
74
|
-
<
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
74
|
+
key: 'created',
|
|
75
|
+
header: 'Created',
|
|
76
|
+
className: 'w-40',
|
|
77
|
+
render: (submission) => (
|
|
78
|
+
<div className="flex flex-wrap gap-2 text-[12px]">
|
|
79
|
+
<CreatedLink to={`/deals/${submission.dealId ?? ''}`} id={submission.dealId} label="deal" />
|
|
80
|
+
<CreatedLink
|
|
81
|
+
to={`/opportunities/${submission.opportunityId ?? ''}`}
|
|
82
|
+
id={submission.opportunityId}
|
|
83
|
+
label="opp"
|
|
84
|
+
/>
|
|
85
|
+
<CreatedLink
|
|
86
|
+
to={`/partnerships/${submission.partnershipId ?? ''}`}
|
|
87
|
+
id={submission.partnershipId}
|
|
88
|
+
label="prt"
|
|
89
|
+
/>
|
|
90
|
+
{submission.dealId === null &&
|
|
91
|
+
submission.opportunityId === null &&
|
|
92
|
+
submission.partnershipId === null && <span className="text-ink-faint">—</span>}
|
|
93
|
+
</div>
|
|
94
|
+
),
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
key: 'actions',
|
|
98
|
+
header: 'Actions',
|
|
99
|
+
className: 'w-32',
|
|
100
|
+
render: (submission) => <ActionLogChip entries={submission.actionLog} />,
|
|
80
101
|
},
|
|
81
102
|
{
|
|
82
103
|
key: 'answers',
|
|
@@ -99,7 +120,7 @@ export function SubmissionsTable({ form, submissions }: SubmissionsTableProps):
|
|
|
99
120
|
<div>
|
|
100
121
|
<SectionHeader
|
|
101
122
|
title="Submissions"
|
|
102
|
-
description="Inbound answers, and the records each one created or matched."
|
|
123
|
+
description="Inbound answers, and the records each one created or matched. Click a row to open it."
|
|
103
124
|
/>
|
|
104
125
|
<Paginator list={submissions} placement="top" />
|
|
105
126
|
<DataTable
|
|
@@ -107,12 +128,66 @@ export function SubmissionsTable({ form, submissions }: SubmissionsTableProps):
|
|
|
107
128
|
rows={submissions.records}
|
|
108
129
|
getRowId={(submission) => submission.id}
|
|
109
130
|
emptyMessage="No submissions yet"
|
|
131
|
+
onRowClick={(submission) => {
|
|
132
|
+
void navigate(`/forms/${form.id}/submissions/${submission.id}`)
|
|
133
|
+
}}
|
|
110
134
|
/>
|
|
111
135
|
<Paginator list={submissions} />
|
|
112
136
|
</div>
|
|
113
137
|
)
|
|
114
138
|
}
|
|
115
139
|
|
|
140
|
+
function CreatedLink({
|
|
141
|
+
to,
|
|
142
|
+
id,
|
|
143
|
+
label,
|
|
144
|
+
}: {
|
|
145
|
+
readonly to: string
|
|
146
|
+
readonly id: string | null
|
|
147
|
+
readonly label: string
|
|
148
|
+
}): React.JSX.Element | null {
|
|
149
|
+
if (id === null) {
|
|
150
|
+
return null
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return (
|
|
154
|
+
<Link
|
|
155
|
+
to={to}
|
|
156
|
+
className="text-accent hover:underline"
|
|
157
|
+
onClick={(event) => {
|
|
158
|
+
event.stopPropagation()
|
|
159
|
+
}}
|
|
160
|
+
>
|
|
161
|
+
{label}
|
|
162
|
+
</Link>
|
|
163
|
+
)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function ActionLogChip({
|
|
167
|
+
entries,
|
|
168
|
+
}: {
|
|
169
|
+
readonly entries: readonly FormSubmissionActionEntry[]
|
|
170
|
+
}): React.JSX.Element {
|
|
171
|
+
if (entries.length === 0) {
|
|
172
|
+
return <span className="text-ink-faint">—</span>
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const errors = entries.filter((entry) => entry.status === 'error').length
|
|
176
|
+
const skipped = entries.filter((entry) => entry.status === 'skipped').length
|
|
177
|
+
const ok = entries.length - errors - skipped
|
|
178
|
+
const detail = entries
|
|
179
|
+
.map((entry) => `${entry.action}: ${entry.status}${entry.detail.length > 0 ? ` — ${entry.detail}` : ''}`)
|
|
180
|
+
.join('\n')
|
|
181
|
+
|
|
182
|
+
return (
|
|
183
|
+
<span title={detail} className="inline-flex gap-1 font-mono text-[11px] text-ink-muted">
|
|
184
|
+
{ok > 0 && <span className="text-success">✓{ok}</span>}
|
|
185
|
+
{skipped > 0 && <span className="text-ink-faint">−{skipped}</span>}
|
|
186
|
+
{errors > 0 && <span className="text-danger">!{errors}</span>}
|
|
187
|
+
</span>
|
|
188
|
+
)
|
|
189
|
+
}
|
|
190
|
+
|
|
116
191
|
function RecordLink({
|
|
117
192
|
to,
|
|
118
193
|
id,
|
|
@@ -129,12 +204,30 @@ function RecordLink({
|
|
|
129
204
|
}
|
|
130
205
|
|
|
131
206
|
return (
|
|
132
|
-
<Link
|
|
207
|
+
<Link
|
|
208
|
+
to={`${to}/${id}`}
|
|
209
|
+
className="text-accent hover:underline"
|
|
210
|
+
onClick={(event) => {
|
|
211
|
+
event.stopPropagation()
|
|
212
|
+
}}
|
|
213
|
+
>
|
|
133
214
|
{name.get(id) ?? fallback}
|
|
134
215
|
</Link>
|
|
135
216
|
)
|
|
136
217
|
}
|
|
137
218
|
|
|
219
|
+
/**
|
|
220
|
+
* Every target whose answer the row already shows in its own column: the Person
|
|
221
|
+
* link. A form asks for a name as one box or as a first and last pair, and
|
|
222
|
+
* either way the answers are what the Person column is already displaying.
|
|
223
|
+
*/
|
|
224
|
+
const PERSON_COLUMN_TARGETS: ReadonlySet<string> = new Set([
|
|
225
|
+
'person.name',
|
|
226
|
+
'person.first_name',
|
|
227
|
+
'person.last_name',
|
|
228
|
+
'person.email',
|
|
229
|
+
])
|
|
230
|
+
|
|
138
231
|
/**
|
|
139
232
|
* The first two answers that are not already a column.
|
|
140
233
|
*
|
|
@@ -144,7 +237,7 @@ function RecordLink({
|
|
|
144
237
|
*/
|
|
145
238
|
function summarise(form: Form, submission: FormSubmission): string {
|
|
146
239
|
const parts = form.fields
|
|
147
|
-
.filter((field) =>
|
|
240
|
+
.filter((field) => !PERSON_COLUMN_TARGETS.has(field.mapTo))
|
|
148
241
|
.map((field) => {
|
|
149
242
|
const answer = submission.answers[field.id]
|
|
150
243
|
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import {
|
|
2
2
|
FORM_FIELD_MAP_TARGETS,
|
|
3
3
|
FORM_FIELD_TYPES,
|
|
4
|
+
PERSON_CONSENT_TARGET,
|
|
4
5
|
PERSON_EMAIL_TARGET,
|
|
5
6
|
} from '@kelpie/schemas'
|
|
6
7
|
import type { Form, FormFieldInput, FormFieldMapTarget, FormFieldType } from '@kelpie/schemas'
|
|
7
8
|
|
|
8
|
-
import { NEW_SELECT_OPTIONS } from './template.ts'
|
|
9
|
+
import { CRM_FIELD_PRESETS, NEW_SELECT_OPTIONS } from './template.ts'
|
|
9
10
|
|
|
10
11
|
/**
|
|
11
12
|
* Editing a form's field list, as pure functions over an array.
|
|
@@ -42,6 +43,9 @@ export function toEditableFields(form: Form): EditableField[] {
|
|
|
42
43
|
mapTo: field.mapTo,
|
|
43
44
|
options: field.options.map((option) => ({ ...option })),
|
|
44
45
|
placeholder: field.placeholder,
|
|
46
|
+
statement: field.statement,
|
|
47
|
+
consentPurposeIds: [...field.consentPurposeIds],
|
|
48
|
+
consentPurposeLabels: { ...field.consentPurposeLabels },
|
|
45
49
|
}))
|
|
46
50
|
}
|
|
47
51
|
|
|
@@ -84,9 +88,12 @@ export function editField(
|
|
|
84
88
|
return fields.map((field) => {
|
|
85
89
|
if (field.id !== id) {
|
|
86
90
|
// Only one field may carry a CRM target, so taking one releases it
|
|
87
|
-
// wherever it was. `submission`
|
|
91
|
+
// wherever it was. `submission` and `person.consent` are the targets
|
|
92
|
+
// that may repeat — consent's uniqueness is per purpose, not per
|
|
93
|
+
// target, and is enforced when the purpose is picked.
|
|
88
94
|
return change.mapTo !== undefined &&
|
|
89
95
|
change.mapTo !== 'submission' &&
|
|
96
|
+
change.mapTo !== PERSON_CONSENT_TARGET &&
|
|
90
97
|
field.mapTo === change.mapTo
|
|
91
98
|
? { ...field, mapTo: 'submission' }
|
|
92
99
|
: field
|
|
@@ -94,6 +101,19 @@ export function editField(
|
|
|
94
101
|
|
|
95
102
|
const next: EditableField = { ...field, ...change }
|
|
96
103
|
|
|
104
|
+
// A consent or notice field must map to person.consent; anything else
|
|
105
|
+
// clears any lingering purpose list.
|
|
106
|
+
const isConsentLike = next.type === 'consent' || next.type === 'notice'
|
|
107
|
+
if (isConsentLike && next.mapTo !== PERSON_CONSENT_TARGET) {
|
|
108
|
+
return { ...withoutOptions(next), mapTo: PERSON_CONSENT_TARGET }
|
|
109
|
+
}
|
|
110
|
+
if (next.mapTo === PERSON_CONSENT_TARGET && !isConsentLike) {
|
|
111
|
+
return { ...withoutOptions(next), type: 'consent' }
|
|
112
|
+
}
|
|
113
|
+
if (!isConsentLike && (next.consentPurposeIds ?? []).length > 0) {
|
|
114
|
+
return withoutOptions({ ...next, consentPurposeIds: [] })
|
|
115
|
+
}
|
|
116
|
+
|
|
97
117
|
if (next.type !== 'select') {
|
|
98
118
|
return withoutOptions(next)
|
|
99
119
|
}
|
|
@@ -166,6 +186,7 @@ export function findProblems(
|
|
|
166
186
|
const list: string[] = []
|
|
167
187
|
const byField = new Map<string, string>()
|
|
168
188
|
const seen = new Set<FormFieldMapTarget>()
|
|
189
|
+
const usedConsentPurposes = new Set<string>()
|
|
169
190
|
|
|
170
191
|
if (!fields.some((field) => field.mapTo === PERSON_EMAIL_TARGET)) {
|
|
171
192
|
list.push(`One field must map to ${PERSON_EMAIL_TARGET}, or no submission can be processed.`)
|
|
@@ -176,7 +197,34 @@ export function findProblems(
|
|
|
176
197
|
}
|
|
177
198
|
|
|
178
199
|
for (const field of fields) {
|
|
179
|
-
if (field.mapTo
|
|
200
|
+
if (field.mapTo === PERSON_CONSENT_TARGET) {
|
|
201
|
+
const purposes = field.consentPurposeIds ?? []
|
|
202
|
+
if (field.type === 'notice' && (field.statement ?? '').trim().length === 0) {
|
|
203
|
+
byField.set(field.id, 'A notice field needs a statement.')
|
|
204
|
+
}
|
|
205
|
+
if (purposes.length === 0) {
|
|
206
|
+
byField.set(
|
|
207
|
+
field.id,
|
|
208
|
+
field.type === 'notice'
|
|
209
|
+
? 'Pick at least one purpose this notice grants implicitly.'
|
|
210
|
+
: 'Pick at least one consent purpose this field offers.',
|
|
211
|
+
)
|
|
212
|
+
} else {
|
|
213
|
+
const localSeen = new Set<string>()
|
|
214
|
+
for (const purposeId of purposes) {
|
|
215
|
+
if (localSeen.has(purposeId)) {
|
|
216
|
+
byField.set(field.id, 'This field lists a purpose twice.')
|
|
217
|
+
break
|
|
218
|
+
}
|
|
219
|
+
localSeen.add(purposeId)
|
|
220
|
+
if (usedConsentPurposes.has(purposeId)) {
|
|
221
|
+
byField.set(field.id, 'Another consent field already offers this purpose.')
|
|
222
|
+
} else {
|
|
223
|
+
usedConsentPurposes.add(purposeId)
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
} else if (field.mapTo !== 'submission') {
|
|
180
228
|
if (seen.has(field.mapTo)) {
|
|
181
229
|
byField.set(field.id, `Another field already maps to ${field.mapTo}.`)
|
|
182
230
|
}
|
|
@@ -203,7 +251,22 @@ export function isUsable(problems: FieldListProblems): boolean {
|
|
|
203
251
|
return problems.list.length === 0 && problems.byField.size === 0
|
|
204
252
|
}
|
|
205
253
|
|
|
206
|
-
/** The types a mapping can sensibly render as.
|
|
254
|
+
/** The types a mapping can sensibly render as. */
|
|
207
255
|
export function typeForTarget(target: FormFieldMapTarget, current: FormFieldType): FormFieldType {
|
|
208
|
-
|
|
256
|
+
if (target === PERSON_EMAIL_TARGET) return 'email'
|
|
257
|
+
if (target === PERSON_CONSENT_TARGET) {
|
|
258
|
+
return current === 'notice' ? 'notice' : 'consent'
|
|
259
|
+
}
|
|
260
|
+
return current === 'consent' || current === 'notice' ? 'text' : current
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* The CRM presets whose mapping no current field carries: what "Add field" may
|
|
265
|
+
* still offer. A CRM target may appear once, so a preset leaves the menu the
|
|
266
|
+
* moment a field takes its target and returns when that field releases it.
|
|
267
|
+
*/
|
|
268
|
+
export function unusedCrmPresets(fields: readonly EditableField[]): readonly FormFieldInput[] {
|
|
269
|
+
const used = new Set(fields.map((field) => field.mapTo))
|
|
270
|
+
|
|
271
|
+
return CRM_FIELD_PRESETS.filter((preset) => !used.has(preset.mapTo))
|
|
209
272
|
}
|
|
@@ -35,3 +35,106 @@ export const NEW_SELECT_OPTIONS = [
|
|
|
35
35
|
{ key: 'option_a', value: 'Option A', valueType: 'string' as const },
|
|
36
36
|
{ key: 'option_b', value: 'Option B', valueType: 'string' as const },
|
|
37
37
|
]
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* One ready-made field per CRM mapping target, in the target enum's order.
|
|
41
|
+
*
|
|
42
|
+
* "Add field" offers these so adding the company field is one click, not
|
|
43
|
+
* add-then-configure. Only one field may carry a CRM target, so the menu shows
|
|
44
|
+
* each preset only while its target is free — `unusedCrmPresets` in
|
|
45
|
+
* `fieldList.ts` does that filtering.
|
|
46
|
+
*/
|
|
47
|
+
export const CRM_FIELD_PRESETS: readonly FormFieldInput[] = [
|
|
48
|
+
{ label: 'Name', type: 'text', required: false, mapTo: 'person.name', placeholder: 'Your name' },
|
|
49
|
+
// The other way to ask for a name. A form takes one box or this pair; the
|
|
50
|
+
// menu hides whichever is already mapped, so both are offered and neither
|
|
51
|
+
// ends up used twice.
|
|
52
|
+
{
|
|
53
|
+
label: 'First name',
|
|
54
|
+
type: 'text',
|
|
55
|
+
required: false,
|
|
56
|
+
mapTo: 'person.first_name',
|
|
57
|
+
placeholder: 'First name',
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
label: 'Last name',
|
|
61
|
+
type: 'text',
|
|
62
|
+
required: false,
|
|
63
|
+
mapTo: 'person.last_name',
|
|
64
|
+
placeholder: 'Last name',
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
label: 'Email',
|
|
68
|
+
type: 'email',
|
|
69
|
+
required: true,
|
|
70
|
+
mapTo: 'person.email',
|
|
71
|
+
placeholder: 'you@company.com',
|
|
72
|
+
},
|
|
73
|
+
{ label: 'Company', type: 'text', required: false, mapTo: 'company.name', placeholder: 'Company name' },
|
|
74
|
+
{
|
|
75
|
+
label: 'Company website',
|
|
76
|
+
type: 'text',
|
|
77
|
+
required: false,
|
|
78
|
+
mapTo: 'company.domain',
|
|
79
|
+
placeholder: 'company.com',
|
|
80
|
+
},
|
|
81
|
+
{ label: 'Job title', type: 'text', required: false, mapTo: 'position.title', placeholder: 'Your role' },
|
|
82
|
+
{ label: 'Enquiry name', type: 'text', required: false, mapTo: 'enquiry.name', placeholder: null },
|
|
83
|
+
{ label: 'Deal name', type: 'text', required: false, mapTo: 'deal.name', placeholder: null },
|
|
84
|
+
{ label: 'Opportunity name', type: 'text', required: false, mapTo: 'opportunity.name', placeholder: null },
|
|
85
|
+
{ label: 'Partnership name', type: 'text', required: false, mapTo: 'partnership.name', placeholder: null },
|
|
86
|
+
]
|
|
87
|
+
|
|
88
|
+
/** A submission-only entry in the "Add field" menu: shown by control, not by mapping. */
|
|
89
|
+
export interface SubmissionFieldPreset {
|
|
90
|
+
/** What the menu shows. The field itself starts with its own label. */
|
|
91
|
+
readonly menuLabel: string
|
|
92
|
+
readonly field: FormFieldInput
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** The submission-only fields "Add field" always offers; they may repeat freely. */
|
|
96
|
+
export const SUBMISSION_FIELD_PRESETS: readonly SubmissionFieldPreset[] = [
|
|
97
|
+
{ menuLabel: 'Text', field: NEW_FIELD },
|
|
98
|
+
{
|
|
99
|
+
menuLabel: 'Text area',
|
|
100
|
+
field: { label: 'Message', type: 'textarea', required: false, mapTo: 'submission', placeholder: null },
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
menuLabel: 'Select',
|
|
104
|
+
field: {
|
|
105
|
+
label: 'New select',
|
|
106
|
+
type: 'select',
|
|
107
|
+
required: false,
|
|
108
|
+
mapTo: 'submission',
|
|
109
|
+
placeholder: null,
|
|
110
|
+
options: NEW_SELECT_OPTIONS,
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
menuLabel: 'Consent',
|
|
115
|
+
field: {
|
|
116
|
+
label: 'Consent',
|
|
117
|
+
type: 'consent',
|
|
118
|
+
required: false,
|
|
119
|
+
mapTo: 'person.consent',
|
|
120
|
+
placeholder: null,
|
|
121
|
+
statement: 'Please tell us how we can contact you.',
|
|
122
|
+
consentPurposeIds: [],
|
|
123
|
+
consentPurposeLabels: {},
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
{
|
|
127
|
+
menuLabel: 'Notice',
|
|
128
|
+
field: {
|
|
129
|
+
label: 'Privacy notice',
|
|
130
|
+
type: 'notice',
|
|
131
|
+
required: false,
|
|
132
|
+
mapTo: 'person.consent',
|
|
133
|
+
placeholder: null,
|
|
134
|
+
statement:
|
|
135
|
+
'By submitting this form you agree to us storing your information and using it to contact you about your enquiry.',
|
|
136
|
+
consentPurposeIds: [],
|
|
137
|
+
consentPurposeLabels: {},
|
|
138
|
+
},
|
|
139
|
+
},
|
|
140
|
+
]
|
|
@@ -132,7 +132,7 @@ export function WorkspaceStepPage(): React.JSX.Element {
|
|
|
132
132
|
className="mt-0.5 h-4 w-4 rounded border-border text-accent focus:ring-accent"
|
|
133
133
|
/>
|
|
134
134
|
<span>
|
|
135
|
-
Install sample data — a small set of companies, people, deals, opportunities,
|
|
135
|
+
Install sample data — a small set of companies, people, deals, enquiries, opportunities,
|
|
136
136
|
fundraising, partnerships, and hiring so the workspace has something to look at.
|
|
137
137
|
You can delete it later.
|
|
138
138
|
</span>
|