nucleus-core-ts 0.9.796 → 0.9.798

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.
@@ -4,6 +4,7 @@ import { useEffect, useEffectEvent, useState } from 'react';
4
4
  import { useIntegrationsStore } from '../store';
5
5
  import { integrationsPageTheme } from '../theme';
6
6
  import { EndpointFormFields } from './EndpointFormFields';
7
+ import { formatJsonObject, parseJsonObject } from './jsonObjectField';
7
8
  import { PaginationFields } from './PaginationFields';
8
9
  import { ParamBindingRows } from './ParamBindingRows';
9
10
  import { pathParamNames } from './pathParams';
@@ -30,7 +31,9 @@ export function EndpointForm({ sourceId, endpoint, actions, onSaved, onDeleted,
30
31
  pagination: endpoint?.pagination ?? {
31
32
  kind: 'none'
32
33
  },
33
- paramBindings: endpoint?.paramBindings ?? []
34
+ paramBindings: endpoint?.paramBindings ?? [],
35
+ queryParams: formatJsonObject(endpoint?.queryParams),
36
+ requestBody: formatJsonObject(endpoint?.requestBody)
34
37
  });
35
38
  // Which tables a binding may read a column from. Loaded once; the list is
36
39
  // small and an operator picking a parameter source should not wait for it.
