nucleus-core-ts 0.9.847 → 0.9.849
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/EndpointForm.d.ts +2 -1
- package/dist/fe/components/IntegrationsPage/components/EndpointForm.js +1 -1
- package/dist/fe/components/IntegrationsPage/components/EndpointSample.js +23 -2
- package/dist/fe/components/IntegrationsPage/components/EndpointsTab.js +6 -1
- package/dist/fe/components/IntegrationsPage/components/MappingsTab.js +6 -0
- package/dist/fe/components/IntegrationsPage/components/settleOwnership.d.ts +35 -0
- package/dist/fe/components/IntegrationsPage/components/settleOwnership.js +30 -0
- package/dist/fe/components/IntegrationsPage/components/settleOwnership.test.d.ts +1 -0
- package/dist/fe/components/IntegrationsPage/components/settleOwnership.test.js +56 -0
- package/dist/fe/components/IntegrationsPage/store/index.js +28 -0
- package/dist/fe/components/IntegrationsPage/store/state.d.ts +22 -0
- package/package.json +1 -1
|
@@ -5,7 +5,8 @@ export type EndpointFormProps = {
|
|
|
5
5
|
endpoint: IntegrationEndpoint | null;
|
|
6
6
|
actions: IntegrationsActions;
|
|
7
7
|
onSaved: (endpoint: IntegrationEndpoint) => void;
|
|
8
|
-
|
|
8
|
+
/** Told WHICH call went, so the list can drop it without waiting for a fetch. */
|
|
9
|
+
onDeleted: (deletedId: string) => void;
|
|
9
10
|
onCancel: () => void;
|
|
10
11
|
};
|
|
11
12
|
export declare function EndpointForm({ sourceId, endpoint, actions, onSaved, onDeleted, onCancel, }: EndpointFormProps): import("react/jsx-runtime").JSX.Element;
|
|
@@ -113,7 +113,7 @@ export function EndpointForm({ sourceId, endpoint, actions, onSaved, onDeleted,
|
|
|
113
113
|
store.setError(null);
|
|
114
114
|
actions.deleteEndpoint(endpoint.id).then((ok)=>{
|
|
115
115
|
setDeleting(false);
|
|
116
|
-
if (ok) onDeleted();
|
|
116
|
+
if (ok) onDeleted(endpoint.id);
|
|
117
117
|
else store.setError('The endpoint was not deleted.');
|
|
118
118
|
}).catch(fail('Deleting the endpoint failed.'));
|
|
119
119
|
};
|
|
@@ -7,6 +7,7 @@ import { integrationsPageTheme } from '../theme';
|
|
|
7
7
|
import { Badge, CodeBlock, Field, Notice, Stat } from './Primitives';
|
|
8
8
|
import { pathParamNames } from './pathParams';
|
|
9
9
|
import { CardSkeleton, StatGridSkeleton } from './Skeletons';
|
|
10
|
+
import { settlementOf } from './settleOwnership';
|
|
10
11
|
/**
|
|
11
12
|
* One real request, shown in full.
|
|
12
13
|
*
|
|
@@ -21,6 +22,7 @@ export function EndpointSample({ endpoint, actions }) {
|
|
|
21
22
|
const [values, setValues] = useState({});
|
|
22
23
|
// Kept in step with the render so an in-flight reply can ask what is selected
|
|
23
24
|
// NOW, not what was selected when it was sent.
|
|
25
|
+
const generationRef = useRef(0);
|
|
24
26
|
const selectedRef = useRef(store.selectedEndpointId);
|
|
25
27
|
selectedRef.current = store.selectedEndpointId;
|
|
26
28
|
const params = pathParamNames(endpoint.path);
|
|
@@ -29,20 +31,39 @@ export function EndpointSample({ endpoint, actions }) {
|
|
|
29
31
|
for (const name of params){
|
|
30
32
|
if (!(values[name] ?? '').trim()) unfilled += 1;
|
|
31
33
|
}
|
|
34
|
+
/**
|
|
35
|
+
* Discard a late answer, but hand the busy flag back on the way out.
|
|
36
|
+
*
|
|
37
|
+
* The discard itself is right — the operator has moved on and the field list
|
|
38
|
+
* must describe the endpoint they are looking at. It used to be a bare
|
|
39
|
+
* `return`, which skipped the clearing too: `sampling` stayed set with nobody
|
|
40
|
+
* left to unset it, and the sample card showed a skeleton until the page was
|
|
41
|
+
* reloaded. See settleOwnership.ts for who owns the flag.
|
|
42
|
+
*/ const settleElsewhere = (mine, requestedFor)=>{
|
|
43
|
+
const { discard, releaseFlag } = settlementOf({
|
|
44
|
+
mine,
|
|
45
|
+
latest: generationRef.current,
|
|
46
|
+
requestedFor,
|
|
47
|
+
selectedNow: selectedRef.current
|
|
48
|
+
});
|
|
49
|
+
if (releaseFlag) store.setSampling(false);
|
|
50
|
+
return discard;
|
|
51
|
+
};
|
|
32
52
|
const run = ()=>{
|
|
33
53
|
// The endpoint this request is FOR. A sample can take seconds against a slow
|
|
34
54
|
// source, and an operator who clicks another endpoint meanwhile would
|
|
35
55
|
// otherwise get the first one's answer written in as the second one's field
|
|
36
56
|
// list — and then map against fields the endpoint does not have.
|
|
37
57
|
const requestedFor = endpoint.id;
|
|
58
|
+
const mine = ++generationRef.current;
|
|
38
59
|
store.setSampling(true);
|
|
39
60
|
store.setError(null);
|
|
40
61
|
actions.sampleEndpoint(requestedFor, params.length > 0 ? values : undefined).then((result)=>{
|
|
41
|
-
if (
|
|
62
|
+
if (settleElsewhere(mine, requestedFor)) return;
|
|
42
63
|
store.setSample(result);
|
|
43
64
|
if (!result.ok && result.error) store.setError(result.error);
|
|
44
65
|
}).catch((error)=>{
|
|
45
|
-
if (
|
|
66
|
+
if (settleElsewhere(mine, requestedFor)) return;
|
|
46
67
|
store.setSample(null);
|
|
47
68
|
store.setError(error instanceof Error ? error.message : 'Sampling the endpoint failed.');
|
|
48
69
|
});
|
|
@@ -52,13 +52,18 @@ export function EndpointsTab({ actions }) {
|
|
|
52
52
|
};
|
|
53
53
|
const handleSaved = (saved)=>{
|
|
54
54
|
setCreating(false);
|
|
55
|
+
// Into the list FIRST. `editing` is looked up in store.endpoints, so
|
|
56
|
+
// selecting an id the list has not fetched yet made the whole editor
|
|
57
|
+
// collapse to "pick one" — with the form still half-filled a moment ago.
|
|
58
|
+
store.upsertEndpoint(saved);
|
|
55
59
|
// Re-selecting drops the old sample: it described the call as it was
|
|
56
60
|
// configured a moment ago, and the mapping would be built from it.
|
|
57
61
|
store.selectEndpoint(saved.id);
|
|
58
62
|
setReloadToken((token)=>token + 1);
|
|
59
63
|
};
|
|
60
|
-
const handleDeleted = ()=>{
|
|
64
|
+
const handleDeleted = (deletedId)=>{
|
|
61
65
|
setCreating(false);
|
|
66
|
+
store.removeEndpoint(deletedId);
|
|
62
67
|
store.selectEndpoint(null);
|
|
63
68
|
setReloadToken((token)=>token + 1);
|
|
64
69
|
};
|
|
@@ -144,6 +144,11 @@ export function MappingsTab({ actions }) {
|
|
|
144
144
|
if (!saved) return store.setError('The mapping was not saved.');
|
|
145
145
|
setCreating(false);
|
|
146
146
|
setSelected(saved);
|
|
147
|
+
// Into the list too, not just local state. The IMPORT step finds its
|
|
148
|
+
// mapping in store.mappings, and the list that fills it is unmounted
|
|
149
|
+
// the moment that step is shown — so "Run import" on a mapping saved a
|
|
150
|
+
// second earlier landed on "choose a mapping".
|
|
151
|
+
store.upsertMapping(saved);
|
|
147
152
|
store.selectMapping(saved.id);
|
|
148
153
|
setReloadToken((token)=>token + 1);
|
|
149
154
|
then?.(saved);
|
|
@@ -178,6 +183,7 @@ export function MappingsTab({ actions }) {
|
|
|
178
183
|
actions.deleteMapping(id).then((removed)=>{
|
|
179
184
|
setDeleting(false);
|
|
180
185
|
if (!removed) return store.setError('The mapping was not deleted.');
|
|
186
|
+
store.removeMapping(id);
|
|
181
187
|
closeEditor();
|
|
182
188
|
setReloadToken((token)=>token + 1);
|
|
183
189
|
}).catch((error)=>{
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Who owns a busy flag when a late answer arrives.
|
|
3
|
+
*
|
|
4
|
+
* Every request in this panel sets a shared "in progress" flag on the way in and
|
|
5
|
+
* clears it on the way out. That breaks the moment an answer is DISCARDED —
|
|
6
|
+
* because the operator moved on, or because a newer request replaced this one —
|
|
7
|
+
* since a bare `return` skips the clearing too and the flag stays set with
|
|
8
|
+
* nobody left to unset it. The panel then shows a skeleton for a request that
|
|
9
|
+
* has already finished and will never be written.
|
|
10
|
+
*
|
|
11
|
+
* Two questions, and they are not the same question:
|
|
12
|
+
*
|
|
13
|
+
* superseded — a NEWER request of the same kind has started. It set the flag
|
|
14
|
+
* after this one did and will clear it when it settles, so this
|
|
15
|
+
* answer must leave the flag strictly alone.
|
|
16
|
+
* movedOn — the operator is looking at something else. Nobody else is
|
|
17
|
+
* coming: this request is the last one out and must clear the
|
|
18
|
+
* flag before dropping its answer.
|
|
19
|
+
*/
|
|
20
|
+
export type Settlement = {
|
|
21
|
+
/** True when this answer must not be written. */
|
|
22
|
+
discard: boolean;
|
|
23
|
+
/** True when this request is responsible for clearing the busy flag. */
|
|
24
|
+
releaseFlag: boolean;
|
|
25
|
+
};
|
|
26
|
+
export declare function settlementOf(args: {
|
|
27
|
+
/** The generation this request was given when it started. */
|
|
28
|
+
mine: number;
|
|
29
|
+
/** The latest generation handed out. */
|
|
30
|
+
latest: number;
|
|
31
|
+
/** What this request was for. */
|
|
32
|
+
requestedFor: string;
|
|
33
|
+
/** What is selected right now. */
|
|
34
|
+
selectedNow: string | null;
|
|
35
|
+
}): Settlement;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Who owns a busy flag when a late answer arrives.
|
|
3
|
+
*
|
|
4
|
+
* Every request in this panel sets a shared "in progress" flag on the way in and
|
|
5
|
+
* clears it on the way out. That breaks the moment an answer is DISCARDED —
|
|
6
|
+
* because the operator moved on, or because a newer request replaced this one —
|
|
7
|
+
* since a bare `return` skips the clearing too and the flag stays set with
|
|
8
|
+
* nobody left to unset it. The panel then shows a skeleton for a request that
|
|
9
|
+
* has already finished and will never be written.
|
|
10
|
+
*
|
|
11
|
+
* Two questions, and they are not the same question:
|
|
12
|
+
*
|
|
13
|
+
* superseded — a NEWER request of the same kind has started. It set the flag
|
|
14
|
+
* after this one did and will clear it when it settles, so this
|
|
15
|
+
* answer must leave the flag strictly alone.
|
|
16
|
+
* movedOn — the operator is looking at something else. Nobody else is
|
|
17
|
+
* coming: this request is the last one out and must clear the
|
|
18
|
+
* flag before dropping its answer.
|
|
19
|
+
*/ export function settlementOf(args) {
|
|
20
|
+
const superseded = args.latest !== args.mine;
|
|
21
|
+
const movedOn = args.selectedNow !== args.requestedFor;
|
|
22
|
+
return {
|
|
23
|
+
discard: superseded || movedOn,
|
|
24
|
+
// Only the request that still holds the flag may clear it — otherwise a
|
|
25
|
+
// slow first answer would switch off the light on a second request that is
|
|
26
|
+
// still running, and the operator would watch a finished-looking panel fill
|
|
27
|
+
// in by itself a moment later.
|
|
28
|
+
releaseFlag: !superseded && movedOn
|
|
29
|
+
};
|
|
30
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test';
|
|
2
|
+
import { settlementOf } from './settleOwnership';
|
|
3
|
+
const forEndpoint = (over = {})=>settlementOf({
|
|
4
|
+
mine: 1,
|
|
5
|
+
latest: 1,
|
|
6
|
+
requestedFor: 'a',
|
|
7
|
+
selectedNow: 'a',
|
|
8
|
+
...over
|
|
9
|
+
});
|
|
10
|
+
describe('settlementOf', ()=>{
|
|
11
|
+
it('writes the answer when nothing has changed', ()=>{
|
|
12
|
+
expect(forEndpoint()).toEqual({
|
|
13
|
+
discard: false,
|
|
14
|
+
releaseFlag: false
|
|
15
|
+
});
|
|
16
|
+
});
|
|
17
|
+
/**
|
|
18
|
+
* The bug this exists for: the operator clicked another endpoint while a
|
|
19
|
+
* sample was in flight, the answer was dropped, and the busy flag was left on
|
|
20
|
+
* with nobody coming to clear it.
|
|
21
|
+
*/ it('drops the answer AND clears the flag when the operator moved on', ()=>{
|
|
22
|
+
expect(forEndpoint({
|
|
23
|
+
selectedNow: 'b'
|
|
24
|
+
})).toEqual({
|
|
25
|
+
discard: true,
|
|
26
|
+
releaseFlag: true
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
it('leaves the flag alone when a newer request has taken over', ()=>{
|
|
30
|
+
expect(forEndpoint({
|
|
31
|
+
latest: 2
|
|
32
|
+
})).toEqual({
|
|
33
|
+
discard: true,
|
|
34
|
+
releaseFlag: false
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
it('never clears the flag for a superseded request, even one that also moved on', ()=>{
|
|
38
|
+
// Both conditions at once: the newer request owns the flag, so this one must
|
|
39
|
+
// not switch it off while that one is still running.
|
|
40
|
+
expect(forEndpoint({
|
|
41
|
+
latest: 2,
|
|
42
|
+
selectedNow: 'b'
|
|
43
|
+
})).toEqual({
|
|
44
|
+
discard: true,
|
|
45
|
+
releaseFlag: false
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
it('treats nothing-selected as having moved on', ()=>{
|
|
49
|
+
expect(forEndpoint({
|
|
50
|
+
selectedNow: null
|
|
51
|
+
})).toEqual({
|
|
52
|
+
discard: true,
|
|
53
|
+
releaseFlag: true
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
});
|
|
@@ -66,6 +66,17 @@ export const { useStore: useIntegrationsStore } = createStore(initial, {
|
|
|
66
66
|
store.endpointsTotal = total;
|
|
67
67
|
store.endpointsLoading = false;
|
|
68
68
|
}),
|
|
69
|
+
upsertEndpoint: (store)=>(endpoint)=>batch(()=>{
|
|
70
|
+
if (isNewTo(store.endpoints, endpoint)) store.endpointsTotal = store.endpointsTotal + 1;
|
|
71
|
+
store.endpoints = upsertInto(store.endpoints, endpoint);
|
|
72
|
+
}),
|
|
73
|
+
removeEndpoint: (store)=>(id)=>batch(()=>{
|
|
74
|
+
const kept = store.endpoints.filter((candidate)=>candidate.id !== id);
|
|
75
|
+
if (kept.length !== store.endpoints.length) {
|
|
76
|
+
store.endpointsTotal = Math.max(0, store.endpointsTotal - 1);
|
|
77
|
+
store.endpoints = kept;
|
|
78
|
+
}
|
|
79
|
+
}),
|
|
69
80
|
setEndpointsSearch: (store)=>(search)=>batch(()=>{
|
|
70
81
|
store.endpointsSearch = search;
|
|
71
82
|
store.endpointsPage = 1;
|
|
@@ -78,12 +89,29 @@ export const { useStore: useIntegrationsStore } = createStore(initial, {
|
|
|
78
89
|
// The sample describes ONE endpoint; carrying it over would offer the
|
|
79
90
|
// wrong field list to the next mapping.
|
|
80
91
|
store.sample = null;
|
|
92
|
+
// And the WAIT for a sample belongs to the endpoint that was left, not to
|
|
93
|
+
// the one arrived at. Clearing the record without clearing the flag left
|
|
94
|
+
// the next endpoint showing a skeleton for a request that was never going
|
|
95
|
+
// to be written — permanently, because the answer, when it came, saw the
|
|
96
|
+
// selection had moved and returned without touching anything.
|
|
97
|
+
store.sampling = false;
|
|
81
98
|
}),
|
|
82
99
|
setMappings: (store)=>(items, total)=>batch(()=>{
|
|
83
100
|
store.mappings = items;
|
|
84
101
|
store.mappingsTotal = total;
|
|
85
102
|
store.mappingsLoading = false;
|
|
86
103
|
}),
|
|
104
|
+
upsertMapping: (store)=>(mapping)=>batch(()=>{
|
|
105
|
+
if (isNewTo(store.mappings, mapping)) store.mappingsTotal = store.mappingsTotal + 1;
|
|
106
|
+
store.mappings = upsertInto(store.mappings, mapping);
|
|
107
|
+
}),
|
|
108
|
+
removeMapping: (store)=>(id)=>batch(()=>{
|
|
109
|
+
const kept = store.mappings.filter((candidate)=>candidate.id !== id);
|
|
110
|
+
if (kept.length !== store.mappings.length) {
|
|
111
|
+
store.mappingsTotal = Math.max(0, store.mappingsTotal - 1);
|
|
112
|
+
store.mappings = kept;
|
|
113
|
+
}
|
|
114
|
+
}),
|
|
87
115
|
setMappingsTotal: (store)=>(total)=>{
|
|
88
116
|
store.mappingsTotal = total;
|
|
89
117
|
},
|
|
@@ -76,6 +76,17 @@ export type Actions = {
|
|
|
76
76
|
setTab: (tab: ConnectionTab) => void;
|
|
77
77
|
setFocusTargetEntity: (entity: string | null, step?: 'mapping' | 'import') => void;
|
|
78
78
|
setEndpoints: (items: IntegrationEndpoint[], total: number) => void;
|
|
79
|
+
/**
|
|
80
|
+
* Put one call into the list, whether or not it is already there.
|
|
81
|
+
*
|
|
82
|
+
* Same reason as [upsertSource]: the list is the only thing that fetches, and
|
|
83
|
+
* the editor finds the record it is editing BY LOOKING IN THAT LIST. Between
|
|
84
|
+
* saving and the refetch landing, the record is in neither — so the editor
|
|
85
|
+
* collapsed to "pick one" and the form the operator was filling in vanished.
|
|
86
|
+
*/
|
|
87
|
+
upsertEndpoint: (endpoint: IntegrationEndpoint) => void;
|
|
88
|
+
/** Drop one call from the list, for when it has just been deleted. */
|
|
89
|
+
removeEndpoint: (id: string) => void;
|
|
79
90
|
/**
|
|
80
91
|
* Just the count, without the rows.
|
|
81
92
|
*
|
|
@@ -86,6 +97,17 @@ export type Actions = {
|
|
|
86
97
|
setEndpointsLoading: (loading: boolean) => void;
|
|
87
98
|
selectEndpoint: (id: string | null) => void;
|
|
88
99
|
setMappings: (items: IntegrationMapping[], total: number) => void;
|
|
100
|
+
/**
|
|
101
|
+
* Put one mapping into the list, whether or not it is already there.
|
|
102
|
+
*
|
|
103
|
+
* The import step looks the selected mapping up in this array, and the list
|
|
104
|
+
* that fills it belongs to the MAPPINGS tab — which is unmounted the moment
|
|
105
|
+
* the import step is shown. Saving a mapping and pressing "Run import" landed
|
|
106
|
+
* on a screen that said "choose a mapping" about the one just saved.
|
|
107
|
+
*/
|
|
108
|
+
upsertMapping: (mapping: IntegrationMapping) => void;
|
|
109
|
+
/** Drop one mapping from the list, for when it has just been deleted. */
|
|
110
|
+
removeMapping: (id: string) => void;
|
|
89
111
|
setMappingsTotal: (total: number) => void;
|
|
90
112
|
setMappingsSearch: (search: string) => void;
|
|
91
113
|
setMappingsLoading: (loading: boolean) => void;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nucleus-core-ts",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.849",
|
|
4
4
|
"description": "Production-ready, enterprise-grade TypeScript framework for building multi-tenant APIs",
|
|
5
5
|
"author": "Hidayet Can Özcan <hidayetcan@gmail.com>",
|
|
6
6
|
"license": "SEE LICENSE IN LICENSE",
|