nucleus-core-ts 0.9.846 → 0.9.848

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.
@@ -25,6 +25,7 @@ export function ConnectionDelete({ actions, source, onDeleted }) {
25
25
  }
26
26
  setAsking(false);
27
27
  setTyped('');
28
+ store.removeSource(source.id);
28
29
  store.selectSource(null);
29
30
  onDeleted?.();
30
31
  }).catch((error)=>{
@@ -5,7 +5,8 @@ export type EndpointFormProps = {
5
5
  endpoint: IntegrationEndpoint | null;
6
6
  actions: IntegrationsActions;
7
7
  onSaved: (endpoint: IntegrationEndpoint) => void;
8
- onDeleted: () => void;
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
  };
@@ -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
  };
@@ -43,7 +43,12 @@ export function IntegrationsPage({ title = 'Integrations', subtitle = 'Connect a
43
43
  * detail treated the missing record as still loading and sat on skeletons
44
44
  * until the page was reloaded by hand. The write itself had succeeded.
45
45
  */ const [sourcesReloadToken, setSourcesReloadToken] = useState(0);
46
- const handleSourceSaved = ()=>{
46
+ const handleSourceSaved = (saved)=>{
47
+ // Into the store FIRST. Bumping the token only tells the rail to refetch,
48
+ // and choosing a connection unmounts the rail — so on a NEW connection the
49
+ // token reached nobody and the detail below could not find the record it
50
+ // had just been switched to.
51
+ store.upsertSource(saved);
47
52
  setSourcesReloadToken((token)=>token + 1);
48
53
  };
49
54
  /**
@@ -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)=>{
@@ -1,12 +1,28 @@
1
1
  'use client';
2
2
  import { batch, createStore } from 'h-state';
3
3
  import { initial } from './state';
4
+ import { isNewTo, upsertInto } from './upsertInto';
4
5
  export const { useStore: useIntegrationsStore } = createStore(initial, {
5
6
  setSources: (store)=>(items, total)=>batch(()=>{
6
7
  store.sources = items;
7
8
  store.sourcesTotal = total;
8
9
  store.sourcesLoading = false;
9
10
  }),
11
+ upsertSource: (store)=>(source)=>batch(()=>{
12
+ // The count moves only for an addition — an edit that bumped the total
13
+ // would make the rail claim a connection nobody can find.
14
+ if (isNewTo(store.sources, source)) store.sourcesTotal = store.sourcesTotal + 1;
15
+ store.sources = upsertInto(store.sources, source);
16
+ }),
17
+ removeSource: (store)=>(id)=>batch(()=>{
18
+ const kept = store.sources.filter((candidate)=>candidate.id !== id);
19
+ // Only when it was actually on this page — the count must not fall for a
20
+ // record the list never held.
21
+ if (kept.length !== store.sources.length) {
22
+ store.sourcesTotal = Math.max(0, store.sourcesTotal - 1);
23
+ store.sources = kept;
24
+ }
25
+ }),
10
26
  setSourcesSearch: (store)=>(search)=>batch(()=>{
11
27
  store.sourcesSearch = search;
12
28
  // A new search is a new result set; staying on page 4 would show an empty
@@ -50,6 +66,17 @@ export const { useStore: useIntegrationsStore } = createStore(initial, {
50
66
  store.endpointsTotal = total;
51
67
  store.endpointsLoading = false;
52
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
+ }),
53
80
  setEndpointsSearch: (store)=>(search)=>batch(()=>{
54
81
  store.endpointsSearch = search;
55
82
  store.endpointsPage = 1;
@@ -68,6 +95,17 @@ export const { useStore: useIntegrationsStore } = createStore(initial, {
68
95
  store.mappingsTotal = total;
69
96
  store.mappingsLoading = false;
70
97
  }),
98
+ upsertMapping: (store)=>(mapping)=>batch(()=>{
99
+ if (isNewTo(store.mappings, mapping)) store.mappingsTotal = store.mappingsTotal + 1;
100
+ store.mappings = upsertInto(store.mappings, mapping);
101
+ }),
102
+ removeMapping: (store)=>(id)=>batch(()=>{
103
+ const kept = store.mappings.filter((candidate)=>candidate.id !== id);
104
+ if (kept.length !== store.mappings.length) {
105
+ store.mappingsTotal = Math.max(0, store.mappingsTotal - 1);
106
+ store.mappings = kept;
107
+ }
108
+ }),
71
109
  setMappingsTotal: (store)=>(total)=>{
72
110
  store.mappingsTotal = total;
73
111
  },
@@ -58,12 +58,35 @@ export type State = {
58
58
  export type Actions = {
59
59
  [key: string]: unknown;
60
60
  setSources: (items: IntegrationSource[], total: number) => void;
61
+ /**
62
+ * Put one connection into the list, whether or not it is already there.
63
+ *
64
+ * The rail is what fetches connections, and choosing one UNMOUNTS the rail —
65
+ * so a connection created from the form was selected into a list that had
66
+ * never heard of it, and every panel below sat on skeletons until the page
67
+ * was reloaded by hand. The save already returns the record; this is where it
68
+ * goes so the detail can find it.
69
+ */
70
+ upsertSource: (source: IntegrationSource) => void;
71
+ /** Drop one connection from the list, for when it has just been deleted. */
72
+ removeSource: (id: string) => void;
61
73
  setSourcesSearch: (search: string) => void;
62
74
  setSourcesLoading: (loading: boolean) => void;
63
75
  selectSource: (id: string | null) => void;
64
76
  setTab: (tab: ConnectionTab) => void;
65
77
  setFocusTargetEntity: (entity: string | null, step?: 'mapping' | 'import') => void;
66
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;
67
90
  /**
68
91
  * Just the count, without the rows.
69
92
  *
@@ -74,6 +97,17 @@ export type Actions = {
74
97
  setEndpointsLoading: (loading: boolean) => void;
75
98
  selectEndpoint: (id: string | null) => void;
76
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;
77
111
  setMappingsTotal: (total: number) => void;
78
112
  setMappingsSearch: (search: string) => void;
79
113
  setMappingsLoading: (loading: boolean) => void;
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Where a saved record goes in a list that may or may not already hold it.
3
+ *
4
+ * Kept as a function so the rule can be tested apart from the store: the panel
5
+ * unmounts the connection rail the moment a connection is chosen, so a newly
6
+ * created connection is selected into a list that never fetched it. Putting the
7
+ * returned record in by hand is what stops the detail below from waiting for a
8
+ * fetch that no mounted component will make.
9
+ *
10
+ * A new record goes to the FRONT — it is the one just made, and the operator is
11
+ * about to be switched to it; an existing one is replaced where it already sits,
12
+ * so an edit never reorders a list somebody is reading.
13
+ */
14
+ export declare function upsertInto<T extends {
15
+ id: string;
16
+ }>(items: T[], record: T): T[];
17
+ /** Whether `record` would be an addition rather than a replacement. */
18
+ export declare function isNewTo<T extends {
19
+ id: string;
20
+ }>(items: T[], record: T): boolean;
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Where a saved record goes in a list that may or may not already hold it.
3
+ *
4
+ * Kept as a function so the rule can be tested apart from the store: the panel
5
+ * unmounts the connection rail the moment a connection is chosen, so a newly
6
+ * created connection is selected into a list that never fetched it. Putting the
7
+ * returned record in by hand is what stops the detail below from waiting for a
8
+ * fetch that no mounted component will make.
9
+ *
10
+ * A new record goes to the FRONT — it is the one just made, and the operator is
11
+ * about to be switched to it; an existing one is replaced where it already sits,
12
+ * so an edit never reorders a list somebody is reading.
13
+ */ export function upsertInto(items, record) {
14
+ return items.some((candidate)=>candidate.id === record.id) ? items.map((candidate)=>candidate.id === record.id ? record : candidate) : [
15
+ record,
16
+ ...items
17
+ ];
18
+ }
19
+ /** Whether `record` would be an addition rather than a replacement. */ export function isNewTo(items, record) {
20
+ return !items.some((candidate)=>candidate.id === record.id);
21
+ }
@@ -0,0 +1,67 @@
1
+ import { describe, expect, it } from 'bun:test';
2
+ import { isNewTo, upsertInto } from './upsertInto';
3
+ const a = {
4
+ id: 'a',
5
+ name: 'First'
6
+ };
7
+ const b = {
8
+ id: 'b',
9
+ name: 'Second'
10
+ };
11
+ describe('upsertInto', ()=>{
12
+ it('puts a new record at the front', ()=>{
13
+ expect(upsertInto([
14
+ a
15
+ ], b)).toEqual([
16
+ b,
17
+ a
18
+ ]);
19
+ });
20
+ it('adds to an empty list', ()=>{
21
+ expect(upsertInto([], a)).toEqual([
22
+ a
23
+ ]);
24
+ });
25
+ it('replaces an existing record where it already sits', ()=>{
26
+ const renamed = {
27
+ id: 'a',
28
+ name: 'Renamed'
29
+ };
30
+ expect(upsertInto([
31
+ b,
32
+ a
33
+ ], renamed)).toEqual([
34
+ b,
35
+ renamed
36
+ ]);
37
+ });
38
+ it('never duplicates — saving the same record twice leaves one', ()=>{
39
+ const once = upsertInto([], a);
40
+ expect(upsertInto(once, {
41
+ id: 'a',
42
+ name: 'Again'
43
+ })).toHaveLength(1);
44
+ });
45
+ it('leaves the original array untouched', ()=>{
46
+ const items = [
47
+ a
48
+ ];
49
+ upsertInto(items, b);
50
+ expect(items).toEqual([
51
+ a
52
+ ]);
53
+ });
54
+ });
55
+ describe('isNewTo', ()=>{
56
+ it('is true only for a record the list does not hold', ()=>{
57
+ expect(isNewTo([
58
+ a
59
+ ], b)).toBe(true);
60
+ expect(isNewTo([
61
+ a
62
+ ], {
63
+ id: 'a',
64
+ name: 'Renamed'
65
+ })).toBe(false);
66
+ });
67
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nucleus-core-ts",
3
- "version": "0.9.846",
3
+ "version": "0.9.848",
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",