@@ -59,7 +62,18 @@ export function EndpointForm({ sourceId, endpoint, actions, onSaved, onDeleted,
59
62
  const tag = draft.tag.trim();
60
63
  const path = draft.path.trim();
61
64
  const busy = saving || deleting;
62
- const canSave = tag.length > 0 && path.length > 0;
65
+ // Parsed here rather than on save so the message appears under the field
66
+ // being typed in. A body stored as broken text fails at RUN time, against a
67
+ // live service, with an error that names neither this field nor this screen.
68
+ const parsedQuery = parseJsonObject(draft.queryParams);
69
+ const parsedBody = parseJsonObject(draft.requestBody);
70
+ const errors = {
71
+ queryParams: parsedQuery.ok ? undefined : parsedQuery.reason,
72
+ // A GET carries no body, so text left behind in that field cannot block a
73
+ // save it will never be part of.
74
+ requestBody: draft.method === 'GET' || parsedBody.ok ? undefined : parsedBody.reason
75
+ };
76
+ const canSave = tag.length > 0 && path.length > 0 && !errors.queryParams && !errors.requestBody;
63
77
  const handleSubmit = (event)=>{
64
78
  event.preventDefault();
65
79
  if (!canSave || busy) return;
@@ -79,6 +93,10 @@ export function EndpointForm({ sourceId, endpoint, actions, onSaved, onDeleted,
79
93
  responseRootPath: draft.responseRootPath.trim() || null,
80
94
  pagination: draft.pagination,
81
95
  paramBindings: draft.paramBindings,
96
+ queryParams: parsedQuery.ok ? parsedQuery.value : null,
97
+ // A GET's body is dropped rather than stored: the engine never sends one,
98
+ // and a value kept out of sight is a value that surprises someone later.
99
+ requestBody: draft.method === 'GET' ? null : parsedBody.ok ? parsedBody.value : null,
82
100
  enabled: draft.enabled
83
101
  }).then((saved)=>{
84
102
  setSaving(false);
@@ -114,6 +132,7 @@ export function EndpointForm({ sourceId, endpoint, actions, onSaved, onDeleted,
114
132
  /*#__PURE__*/ _jsx(EndpointFormFields, {
115
133
  disabled: busy,
116
134
  draft: draft,
135
+ errors: errors,
117
136
  onChange: set
118
137
  }),
119
138
  /*#__PURE__*/ _jsx(PaginationFields, {
@@ -10,10 +10,19 @@ export type EndpointDraft = {
10
10
  pagination: PaginationProfileValue;
11
11
  /** Where each path/query parameter of this endpoint gets its value. */
12
12
  paramBindings: ParamBindingValue[];
13
+ /** JSON as typed. Parsed on save so a broken object cannot be stored. */
14
+ queryParams: string;
15
+ requestBody: string;
16
+ };
17
+ /** What could not be parsed, keyed the way the draft names it. */
18
+ export type EndpointDraftErrors = {
19
+ queryParams?: string;
20
+ requestBody?: string;
13
21
  };
14
22
  export type EndpointFormFieldsProps = {
15
23
  draft: EndpointDraft;
16
24
  disabled?: boolean;
25
+ errors?: EndpointDraftErrors;
17
26
  onChange: (patch: Partial<EndpointDraft>) => void;
18
27
  };
19
- export declare function EndpointFormFields({ draft, disabled, onChange }: EndpointFormFieldsProps): import("react/jsx-runtime").JSX.Element;
28
+ export declare function EndpointFormFields({ draft, disabled, errors, onChange }: EndpointFormFieldsProps): import("react/jsx-runtime").JSX.Element;
@@ -2,6 +2,7 @@
2
2
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
3
  import { cn } from '../../../utils/cn';
4
4
  import { integrationsPageTheme } from '../theme';
5
+ import { describedBy } from './formSupport';
5
6
  import { Field } from './Primitives';
6
7
  /**
7
8
  * The plain fields of an endpoint.
@@ -16,7 +17,7 @@ const METHODS = [
16
17
  'PATCH',
17
18
  'DELETE'
18
19
  ];
19
- export function EndpointFormFields({ draft, disabled, onChange }) {
20
+ export function EndpointFormFields({ draft, disabled, errors, onChange }) {
20
21
  return /*#__PURE__*/ _jsxs(_Fragment, {
21
22
  children: [
22
23
  /*#__PURE__*/ _jsxs("div", {
@@ -122,6 +123,42 @@ export function EndpointFormFields({ draft, disabled, onChange }) {
122
123
  value: draft.responseRootPath
123
124
  })
124
125
  }),
126
+ /*#__PURE__*/ _jsx(Field, {
127
+ error: errors?.queryParams,
128
+ hint: "Sent with every call, as JSON. A parameter bound below overrides the same name here.",
129
+ htmlFor: "endpoint-query",
130
+ label: "Fixed query parameters",
131
+ children: /*#__PURE__*/ _jsx("textarea", {
132
+ "aria-describedby": describedBy('endpoint-query', true, Boolean(errors?.queryParams)),
133
+ className: cn(theme.field.input, 'h-auto min-h-16 py-2 font-mono text-xs'),
134
+ disabled: disabled,
135
+ id: "endpoint-query",
136
+ onChange: (event)=>onChange({
137
+ queryParams: event.target.value
138
+ }),
139
+ placeholder: '{"state": "ACTIVE"}',
140
+ rows: 3,
141
+ value: draft.queryParams
142
+ })
143
+ }),
144
+ draft.method === 'GET' ? null : /*#__PURE__*/ _jsx(Field, {
145
+ error: errors?.requestBody,
146
+ hint: "The JSON body sent with this call. Some list endpoints are a POST whose body decides what comes back.",
147
+ htmlFor: "endpoint-body",
148
+ label: "Request body",
149
+ children: /*#__PURE__*/ _jsx("textarea", {
150
+ "aria-describedby": describedBy('endpoint-body', true, Boolean(errors?.requestBody)),
151
+ className: cn(theme.field.input, 'h-auto min-h-20 py-2 font-mono text-xs'),
152
+ disabled: disabled,
153
+ id: "endpoint-body",
154
+ onChange: (event)=>onChange({
155
+ requestBody: event.target.value
156
+ }),
157
+ placeholder: '{"employeeTypes": ["A", "S"]}',
158
+ rows: 4,
159
+ value: draft.requestBody
160
+ })
161
+ }),
125
162
  /*#__PURE__*/ _jsx(Field, {
126
163
  htmlFor: "endpoint-description",
127
164
  label: "Description",
@@ -43,9 +43,13 @@ export function RunPlanIssues({ issues }) {
43
43
  className: theme.field.hint,
44
44
  children: `Dedup key: ${issue.dedupKey}`
45
45
  }) : null,
46
+ issue.keptRow ? /*#__PURE__*/ _jsx("span", {
47
+ className: theme.field.hint,
48
+ children: `Kept — ${describeRow(issue.keptRow)}`
49
+ }) : null,
46
50
  issue.row ? /*#__PURE__*/ _jsx("span", {
47
51
  className: theme.field.hint,
48
- children: describeRow(issue.row)
52
+ children: issue.keptRow ? `Skipped — ${describeRow(issue.row)}` : describeRow(issue.row)
49
53
  }) : null
50
54
  ]
51
55
  }, `${issue.sourceIndex}-${issue.kind}-${index}`))
@@ -0,0 +1,27 @@
1
+ /**
2
+ * A JSON object typed into a form field.
3
+ *
4
+ * Query parameters and a request body are `Record<string, unknown>` on the wire
5
+ * — an endpoint may need `{"employeeTypes": ["A", "S"]}`, which no key/value
6
+ * grid of strings can express. So the field takes JSON, and the only question
7
+ * is whether a form is allowed to save what it cannot parse. It is not: a body
8
+ * saved as broken text fails at RUN time, against a live service, with an error
9
+ * from somewhere else entirely.
10
+ */
11
+ export type JsonObjectResult = {
12
+ ok: true;
13
+ value: Record<string, unknown> | null;
14
+ } | {
15
+ ok: false;
16
+ reason: string;
17
+ };
18
+ /**
19
+ * Parses the text of a JSON-object field.
20
+ *
21
+ * Empty is `null` rather than `{}` — "nothing configured" and "an empty object
22
+ * configured" read the same on screen but not in a request, and the column is
23
+ * nullable precisely so the difference survives.
24
+ */
25
+ export declare function parseJsonObject(text: string): JsonObjectResult;
26
+ /** What a stored object looks like back in the field. */
27
+ export declare function formatJsonObject(value: Record<string, unknown> | null | undefined): string;
@@ -0,0 +1,49 @@
1
+ /**
2
+ * A JSON object typed into a form field.
3
+ *
4
+ * Query parameters and a request body are `Record<string, unknown>` on the wire
5
+ * — an endpoint may need `{"employeeTypes": ["A", "S"]}`, which no key/value
6
+ * grid of strings can express. So the field takes JSON, and the only question
7
+ * is whether a form is allowed to save what it cannot parse. It is not: a body
8
+ * saved as broken text fails at RUN time, against a live service, with an error
9
+ * from somewhere else entirely.
10
+ */ /**
11
+ * Parses the text of a JSON-object field.
12
+ *
13
+ * Empty is `null` rather than `{}` — "nothing configured" and "an empty object
14
+ * configured" read the same on screen but not in a request, and the column is
15
+ * nullable precisely so the difference survives.
16
+ */ export function parseJsonObject(text) {
17
+ const trimmed = text.trim();
18
+ if (trimmed === '') return {
19
+ ok: true,
20
+ value: null
21
+ };
22
+ let parsed;
23
+ try {
24
+ parsed = JSON.parse(trimmed);
25
+ } catch (error) {
26
+ return {
27
+ ok: false,
28
+ reason: error instanceof Error ? error.message : 'This is not valid JSON.'
29
+ };
30
+ }
31
+ if (parsed === null) return {
32
+ ok: true,
33
+ value: null
34
+ };
35
+ if (typeof parsed !== 'object' || Array.isArray(parsed)) {
36
+ return {
37
+ ok: false,
38
+ reason: 'Expected an object — {"key": "value"}.'
39
+ };
40
+ }
41
+ return {
42
+ ok: true,
43
+ value: parsed
44
+ };
45
+ }
46
+ /** What a stored object looks like back in the field. */ export function formatJsonObject(value) {
47
+ if (!value || Object.keys(value).length === 0) return '';
48
+ return JSON.stringify(value, null, 2);
49
+ }
@@ -0,0 +1,86 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { formatJsonObject, parseJsonObject } from './jsonObjectField';
3
+ /**
4
+ * The HR service's employee list is a POST whose body selects which employees
5
+ * come back — `{"employeeTypes": ["A", "S"]}`. The engine has always sent a
6
+ * stored body; the panel had no field for one, so that endpoint could be
7
+ * described everywhere except in the one place that decides what it returns.
8
+ */ describe('parseJsonObject', ()=>{
9
+ test('an object comes back as an object', ()=>{
10
+ expect(parseJsonObject('{"employeeTypes": ["A", "S"]}')).toEqual({
11
+ ok: true,
12
+ value: {
13
+ employeeTypes: [
14
+ 'A',
15
+ 'S'
16
+ ]
17
+ }
18
+ });
19
+ });
20
+ test('empty means nothing configured, not an empty object', ()=>{
21
+ expect(parseJsonObject('')).toEqual({
22
+ ok: true,
23
+ value: null
24
+ });
25
+ expect(parseJsonObject(' \n ')).toEqual({
26
+ ok: true,
27
+ value: null
28
+ });
29
+ expect(parseJsonObject('null')).toEqual({
30
+ ok: true,
31
+ value: null
32
+ });
33
+ });
34
+ test('broken JSON is refused here rather than at run time', ()=>{
35
+ const result = parseJsonObject('{"a": }');
36
+ expect(result.ok).toBe(false);
37
+ if (!result.ok) expect(result.reason.length).toBeGreaterThan(0);
38
+ });
39
+ test('an array or a scalar is not a body — both are refused', ()=>{
40
+ expect(parseJsonObject('[1, 2]').ok).toBe(false);
41
+ expect(parseJsonObject('"text"').ok).toBe(false);
42
+ expect(parseJsonObject('42').ok).toBe(false);
43
+ });
44
+ test('nested values survive, which is why this is JSON and not a key/value grid', ()=>{
45
+ const result = parseJsonObject('{"filter": {"state": ["ACTIVE"], "limit": 50}}');
46
+ expect(result).toEqual({
47
+ ok: true,
48
+ value: {
49
+ filter: {
50
+ state: [
51
+ 'ACTIVE'
52
+ ],
53
+ limit: 50
54
+ }
55
+ }
56
+ });
57
+ });
58
+ });
59
+ describe('formatJsonObject', ()=>{
60
+ test('a stored object comes back readable', ()=>{
61
+ expect(formatJsonObject({
62
+ employeeTypes: [
63
+ 'A'
64
+ ]
65
+ })).toBe('{\n "employeeTypes": [\n "A"\n ]\n}');
66
+ });
67
+ test('nothing stored shows an empty field, not the word null or a bare {}', ()=>{
68
+ expect(formatJsonObject(null)).toBe('');
69
+ expect(formatJsonObject(undefined)).toBe('');
70
+ expect(formatJsonObject({})).toBe('');
71
+ });
72
+ test('a round trip through the form changes nothing', ()=>{
73
+ const original = {
74
+ employeeTypes: [
75
+ 'A',
76
+ 'S'
77
+ ],
78
+ employeeNo: '101307'
79
+ };
80
+ const result = parseJsonObject(formatJsonObject(original));
81
+ expect(result).toEqual({
82
+ ok: true,
83
+ value: original
84
+ });
85
+ });
86
+ });
@@ -16,6 +16,8 @@ export type RunIssueValue = {
16
16
  * narrow and rows are being lost. The values are the difference.
17
17
  */
18
18
  row?: Record<string, unknown>;
19
+ /** The record that claimed this key first — what the skipped one lost to. */
20
+ keptRow?: Record<string, unknown>;
19
21
  };
20
22
  /** What a run would do, computed without writing. */
21
23
  export type RunPlanValue = {