@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.
- package/dist/api/resources/activities.d.ts +2 -1
- package/dist/api/resources/activities.d.ts.map +1 -1
- package/dist/api/resources/activities.js +6 -2
- package/dist/api/resources/activities.js.map +1 -1
- package/dist/api/resources/conversions.d.ts +16 -0
- package/dist/api/resources/conversions.d.ts.map +1 -0
- package/dist/api/resources/conversions.js +81 -0
- package/dist/api/resources/conversions.js.map +1 -0
- package/dist/api/resources/enquiries.d.ts +2 -7
- package/dist/api/resources/enquiries.d.ts.map +1 -1
- package/dist/api/resources/enquiries.js +2 -30
- package/dist/api/resources/enquiries.js.map +1 -1
- package/dist/api/resources/importJobs.d.ts +2 -2
- package/dist/api/resources/importJobs.d.ts.map +1 -1
- package/dist/api/resources/importJobs.js.map +1 -1
- package/dist/app/KelpieApp.js +1 -1
- package/dist/app/KelpieApp.js.map +1 -1
- package/dist/components/ActivitiesPanel.d.ts +2 -2
- package/dist/components/ActivitiesPanel.d.ts.map +1 -1
- package/dist/components/ActivitiesPanel.js +7 -5
- package/dist/components/ActivitiesPanel.js.map +1 -1
- package/dist/components/ApiKeysPanel.d.ts +0 -7
- package/dist/components/ApiKeysPanel.d.ts.map +1 -1
- package/dist/components/ApiKeysPanel.js +83 -16
- package/dist/components/ApiKeysPanel.js.map +1 -1
- package/dist/components/ConvertRecordDialog.d.ts +30 -0
- package/dist/components/ConvertRecordDialog.d.ts.map +1 -0
- package/dist/components/ConvertRecordDialog.js +50 -0
- package/dist/components/ConvertRecordDialog.js.map +1 -0
- package/dist/components/Shell.d.ts.map +1 -1
- package/dist/components/Shell.js +19 -7
- package/dist/components/Shell.js.map +1 -1
- package/dist/index.d.ts +4 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -1
- package/dist/index.js.map +1 -1
- package/dist/pages/DealDetail.d.ts.map +1 -1
- package/dist/pages/DealDetail.js +15 -2
- package/dist/pages/DealDetail.js.map +1 -1
- package/dist/pages/DecisionsPage.d.ts.map +1 -1
- package/dist/pages/DecisionsPage.js +1 -7
- package/dist/pages/DecisionsPage.js.map +1 -1
- package/dist/pages/EnquiryDetail.d.ts.map +1 -1
- package/dist/pages/EnquiryDetail.js +17 -25
- package/dist/pages/EnquiryDetail.js.map +1 -1
- package/dist/pages/ListDetail.d.ts.map +1 -1
- package/dist/pages/ListDetail.js +1 -14
- package/dist/pages/ListDetail.js.map +1 -1
- package/dist/pages/OpportunityDetail.d.ts.map +1 -1
- package/dist/pages/OpportunityDetail.js +15 -2
- package/dist/pages/OpportunityDetail.js.map +1 -1
- package/dist/pages/PartnershipDetail.d.ts.map +1 -1
- package/dist/pages/PartnershipDetail.js +15 -2
- package/dist/pages/PartnershipDetail.js.map +1 -1
- package/dist/pages/RaiseDetail.d.ts.map +1 -1
- package/dist/pages/RaiseDetail.js +15 -2
- package/dist/pages/RaiseDetail.js.map +1 -1
- package/dist/pages/account/SecurityPage.js +2 -2
- package/dist/pages/account/SecurityPage.js.map +1 -1
- package/dist/pages/admin/DataPage.d.ts.map +1 -1
- package/dist/pages/admin/DataPage.js +3 -3
- package/dist/pages/admin/DataPage.js.map +1 -1
- package/dist/pages/admin/PrivacyPage.js +3 -3
- package/dist/pages/admin/PrivacyPage.js.map +1 -1
- package/dist/pages/admin/TeamPage.js +1 -1
- package/dist/pages/admin/TeamPage.js.map +1 -1
- package/dist/pages/admin/WorkspaceSettingsPage.js +2 -12
- package/dist/pages/admin/WorkspaceSettingsPage.js.map +1 -1
- package/dist/pages/auth/AuthForm.d.ts +18 -0
- package/dist/pages/auth/AuthForm.d.ts.map +1 -1
- package/dist/pages/auth/AuthForm.js +17 -0
- package/dist/pages/auth/AuthForm.js.map +1 -1
- package/dist/pages/auth/AuthMethods.d.ts +12 -0
- package/dist/pages/auth/AuthMethods.d.ts.map +1 -0
- package/dist/pages/auth/AuthMethods.js +20 -0
- package/dist/pages/auth/AuthMethods.js.map +1 -0
- package/dist/pages/auth/SignInPage.d.ts +7 -0
- package/dist/pages/auth/SignInPage.d.ts.map +1 -1
- package/dist/pages/auth/SignInPage.js +3 -8
- package/dist/pages/auth/SignInPage.js.map +1 -1
- package/dist/pages/auth/SignUpPage.d.ts.map +1 -1
- package/dist/pages/auth/SignUpPage.js +2 -1
- package/dist/pages/auth/SignUpPage.js.map +1 -1
- package/dist/pages/auth/nextPath.d.ts +15 -0
- package/dist/pages/auth/nextPath.d.ts.map +1 -0
- package/dist/pages/auth/nextPath.js +17 -0
- package/dist/pages/auth/nextPath.js.map +1 -0
- package/dist/pages/forms/AddFieldMenu.js +2 -2
- package/dist/pages/forms/AddFieldMenu.js.map +1 -1
- package/dist/pages/forms/FieldsEditor.d.ts.map +1 -1
- package/dist/pages/forms/FieldsEditor.js +22 -10
- package/dist/pages/forms/FieldsEditor.js.map +1 -1
- package/dist/pages/forms/MapTargetSearch.d.ts +7 -0
- package/dist/pages/forms/MapTargetSearch.d.ts.map +1 -0
- package/dist/pages/forms/MapTargetSearch.js +32 -0
- package/dist/pages/forms/MapTargetSearch.js.map +1 -0
- package/dist/pages/forms/fieldList.d.ts +6 -7
- package/dist/pages/forms/fieldList.d.ts.map +1 -1
- package/dist/pages/forms/fieldList.js +14 -16
- package/dist/pages/forms/fieldList.js.map +1 -1
- package/dist/pages/forms/template.d.ts.map +1 -1
- package/dist/pages/forms/template.js +7 -0
- package/dist/pages/forms/template.js.map +1 -1
- package/dist/registry/context.d.ts +3 -1
- package/dist/registry/context.d.ts.map +1 -1
- package/dist/registry/context.js +4 -0
- package/dist/registry/context.js.map +1 -1
- package/dist/registry/contributions.d.ts +24 -0
- package/dist/registry/contributions.d.ts.map +1 -1
- package/dist/registry/contributions.js.map +1 -1
- package/dist/registry/registry.d.ts +4 -1
- package/dist/registry/registry.d.ts.map +1 -1
- package/dist/registry/registry.js +6 -0
- package/dist/registry/registry.js.map +1 -1
- package/package.json +2 -2
- package/src/api/resources/activities.ts +10 -2
- package/src/api/resources/conversions.ts +125 -0
- package/src/api/resources/enquiries.ts +9 -35
- package/src/api/resources/importJobs.ts +3 -2
- package/src/app/KelpieApp.tsx +1 -0
- package/src/components/ActivitiesPanel.tsx +20 -14
- package/src/components/ApiKeysPanel.tsx +206 -21
- package/src/components/ConvertRecordDialog.tsx +210 -0
- package/src/components/Shell.tsx +91 -36
- package/src/index.ts +7 -0
- package/src/pages/DealDetail.tsx +35 -0
- package/src/pages/DecisionsPage.tsx +0 -14
- package/src/pages/EnquiryDetail.tsx +37 -90
- package/src/pages/ListDetail.tsx +0 -47
- package/src/pages/OpportunityDetail.tsx +35 -0
- package/src/pages/PartnershipDetail.tsx +35 -0
- package/src/pages/RaiseDetail.tsx +35 -0
- package/src/pages/account/SecurityPage.tsx +6 -1
- package/src/pages/admin/DataPage.tsx +14 -9
- package/src/pages/admin/PrivacyPage.tsx +4 -4
- package/src/pages/admin/TeamPage.tsx +1 -2
- package/src/pages/admin/WorkspaceSettingsPage.tsx +1 -31
- package/src/pages/auth/AuthForm.tsx +38 -0
- package/src/pages/auth/AuthMethods.tsx +29 -0
- package/src/pages/auth/SignInPage.tsx +3 -8
- package/src/pages/auth/SignUpPage.tsx +7 -0
- package/src/pages/auth/nextPath.ts +17 -0
- package/src/pages/forms/AddFieldMenu.tsx +2 -2
- package/src/pages/forms/FieldsEditor.tsx +45 -20
- package/src/pages/forms/MapTargetSearch.tsx +69 -0
- package/src/pages/forms/fieldList.ts +34 -16
- package/src/pages/forms/template.ts +7 -0
- package/src/registry/context.ts +6 -0
- package/src/registry/contributions.ts +26 -0
- 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,
|
|
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 {
|
|
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
|
-
{
|
|
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 {
|
|
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
|
-
<
|
|
497
|
+
<div className="block">
|
|
468
498
|
<span className="mb-1 block text-[11px] font-medium text-ink-faint">Maps to</span>
|
|
469
|
-
<
|
|
470
|
-
className={inputClass}
|
|
499
|
+
<MapTargetSearch
|
|
471
500
|
value={field.mapTo}
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
501
|
+
usedTargets={usedTargets}
|
|
502
|
+
onChange={(mapTo) => {
|
|
503
|
+
onChange({
|
|
504
|
+
mapTo,
|
|
505
|
+
type: typeForTarget(mapTo, field.type, customFieldDefinitions),
|
|
506
|
+
})
|
|
476
507
|
}}
|
|
477
|
-
|
|
478
|
-
|
|
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
|
|
32
|
-
|
|
33
|
-
|
|
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
|
|
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<
|
|
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
|
|
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(
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
return
|
|
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',
|
package/src/registry/context.ts
CHANGED
|
@@ -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
|
package/src/registry/registry.ts
CHANGED
|
@@ -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
|
}
|