@kelpie/ui 0.10.0 → 0.12.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.
Files changed (150) hide show
  1. package/dist/api/resources/activities.d.ts +2 -1
  2. package/dist/api/resources/activities.d.ts.map +1 -1
  3. package/dist/api/resources/activities.js +6 -2
  4. package/dist/api/resources/activities.js.map +1 -1
  5. package/dist/api/resources/conversions.d.ts +16 -0
  6. package/dist/api/resources/conversions.d.ts.map +1 -0
  7. package/dist/api/resources/conversions.js +81 -0
  8. package/dist/api/resources/conversions.js.map +1 -0
  9. package/dist/api/resources/enquiries.d.ts +2 -7
  10. package/dist/api/resources/enquiries.d.ts.map +1 -1
  11. package/dist/api/resources/enquiries.js +2 -30
  12. package/dist/api/resources/enquiries.js.map +1 -1
  13. package/dist/api/resources/importJobs.d.ts +2 -2
  14. package/dist/api/resources/importJobs.d.ts.map +1 -1
  15. package/dist/api/resources/importJobs.js.map +1 -1
  16. package/dist/app/KelpieApp.js +1 -1
  17. package/dist/app/KelpieApp.js.map +1 -1
  18. package/dist/components/ActivitiesPanel.d.ts +2 -2
  19. package/dist/components/ActivitiesPanel.d.ts.map +1 -1
  20. package/dist/components/ActivitiesPanel.js +7 -5
  21. package/dist/components/ActivitiesPanel.js.map +1 -1
  22. package/dist/components/ApiKeysPanel.d.ts +0 -7
  23. package/dist/components/ApiKeysPanel.d.ts.map +1 -1
  24. package/dist/components/ApiKeysPanel.js +83 -16
  25. package/dist/components/ApiKeysPanel.js.map +1 -1
  26. package/dist/components/ConvertRecordDialog.d.ts +30 -0
  27. package/dist/components/ConvertRecordDialog.d.ts.map +1 -0
  28. package/dist/components/ConvertRecordDialog.js +50 -0
  29. package/dist/components/ConvertRecordDialog.js.map +1 -0
  30. package/dist/components/Shell.d.ts.map +1 -1
  31. package/dist/components/Shell.js +19 -7
  32. package/dist/components/Shell.js.map +1 -1
  33. package/dist/index.d.ts +4 -2
  34. package/dist/index.d.ts.map +1 -1
  35. package/dist/index.js +5 -1
  36. package/dist/index.js.map +1 -1
  37. package/dist/pages/DealDetail.d.ts.map +1 -1
  38. package/dist/pages/DealDetail.js +15 -2
  39. package/dist/pages/DealDetail.js.map +1 -1
  40. package/dist/pages/DecisionsPage.d.ts.map +1 -1
  41. package/dist/pages/DecisionsPage.js +1 -7
  42. package/dist/pages/DecisionsPage.js.map +1 -1
  43. package/dist/pages/EnquiryDetail.d.ts.map +1 -1
  44. package/dist/pages/EnquiryDetail.js +17 -25
  45. package/dist/pages/EnquiryDetail.js.map +1 -1
  46. package/dist/pages/ListDetail.d.ts.map +1 -1
  47. package/dist/pages/ListDetail.js +1 -14
  48. package/dist/pages/ListDetail.js.map +1 -1
  49. package/dist/pages/OpportunityDetail.d.ts.map +1 -1
  50. package/dist/pages/OpportunityDetail.js +15 -2
  51. package/dist/pages/OpportunityDetail.js.map +1 -1
  52. package/dist/pages/PartnershipDetail.d.ts.map +1 -1
  53. package/dist/pages/PartnershipDetail.js +15 -2
  54. package/dist/pages/PartnershipDetail.js.map +1 -1
  55. package/dist/pages/RaiseDetail.d.ts.map +1 -1
  56. package/dist/pages/RaiseDetail.js +15 -2
  57. package/dist/pages/RaiseDetail.js.map +1 -1
  58. package/dist/pages/account/SecurityPage.js +2 -2
  59. package/dist/pages/account/SecurityPage.js.map +1 -1
  60. package/dist/pages/admin/DataPage.d.ts.map +1 -1
  61. package/dist/pages/admin/DataPage.js +3 -3
  62. package/dist/pages/admin/DataPage.js.map +1 -1
  63. package/dist/pages/admin/PrivacyPage.js +3 -3
  64. package/dist/pages/admin/PrivacyPage.js.map +1 -1
  65. package/dist/pages/admin/TeamPage.js +1 -1
  66. package/dist/pages/admin/TeamPage.js.map +1 -1
  67. package/dist/pages/admin/WorkspaceSettingsPage.js +2 -12
  68. package/dist/pages/admin/WorkspaceSettingsPage.js.map +1 -1
  69. package/dist/pages/auth/AuthForm.d.ts +18 -0
  70. package/dist/pages/auth/AuthForm.d.ts.map +1 -1
  71. package/dist/pages/auth/AuthForm.js +17 -0
  72. package/dist/pages/auth/AuthForm.js.map +1 -1
  73. package/dist/pages/auth/AuthMethods.d.ts +12 -0
  74. package/dist/pages/auth/AuthMethods.d.ts.map +1 -0
  75. package/dist/pages/auth/AuthMethods.js +20 -0
  76. package/dist/pages/auth/AuthMethods.js.map +1 -0
  77. package/dist/pages/auth/SignInPage.d.ts +7 -0
  78. package/dist/pages/auth/SignInPage.d.ts.map +1 -1
  79. package/dist/pages/auth/SignInPage.js +3 -8
  80. package/dist/pages/auth/SignInPage.js.map +1 -1
  81. package/dist/pages/auth/SignUpPage.d.ts.map +1 -1
  82. package/dist/pages/auth/SignUpPage.js +2 -1
  83. package/dist/pages/auth/SignUpPage.js.map +1 -1
  84. package/dist/pages/auth/nextPath.d.ts +15 -0
  85. package/dist/pages/auth/nextPath.d.ts.map +1 -0
  86. package/dist/pages/auth/nextPath.js +17 -0
  87. package/dist/pages/auth/nextPath.js.map +1 -0
  88. package/dist/pages/forms/AddFieldMenu.js +2 -2
  89. package/dist/pages/forms/AddFieldMenu.js.map +1 -1
  90. package/dist/pages/forms/FieldsEditor.d.ts.map +1 -1
  91. package/dist/pages/forms/FieldsEditor.js +22 -10
  92. package/dist/pages/forms/FieldsEditor.js.map +1 -1
  93. package/dist/pages/forms/MapTargetSearch.d.ts +7 -0
  94. package/dist/pages/forms/MapTargetSearch.d.ts.map +1 -0
  95. package/dist/pages/forms/MapTargetSearch.js +32 -0
  96. package/dist/pages/forms/MapTargetSearch.js.map +1 -0
  97. package/dist/pages/forms/fieldList.d.ts +6 -7
  98. package/dist/pages/forms/fieldList.d.ts.map +1 -1
  99. package/dist/pages/forms/fieldList.js +14 -16
  100. package/dist/pages/forms/fieldList.js.map +1 -1
  101. package/dist/pages/forms/template.d.ts.map +1 -1
  102. package/dist/pages/forms/template.js +7 -0
  103. package/dist/pages/forms/template.js.map +1 -1
  104. package/dist/registry/context.d.ts +3 -1
  105. package/dist/registry/context.d.ts.map +1 -1
  106. package/dist/registry/context.js +4 -0
  107. package/dist/registry/context.js.map +1 -1
  108. package/dist/registry/contributions.d.ts +24 -0
  109. package/dist/registry/contributions.d.ts.map +1 -1
  110. package/dist/registry/contributions.js.map +1 -1
  111. package/dist/registry/registry.d.ts +4 -1
  112. package/dist/registry/registry.d.ts.map +1 -1
  113. package/dist/registry/registry.js +6 -0
  114. package/dist/registry/registry.js.map +1 -1
  115. package/package.json +2 -2
  116. package/src/api/resources/activities.ts +10 -2
  117. package/src/api/resources/conversions.ts +125 -0
  118. package/src/api/resources/enquiries.ts +9 -35
  119. package/src/api/resources/importJobs.ts +3 -2
  120. package/src/app/KelpieApp.tsx +1 -0
  121. package/src/components/ActivitiesPanel.tsx +20 -14
  122. package/src/components/ApiKeysPanel.tsx +206 -21
  123. package/src/components/ConvertRecordDialog.tsx +210 -0
  124. package/src/components/Shell.tsx +91 -36
  125. package/src/index.ts +7 -0
  126. package/src/pages/DealDetail.tsx +35 -0
  127. package/src/pages/DecisionsPage.tsx +0 -14
  128. package/src/pages/EnquiryDetail.tsx +37 -90
  129. package/src/pages/ListDetail.tsx +0 -47
  130. package/src/pages/OpportunityDetail.tsx +35 -0
  131. package/src/pages/PartnershipDetail.tsx +35 -0
  132. package/src/pages/RaiseDetail.tsx +35 -0
  133. package/src/pages/account/SecurityPage.tsx +6 -1
  134. package/src/pages/admin/DataPage.tsx +14 -9
  135. package/src/pages/admin/PrivacyPage.tsx +4 -4
  136. package/src/pages/admin/TeamPage.tsx +1 -2
  137. package/src/pages/admin/WorkspaceSettingsPage.tsx +1 -31
  138. package/src/pages/auth/AuthForm.tsx +38 -0
  139. package/src/pages/auth/AuthMethods.tsx +29 -0
  140. package/src/pages/auth/SignInPage.tsx +3 -8
  141. package/src/pages/auth/SignUpPage.tsx +7 -0
  142. package/src/pages/auth/nextPath.ts +17 -0
  143. package/src/pages/forms/AddFieldMenu.tsx +2 -2
  144. package/src/pages/forms/FieldsEditor.tsx +45 -20
  145. package/src/pages/forms/MapTargetSearch.tsx +69 -0
  146. package/src/pages/forms/fieldList.ts +34 -16
  147. package/src/pages/forms/template.ts +7 -0
  148. package/src/registry/context.ts +6 -0
  149. package/src/registry/contributions.ts +26 -0
  150. package/src/registry/registry.ts +12 -0
