nucleus-core-ts 0.9.821 → 0.9.823
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/client.js +1 -1
- package/dist/fe/components/IntegrationsPage/components/ConnectionList.js +5 -0
- package/dist/fe/components/IntegrationsPage/components/ConnectionTransfer.d.ts +7 -0
- package/dist/fe/components/IntegrationsPage/components/ConnectionTransfer.js +157 -0
- package/dist/fe/components/IntegrationsPage/components/MappingAccountFields.d.ts +12 -0
- package/dist/fe/components/IntegrationsPage/components/MappingAccountFields.js +214 -0
- package/dist/fe/components/IntegrationsPage/components/MappingEditor.js +10 -0
- package/dist/fe/components/IntegrationsPage/components/RunPanel.js +14 -3
- package/dist/fe/components/IntegrationsPage/components/RunPlanSuggestions.d.ts +15 -0
- package/dist/fe/components/IntegrationsPage/components/RunPlanSuggestions.js +120 -0
- package/dist/fe/components/IntegrationsPage/components/RunPlanSuggestions.test.d.ts +1 -0
- package/dist/fe/components/IntegrationsPage/components/RunPlanSuggestions.test.js +103 -0
- package/dist/fe/components/IntegrationsPage/components/RunPlanSummary.js +5 -0
- package/dist/fe/components/IntegrationsPage/components/fieldMappingOptions.js +4 -2
- package/dist/fe/components/IntegrationsPage/components/mappingDraft.d.ts +3 -1
- package/dist/fe/components/IntegrationsPage/components/mappingDraft.js +14 -0
- package/dist/fe/components/IntegrationsPage/index.d.ts +3 -0
- package/dist/fe/components/IntegrationsPage/index.js +3 -0
- package/dist/fe/components/IntegrationsPage/types/actions.d.ts +18 -0
- package/dist/fe/components/IntegrationsPage/types/records.d.ts +26 -1
- package/dist/fe/components/IntegrationsPage/types/results.d.ts +2 -0
- package/dist/index.js +5 -5
- package/dist/src/ElysiaPlugin/routes/integrations/inspect.d.ts +1 -0
- package/dist/src/ElysiaPlugin/routes/integrations/repository.d.ts +36 -0
- package/dist/src/Services/Integrations/plan.d.ts +18 -1
- package/dist/src/Services/Integrations/runner.d.ts +13 -1
- package/dist/src/Services/Integrations/transfer.d.ts +123 -0
- package/dist/src/Services/Integrations/transfer.test.d.ts +1 -0
- package/dist/src/Services/Integrations/types.d.ts +47 -2
- package/dist/src/Services/Integrations/writer.d.ts +24 -0
- package/package.json +1 -1
- package/src/system.tables.json +12 -0
|
@@ -5,6 +5,7 @@ import { cn } from '../../../utils/cn';
|
|
|
5
5
|
import { useIntegrationsStore } from '../store';
|
|
6
6
|
import { useLabels } from '../text/context';
|
|
7
7
|
import { integrationsPageTheme } from '../theme';
|
|
8
|
+
import { ConnectionTransfer } from './ConnectionTransfer';
|
|
8
9
|
import { Badge, Field, Notice } from './Primitives';
|
|
9
10
|
import { ListSkeleton } from './Skeletons';
|
|
10
11
|
import { useInfiniteList } from './useInfiniteList';
|
|
@@ -105,6 +106,10 @@ export function ConnectionList({ actions, onNewConnection, reloadToken = 0 }) {
|
|
|
105
106
|
})
|
|
106
107
|
]
|
|
107
108
|
}),
|
|
109
|
+
/*#__PURE__*/ _jsx(ConnectionTransfer, {
|
|
110
|
+
actions: actions,
|
|
111
|
+
onImported: list.reload
|
|
112
|
+
}),
|
|
108
113
|
/*#__PURE__*/ _jsx(Field, {
|
|
109
114
|
htmlFor: "connection-search",
|
|
110
115
|
label: labels.connections.searchLabel,
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { IntegrationsActions } from '../types';
|
|
2
|
+
export type ConnectionTransferProps = {
|
|
3
|
+
actions: IntegrationsActions;
|
|
4
|
+
/** Bumped by the caller so the rail refetches after a file is brought in. */
|
|
5
|
+
onImported?: () => void;
|
|
6
|
+
};
|
|
7
|
+
export declare function ConnectionTransfer({ actions, onImported }: ConnectionTransferProps): import("react/jsx-runtime").JSX.Element;
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useRef, useState } from 'react';
|
|
4
|
+
import { useIntegrationsStore } from '../store';
|
|
5
|
+
import { integrationsPageTheme } from '../theme';
|
|
6
|
+
import { Notice } from './Primitives';
|
|
7
|
+
/**
|
|
8
|
+
* Taking a whole integration somewhere else, and bringing one back.
|
|
9
|
+
*
|
|
10
|
+
* A connection is the connection, the calls it makes, the parameters those calls
|
|
11
|
+
* take from each other, and the mappings that turn the answers into rows.
|
|
12
|
+
* Rebuilding that by hand in a second environment is hours of work and a fresh
|
|
13
|
+
* chance to get one field wrong in a way nobody notices until an import writes
|
|
14
|
+
* it. So it travels as one file.
|
|
15
|
+
*
|
|
16
|
+
* The file carries NO credentials — deliberately, and it says so on both sides,
|
|
17
|
+
* because a connection that arrives looking configured and cannot authenticate
|
|
18
|
+
* is a confusing half hour otherwise.
|
|
19
|
+
*/ const theme = integrationsPageTheme;
|
|
20
|
+
const messageOf = (error)=>error instanceof Error ? error.message : 'The request failed.';
|
|
21
|
+
/** A filename somebody can still recognise a week later. */ const fileNameFor = (slug)=>`integration-${slug || 'connection'}.json`;
|
|
22
|
+
export function ConnectionTransfer({ actions, onImported }) {
|
|
23
|
+
const store = useIntegrationsStore();
|
|
24
|
+
const fileRef = useRef(null);
|
|
25
|
+
const [busy, setBusy] = useState(false);
|
|
26
|
+
const [problems, setProblems] = useState([]);
|
|
27
|
+
const [note, setNote] = useState(null);
|
|
28
|
+
const selected = store.sources.find((source)=>source.id === store.selectedSourceId);
|
|
29
|
+
const exportOne = ()=>{
|
|
30
|
+
if (!selected) return;
|
|
31
|
+
setBusy(true);
|
|
32
|
+
setProblems([]);
|
|
33
|
+
setNote(null);
|
|
34
|
+
actions.exportConnection(selected.id).then((document)=>{
|
|
35
|
+
setBusy(false);
|
|
36
|
+
if (!document) {
|
|
37
|
+
store.setError('The connection could not be exported.');
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
// Written from the browser rather than served as a download, so the file
|
|
41
|
+
// never travels back through a URL somebody could share by accident.
|
|
42
|
+
const blob = new Blob([
|
|
43
|
+
JSON.stringify(document, null, 2)
|
|
44
|
+
], {
|
|
45
|
+
type: 'application/json'
|
|
46
|
+
});
|
|
47
|
+
const url = URL.createObjectURL(blob);
|
|
48
|
+
const link = window.document.createElement('a');
|
|
49
|
+
link.href = url;
|
|
50
|
+
link.download = fileNameFor(selected.slug);
|
|
51
|
+
link.click();
|
|
52
|
+
URL.revokeObjectURL(url);
|
|
53
|
+
setNote('Exported. Credentials are not in the file — enter them wherever it lands.');
|
|
54
|
+
}).catch((error)=>{
|
|
55
|
+
setBusy(false);
|
|
56
|
+
store.setError(messageOf(error));
|
|
57
|
+
});
|
|
58
|
+
};
|
|
59
|
+
const importFile = (file)=>{
|
|
60
|
+
setBusy(true);
|
|
61
|
+
setProblems([]);
|
|
62
|
+
setNote(null);
|
|
63
|
+
file.text().then((text)=>{
|
|
64
|
+
let parsed;
|
|
65
|
+
try {
|
|
66
|
+
parsed = JSON.parse(text);
|
|
67
|
+
} catch {
|
|
68
|
+
setBusy(false);
|
|
69
|
+
setProblems([
|
|
70
|
+
{
|
|
71
|
+
path: file.name,
|
|
72
|
+
message: 'This file is not readable as JSON.'
|
|
73
|
+
}
|
|
74
|
+
]);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
return actions.importConnection(parsed).then((result)=>{
|
|
78
|
+
setBusy(false);
|
|
79
|
+
if (!result.ok) {
|
|
80
|
+
setProblems(result.problems ?? [
|
|
81
|
+
{
|
|
82
|
+
path: '',
|
|
83
|
+
message: result.message
|
|
84
|
+
}
|
|
85
|
+
]);
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
setNote(result.message);
|
|
89
|
+
onImported?.();
|
|
90
|
+
});
|
|
91
|
+
}).catch((error)=>{
|
|
92
|
+
setBusy(false);
|
|
93
|
+
store.setError(messageOf(error));
|
|
94
|
+
});
|
|
95
|
+
};
|
|
96
|
+
return /*#__PURE__*/ _jsxs("section", {
|
|
97
|
+
className: "flex flex-col gap-2",
|
|
98
|
+
children: [
|
|
99
|
+
/*#__PURE__*/ _jsxs("div", {
|
|
100
|
+
className: "flex flex-wrap gap-2",
|
|
101
|
+
children: [
|
|
102
|
+
/*#__PURE__*/ _jsx("button", {
|
|
103
|
+
className: theme.button.secondary,
|
|
104
|
+
disabled: busy || !selected,
|
|
105
|
+
onClick: exportOne,
|
|
106
|
+
title: selected ? `Export ${selected.name} — its calls and mappings, without any credential` : 'Choose a connection to export',
|
|
107
|
+
type: "button",
|
|
108
|
+
children: busy ? 'Working…' : 'Export'
|
|
109
|
+
}),
|
|
110
|
+
/*#__PURE__*/ _jsx("button", {
|
|
111
|
+
className: theme.button.secondary,
|
|
112
|
+
disabled: busy,
|
|
113
|
+
onClick: ()=>fileRef.current?.click(),
|
|
114
|
+
type: "button",
|
|
115
|
+
children: "Import"
|
|
116
|
+
}),
|
|
117
|
+
/*#__PURE__*/ _jsx("input", {
|
|
118
|
+
accept: "application/json,.json",
|
|
119
|
+
className: "hidden",
|
|
120
|
+
onChange: (event)=>{
|
|
121
|
+
const file = event.target.files?.[0];
|
|
122
|
+
// Cleared so choosing the same file twice fires again — the second
|
|
123
|
+
// attempt is usually the one after fixing something.
|
|
124
|
+
event.target.value = '';
|
|
125
|
+
if (file) importFile(file);
|
|
126
|
+
},
|
|
127
|
+
ref: fileRef,
|
|
128
|
+
type: "file"
|
|
129
|
+
})
|
|
130
|
+
]
|
|
131
|
+
}),
|
|
132
|
+
note ? /*#__PURE__*/ _jsx("p", {
|
|
133
|
+
className: theme.field.hint,
|
|
134
|
+
children: note
|
|
135
|
+
}) : null,
|
|
136
|
+
problems.length > 0 ? /*#__PURE__*/ _jsx(Notice, {
|
|
137
|
+
title: "This file cannot be imported as it stands",
|
|
138
|
+
tone: "danger",
|
|
139
|
+
children: /*#__PURE__*/ _jsx("ul", {
|
|
140
|
+
className: "flex flex-col gap-1",
|
|
141
|
+
children: problems.map((problem)=>/*#__PURE__*/ _jsxs("li", {
|
|
142
|
+
className: theme.field.hint,
|
|
143
|
+
children: [
|
|
144
|
+
problem.path ? /*#__PURE__*/ _jsxs("strong", {
|
|
145
|
+
children: [
|
|
146
|
+
problem.path,
|
|
147
|
+
": "
|
|
148
|
+
]
|
|
149
|
+
}) : null,
|
|
150
|
+
problem.message
|
|
151
|
+
]
|
|
152
|
+
}, `${problem.path}-${problem.message}`))
|
|
153
|
+
})
|
|
154
|
+
}) : null
|
|
155
|
+
]
|
|
156
|
+
});
|
|
157
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { AccountRuleValue } from '../types';
|
|
2
|
+
import type { MappingTarget } from './mappingDraft';
|
|
3
|
+
export type MappingAccountFieldsProps = {
|
|
4
|
+
value: AccountRuleValue | null;
|
|
5
|
+
/** Columns of the entity being written into. */
|
|
6
|
+
targetFields: string[];
|
|
7
|
+
/** Every entity, so the account table and its columns can be chosen. */
|
|
8
|
+
targets: MappingTarget[];
|
|
9
|
+
disabled?: boolean;
|
|
10
|
+
onChange: (value: AccountRuleValue | null) => void;
|
|
11
|
+
};
|
|
12
|
+
export declare function MappingAccountFields({ value, targetFields, targets, disabled, onChange, }: MappingAccountFieldsProps): import("react/jsx-runtime").JSX.Element;
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
|
+
import { useId } from 'react';
|
|
4
|
+
import { integrationsPageTheme } from '../theme';
|
|
5
|
+
import { Field, Notice } from './Primitives';
|
|
6
|
+
/**
|
|
7
|
+
* A login for every person this import brings in.
|
|
8
|
+
*
|
|
9
|
+
* A directory carries people, not accounts. Importing eight hundred employees
|
|
10
|
+
* and then creating eight hundred logins by hand — matching them up by e-mail,
|
|
11
|
+
* one at a time — is the job nobody gets to, so the import that was supposed to
|
|
12
|
+
* save the afternoon leaves a table of people who cannot sign in.
|
|
13
|
+
*
|
|
14
|
+
* Turned on here, it happens in the SAME import and the same transaction: the
|
|
15
|
+
* person and their login both exist, or neither does. A record whose address is
|
|
16
|
+
* already an account is linked to it rather than given a second one, so running
|
|
17
|
+
* the import again is safe.
|
|
18
|
+
*
|
|
19
|
+
* The password is not on this screen. It belongs to the run — typed once, used
|
|
20
|
+
* for every account that run creates, stored nowhere.
|
|
21
|
+
*/ const theme = integrationsPageTheme;
|
|
22
|
+
/** Columns that plausibly hold a password, so the guess is a good one. */ const PASSWORD_LIKE = /password|passwd|sifre|şifre|parola/i;
|
|
23
|
+
/** Columns that plausibly point at the account row. */ const LINK_LIKE = /^(user_?id|account_?id|login_?id)$/i;
|
|
24
|
+
export function MappingAccountFields({ value, targetFields, targets, disabled, onChange }) {
|
|
25
|
+
const uid = useId();
|
|
26
|
+
const accountEntity = targets.find((target)=>target.entity === value?.entity);
|
|
27
|
+
const accountFields = accountEntity?.fields ?? [];
|
|
28
|
+
const patch = (next)=>{
|
|
29
|
+
if (!value) return;
|
|
30
|
+
onChange({
|
|
31
|
+
...value,
|
|
32
|
+
...next
|
|
33
|
+
});
|
|
34
|
+
};
|
|
35
|
+
const turnOn = ()=>{
|
|
36
|
+
// Guessed from the names the tables already use, because the operator has
|
|
37
|
+
// just told us which table this writes into and the answer is usually there.
|
|
38
|
+
const idColumn = targetFields.find((field)=>LINK_LIKE.test(field)) ?? '';
|
|
39
|
+
const emailish = targetFields.find((field)=>/mail/i.test(field)) ?? '';
|
|
40
|
+
onChange({
|
|
41
|
+
entity: '',
|
|
42
|
+
idColumn,
|
|
43
|
+
matchColumn: 'email',
|
|
44
|
+
matchFrom: emailish,
|
|
45
|
+
passwordColumn: ''
|
|
46
|
+
});
|
|
47
|
+
};
|
|
48
|
+
return /*#__PURE__*/ _jsxs("section", {
|
|
49
|
+
className: "flex flex-col gap-3",
|
|
50
|
+
children: [
|
|
51
|
+
/*#__PURE__*/ _jsxs("div", {
|
|
52
|
+
className: "flex flex-wrap items-center justify-between gap-2",
|
|
53
|
+
children: [
|
|
54
|
+
/*#__PURE__*/ _jsx("h3", {
|
|
55
|
+
className: theme.card.title,
|
|
56
|
+
children: "Also create a login for each new record"
|
|
57
|
+
}),
|
|
58
|
+
/*#__PURE__*/ _jsx("button", {
|
|
59
|
+
className: theme.button.secondary,
|
|
60
|
+
disabled: disabled || targetFields.length === 0,
|
|
61
|
+
onClick: ()=>value ? onChange(null) : turnOn(),
|
|
62
|
+
type: "button",
|
|
63
|
+
children: value ? 'Turn off' : 'Turn on'
|
|
64
|
+
})
|
|
65
|
+
]
|
|
66
|
+
}),
|
|
67
|
+
value ? /*#__PURE__*/ _jsxs(_Fragment, {
|
|
68
|
+
children: [
|
|
69
|
+
/*#__PURE__*/ _jsxs("div", {
|
|
70
|
+
className: "grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3",
|
|
71
|
+
children: [
|
|
72
|
+
/*#__PURE__*/ _jsx(Field, {
|
|
73
|
+
htmlFor: `${uid}-entity`,
|
|
74
|
+
label: "Accounts live in",
|
|
75
|
+
children: /*#__PURE__*/ _jsxs("select", {
|
|
76
|
+
className: theme.field.input,
|
|
77
|
+
disabled: disabled,
|
|
78
|
+
id: `${uid}-entity`,
|
|
79
|
+
onChange: (event)=>patch({
|
|
80
|
+
entity: event.target.value,
|
|
81
|
+
passwordColumn: '',
|
|
82
|
+
matchColumn: 'email'
|
|
83
|
+
}),
|
|
84
|
+
value: value.entity,
|
|
85
|
+
children: [
|
|
86
|
+
/*#__PURE__*/ _jsx("option", {
|
|
87
|
+
value: "",
|
|
88
|
+
children: "Not set"
|
|
89
|
+
}),
|
|
90
|
+
targets.map((target)=>/*#__PURE__*/ _jsx("option", {
|
|
91
|
+
value: target.entity,
|
|
92
|
+
children: target.label
|
|
93
|
+
}, target.entity))
|
|
94
|
+
]
|
|
95
|
+
})
|
|
96
|
+
}),
|
|
97
|
+
/*#__PURE__*/ _jsx(Field, {
|
|
98
|
+
hint: "The column on the account that identifies it.",
|
|
99
|
+
htmlFor: `${uid}-match-column`,
|
|
100
|
+
label: "Identified by",
|
|
101
|
+
children: /*#__PURE__*/ _jsxs("select", {
|
|
102
|
+
className: theme.field.input,
|
|
103
|
+
disabled: disabled || accountFields.length === 0,
|
|
104
|
+
id: `${uid}-match-column`,
|
|
105
|
+
onChange: (event)=>patch({
|
|
106
|
+
matchColumn: event.target.value
|
|
107
|
+
}),
|
|
108
|
+
value: value.matchColumn,
|
|
109
|
+
children: [
|
|
110
|
+
/*#__PURE__*/ _jsx("option", {
|
|
111
|
+
value: "",
|
|
112
|
+
children: "Not set"
|
|
113
|
+
}),
|
|
114
|
+
accountFields.map((field)=>/*#__PURE__*/ _jsx("option", {
|
|
115
|
+
value: field,
|
|
116
|
+
children: field
|
|
117
|
+
}, field))
|
|
118
|
+
]
|
|
119
|
+
})
|
|
120
|
+
}),
|
|
121
|
+
/*#__PURE__*/ _jsx(Field, {
|
|
122
|
+
hint: "Where the run's shared password is written.",
|
|
123
|
+
htmlFor: `${uid}-password`,
|
|
124
|
+
label: "Password goes into",
|
|
125
|
+
children: /*#__PURE__*/ _jsxs("select", {
|
|
126
|
+
className: theme.field.input,
|
|
127
|
+
disabled: disabled || accountFields.length === 0,
|
|
128
|
+
id: `${uid}-password`,
|
|
129
|
+
onChange: (event)=>patch({
|
|
130
|
+
passwordColumn: event.target.value
|
|
131
|
+
}),
|
|
132
|
+
value: value.passwordColumn,
|
|
133
|
+
children: [
|
|
134
|
+
/*#__PURE__*/ _jsx("option", {
|
|
135
|
+
value: "",
|
|
136
|
+
children: "Not set"
|
|
137
|
+
}),
|
|
138
|
+
accountFields.filter((field)=>PASSWORD_LIKE.test(field)).map((field)=>/*#__PURE__*/ _jsx("option", {
|
|
139
|
+
value: field,
|
|
140
|
+
children: field
|
|
141
|
+
}, field)),
|
|
142
|
+
accountFields.filter((field)=>!PASSWORD_LIKE.test(field)).length > 0 ? /*#__PURE__*/ _jsx("optgroup", {
|
|
143
|
+
label: "Other columns",
|
|
144
|
+
children: accountFields.filter((field)=>!PASSWORD_LIKE.test(field)).map((field)=>/*#__PURE__*/ _jsx("option", {
|
|
145
|
+
value: field,
|
|
146
|
+
children: field
|
|
147
|
+
}, field))
|
|
148
|
+
}) : null
|
|
149
|
+
]
|
|
150
|
+
})
|
|
151
|
+
}),
|
|
152
|
+
/*#__PURE__*/ _jsx(Field, {
|
|
153
|
+
hint: "The imported record's column carrying that same value.",
|
|
154
|
+
htmlFor: `${uid}-match-from`,
|
|
155
|
+
label: "Matched from",
|
|
156
|
+
children: /*#__PURE__*/ _jsxs("select", {
|
|
157
|
+
className: theme.field.input,
|
|
158
|
+
disabled: disabled,
|
|
159
|
+
id: `${uid}-match-from`,
|
|
160
|
+
onChange: (event)=>patch({
|
|
161
|
+
matchFrom: event.target.value
|
|
162
|
+
}),
|
|
163
|
+
value: value.matchFrom,
|
|
164
|
+
children: [
|
|
165
|
+
/*#__PURE__*/ _jsx("option", {
|
|
166
|
+
value: "",
|
|
167
|
+
children: "Not set"
|
|
168
|
+
}),
|
|
169
|
+
targetFields.map((field)=>/*#__PURE__*/ _jsx("option", {
|
|
170
|
+
value: field,
|
|
171
|
+
children: field
|
|
172
|
+
}, field))
|
|
173
|
+
]
|
|
174
|
+
})
|
|
175
|
+
}),
|
|
176
|
+
/*#__PURE__*/ _jsx(Field, {
|
|
177
|
+
hint: "Filled with the new account's id, so the record and its login know each other.",
|
|
178
|
+
htmlFor: `${uid}-id-column`,
|
|
179
|
+
label: "Account id goes into",
|
|
180
|
+
children: /*#__PURE__*/ _jsxs("select", {
|
|
181
|
+
className: theme.field.input,
|
|
182
|
+
disabled: disabled,
|
|
183
|
+
id: `${uid}-id-column`,
|
|
184
|
+
onChange: (event)=>patch({
|
|
185
|
+
idColumn: event.target.value
|
|
186
|
+
}),
|
|
187
|
+
value: value.idColumn,
|
|
188
|
+
children: [
|
|
189
|
+
/*#__PURE__*/ _jsx("option", {
|
|
190
|
+
value: "",
|
|
191
|
+
children: "Not set"
|
|
192
|
+
}),
|
|
193
|
+
targetFields.map((field)=>/*#__PURE__*/ _jsx("option", {
|
|
194
|
+
value: field,
|
|
195
|
+
children: field
|
|
196
|
+
}, field))
|
|
197
|
+
]
|
|
198
|
+
})
|
|
199
|
+
})
|
|
200
|
+
]
|
|
201
|
+
}),
|
|
202
|
+
/*#__PURE__*/ _jsx(Notice, {
|
|
203
|
+
title: "One password, typed on the import screen",
|
|
204
|
+
tone: "info",
|
|
205
|
+
children: "Every account this run creates is given the same opening password — asked for once, when the import is started, and stored nowhere. People change it themselves afterwards. A record whose value already has an account is linked to that one instead, so running this import again does not create a second."
|
|
206
|
+
})
|
|
207
|
+
]
|
|
208
|
+
}) : /*#__PURE__*/ _jsx("p", {
|
|
209
|
+
className: theme.list.empty,
|
|
210
|
+
children: "Off. Records are imported without a login — useful for a directory, not for people who have to sign in."
|
|
211
|
+
})
|
|
212
|
+
]
|
|
213
|
+
});
|
|
214
|
+
}
|
|
@@ -5,6 +5,7 @@ import { useLabels } from '../text/context';
|
|
|
5
5
|
import { integrationsPageTheme } from '../theme';
|
|
6
6
|
import { FieldMappingRows } from './FieldMappingRows';
|
|
7
7
|
import { endpointLabel, fieldsOfStoredSample } from './formSupport';
|
|
8
|
+
import { MappingAccountFields } from './MappingAccountFields';
|
|
8
9
|
import { MappingPasswordField } from './MappingPasswordField';
|
|
9
10
|
import { MappingWriteModeFields } from './MappingWriteModeFields';
|
|
10
11
|
import { mappingDraftIssues } from './mappingDraft';
|
|
@@ -203,6 +204,15 @@ export function MappingEditor({ mapping, targets, targetsLoading, endpoints, fre
|
|
|
203
204
|
targetFields: targetFields,
|
|
204
205
|
targets: targets
|
|
205
206
|
}),
|
|
207
|
+
/*#__PURE__*/ _jsx(MappingAccountFields, {
|
|
208
|
+
disabled: busy,
|
|
209
|
+
onChange: (accountRule)=>patch({
|
|
210
|
+
accountRule
|
|
211
|
+
}),
|
|
212
|
+
targetFields: targetFields,
|
|
213
|
+
targets: targets,
|
|
214
|
+
value: draft.accountRule ?? null
|
|
215
|
+
}),
|
|
206
216
|
/*#__PURE__*/ _jsx(MappingPasswordField, {
|
|
207
217
|
disabled: busy,
|
|
208
218
|
onChange: (value)=>patch({
|
|
@@ -8,6 +8,7 @@ import { Notice } from './Primitives';
|
|
|
8
8
|
import { RunConfirmStep } from './RunConfirmStep';
|
|
9
9
|
import { MIN_SHARED_PASSWORD, RunPasswordField } from './RunPasswordField';
|
|
10
10
|
import { RunPlanReferences } from './RunPlanReferences';
|
|
11
|
+
import { RunPlanSuggestions } from './RunPlanSuggestions';
|
|
11
12
|
import { RunPlanSummary } from './RunPlanSummary';
|
|
12
13
|
import { RunProgressBar } from './RunProgressBar';
|
|
13
14
|
import { describeLosses, errorText } from './runFormat';
|
|
@@ -29,8 +30,13 @@ export function RunPanel({ mapping, actions, onSubscribeProgress, onRunSettled }
|
|
|
29
30
|
// Held only while this panel is open. Never put in the store, never saved:
|
|
30
31
|
// the point of asking per run is that it does not live anywhere afterwards.
|
|
31
32
|
const [sharedPassword, setSharedPassword] = useState('');
|
|
33
|
+
// Two ways an import can need a password: it writes one into the record's own
|
|
34
|
+
// table, or it creates a login alongside each record. Either way it is typed
|
|
35
|
+
// here, once, and goes no further than this run.
|
|
36
|
+
const accountRule = mapping.accountRule ?? null;
|
|
32
37
|
const passwordColumn = mapping.passwordTargetColumn ?? '';
|
|
33
|
-
const
|
|
38
|
+
const needsPassword = passwordColumn !== '' || accountRule !== null;
|
|
39
|
+
const passwordReady = !needsPassword || sharedPassword.length >= MIN_SHARED_PASSWORD;
|
|
34
40
|
const activeRunId = store.activeRunId;
|
|
35
41
|
const plan = store.plan;
|
|
36
42
|
const preview = ()=>{
|
|
@@ -182,8 +188,8 @@ export function RunPanel({ mapping, actions, onSubscribeProgress, onRunSettled }
|
|
|
182
188
|
/*#__PURE__*/ _jsxs("div", {
|
|
183
189
|
className: theme.card.body,
|
|
184
190
|
children: [
|
|
185
|
-
|
|
186
|
-
column: passwordColumn,
|
|
191
|
+
needsPassword && !activeRunId ? /*#__PURE__*/ _jsx(RunPasswordField, {
|
|
192
|
+
column: accountRule ? `${accountRule.entity}.${accountRule.passwordColumn}` : passwordColumn,
|
|
187
193
|
disabled: starting,
|
|
188
194
|
onChange: setSharedPassword,
|
|
189
195
|
value: sharedPassword
|
|
@@ -207,6 +213,11 @@ export function RunPanel({ mapping, actions, onSubscribeProgress, onRunSettled }
|
|
|
207
213
|
/*#__PURE__*/ _jsx(RunPlanSummary, {
|
|
208
214
|
plan: plan
|
|
209
215
|
}),
|
|
216
|
+
/*#__PURE__*/ _jsx(RunPlanSuggestions, {
|
|
217
|
+
mapping: mapping,
|
|
218
|
+
onEditMapping: ()=>store.setTab('mappings'),
|
|
219
|
+
plan: plan
|
|
220
|
+
}),
|
|
210
221
|
/*#__PURE__*/ _jsx(RunPlanReferences, {
|
|
211
222
|
busy: starting || activeRunId !== null || !passwordReady,
|
|
212
223
|
onCreateAndRun: ()=>start(false, 'create'),
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { IntegrationMapping, RunPlanValue } from '../types';
|
|
2
|
+
/** `{name}.{surname}@example.com`, built from what this source really sends. */
|
|
3
|
+
export declare function suggestEmailTemplate(mapping: IntegrationMapping): string | null;
|
|
4
|
+
/** How many records each required field kept out, largest first. */
|
|
5
|
+
export declare function missingRequiredCounts(plan: RunPlanValue): {
|
|
6
|
+
field: string;
|
|
7
|
+
records: number;
|
|
8
|
+
}[];
|
|
9
|
+
export type RunPlanSuggestionsProps = {
|
|
10
|
+
plan: RunPlanValue;
|
|
11
|
+
mapping: IntegrationMapping;
|
|
12
|
+
/** Takes the operator to the mapping, where the fix is made. */
|
|
13
|
+
onEditMapping?: () => void;
|
|
14
|
+
};
|
|
15
|
+
export declare function RunPlanSuggestions({ plan, mapping, onEditMapping }: RunPlanSuggestionsProps): import("react/jsx-runtime").JSX.Element | null;
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
|
+
import { integrationsPageTheme } from '../theme';
|
|
4
|
+
import { Notice } from './Primitives';
|
|
5
|
+
/**
|
|
6
|
+
* What to DO about the records this import would leave out.
|
|
7
|
+
*
|
|
8
|
+
* A preview that says "561 records are missing e-mail" has told the truth and
|
|
9
|
+
* stopped one step short of being useful: the answer is nearly always to build
|
|
10
|
+
* the value from the fields that ARE there, and the panel knows which those are.
|
|
11
|
+
* Leaving the operator to work that out — and to guess that Fallback takes
|
|
12
|
+
* `{braces}` at all — is how a capability that exists goes unused.
|
|
13
|
+
*
|
|
14
|
+
* So the suggestion is written out, with the record's own field names in it,
|
|
15
|
+
* ready to be copied into the mapping.
|
|
16
|
+
*/ const theme = integrationsPageTheme;
|
|
17
|
+
/** Column names that plausibly hold an address, so the advice can be specific. */ const EMAIL_LIKE = /mail/i;
|
|
18
|
+
/** Source fields that plausibly name a person, in the order they read best. */ const GIVEN_NAME = /^(name|first|firstname|given|ad)$/i;
|
|
19
|
+
const FAMILY_NAME = /^(surname|last|lastname|family|soyad)$/i;
|
|
20
|
+
/** Fields the source actually sends, from what the mapping already reads. */ function sourceFieldsOf(mapping) {
|
|
21
|
+
return (Array.isArray(mapping.fieldMappings) ? mapping.fieldMappings : []).map((row)=>row.source).filter((source)=>source.trim() !== '' && !source.includes('{'));
|
|
22
|
+
}
|
|
23
|
+
/** `{name}.{surname}@example.com`, built from what this source really sends. */ export function suggestEmailTemplate(mapping) {
|
|
24
|
+
const fields = sourceFieldsOf(mapping);
|
|
25
|
+
const given = fields.find((field)=>GIVEN_NAME.test(field));
|
|
26
|
+
const family = fields.find((field)=>FAMILY_NAME.test(field));
|
|
27
|
+
if (!given && !family) return null;
|
|
28
|
+
const parts = [
|
|
29
|
+
given,
|
|
30
|
+
family
|
|
31
|
+
].filter(Boolean).join('}.{');
|
|
32
|
+
return `{${parts}}@example.com`;
|
|
33
|
+
}
|
|
34
|
+
/** How many records each required field kept out, largest first. */ export function missingRequiredCounts(plan) {
|
|
35
|
+
const counts = new Map();
|
|
36
|
+
for (const issue of plan.issues){
|
|
37
|
+
if (issue.kind !== 'missing_required') continue;
|
|
38
|
+
// The field names are in the message the planner wrote; it is the only
|
|
39
|
+
// place they exist, and re-deriving them from the row would be a guess.
|
|
40
|
+
const named = issue.message.replace(/^Required field\(s\) empty:\s*/, '');
|
|
41
|
+
for (const field of named.split(',').map((part)=>part.trim())){
|
|
42
|
+
if (field) counts.set(field, (counts.get(field) ?? 0) + 1);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return [
|
|
46
|
+
...counts.entries()
|
|
47
|
+
].map(([field, records])=>({
|
|
48
|
+
field,
|
|
49
|
+
records
|
|
50
|
+
})).sort((a, b)=>b.records - a.records);
|
|
51
|
+
}
|
|
52
|
+
export function RunPlanSuggestions({ plan, mapping, onEditMapping }) {
|
|
53
|
+
const missing = missingRequiredCounts(plan);
|
|
54
|
+
if (missing.length === 0) return null;
|
|
55
|
+
const template = suggestEmailTemplate(mapping);
|
|
56
|
+
return /*#__PURE__*/ _jsx("section", {
|
|
57
|
+
className: "flex flex-col gap-3",
|
|
58
|
+
children: missing.map((entry)=>{
|
|
59
|
+
const isEmail = EMAIL_LIKE.test(entry.field);
|
|
60
|
+
return /*#__PURE__*/ _jsxs(Notice, {
|
|
61
|
+
title: `${entry.records} record(s) have no ${entry.field}, so they are left out`,
|
|
62
|
+
tone: "warning",
|
|
63
|
+
children: [
|
|
64
|
+
isEmail && template ? /*#__PURE__*/ _jsxs(_Fragment, {
|
|
65
|
+
children: [
|
|
66
|
+
/*#__PURE__*/ _jsxs("p", {
|
|
67
|
+
children: [
|
|
68
|
+
"An address can be built from the fields the source DOES send. On the mapping, set",
|
|
69
|
+
' ',
|
|
70
|
+
/*#__PURE__*/ _jsx("strong", {
|
|
71
|
+
children: "Fallback"
|
|
72
|
+
}),
|
|
73
|
+
" on the ",
|
|
74
|
+
/*#__PURE__*/ _jsx("strong", {
|
|
75
|
+
children: entry.field
|
|
76
|
+
}),
|
|
77
|
+
" row to something like:"
|
|
78
|
+
]
|
|
79
|
+
}),
|
|
80
|
+
/*#__PURE__*/ _jsx("p", {
|
|
81
|
+
className: "mt-1",
|
|
82
|
+
children: /*#__PURE__*/ _jsx("code", {
|
|
83
|
+
className: theme.code,
|
|
84
|
+
children: template
|
|
85
|
+
})
|
|
86
|
+
}),
|
|
87
|
+
/*#__PURE__*/ _jsxs("p", {
|
|
88
|
+
className: "mt-1",
|
|
89
|
+
children: [
|
|
90
|
+
"Pair it with the ",
|
|
91
|
+
/*#__PURE__*/ _jsx("strong", {
|
|
92
|
+
children: "E-mail safe"
|
|
93
|
+
}),
|
|
94
|
+
" transform and ",
|
|
95
|
+
/*#__PURE__*/ _jsx("em", {
|
|
96
|
+
children: "Ayşe Yılmaz"
|
|
97
|
+
}),
|
|
98
|
+
' ',
|
|
99
|
+
"becomes ",
|
|
100
|
+
/*#__PURE__*/ _jsx("em", {
|
|
101
|
+
children: "ayse.yilmaz@example.com"
|
|
102
|
+
}),
|
|
103
|
+
". Records that already carry an address keep the one they have — the fallback is only used when the source is empty."
|
|
104
|
+
]
|
|
105
|
+
})
|
|
106
|
+
]
|
|
107
|
+
}) : /*#__PURE__*/ _jsx("p", {
|
|
108
|
+
children: `Nothing in this import fills ${entry.field}, and the table will not accept a row without it. Either map a source field to it, or give that row a Fallback — a fixed value, or one built from other fields with {braces}.`
|
|
109
|
+
}),
|
|
110
|
+
onEditMapping ? /*#__PURE__*/ _jsx("button", {
|
|
111
|
+
className: `${theme.button.secondary} mt-3`,
|
|
112
|
+
onClick: onEditMapping,
|
|
113
|
+
type: "button",
|
|
114
|
+
children: "Open the mapping"
|
|
115
|
+
}) : null
|
|
116
|
+
]
|
|
117
|
+
}, entry.field);
|
|
118
|
+
})
|
|
119
|
+
});
|
|
120
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|