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.
@@ -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,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;