@@ -74,8 +74,6 @@ function WorkspaceSettingsForm({
74
74
  const [name, setName] = useState(workspace.name)
75
75
  const [slug, setSlug] = useState(workspace.slug)
76
76
  const [timezone, setTimezone] = useState(workspace.timezone)
77
- const [tagline, setTagline] = useState(workspace.tagline ?? '')
78
- const [oneLiner, setOneLiner] = useState(workspace.oneLiner ?? '')
79
77
  const [saved, setSaved] = useState(false)
80
78
 
81
79
  const canEdit = session?.role === 'owner' || session?.role === 'admin'
@@ -92,10 +90,6 @@ function WorkspaceSettingsForm({
92
90
  name: name.trim(),
93
91
  slug: slug.trim(),
94
92
  timezone,
95
- // Empty is not blank text, it is no tagline. `null` clears the column;
96
- // an empty string would store one and hand agents an empty identity.
97
- tagline: tagline.trim().length === 0 ? null : tagline.trim(),
98
- oneLiner: oneLiner.trim().length === 0 ? null : oneLiner.trim(),
99
93
  })
100
94
  .then(() => {
101
95
  setSaved(true)
@@ -107,7 +101,7 @@ function WorkspaceSettingsForm({
107
101
  <div className="animate-slide-in mx-auto max-w-4xl space-y-8">
108
102
  <PageHeader
109
103
  title="Workspace"
110
- description="Name, address, timezone, and how the company introduces itself to an agent."
104
+ description="Name, address, and timezone."
111
105
  />
112
106
 
113
107
  <form onSubmit={save} className="space-y-4">
@@ -135,30 +129,6 @@ function WorkspaceSettingsForm({
135
129
  />
136
130
  </Field>
137
131
 
138
- <Field label="Tagline" hint="The short identity an agent loads first.">
139
- <input
140
- value={tagline}
141
- onChange={(event) => {
142
- setTagline(event.target.value)
143
- }}
144
- placeholder="CRM and company brain for agent-native startups"
145
- disabled={!canEdit}
146
- className="w-full max-w-xl rounded-md border border-border bg-surface-raised px-3 py-2 text-[13px] outline-none focus:border-accent focus:ring-2 focus:ring-accent/20 disabled:opacity-60"
147
- />
148
- </Field>
149
-
150
- <Field label="One-liner" hint="What the company does, in a sentence.">
151
- <textarea
152
- value={oneLiner}
153
- onChange={(event) => {
154
- setOneLiner(event.target.value)
155
- }}
156
- rows={2}
157
- disabled={!canEdit}
158
- className="w-full max-w-xl rounded-md border border-border bg-surface-raised px-3 py-2 text-[13px] outline-none focus:border-accent focus:ring-2 focus:ring-accent/20 disabled:opacity-60"
159
- />
160
- </Field>
161
-
162
132
  <Field label="Timezone">
163
133
  <select
164
134
  value={timezone}
@@ -116,3 +116,41 @@ export function SecondaryButton({
116
116
  </button>
117
117
  )
118
118
  }
119
+
120
+ /**
121
+ * A labelled rule, for a module separating its own buttons from the password
122
+ * form above them. Core never renders one itself; see `AuthMethods`.
123
+ */
124
+ export function AuthDivider({ label = 'or' }: { readonly label?: string }): React.JSX.Element {
125
+ return (
126
+ <div className="flex items-center gap-3 py-1">
127
+ <span className="h-px flex-1 bg-border" />
128
+ <span className="text-[11px] text-ink-faint">{label}</span>
129
+ <span className="h-px flex-1 bg-border" />
130
+ </div>
131
+ )
132
+ }
133
+
134
+ /**
135
+ * The secondary button as a link.
136
+ *
137
+ * A plain anchor, not a router link: a sign-in method leaves the app for its
138
+ * provider, and the destination is a server route the SPA router knows nothing
139
+ * about.
140
+ */
141
+ export function AuthLinkButton({
142
+ label,
143
+ href,
144
+ }: {
145
+ readonly label: string
146
+ readonly href: string
147
+ }): React.JSX.Element {
148
+ return (
149
+ <a
150
+ href={href}
151
+ className="block w-full rounded-md border border-border bg-surface px-3.5 py-2 text-center text-[12px] font-medium text-ink-muted transition hover:bg-surface-raised"
152
+ >
153
+ {label}
154
+ </a>
155
+ )
156
+ }
@@ -0,0 +1,29 @@
1
+ import { Fragment } from 'react'
2
+
3
+ import { useAuthMethods } from '../../registry/context.ts'
4
+ import type { AuthMethodContext } from '../../registry/contributions.ts'
5
+
6
+ /**
7
+ * The `auth.methods` slot, rendered by the signed-out pages beside their form.
8
+ *
9
+ * Nothing is drawn around the contributions: no divider, no "or" rule. Whether
10
+ * a module has anything to show is decided at run time (a sign-in module with
11
+ * no provider configured renders nothing), so core framing would leave a
12
+ * heading over an empty space. A module that draws buttons draws its own
13
+ * divider with `AuthDivider`.
14
+ */
15
+ export function AuthMethods({ intent, next }: AuthMethodContext): React.JSX.Element | null {
16
+ const methods = useAuthMethods()
17
+
18
+ if (methods.length === 0) {
19
+ return null
20
+ }
21
+
22
+ return (
23
+ <div className="mt-4 space-y-2">
24
+ {methods.map((method) => (
25
+ <Fragment key={method.id}>{method.render({ intent, next })}</Fragment>
26
+ ))}
27
+ </div>
28
+ )
29
+ }
@@ -6,6 +6,8 @@ import { useLogIn } from '../../api/resources/session.ts'
6
6
  import { ErrorPanel } from '../../components/QueryState.tsx'
7
7
  import { SubmitButton, TextField } from './AuthForm.tsx'
8
8
  import { AuthLayout } from './AuthLayout.tsx'
9
+ import { AuthMethods } from './AuthMethods.tsx'
10
+ import { safeNext } from './nextPath.ts'
9
11
 
10
12
  /**
11
13
  * Sign in, against `POST /v1/auth/login`.
@@ -15,14 +17,6 @@ import { AuthLayout } from './AuthLayout.tsx'
15
17
  * People with the invitation still unaccepted.
16
18
  */
17
19
 
18
- /**
19
- * Only a path within this app. An absolute URL in `?next=` would turn the
20
- * sign-in form into an open redirect.
21
- */
22
- function safeNext(value: string | null): string {
23
- return value !== null && value.startsWith('/') && !value.startsWith('//') ? value : '/dashboard'
24
- }
25
-
26
20
  export function SignInPage(): React.JSX.Element {
27
21
  const navigate = useNavigate()
28
22
  const [searchParameters] = useSearchParams()
@@ -78,6 +72,7 @@ export function SignInPage(): React.JSX.Element {
78
72
  {logIn.error !== null && <ErrorPanel error={logIn.error} />}
79
73
  <SubmitButton label="Sign in" pendingLabel="Signing in…" isPending={logIn.isPending} />
80
74
  </form>
75
+ <AuthMethods intent="login" next={next} />
81
76
  </AuthLayout>
82
77
  )
83
78
  }
@@ -7,6 +7,7 @@ import { useSignUp } from '../../api/resources/session.ts'
7
7
  import { ErrorPanel } from '../../components/QueryState.tsx'
8
8
  import { SubmitButton, TextField } from './AuthForm.tsx'
9
9
  import { AuthLayout } from './AuthLayout.tsx'
10
+ import { AuthMethods } from './AuthMethods.tsx'
10
11
 
11
12
  /**
12
13
  * Creating an account, against `POST /v1/auth/signup`.
@@ -94,6 +95,12 @@ export function SignUpPage(): React.JSX.Element {
94
95
  {signUp.error !== null && <ErrorPanel error={signUp.error} />}
95
96
  <SubmitButton label="Continue" pendingLabel="Creating…" isPending={signUp.isPending} />
96
97
  </form>
98
+ {/*
99
+ `/dashboard` rather than the verification screen a password signup goes
100
+ to: an account a module provisions is verified already, and having no
101
+ workspace, `SessionGate` sends it on to the workspace step itself.
102
+ */}
103
+ <AuthMethods intent="signup" next="/dashboard" />
97
104
  </AuthLayout>
98
105
  )
99
106
  }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Where a signed-out page sends the browser once it is signed in.
3
+ *
4
+ * Its own file rather than beside the form controls: a module exporting a
5
+ * component and a function from one file loses Fast Refresh, and this is
6
+ * exported to modules, which is the reason it is not page-local. A module
7
+ * contributing a sign-in method is handed the result and has to be able to
8
+ * rely on what it means.
9
+ */
10
+
11
+ /**
12
+ * Only a path within this app. An absolute URL in `?next=` would turn a
13
+ * sign-in page into an open redirect.
14
+ */
15
+ export function safeNext(value: string | null): string {
16
+ return value !== null && value.startsWith('/') && !value.startsWith('//') ? value : '/dashboard'
17
+ }
@@ -1,4 +1,4 @@
1
- import { FORM_FIELD_MAP_TARGET_LABELS } from '@kelpie/schemas'
1
+ import { labelForMapTarget } from '@kelpie/schemas'
2
2
  import type { FormFieldInput } from '@kelpie/schemas'
3
3
  import { useEffect, useRef, useState } from 'react'
4
4
 
@@ -92,7 +92,7 @@ export function AddFieldMenu({ fields, onAdd }: AddFieldMenuProps): React.JSX.El
92
92
  >
93
93
  <span>{preset.label}</span>
94
94
  <span className="shrink-0 text-[11px] text-ink-faint">
95
- {FORM_FIELD_MAP_TARGET_LABELS[preset.mapTo]}
95
+ {labelForMapTarget(preset.mapTo)}
96
96
  </span>
97
97
  </button>
98
98
  ))}
@@ -9,24 +9,24 @@ import {
9
9
  import type { DragEndEvent } from '@dnd-kit/core'
10
10
  import { SortableContext, useSortable, verticalListSortingStrategy } from '@dnd-kit/sortable'
11
11
  import { CSS } from '@dnd-kit/utilities'
12
- import { FORM_FIELD_MAP_TARGET_LABELS, FORM_OPTION_VALUE_TYPES } from '@kelpie/schemas'
12
+ import { FORM_OPTION_VALUE_TYPES } from '@kelpie/schemas'
13
13
  import type {
14
14
  Form,
15
15
  FormFieldInput,
16
- FormFieldMapTarget,
17
16
  FormFieldOptionInput,
18
17
  FormFieldType,
19
18
  } from '@kelpie/schemas'
20
- import { useEffect, useRef, useState } from 'react'
19
+ import { useEffect, useMemo, useRef, useState } from 'react'
21
20
 
22
21
  import { useConsentPurposes } from '../../api/resources/consentPurposes.ts'
22
+ import { useCustomFields } from '../../api/resources/customFields.ts'
23
23
  import type { ConsentPurpose } from '@kelpie/schemas'
24
24
  import { useUpdateFormFields } from '../../api/resources/forms.ts'
25
25
  import { ErrorPanel } from '../../components/QueryState.tsx'
26
26
  import { AddFieldMenu } from './AddFieldMenu.tsx'
27
+ import { MapTargetSearch } from './MapTargetSearch.tsx'
27
28
  import {
28
29
  FIELD_TYPE_OPTIONS,
29
- MAP_TARGET_OPTIONS,
30
30
  editField,
31
31
  fieldsChanged,
32
32
  findProblems,
@@ -78,6 +78,17 @@ export function FieldsEditor({ form }: FieldsEditorProps): React.JSX.Element {
78
78
  const [fields, setFields] = useState<EditableField[]>(() => toEditableFields(form))
79
79
  const [selectedId, setSelectedId] = useState<string | null>(null)
80
80
  const updateFields = useUpdateFormFields()
81
+ const customFields = useCustomFields()
82
+ const customFieldDefinitions = useMemo(
83
+ () =>
84
+ customFields.records.map((definition) => ({
85
+ objectType: definition.objectType,
86
+ key: definition.key,
87
+ label: definition.label,
88
+ type: definition.type,
89
+ })),
90
+ [customFields.records],
91
+ )
81
92
  const fieldsRef = useRef<readonly EditableField[]>(fields)
82
93
  fieldsRef.current = fields
83
94
  const sensors = useSensors(
@@ -104,7 +115,7 @@ export function FieldsEditor({ form }: FieldsEditorProps): React.JSX.Element {
104
115
  setFields(next)
105
116
  }, [form])
106
117
 
107
- const problems = findProblems(fields, form.createDeal)
118
+ const problems = findProblems(fields, form.createDeal, { customFieldDefinitions })
108
119
  const changed = fieldsChanged(form, fields)
109
120
  const selected = fields.find((field) => field.id === selectedId)
110
121
 
@@ -190,6 +201,8 @@ export function FieldsEditor({ form }: FieldsEditorProps): React.JSX.Element {
190
201
  ) : (
191
202
  <FieldSettings
192
203
  field={selected}
204
+ fields={fields}
205
+ customFieldDefinitions={customFieldDefinitions}
193
206
  problem={problems.byField.get(selected.id)}
194
207
  onChange={(change) => {
195
208
  setFields((current) => editField(current, selected.id, change))
@@ -424,6 +437,13 @@ function ConsentPreview({ field }: { readonly field: EditableField }): React.JSX
424
437
 
425
438
  interface FieldSettingsProps {
426
439
  readonly field: EditableField
440
+ readonly fields: readonly EditableField[]
441
+ readonly customFieldDefinitions: readonly {
442
+ readonly objectType: import('@kelpie/schemas').CustomFieldObjectType
443
+ readonly key: string
444
+ readonly label: string
445
+ readonly type: import('@kelpie/schemas').CustomFieldType
446
+ }[]
427
447
  readonly problem: string | undefined
428
448
  readonly onChange: (change: Partial<FormFieldInput>) => void
429
449
  readonly onRemove: () => void
@@ -433,11 +453,21 @@ interface FieldSettingsProps {
433
453
  /** The panel a selected field's settings edit in. */
434
454
  function FieldSettings({
435
455
  field,
456
+ fields,
457
+ customFieldDefinitions,
436
458
  problem,
437
459
  onChange,
438
460
  onRemove,
439
461
  onClose,
440
462
  }: FieldSettingsProps): React.JSX.Element {
463
+ const usedTargets = useMemo(
464
+ () =>
465
+ new Set(
466
+ fields.filter((entry) => entry.id !== field.id).map((entry) => entry.mapTo),
467
+ ),
468
+ [fields, field.id],
469
+ )
470
+
441
471
  return (
442
472
  <aside className="rounded-lg border border-border bg-surface-raised p-4 lg:sticky lg:top-4">
443
473
  <div className="mb-3 flex items-center justify-between gap-2">
@@ -464,24 +494,19 @@ function FieldSettings({
464
494
  />
465
495
  </label>
466
496
 
467
- <label className="block">
497
+ <div className="block">
468
498
  <span className="mb-1 block text-[11px] font-medium text-ink-faint">Maps to</span>
469
- <select
470
- className={inputClass}
499
+ <MapTargetSearch
471
500
  value={field.mapTo}
472
- onChange={(event) => {
473
- const mapTo = event.target.value as FormFieldMapTarget
474
-
475
- onChange({ mapTo, type: typeForTarget(mapTo, field.type) })
501
+ usedTargets={usedTargets}
502
+ onChange={(mapTo) => {
503
+ onChange({
504
+ mapTo,
505
+ type: typeForTarget(mapTo, field.type, customFieldDefinitions),
506
+ })
476
507
  }}
477
- >
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>
508
+ />
509
+ </div>
485
510
 
486
511
  <label className="block">
487
512
  <span className="mb-1 block text-[11px] font-medium text-ink-faint">Type</span>
@@ -0,0 +1,69 @@
1
+ import {
2
+ isRepeatableMapTarget,
3
+ labelForMapTarget,
4
+ listMapTargetEntries,
5
+ metaForMapTargetEntry,
6
+ } from '@kelpie/schemas'
7
+ import type { CustomFieldDefinition } from '@kelpie/schemas'
8
+
9
+ import { useCustomFields } from '../../api/resources/customFields.ts'
10
+ import { EntitySearch } from '../../components/EntitySearch.tsx'
11
+ import type { SearchOption } from '../../components/EntitySearch.tsx'
12
+
13
+ export interface MapTargetSearchProps {
14
+ readonly value: string
15
+ readonly usedTargets: ReadonlySet<string>
16
+ readonly onChange: (target: string) => void
17
+ }
18
+
19
+ function toCustomRefs(
20
+ definitions: readonly CustomFieldDefinition[],
21
+ ): readonly { objectType: CustomFieldDefinition['objectType']; key: string; label: string; type: CustomFieldDefinition['type'] }[] {
22
+ return definitions.map((definition) => ({
23
+ objectType: definition.objectType,
24
+ key: definition.key,
25
+ label: definition.label,
26
+ type: definition.type,
27
+ }))
28
+ }
29
+
30
+ export function MapTargetSearch({
31
+ value,
32
+ usedTargets,
33
+ onChange,
34
+ }: MapTargetSearchProps): React.JSX.Element {
35
+ const customFields = useCustomFields()
36
+ const customRefs = toCustomRefs(customFields.records)
37
+ const entries = listMapTargetEntries(customRefs)
38
+
39
+ const options: readonly SearchOption[] = entries
40
+ .filter((entry) => entry.target === value || !usedTargets.has(entry.target) || isRepeatableMapTarget(entry.target))
41
+ .map((entry) => {
42
+ const customType =
43
+ entry.fieldKind === 'custom'
44
+ ? customRefs.find(
45
+ (definition) =>
46
+ entry.target.endsWith(`.custom_fields.${definition.key}`) &&
47
+ entry.target.startsWith(`${definition.objectType}.`),
48
+ )?.type
49
+ : undefined
50
+
51
+ return {
52
+ id: entry.target,
53
+ label: labelForMapTarget(entry.target, customRefs),
54
+ meta: metaForMapTargetEntry(entry, customType),
55
+ }
56
+ })
57
+
58
+ return (
59
+ <EntitySearch
60
+ options={options}
61
+ value={value}
62
+ onChange={onChange}
63
+ placeholder="Search object and field…"
64
+ emptyMessage="No matching fields"
65
+ limit={12}
66
+ size="md"
67
+ />
68
+ )
69
+ }
@@ -1,10 +1,19 @@
1
1
  import {
2
- FORM_FIELD_MAP_TARGETS,
3
2
  FORM_FIELD_TYPES,
3
+ isCompatibleFormFieldType,
4
+ isKnownMapTarget,
5
+ isRepeatableMapTarget,
4
6
  PERSON_CONSENT_TARGET,
5
7
  PERSON_EMAIL_TARGET,
8
+ suggestedFormFieldType,
9
+ } from '@kelpie/schemas'
10
+ import type {
11
+ CustomFieldDefinitionRef,
12
+ Form,
13
+ FormFieldInput,
14
+ FormFieldMapTarget,
15
+ FormFieldType,
6
16
  } from '@kelpie/schemas'
7
- import type { Form, FormFieldInput, FormFieldMapTarget, FormFieldType } from '@kelpie/schemas'
8
17
 
9
18
  import { CRM_FIELD_PRESETS, NEW_SELECT_OPTIONS } from './template.ts'
10
19
 
@@ -28,10 +37,9 @@ export interface EditableField extends FormFieldInput {
28
37
 
29
38
  export const FIELD_TYPE_OPTIONS = FORM_FIELD_TYPES.map((type) => ({ value: type, label: type }))
30
39
 
31
- export const MAP_TARGET_OPTIONS = FORM_FIELD_MAP_TARGETS.map((target) => ({
32
- value: target,
33
- label: target,
34
- }))
40
+ export interface FindProblemsOptions {
41
+ readonly customFieldDefinitions?: readonly CustomFieldDefinitionRef[]
42
+ }
35
43
 
36
44
  /** Reads a saved form into the shape the builder edits. */
37
45
  export function toEditableFields(form: Form): EditableField[] {
@@ -92,8 +100,7 @@ export function editField(
92
100
  // that may repeat — consent's uniqueness is per purpose, not per
93
101
  // target, and is enforced when the purpose is picked.
94
102
  return change.mapTo !== undefined &&
95
- change.mapTo !== 'submission' &&
96
- change.mapTo !== PERSON_CONSENT_TARGET &&
103
+ !isRepeatableMapTarget(change.mapTo) &&
97
104
  field.mapTo === change.mapTo
98
105
  ? { ...field, mapTo: 'submission' }
99
106
  : field
@@ -182,11 +189,13 @@ export interface FieldListProblems {
182
189
  export function findProblems(
183
190
  fields: readonly EditableField[],
184
191
  createsDeal: boolean,
192
+ options: FindProblemsOptions = {},
185
193
  ): FieldListProblems {
186
194
  const list: string[] = []
187
195
  const byField = new Map<string, string>()
188
- const seen = new Set<FormFieldMapTarget>()
196
+ const seen = new Set<string>()
189
197
  const usedConsentPurposes = new Set<string>()
198
+ const customFieldDefinitions = options.customFieldDefinitions ?? []
190
199
 
191
200
  if (!fields.some((field) => field.mapTo === PERSON_EMAIL_TARGET)) {
192
201
  list.push(`One field must map to ${PERSON_EMAIL_TARGET}, or no submission can be processed.`)
@@ -197,6 +206,15 @@ export function findProblems(
197
206
  }
198
207
 
199
208
  for (const field of fields) {
209
+ if (!isKnownMapTarget(field.mapTo, customFieldDefinitions)) {
210
+ byField.set(field.id, `Unknown map target ${field.mapTo}.`)
211
+ } else if (
212
+ field.mapTo !== PERSON_CONSENT_TARGET &&
213
+ !isCompatibleFormFieldType(field.type, field.mapTo, customFieldDefinitions)
214
+ ) {
215
+ byField.set(field.id, `A ${field.type} field cannot map to ${field.mapTo}.`)
216
+ }
217
+
200
218
  if (field.mapTo === PERSON_CONSENT_TARGET) {
201
219
  const purposes = field.consentPurposeIds ?? []
202
220
  if (field.type === 'notice' && (field.statement ?? '').trim().length === 0) {
@@ -224,7 +242,7 @@ export function findProblems(
224
242
  }
225
243
  }
226
244
  }
227
- } else if (field.mapTo !== 'submission') {
245
+ } else if (!isRepeatableMapTarget(field.mapTo)) {
228
246
  if (seen.has(field.mapTo)) {
229
247
  byField.set(field.id, `Another field already maps to ${field.mapTo}.`)
230
248
  }
@@ -252,12 +270,12 @@ export function isUsable(problems: FieldListProblems): boolean {
252
270
  }
253
271
 
254
272
  /** The types a mapping can sensibly render as. */
255
- export function typeForTarget(target: FormFieldMapTarget, current: FormFieldType): FormFieldType {
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
273
+ export function typeForTarget(
274
+ target: FormFieldMapTarget,
275
+ current: FormFieldType,
276
+ customFieldDefinitions: readonly CustomFieldDefinitionRef[] = [],
277
+ ): FormFieldType {
278
+ return suggestedFormFieldType(target, customFieldDefinitions, current)
261
279
  }
262
280
 
263
281
  /**
@@ -70,6 +70,13 @@ export const CRM_FIELD_PRESETS: readonly FormFieldInput[] = [
70
70
  mapTo: 'person.email',
71
71
  placeholder: 'you@company.com',
72
72
  },
73
+ {
74
+ label: 'Phone',
75
+ type: 'text',
76
+ required: false,
77
+ mapTo: 'person.phones',
78
+ placeholder: 'Phone number',
79
+ },
73
80
  { label: 'Company', type: 'text', required: false, mapTo: 'company.name', placeholder: 'Company name' },
74
81
  {
75
82
  label: 'Company website',
@@ -2,6 +2,7 @@ import { createContext, useContext } from 'react'
2
2
  import type { ComponentType } from 'react'
3
3
 
4
4
  import type {
5
+ AuthMethod,
5
6
  DashboardCard,
6
7
  ExtensibleRecordType,
7
8
  NavItem,
@@ -53,6 +54,11 @@ export function useDashboardCards(): readonly DashboardCard[] {
53
54
  return useUiExtensions().dashboardCards()
54
55
  }
55
56
 
57
+ /** Extra ways to sign in, for the signed-out pages to render beside their form. */
58
+ export function useAuthMethods(): readonly AuthMethod[] {
59
+ return useUiExtensions().authMethods()
60
+ }
61
+
56
62
  /**
57
63
  * The component to render for an overridable: a module's replacement, or core's
58
64
  * own. Core calls this instead of rendering its component directly.
@@ -25,6 +25,32 @@ export interface NavItem {
25
25
  readonly icon?: ReactNode
26
26
  }
27
27
 
28
+ /**
29
+ * What a sign-in method is rendering against.
30
+ *
31
+ * `intent` because the same button reads differently on the two pages, and
32
+ * `next` because where the browser lands afterwards is the page's to decide,
33
+ * not the module's. The page has already checked it is a path within the app.
34
+ */
35
+ export interface AuthMethodContext {
36
+ readonly intent: 'login' | 'signup'
37
+ readonly next: string
38
+ }
39
+
40
+ /**
41
+ * Another way to sign in, offered beside the password form.
42
+ *
43
+ * Core renders whatever a module returns and nothing around it: no divider, no
44
+ * heading. A module cannot always tell at build time whether it has anything to
45
+ * offer (an unconfigured provider renders nothing at all), and core framing
46
+ * would then decorate an empty space.
47
+ */
48
+ export interface AuthMethod {
49
+ readonly id: string
50
+ readonly order?: number
51
+ readonly render: (context: AuthMethodContext) => ReactNode
52
+ }
53
+
28
54
  /** A whole page, mounted under the shell's chrome. */
29
55
  export interface RouteContribution {
30
56
  readonly path: string
@@ -1,6 +1,7 @@
1
1
  import type { ComponentType } from 'react'
2
2
 
3
3
  import type {
4
+ AuthMethod,
4
5
  DashboardCard,
5
6
  ExtensibleRecordType,
6
7
  NavItem,
@@ -31,6 +32,8 @@ export interface UiModuleContext {
31
32
  recordTab(objectType: ExtensibleRecordType, tab: RecordTab): void
32
33
  recordSidebarCard(objectType: ExtensibleRecordType, card: RecordSidebarCard): void
33
34
  dashboardCard(card: DashboardCard): void
35
+ /** Another way to sign in, rendered beside the password form on `/login` and `/signup`. */
36
+ authMethod(method: AuthMethod): void
34
37
  /** Replaces a core component. Prefer a slot; see `modules.md`. */
35
38
  override<Props>(token: Overridable<Props>, component: ComponentType<Props>): void
36
39
  }
@@ -47,6 +50,7 @@ export interface UiExtensions {
47
50
  recordTabs(objectType: ExtensibleRecordType): readonly RecordTab[]
48
51
  recordSidebarCards(objectType: ExtensibleRecordType): readonly RecordSidebarCard[]
49
52
  dashboardCards(): readonly DashboardCard[]
53
+ authMethods(): readonly AuthMethod[]
50
54
  componentFor<Props>(token: Overridable<Props>): ComponentType<Props>
51
55
  }
52
56
 
@@ -70,6 +74,7 @@ interface Accumulator {
70
74
  readonly recordTabs: Map<ExtensibleRecordType, RecordTab[]>
71
75
  readonly recordSidebarCards: Map<ExtensibleRecordType, RecordSidebarCard[]>
72
76
  readonly dashboardCards: DashboardCard[]
77
+ readonly authMethods: AuthMethod[]
73
78
  readonly overrides: OverrideStore
74
79
  }
75
80
 
@@ -134,6 +139,11 @@ function createModuleContext(
134
139
  accumulator.dashboardCards.push(card)
135
140
  },
136
141
 
142
+ authMethod(method) {
143
+ claim(taken, module.id, 'auth.methods', method.id)
144
+ accumulator.authMethods.push(method)
145
+ },
146
+
137
147
  override(token, component) {
138
148
  if (accumulator.overrides.has(token.key)) {
139
149
  throw new UiModuleError(
@@ -158,6 +168,7 @@ export function registerUiModules(modules: readonly UiModule[]): UiExtensions {
158
168
  recordTabs: new Map(),
159
169
  recordSidebarCards: new Map(),
160
170
  dashboardCards: [],
171
+ authMethods: [],
161
172
  overrides: createOverrideStore(),
162
173
  }
163
174
  const taken = new Set<string>()
@@ -179,6 +190,7 @@ export function registerUiModules(modules: readonly UiModule[]): UiExtensions {
179
190
  recordSidebarCards: (objectType) =>
180
191
  inSlotOrder(accumulator.recordSidebarCards.get(objectType) ?? []),
181
192
  dashboardCards: () => inSlotOrder(accumulator.dashboardCards),
193
+ authMethods: () => inSlotOrder(accumulator.authMethods),
182
194
  componentFor: (token) => accumulator.overrides.get(token),
183
195
  }
184
196
  }