nucleus-core-ts 0.9.821 → 0.9.822
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/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/RunPanel.js +6 -0
- 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/fieldMappingOptions.js +4 -2
- package/dist/fe/components/IntegrationsPage/index.d.ts +2 -0
- package/dist/fe/components/IntegrationsPage/index.js +2 -0
- package/dist/fe/components/IntegrationsPage/types/actions.d.ts +18 -0
- package/dist/fe/components/IntegrationsPage/types/records.d.ts +1 -1
- package/dist/index.js +4 -4
- package/dist/src/ElysiaPlugin/routes/integrations/repository.d.ts +36 -0
- 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 +3 -1
- package/package.json +1 -1
|
@@ -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
|
+
}
|
|
@@ -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';
|
|
@@ -207,6 +208,11 @@ export function RunPanel({ mapping, actions, onSubscribeProgress, onRunSettled }
|
|
|
207
208
|
/*#__PURE__*/ _jsx(RunPlanSummary, {
|
|
208
209
|
plan: plan
|
|
209
210
|
}),
|
|
211
|
+
/*#__PURE__*/ _jsx(RunPlanSuggestions, {
|
|
212
|
+
mapping: mapping,
|
|
213
|
+
onEditMapping: ()=>store.setTab('mappings'),
|
|
214
|
+
plan: plan
|
|
215
|
+
}),
|
|
210
216
|
/*#__PURE__*/ _jsx(RunPlanReferences, {
|
|
211
217
|
busy: starting || activeRunId !== null || !passwordReady,
|
|
212
218
|
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 {};
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test';
|
|
2
|
+
import { missingRequiredCounts, suggestEmailTemplate } from './RunPlanSuggestions';
|
|
3
|
+
/**
|
|
4
|
+
* A preview that says "561 records are missing e-mail" has told the truth and
|
|
5
|
+
* stopped one step short of being useful. The answer is nearly always to build
|
|
6
|
+
* the value from the fields that ARE there, and the panel knows which those are.
|
|
7
|
+
*/ const plan = (issues)=>({
|
|
8
|
+
issues,
|
|
9
|
+
referenceInserts: [],
|
|
10
|
+
warnings: []
|
|
11
|
+
});
|
|
12
|
+
const issue = (message, i)=>({
|
|
13
|
+
sourceIndex: i,
|
|
14
|
+
kind: 'missing_required',
|
|
15
|
+
message
|
|
16
|
+
});
|
|
17
|
+
describe('counting what each missing field costs', ()=>{
|
|
18
|
+
it('counts the records one field kept out', ()=>{
|
|
19
|
+
const counts = missingRequiredCounts(plan([
|
|
20
|
+
issue('Required field(s) empty: email', 0),
|
|
21
|
+
issue('Required field(s) empty: email', 1)
|
|
22
|
+
]));
|
|
23
|
+
expect(counts).toEqual([
|
|
24
|
+
{
|
|
25
|
+
field: 'email',
|
|
26
|
+
records: 2
|
|
27
|
+
}
|
|
28
|
+
]);
|
|
29
|
+
});
|
|
30
|
+
it('counts each field of a record that is missing two', ()=>{
|
|
31
|
+
const counts = missingRequiredCounts(plan([
|
|
32
|
+
issue('Required field(s) empty: email, code', 0)
|
|
33
|
+
]));
|
|
34
|
+
expect(counts).toEqual([
|
|
35
|
+
{
|
|
36
|
+
field: 'email',
|
|
37
|
+
records: 1
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
field: 'code',
|
|
41
|
+
records: 1
|
|
42
|
+
}
|
|
43
|
+
]);
|
|
44
|
+
});
|
|
45
|
+
it('puts the biggest problem first', ()=>{
|
|
46
|
+
const counts = missingRequiredCounts(plan([
|
|
47
|
+
issue('Required field(s) empty: code', 0),
|
|
48
|
+
issue('Required field(s) empty: email', 1),
|
|
49
|
+
issue('Required field(s) empty: email', 2)
|
|
50
|
+
]));
|
|
51
|
+
expect(counts[0]).toEqual({
|
|
52
|
+
field: 'email',
|
|
53
|
+
records: 2
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
it('ignores issues of every other kind', ()=>{
|
|
57
|
+
const counts = missingRequiredCounts(plan([
|
|
58
|
+
{
|
|
59
|
+
sourceIndex: 0,
|
|
60
|
+
kind: 'duplicate_in_batch',
|
|
61
|
+
message: 'twice'
|
|
62
|
+
}
|
|
63
|
+
]));
|
|
64
|
+
expect(counts).toEqual([]);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
describe('the address it suggests building', ()=>{
|
|
68
|
+
const mapping = (sources)=>({
|
|
69
|
+
fieldMappings: sources.map((source)=>({
|
|
70
|
+
source,
|
|
71
|
+
target: source
|
|
72
|
+
}))
|
|
73
|
+
});
|
|
74
|
+
it('is built from the fields this source really sends', ()=>{
|
|
75
|
+
expect(suggestEmailTemplate(mapping([
|
|
76
|
+
'name',
|
|
77
|
+
'surname',
|
|
78
|
+
'employeeNo'
|
|
79
|
+
]))).toBe('{name}.{surname}@example.com');
|
|
80
|
+
});
|
|
81
|
+
it('recognises the other names those fields go by', ()=>{
|
|
82
|
+
expect(suggestEmailTemplate(mapping([
|
|
83
|
+
'firstName',
|
|
84
|
+
'lastName'
|
|
85
|
+
]))).toBe('{firstName}.{lastName}@example.com');
|
|
86
|
+
});
|
|
87
|
+
it('offers what it can when only one of the two is there', ()=>{
|
|
88
|
+
expect(suggestEmailTemplate(mapping([
|
|
89
|
+
'surname'
|
|
90
|
+
]))).toBe('{surname}@example.com');
|
|
91
|
+
});
|
|
92
|
+
it('suggests nothing rather than nonsense when there is no name at all', ()=>{
|
|
93
|
+
expect(suggestEmailTemplate(mapping([
|
|
94
|
+
'employeeNo',
|
|
95
|
+
'departmentCode'
|
|
96
|
+
]))).toBeNull();
|
|
97
|
+
});
|
|
98
|
+
it('ignores a source that is already a template', ()=>{
|
|
99
|
+
expect(suggestEmailTemplate(mapping([
|
|
100
|
+
'{a}-{b}'
|
|
101
|
+
]))).toBeNull();
|
|
102
|
+
});
|
|
103
|
+
});
|
|
@@ -26,7 +26,8 @@ export const TRANSFORMS = [
|
|
|
26
26
|
'toBoolean',
|
|
27
27
|
'dateOnly',
|
|
28
28
|
'emailSafe',
|
|
29
|
-
'pathLeaf'
|
|
29
|
+
'pathLeaf',
|
|
30
|
+
'toList'
|
|
30
31
|
];
|
|
31
32
|
export const TRANSFORM_LABEL = {
|
|
32
33
|
none: 'No transform',
|
|
@@ -38,7 +39,8 @@ export const TRANSFORM_LABEL = {
|
|
|
38
39
|
toBoolean: 'To boolean',
|
|
39
40
|
dateOnly: 'Date only',
|
|
40
41
|
emailSafe: 'E-mail safe (ascii, lowercase)',
|
|
41
|
-
pathLeaf: 'Last part of a path (A/B/C → C)'
|
|
42
|
+
pathLeaf: 'Last part of a path (A/B/C → C)',
|
|
43
|
+
toList: 'To a list (for a column that holds many)'
|
|
42
44
|
};
|
|
43
45
|
/** Narrows what a select reported back to a transform this panel knows. */ export function toTransform(value) {
|
|
44
46
|
return TRANSFORMS.find((option)=>option === value) ?? 'none';
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
export { ConnectionList } from './components/ConnectionList';
|
|
2
2
|
export type { ConnectionTabsProps } from './components/ConnectionTabs';
|
|
3
3
|
export { ConnectionTabs, connectionTabId, connectionTabPanelId } from './components/ConnectionTabs';
|
|
4
|
+
export { ConnectionTransfer } from './components/ConnectionTransfer';
|
|
4
5
|
export { EndpointsTab } from './components/EndpointsTab';
|
|
5
6
|
export { IntegrationsPage } from './components/IntegrationsPage';
|
|
6
7
|
export { MappingsTab } from './components/MappingsTab';
|
|
7
8
|
export type { BadgeProps, BadgeTone, CodeBlockProps, FieldProps, NoticeProps, NoticeTone, StatProps, } from './components/Primitives';
|
|
8
9
|
export { Badge, CodeBlock, Field, Notice, Stat } from './components/Primitives';
|
|
10
|
+
export { RunPlanSuggestions } from './components/RunPlanSuggestions';
|
|
9
11
|
export { RunsTab } from './components/RunsTab';
|
|
10
12
|
export type { ListSkeletonProps } from './components/Skeletons';
|
|
11
13
|
export { CardSkeleton, ListSkeleton, StatGridSkeleton } from './components/Skeletons';
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
export { ConnectionList } from './components/ConnectionList';
|
|
2
2
|
export { ConnectionTabs, connectionTabId, connectionTabPanelId } from './components/ConnectionTabs';
|
|
3
|
+
export { ConnectionTransfer } from './components/ConnectionTransfer';
|
|
3
4
|
export { EndpointsTab } from './components/EndpointsTab';
|
|
4
5
|
export { IntegrationsPage } from './components/IntegrationsPage';
|
|
5
6
|
export { MappingsTab } from './components/MappingsTab';
|
|
6
7
|
export { Badge, CodeBlock, Field, Notice, Stat } from './components/Primitives';
|
|
8
|
+
export { RunPlanSuggestions } from './components/RunPlanSuggestions';
|
|
7
9
|
export { RunsTab } from './components/RunsTab';
|
|
8
10
|
export { CardSkeleton, ListSkeleton, StatGridSkeleton } from './components/Skeletons';
|
|
9
11
|
export { useInfiniteList } from './components/useInfiniteList';
|
|
@@ -92,6 +92,24 @@ export type IntegrationsActions = {
|
|
|
92
92
|
label: string;
|
|
93
93
|
fields: string[];
|
|
94
94
|
}[]>;
|
|
95
|
+
/**
|
|
96
|
+
* The whole connection as one portable document — WITHOUT any credential.
|
|
97
|
+
*
|
|
98
|
+
* A connection is the connection, its calls, the parameters those calls take
|
|
99
|
+
* from each other and the mappings that turn the answers into rows. Moving
|
|
100
|
+
* that by hand between environments is hours of work and a fresh chance to
|
|
101
|
+
* get one field wrong in a way nobody notices until an import writes it.
|
|
102
|
+
*/
|
|
103
|
+
exportConnection: (sourceId: string) => Promise<unknown>;
|
|
104
|
+
/** Brings such a document in. Re-importing the same file updates rather than duplicates. */
|
|
105
|
+
importConnection: (document: unknown) => Promise<{
|
|
106
|
+
ok: boolean;
|
|
107
|
+
message: string;
|
|
108
|
+
problems?: {
|
|
109
|
+
path: string;
|
|
110
|
+
message: string;
|
|
111
|
+
}[];
|
|
112
|
+
}>;
|
|
95
113
|
};
|
|
96
114
|
export type IntegrationsPageProps = {
|
|
97
115
|
title?: string;
|
|
@@ -86,7 +86,7 @@ export type IntegrationEndpoint = {
|
|
|
86
86
|
} | null;
|
|
87
87
|
enabled?: boolean;
|
|
88
88
|
};
|
|
89
|
-
export type MappingTransformValue = 'none' | 'trim' | 'upper' | 'lower' | 'toString' | 'toNumber' | 'toBoolean' | 'dateOnly' | 'emailSafe' | 'pathLeaf';
|
|
89
|
+
export type MappingTransformValue = 'none' | 'trim' | 'upper' | 'lower' | 'toString' | 'toNumber' | 'toBoolean' | 'dateOnly' | 'emailSafe' | 'pathLeaf' | 'toList';
|
|
90
90
|
export type FieldMappingValue = {
|
|
91
91
|
source: string;
|
|
92
92
|
target: string;
|