@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
|
@@ -10,12 +10,20 @@ import type { DragEndEvent } from '@dnd-kit/core'
|
|
|
10
10
|
import { SortableContext, useSortable, verticalListSortingStrategy } from '@dnd-kit/sortable'
|
|
11
11
|
import { CSS } from '@dnd-kit/utilities'
|
|
12
12
|
import { FORM_FIELD_MAP_TARGET_LABELS, FORM_OPTION_VALUE_TYPES } from '@kelpie/schemas'
|
|
13
|
-
import type {
|
|
14
|
-
|
|
15
|
-
|
|
13
|
+
import type {
|
|
14
|
+
Form,
|
|
15
|
+
FormFieldInput,
|
|
16
|
+
FormFieldMapTarget,
|
|
17
|
+
FormFieldOptionInput,
|
|
18
|
+
FormFieldType,
|
|
19
|
+
} from '@kelpie/schemas'
|
|
20
|
+
import { useEffect, useRef, useState } from 'react'
|
|
21
|
+
|
|
22
|
+
import { useConsentPurposes } from '../../api/resources/consentPurposes.ts'
|
|
23
|
+
import type { ConsentPurpose } from '@kelpie/schemas'
|
|
16
24
|
import { useUpdateFormFields } from '../../api/resources/forms.ts'
|
|
17
25
|
import { ErrorPanel } from '../../components/QueryState.tsx'
|
|
18
|
-
import {
|
|
26
|
+
import { AddFieldMenu } from './AddFieldMenu.tsx'
|
|
19
27
|
import {
|
|
20
28
|
FIELD_TYPE_OPTIONS,
|
|
21
29
|
MAP_TARGET_OPTIONS,
|
|
@@ -31,10 +39,14 @@ import {
|
|
|
31
39
|
typeForTarget,
|
|
32
40
|
} from './fieldList.ts'
|
|
33
41
|
import type { EditableField } from './fieldList.ts'
|
|
34
|
-
import { NEW_FIELD } from './template.ts'
|
|
35
42
|
|
|
36
43
|
/**
|
|
37
|
-
* The field builder.
|
|
44
|
+
* The field builder: the form as a visitor will see it, edited in place.
|
|
45
|
+
*
|
|
46
|
+
* The left column renders the fields the way the embed does — labels, required
|
|
47
|
+
* markers, placeholders, the Submit button. Clicking a field selects it and the
|
|
48
|
+
* panel beside the preview edits its settings; the anchor that appears on hover
|
|
49
|
+
* drags it up and down; "Add field" offers ready-made fields to append.
|
|
38
50
|
*
|
|
39
51
|
* Explicitly saved rather than saved per keystroke, unlike the inline editing
|
|
40
52
|
* everywhere else in the app. A write replaces the whole list, so an
|
|
@@ -64,20 +76,37 @@ export interface FieldsEditorProps {
|
|
|
64
76
|
|
|
65
77
|
export function FieldsEditor({ form }: FieldsEditorProps): React.JSX.Element {
|
|
66
78
|
const [fields, setFields] = useState<EditableField[]>(() => toEditableFields(form))
|
|
79
|
+
const [selectedId, setSelectedId] = useState<string | null>(null)
|
|
67
80
|
const updateFields = useUpdateFormFields()
|
|
81
|
+
const fieldsRef = useRef<readonly EditableField[]>(fields)
|
|
82
|
+
fieldsRef.current = fields
|
|
68
83
|
const sensors = useSensors(
|
|
69
84
|
useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
|
|
70
85
|
useSensor(KeyboardSensor),
|
|
71
86
|
)
|
|
72
87
|
|
|
73
88
|
// A save answers with the stored list, ids and all. Re-reading it here is what
|
|
74
|
-
// turns the local ids of newly added fields into the real ones.
|
|
89
|
+
// turns the local ids of newly added fields into the real ones. The selection
|
|
90
|
+
// survives by position: a just-added field comes back at the same index under
|
|
91
|
+
// the id the server gave it.
|
|
75
92
|
useEffect(() => {
|
|
76
|
-
|
|
93
|
+
const next = toEditableFields(form)
|
|
94
|
+
|
|
95
|
+
setSelectedId((current) => {
|
|
96
|
+
if (current === null || next.some((field) => field.id === current)) {
|
|
97
|
+
return current
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const index = fieldsRef.current.findIndex((field) => field.id === current)
|
|
101
|
+
|
|
102
|
+
return index >= 0 ? (next[index]?.id ?? null) : null
|
|
103
|
+
})
|
|
104
|
+
setFields(next)
|
|
77
105
|
}, [form])
|
|
78
106
|
|
|
79
107
|
const problems = findProblems(fields, form.createDeal)
|
|
80
108
|
const changed = fieldsChanged(form, fields)
|
|
109
|
+
const selected = fields.find((field) => field.id === selectedId)
|
|
81
110
|
|
|
82
111
|
function onDragEnd(event: DragEndEvent): void {
|
|
83
112
|
const overId = event.over?.id
|
|
@@ -87,20 +116,29 @@ export function FieldsEditor({ form }: FieldsEditorProps): React.JSX.Element {
|
|
|
87
116
|
}
|
|
88
117
|
}
|
|
89
118
|
|
|
119
|
+
function addField(input: FormFieldInput): void {
|
|
120
|
+
const added: EditableField = { ...input, id: localFieldId() }
|
|
121
|
+
|
|
122
|
+
setFields((current) => insertField(current, added, selectedId))
|
|
123
|
+
setSelectedId(added.id)
|
|
124
|
+
}
|
|
125
|
+
|
|
90
126
|
function save(): void {
|
|
91
127
|
updateFields.run({ id: form.id, fields: toFieldInputs(fields) })
|
|
92
128
|
}
|
|
93
129
|
|
|
94
130
|
return (
|
|
95
|
-
<div
|
|
96
|
-
<
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
131
|
+
<div>
|
|
132
|
+
<div className="mb-3 flex items-start justify-between gap-2">
|
|
133
|
+
<div className="min-w-0">
|
|
134
|
+
<h2 className="text-[13px] font-semibold text-ink">Fields</h2>
|
|
135
|
+
<p className="mt-0.5 text-[11px] text-ink-faint">
|
|
136
|
+
Click a field to edit it. Drag the anchor to reorder. What a field maps to decides what
|
|
137
|
+
a submission writes.
|
|
138
|
+
</p>
|
|
139
|
+
</div>
|
|
140
|
+
<AddFieldMenu fields={fields} onAdd={addField} />
|
|
141
|
+
</div>
|
|
104
142
|
|
|
105
143
|
{problems.list.map((problem) => (
|
|
106
144
|
<p key={problem} className="mb-2 text-[12px] text-danger">
|
|
@@ -108,36 +146,64 @@ export function FieldsEditor({ form }: FieldsEditorProps): React.JSX.Element {
|
|
|
108
146
|
</p>
|
|
109
147
|
))}
|
|
110
148
|
|
|
111
|
-
<
|
|
112
|
-
<
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
149
|
+
<div className="grid items-start gap-6 lg:grid-cols-[minmax(0,1fr)_300px]">
|
|
150
|
+
<div className="rounded-lg border border-border bg-surface-raised p-4 sm:p-6">
|
|
151
|
+
{fields.length === 0 ? (
|
|
152
|
+
<p className="rounded-md border border-dashed border-border px-6 py-10 text-center text-[13px] text-ink-muted">
|
|
153
|
+
No fields yet. A form needs at least an email field.
|
|
154
|
+
</p>
|
|
155
|
+
) : (
|
|
156
|
+
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={onDragEnd}>
|
|
157
|
+
<SortableContext
|
|
158
|
+
items={fields.map((field) => field.id)}
|
|
159
|
+
strategy={verticalListSortingStrategy}
|
|
160
|
+
>
|
|
161
|
+
<ul className="flex flex-col gap-1">
|
|
162
|
+
{fields.map((field) => (
|
|
163
|
+
<PreviewField
|
|
164
|
+
key={field.id}
|
|
165
|
+
field={field}
|
|
166
|
+
selected={field.id === selectedId}
|
|
167
|
+
problem={problems.byField.get(field.id)}
|
|
168
|
+
onSelect={() => {
|
|
169
|
+
setSelectedId(field.id)
|
|
170
|
+
}}
|
|
171
|
+
/>
|
|
172
|
+
))}
|
|
173
|
+
</ul>
|
|
174
|
+
</SortableContext>
|
|
175
|
+
</DndContext>
|
|
176
|
+
)}
|
|
177
|
+
|
|
178
|
+
<div className="mt-4 pl-7" aria-hidden="true">
|
|
179
|
+
<span className="pointer-events-none inline-flex rounded-md bg-accent px-4 py-2 text-[13px] font-semibold text-accent-fg">
|
|
180
|
+
Submit
|
|
181
|
+
</span>
|
|
182
|
+
</div>
|
|
183
|
+
</div>
|
|
135
184
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
185
|
+
{selected === undefined ? (
|
|
186
|
+
<aside className="rounded-lg border border-dashed border-border p-4 text-[13px] text-ink-muted lg:sticky lg:top-4">
|
|
187
|
+
Select a field in the preview to edit its label, mapping, and type. "Add field" appends
|
|
188
|
+
a new one.
|
|
189
|
+
</aside>
|
|
190
|
+
) : (
|
|
191
|
+
<FieldSettings
|
|
192
|
+
field={selected}
|
|
193
|
+
problem={problems.byField.get(selected.id)}
|
|
194
|
+
onChange={(change) => {
|
|
195
|
+
setFields((current) => editField(current, selected.id, change))
|
|
196
|
+
}}
|
|
197
|
+
onRemove={() => {
|
|
198
|
+
setFields((current) => removeField(current, selected.id))
|
|
199
|
+
setSelectedId(null)
|
|
200
|
+
}}
|
|
201
|
+
onClose={() => {
|
|
202
|
+
setSelectedId(null)
|
|
203
|
+
}}
|
|
204
|
+
/>
|
|
205
|
+
)}
|
|
206
|
+
</div>
|
|
141
207
|
|
|
142
208
|
{updateFields.error !== null && (
|
|
143
209
|
<div className="mt-3">
|
|
@@ -159,6 +225,7 @@ export function FieldsEditor({ form }: FieldsEditorProps): React.JSX.Element {
|
|
|
159
225
|
type="button"
|
|
160
226
|
onClick={() => {
|
|
161
227
|
setFields(toEditableFields(form))
|
|
228
|
+
setSelectedId(null)
|
|
162
229
|
}}
|
|
163
230
|
className="text-[12px] font-medium text-ink-muted transition hover:text-ink"
|
|
164
231
|
>
|
|
@@ -171,21 +238,20 @@ export function FieldsEditor({ form }: FieldsEditorProps): React.JSX.Element {
|
|
|
171
238
|
)
|
|
172
239
|
}
|
|
173
240
|
|
|
174
|
-
interface
|
|
241
|
+
interface PreviewFieldProps {
|
|
175
242
|
readonly field: EditableField
|
|
243
|
+
readonly selected: boolean
|
|
176
244
|
readonly problem: string | undefined
|
|
177
|
-
readonly
|
|
178
|
-
readonly onRemove: () => void
|
|
179
|
-
readonly onAddBelow: () => void
|
|
245
|
+
readonly onSelect: () => void
|
|
180
246
|
}
|
|
181
247
|
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
}:
|
|
248
|
+
/**
|
|
249
|
+
* One field, rendered the way the embed renders it and inert: the control never
|
|
250
|
+
* takes input here. A full-row button on top is what selects the field, so the
|
|
251
|
+
* click target is the whole thing a visitor would see, and the drag anchor
|
|
252
|
+
* beside the row is the one place a press means "move" instead of "edit".
|
|
253
|
+
*/
|
|
254
|
+
function PreviewField({ field, selected, problem, onSelect }: PreviewFieldProps): React.JSX.Element {
|
|
189
255
|
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
|
190
256
|
id: field.id,
|
|
191
257
|
})
|
|
@@ -198,124 +264,318 @@ function SortableField({
|
|
|
198
264
|
transition,
|
|
199
265
|
opacity: isDragging ? 0.35 : 1,
|
|
200
266
|
}}
|
|
201
|
-
className=
|
|
202
|
-
'rounded-md border bg-surface-raised p-3',
|
|
203
|
-
problem === undefined ? 'border-border' : 'border-danger/50',
|
|
204
|
-
].join(' ')}
|
|
267
|
+
className="group flex items-start gap-0.5"
|
|
205
268
|
>
|
|
206
|
-
<
|
|
269
|
+
<button
|
|
270
|
+
type="button"
|
|
271
|
+
className="mt-8 shrink-0 cursor-grab touch-none rounded-md px-1 py-1 text-[10px] leading-none text-ink-faint opacity-0 transition hover:text-ink focus-visible:opacity-100 active:cursor-grabbing group-hover:opacity-100"
|
|
272
|
+
aria-label={`Drag ${field.label} to reorder`}
|
|
273
|
+
{...attributes}
|
|
274
|
+
{...listeners}
|
|
275
|
+
>
|
|
276
|
+
⋮⋮
|
|
277
|
+
</button>
|
|
278
|
+
|
|
279
|
+
<div
|
|
280
|
+
className={[
|
|
281
|
+
'relative min-w-0 flex-1 rounded-md p-2.5 transition',
|
|
282
|
+
selected
|
|
283
|
+
? 'ring-2 ring-accent'
|
|
284
|
+
: problem === undefined
|
|
285
|
+
? 'ring-1 ring-transparent group-hover:ring-border-strong'
|
|
286
|
+
: 'ring-1 ring-danger/50',
|
|
287
|
+
].join(' ')}
|
|
288
|
+
>
|
|
289
|
+
<button
|
|
290
|
+
type="button"
|
|
291
|
+
onClick={onSelect}
|
|
292
|
+
aria-label={`Edit ${field.label}`}
|
|
293
|
+
aria-pressed={selected}
|
|
294
|
+
className="absolute inset-0 z-10 cursor-pointer rounded-md"
|
|
295
|
+
/>
|
|
296
|
+
|
|
297
|
+
<div className="pointer-events-none">
|
|
298
|
+
<span className="mb-1 block text-[13px] font-medium text-ink">
|
|
299
|
+
{field.label}
|
|
300
|
+
{(field.required ?? false) && <span className="ml-0.5 text-danger">*</span>}
|
|
301
|
+
</span>
|
|
302
|
+
<PreviewControl field={field} />
|
|
303
|
+
{problem !== undefined && <p className="mt-1.5 text-[12px] text-danger">{problem}</p>}
|
|
304
|
+
</div>
|
|
305
|
+
</div>
|
|
306
|
+
</li>
|
|
307
|
+
)
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** The control itself, matching what the embed serves for each field type. */
|
|
311
|
+
function PreviewControl({ field }: { readonly field: EditableField }): React.JSX.Element {
|
|
312
|
+
const controlClass =
|
|
313
|
+
'w-full rounded-md border border-border bg-surface px-3 py-2 text-[13px] text-ink placeholder:text-ink-faint'
|
|
314
|
+
|
|
315
|
+
if (field.type === 'textarea') {
|
|
316
|
+
return (
|
|
317
|
+
<textarea
|
|
318
|
+
readOnly
|
|
319
|
+
tabIndex={-1}
|
|
320
|
+
rows={3}
|
|
321
|
+
placeholder={field.placeholder ?? undefined}
|
|
322
|
+
className={controlClass}
|
|
323
|
+
/>
|
|
324
|
+
)
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
if (field.type === 'select') {
|
|
328
|
+
const options = field.options ?? []
|
|
329
|
+
|
|
330
|
+
// Remounted whenever the choices change, so the uncontrolled select cannot
|
|
331
|
+
// show a choice that no longer exists. The embed gives a non-required
|
|
332
|
+
// select a blank "—" first, and the preview shows the same.
|
|
333
|
+
return (
|
|
334
|
+
<select
|
|
335
|
+
key={options.map((option) => option.key).join('\n')}
|
|
336
|
+
tabIndex={-1}
|
|
337
|
+
defaultValue=""
|
|
338
|
+
className={controlClass}
|
|
339
|
+
>
|
|
340
|
+
{(field.required ?? false) === false && <option value="">—</option>}
|
|
341
|
+
{options.map((option) => (
|
|
342
|
+
<option key={option.key} value={option.key}>
|
|
343
|
+
{option.value}
|
|
344
|
+
</option>
|
|
345
|
+
))}
|
|
346
|
+
</select>
|
|
347
|
+
)
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
if (field.type === 'consent') {
|
|
351
|
+
return <ConsentPreview field={field} />
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
if (field.type === 'notice') {
|
|
355
|
+
return <NoticePreview field={field} />
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
return (
|
|
359
|
+
<input
|
|
360
|
+
readOnly
|
|
361
|
+
tabIndex={-1}
|
|
362
|
+
type={field.type === 'email' ? 'email' : 'text'}
|
|
363
|
+
placeholder={field.placeholder ?? undefined}
|
|
364
|
+
className={controlClass}
|
|
365
|
+
/>
|
|
366
|
+
)
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* The consent preview — matches what the visitor sees: a statement above,
|
|
371
|
+
* one checkbox per selected purpose below, each labelled by the purpose. The
|
|
372
|
+
* label above (in FieldRow) still shows the field heading.
|
|
373
|
+
*/
|
|
374
|
+
/**
|
|
375
|
+
* The notice preview — prose only, styled like the hosted embed. There is no
|
|
376
|
+
* checkbox because a notice is granted implicitly by submission.
|
|
377
|
+
*/
|
|
378
|
+
function NoticePreview({ field }: { readonly field: EditableField }): React.JSX.Element {
|
|
379
|
+
const statement = (field.statement ?? '').trim()
|
|
380
|
+
return (
|
|
381
|
+
<div>
|
|
382
|
+
{statement.length === 0 ? (
|
|
383
|
+
<p className="text-[11px] italic text-ink-faint">
|
|
384
|
+
Add a statement in the settings panel — the notice text the visitor reads.
|
|
385
|
+
</p>
|
|
386
|
+
) : (
|
|
387
|
+
<p className="rounded-md border border-border bg-surface px-3 py-2 text-[12px] leading-snug text-ink-muted whitespace-pre-line">
|
|
388
|
+
{statement}
|
|
389
|
+
</p>
|
|
390
|
+
)}
|
|
391
|
+
</div>
|
|
392
|
+
)
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function ConsentPreview({ field }: { readonly field: EditableField }): React.JSX.Element {
|
|
396
|
+
const purposes = useConsentPurposes({ sort: 'sort_order', limit: 200 })
|
|
397
|
+
const labelById = new Map<string, string>(
|
|
398
|
+
purposes.records.map((purpose: ConsentPurpose) => [purpose.id, purpose.label]),
|
|
399
|
+
)
|
|
400
|
+
const statement = field.statement ?? field.label
|
|
401
|
+
const ids = field.consentPurposeIds ?? []
|
|
402
|
+
const overrides = field.consentPurposeLabels ?? {}
|
|
403
|
+
|
|
404
|
+
return (
|
|
405
|
+
<div className="space-y-1.5">
|
|
406
|
+
<p className="text-[12px] text-ink-muted">{statement}</p>
|
|
407
|
+
{ids.length === 0 ? (
|
|
408
|
+
<p className="text-[11px] italic text-ink-faint">
|
|
409
|
+
Pick at least one purpose in the settings panel.
|
|
410
|
+
</p>
|
|
411
|
+
) : (
|
|
412
|
+
ids.map((id) => (
|
|
413
|
+
<div key={id} className="flex items-start gap-2">
|
|
414
|
+
<input readOnly tabIndex={-1} type="checkbox" className="mt-0.5" />
|
|
415
|
+
<span className="text-[13px] text-ink">
|
|
416
|
+
{overrides[id] ?? labelById.get(id) ?? id}
|
|
417
|
+
</span>
|
|
418
|
+
</div>
|
|
419
|
+
))
|
|
420
|
+
)}
|
|
421
|
+
</div>
|
|
422
|
+
)
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
interface FieldSettingsProps {
|
|
426
|
+
readonly field: EditableField
|
|
427
|
+
readonly problem: string | undefined
|
|
428
|
+
readonly onChange: (change: Partial<FormFieldInput>) => void
|
|
429
|
+
readonly onRemove: () => void
|
|
430
|
+
readonly onClose: () => void
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/** The panel a selected field's settings edit in. */
|
|
434
|
+
function FieldSettings({
|
|
435
|
+
field,
|
|
436
|
+
problem,
|
|
437
|
+
onChange,
|
|
438
|
+
onRemove,
|
|
439
|
+
onClose,
|
|
440
|
+
}: FieldSettingsProps): React.JSX.Element {
|
|
441
|
+
return (
|
|
442
|
+
<aside className="rounded-lg border border-border bg-surface-raised p-4 lg:sticky lg:top-4">
|
|
443
|
+
<div className="mb-3 flex items-center justify-between gap-2">
|
|
444
|
+
<h3 className="text-[12px] font-semibold text-ink">Field settings</h3>
|
|
207
445
|
<button
|
|
208
446
|
type="button"
|
|
209
|
-
|
|
210
|
-
aria-label=
|
|
211
|
-
|
|
212
|
-
{...listeners}
|
|
447
|
+
onClick={onClose}
|
|
448
|
+
aria-label="Close field settings"
|
|
449
|
+
className="rounded-md px-1.5 py-1 text-[12px] text-ink-faint transition hover:text-ink"
|
|
213
450
|
>
|
|
214
|
-
|
|
451
|
+
✕
|
|
215
452
|
</button>
|
|
453
|
+
</div>
|
|
216
454
|
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
<label className="block">
|
|
230
|
-
<span className="mb-1 block text-[11px] font-medium text-ink-faint">Maps to</span>
|
|
231
|
-
<select
|
|
232
|
-
className={inputClass}
|
|
233
|
-
value={field.mapTo}
|
|
234
|
-
onChange={(event) => {
|
|
235
|
-
const mapTo = event.target.value as FormFieldMapTarget
|
|
455
|
+
<div className="flex flex-col gap-3">
|
|
456
|
+
<label className="block">
|
|
457
|
+
<span className="mb-1 block text-[11px] font-medium text-ink-faint">Label</span>
|
|
458
|
+
<input
|
|
459
|
+
className={inputClass}
|
|
460
|
+
value={field.label}
|
|
461
|
+
onChange={(event) => {
|
|
462
|
+
onChange({ label: event.target.value })
|
|
463
|
+
}}
|
|
464
|
+
/>
|
|
465
|
+
</label>
|
|
236
466
|
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
))}
|
|
245
|
-
</select>
|
|
246
|
-
</label>
|
|
247
|
-
|
|
248
|
-
<label className="block">
|
|
249
|
-
<span className="mb-1 block text-[11px] font-medium text-ink-faint">Type</span>
|
|
250
|
-
<select
|
|
251
|
-
className={inputClass}
|
|
252
|
-
value={field.type}
|
|
253
|
-
onChange={(event) => {
|
|
254
|
-
onChange({ type: event.target.value as FormFieldType })
|
|
255
|
-
}}
|
|
256
|
-
>
|
|
257
|
-
{FIELD_TYPE_OPTIONS.map((option) => (
|
|
258
|
-
<option key={option.value} value={option.value}>
|
|
259
|
-
{option.label}
|
|
260
|
-
</option>
|
|
261
|
-
))}
|
|
262
|
-
</select>
|
|
263
|
-
</label>
|
|
264
|
-
|
|
265
|
-
<label className="block">
|
|
266
|
-
<span className="mb-1 block text-[11px] font-medium text-ink-faint">Placeholder</span>
|
|
267
|
-
<input
|
|
268
|
-
className={inputClass}
|
|
269
|
-
value={field.placeholder ?? ''}
|
|
270
|
-
onChange={(event) => {
|
|
271
|
-
onChange({ placeholder: event.target.value.length === 0 ? null : event.target.value })
|
|
272
|
-
}}
|
|
273
|
-
/>
|
|
274
|
-
</label>
|
|
275
|
-
</div>
|
|
467
|
+
<label className="block">
|
|
468
|
+
<span className="mb-1 block text-[11px] font-medium text-ink-faint">Maps to</span>
|
|
469
|
+
<select
|
|
470
|
+
className={inputClass}
|
|
471
|
+
value={field.mapTo}
|
|
472
|
+
onChange={(event) => {
|
|
473
|
+
const mapTo = event.target.value as FormFieldMapTarget
|
|
276
474
|
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
type="button"
|
|
280
|
-
onClick={onRemove}
|
|
281
|
-
aria-label={`Remove ${field.label}`}
|
|
282
|
-
className="rounded-md px-1.5 py-1 text-[12px] text-ink-faint transition hover:text-danger"
|
|
475
|
+
onChange({ mapTo, type: typeForTarget(mapTo, field.type) })
|
|
476
|
+
}}
|
|
283
477
|
>
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
478
|
+
{MAP_TARGET_OPTIONS.map((option) => (
|
|
479
|
+
<option key={option.value} value={option.value}>
|
|
480
|
+
{FORM_FIELD_MAP_TARGET_LABELS[option.value]}
|
|
481
|
+
</option>
|
|
482
|
+
))}
|
|
483
|
+
</select>
|
|
484
|
+
</label>
|
|
485
|
+
|
|
486
|
+
<label className="block">
|
|
487
|
+
<span className="mb-1 block text-[11px] font-medium text-ink-faint">Type</span>
|
|
488
|
+
<select
|
|
489
|
+
className={inputClass}
|
|
490
|
+
value={field.type}
|
|
491
|
+
onChange={(event) => {
|
|
492
|
+
onChange({ type: event.target.value as FormFieldType })
|
|
493
|
+
}}
|
|
291
494
|
>
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
495
|
+
{FIELD_TYPE_OPTIONS.map((option) => (
|
|
496
|
+
<option key={option.value} value={option.value}>
|
|
497
|
+
{option.label}
|
|
498
|
+
</option>
|
|
499
|
+
))}
|
|
500
|
+
</select>
|
|
501
|
+
</label>
|
|
296
502
|
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
onChange(
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
{field.type === 'select' && (
|
|
309
|
-
<OptionsEditor
|
|
310
|
-
options={field.options ?? []}
|
|
311
|
-
onChange={(options) => {
|
|
312
|
-
onChange({ options })
|
|
313
|
-
}}
|
|
314
|
-
/>
|
|
315
|
-
)}
|
|
503
|
+
<label className="block">
|
|
504
|
+
<span className="mb-1 block text-[11px] font-medium text-ink-faint">Placeholder</span>
|
|
505
|
+
<input
|
|
506
|
+
className={inputClass}
|
|
507
|
+
value={field.placeholder ?? ''}
|
|
508
|
+
onChange={(event) => {
|
|
509
|
+
onChange({ placeholder: event.target.value.length === 0 ? null : event.target.value })
|
|
510
|
+
}}
|
|
511
|
+
/>
|
|
512
|
+
</label>
|
|
316
513
|
|
|
317
|
-
|
|
318
|
-
|
|
514
|
+
<label className="flex items-center gap-2 text-[12px] text-ink-muted">
|
|
515
|
+
<input
|
|
516
|
+
type="checkbox"
|
|
517
|
+
checked={field.required ?? false}
|
|
518
|
+
onChange={(event) => {
|
|
519
|
+
onChange({ required: event.target.checked })
|
|
520
|
+
}}
|
|
521
|
+
/>
|
|
522
|
+
Required
|
|
523
|
+
</label>
|
|
524
|
+
|
|
525
|
+
{field.type === 'select' && (
|
|
526
|
+
<OptionsEditor
|
|
527
|
+
options={field.options ?? []}
|
|
528
|
+
onChange={(options) => {
|
|
529
|
+
onChange({ options })
|
|
530
|
+
}}
|
|
531
|
+
/>
|
|
532
|
+
)}
|
|
533
|
+
|
|
534
|
+
{(field.type === 'consent' || field.type === 'notice') && (
|
|
535
|
+
<>
|
|
536
|
+
<label className="block">
|
|
537
|
+
<span className="mb-1 block text-[11px] font-medium text-ink-faint">
|
|
538
|
+
{field.type === 'notice'
|
|
539
|
+
? 'Notice (the text the visitor reads)'
|
|
540
|
+
: 'Statement (intro sentence above the checkboxes)'}
|
|
541
|
+
</span>
|
|
542
|
+
<textarea
|
|
543
|
+
className={inputClass}
|
|
544
|
+
rows={field.type === 'notice' ? 5 : 3}
|
|
545
|
+
value={field.statement ?? ''}
|
|
546
|
+
onChange={(event) => {
|
|
547
|
+
const value = event.target.value
|
|
548
|
+
onChange({ statement: value.length === 0 ? null : value })
|
|
549
|
+
}}
|
|
550
|
+
placeholder={
|
|
551
|
+
field.type === 'notice'
|
|
552
|
+
? 'By submitting this form you agree to us storing your information and using it to contact you about your enquiry.'
|
|
553
|
+
: 'Please tell us how we can contact you.'
|
|
554
|
+
}
|
|
555
|
+
/>
|
|
556
|
+
</label>
|
|
557
|
+
<ConsentPurposePicker
|
|
558
|
+
mode={field.type === 'notice' ? 'notice' : 'checkbox'}
|
|
559
|
+
value={field.consentPurposeIds ?? []}
|
|
560
|
+
labels={field.consentPurposeLabels ?? {}}
|
|
561
|
+
onChange={(consentPurposeIds, consentPurposeLabels) => {
|
|
562
|
+
onChange({ consentPurposeIds, consentPurposeLabels })
|
|
563
|
+
}}
|
|
564
|
+
/>
|
|
565
|
+
</>
|
|
566
|
+
)}
|
|
567
|
+
|
|
568
|
+
{problem !== undefined && <p className="text-[12px] text-danger">{problem}</p>}
|
|
569
|
+
|
|
570
|
+
<button
|
|
571
|
+
type="button"
|
|
572
|
+
onClick={onRemove}
|
|
573
|
+
className="self-start rounded-md border border-border px-2.5 py-1.5 text-[12px] font-medium text-danger transition hover:border-danger/50 hover:bg-danger-soft"
|
|
574
|
+
>
|
|
575
|
+
Remove field
|
|
576
|
+
</button>
|
|
577
|
+
</div>
|
|
578
|
+
</aside>
|
|
319
579
|
)
|
|
320
580
|
}
|
|
321
581
|
|
|
@@ -335,7 +595,7 @@ function OptionsEditor({
|
|
|
335
595
|
readonly onChange: (options: readonly FormFieldOptionInput[]) => void
|
|
336
596
|
}): React.JSX.Element {
|
|
337
597
|
return (
|
|
338
|
-
<div className="
|
|
598
|
+
<div className="space-y-2">
|
|
339
599
|
<span className="block text-[11px] font-medium text-ink-faint">Options</span>
|
|
340
600
|
{options.map((option, index) => (
|
|
341
601
|
<div key={`${option.key}-${String(index)}`} className="flex items-center gap-2">
|
|
@@ -364,7 +624,7 @@ function OptionsEditor({
|
|
|
364
624
|
}}
|
|
365
625
|
/>
|
|
366
626
|
<select
|
|
367
|
-
className={`${inputClass} w-
|
|
627
|
+
className={`${inputClass} w-24`}
|
|
368
628
|
aria-label={`Option ${String(index + 1)} value type`}
|
|
369
629
|
value={option.valueType ?? 'string'}
|
|
370
630
|
onChange={(event) => {
|
|
@@ -410,3 +670,117 @@ function OptionsEditor({
|
|
|
410
670
|
</div>
|
|
411
671
|
)
|
|
412
672
|
}
|
|
673
|
+
|
|
674
|
+
/**
|
|
675
|
+
* Picks the workspace consent purposes this field offers, with a text input
|
|
676
|
+
* beside each selected purpose so the admin can override the checkbox text.
|
|
677
|
+
* The override is bound to the same purpose the checkbox grants — the wording
|
|
678
|
+
* changes, the mapping never does. An empty override falls back to the
|
|
679
|
+
* workspace purpose's own label at render time.
|
|
680
|
+
*/
|
|
681
|
+
function ConsentPurposePicker({
|
|
682
|
+
mode,
|
|
683
|
+
value,
|
|
684
|
+
labels,
|
|
685
|
+
onChange,
|
|
686
|
+
}: {
|
|
687
|
+
/**
|
|
688
|
+
* `checkbox` shows a text override per selected purpose (each purpose has
|
|
689
|
+
* its own checkbox in the embed). `notice` hides the overrides — the
|
|
690
|
+
* consent is granted implicitly, without a per-purpose checkbox.
|
|
691
|
+
*/
|
|
692
|
+
readonly mode: 'checkbox' | 'notice'
|
|
693
|
+
readonly value: readonly string[]
|
|
694
|
+
readonly labels: Readonly<Record<string, string>>
|
|
695
|
+
readonly onChange: (
|
|
696
|
+
ids: readonly string[],
|
|
697
|
+
labels: Readonly<Record<string, string>>,
|
|
698
|
+
) => void
|
|
699
|
+
}): React.JSX.Element {
|
|
700
|
+
const purposes = useConsentPurposes({ sort: 'sort_order', limit: 200 })
|
|
701
|
+
|
|
702
|
+
if (purposes.isLoading) {
|
|
703
|
+
return <p className="text-[12px] text-ink-muted">Loading consent purposes…</p>
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
if (purposes.records.length === 0) {
|
|
707
|
+
return (
|
|
708
|
+
<p className="text-[12px] text-ink-muted">
|
|
709
|
+
No consent purposes yet. Add one on the Privacy admin page, then pick it here.
|
|
710
|
+
</p>
|
|
711
|
+
)
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
const chosen = new Set(value)
|
|
715
|
+
|
|
716
|
+
function toggle(id: string, checked: boolean): void {
|
|
717
|
+
// Preserve the workspace's purpose order so the visitor sees the checkboxes
|
|
718
|
+
// in a consistent order regardless of which one the admin ticked first.
|
|
719
|
+
const order = purposes.records.map((purpose) => purpose.id)
|
|
720
|
+
const nextIds = order.filter((purposeId) =>
|
|
721
|
+
purposeId === id ? checked : chosen.has(purposeId),
|
|
722
|
+
)
|
|
723
|
+
// A deselected purpose drops its override too — silent orphan overrides
|
|
724
|
+
// would resurface if the admin ticked it again later.
|
|
725
|
+
const nextLabels: Record<string, string> = {}
|
|
726
|
+
for (const [key, val] of Object.entries(labels)) {
|
|
727
|
+
if (nextIds.includes(key)) nextLabels[key] = val
|
|
728
|
+
}
|
|
729
|
+
onChange(nextIds, nextLabels)
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
function setLabel(id: string, text: string): void {
|
|
733
|
+
const nextLabels: Record<string, string> = { ...labels }
|
|
734
|
+
if (text.length === 0) {
|
|
735
|
+
delete nextLabels[id]
|
|
736
|
+
} else {
|
|
737
|
+
nextLabels[id] = text
|
|
738
|
+
}
|
|
739
|
+
onChange(value, nextLabels)
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
const legend =
|
|
743
|
+
mode === 'notice'
|
|
744
|
+
? 'Purposes granted when the visitor submits (implicit consent)'
|
|
745
|
+
: 'Consent purposes (each selected one becomes a checkbox)'
|
|
746
|
+
|
|
747
|
+
return (
|
|
748
|
+
<fieldset className="block">
|
|
749
|
+
<legend className="mb-1 block text-[11px] font-medium text-ink-faint">{legend}</legend>
|
|
750
|
+
<div className="flex flex-col gap-2">
|
|
751
|
+
{purposes.records.map((purpose) => {
|
|
752
|
+
const isChosen = chosen.has(purpose.id)
|
|
753
|
+
return (
|
|
754
|
+
<div key={purpose.id} className="rounded-md border border-border p-2">
|
|
755
|
+
<label className="flex items-center gap-2 text-[13px] text-ink">
|
|
756
|
+
<input
|
|
757
|
+
type="checkbox"
|
|
758
|
+
checked={isChosen}
|
|
759
|
+
onChange={(event) => {
|
|
760
|
+
toggle(purpose.id, event.target.checked)
|
|
761
|
+
}}
|
|
762
|
+
/>
|
|
763
|
+
{purpose.label}
|
|
764
|
+
</label>
|
|
765
|
+
{isChosen && mode === 'checkbox' && (
|
|
766
|
+
<label className="mt-1.5 block">
|
|
767
|
+
<span className="mb-1 block text-[11px] font-medium text-ink-faint">
|
|
768
|
+
Checkbox text (defaults to the purpose label)
|
|
769
|
+
</span>
|
|
770
|
+
<input
|
|
771
|
+
className={inputClass}
|
|
772
|
+
value={labels[purpose.id] ?? ''}
|
|
773
|
+
onChange={(event) => {
|
|
774
|
+
setLabel(purpose.id, event.target.value)
|
|
775
|
+
}}
|
|
776
|
+
placeholder={purpose.label}
|
|
777
|
+
/>
|
|
778
|
+
</label>
|
|
779
|
+
)}
|
|
780
|
+
</div>
|
|
781
|
+
)
|
|
782
|
+
})}
|
|
783
|
+
</div>
|
|
784
|
+
</fieldset>
|
|
785
|
+
)
|
|
786
|
+
}
|