@rebasepro/plugin-ai 0.13.0 → 0.13.1-canary.g06dbe5b
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/README.md +6 -2
- package/dist/api.d.ts +19 -0
- package/dist/components/DataEnhancementControllerProvider.d.ts +8 -1
- package/dist/index.es.js +114 -26
- package/dist/index.es.js.map +1 -1
- package/dist/utils/values.d.ts +33 -1
- package/package.json +8 -8
- package/src/api.ts +56 -1
- package/src/components/DataEnhancementControllerProvider.tsx +50 -14
- package/src/components/FormEnhanceAction.tsx +8 -5
- package/src/tests/api.test.ts +101 -2
- package/src/tests/request_agreement.test.ts +240 -0
- package/src/tests/review.test.tsx +211 -8
- package/src/utils/values.ts +63 -3
|
@@ -7,11 +7,12 @@ import {
|
|
|
7
7
|
InputProperty,
|
|
8
8
|
ProposedField
|
|
9
9
|
} from "../types/data_enhancement_controller";
|
|
10
|
-
import { CollectionConfig } from "@rebasepro/types";
|
|
10
|
+
import { CollectionConfig, User } from "@rebasepro/types";
|
|
11
11
|
import { PluginFormActionProps } from "@rebasepro/admin-types";
|
|
12
|
-
import {
|
|
12
|
+
import { useAuthController } from "@rebasepro/app";
|
|
13
|
+
import { autofillStream, fetchAiStatusCached, fetchPromptSuggestions } from "../api";
|
|
13
14
|
import { getSimplifiedProperties } from "../utils/properties";
|
|
14
|
-
import { flatMapEntityValues } from "../utils/values";
|
|
15
|
+
import { flatMapEntityValues, omitDisabledValues } from "../utils/values";
|
|
15
16
|
import { useEditorAIController } from "../editor/useEditorAIController";
|
|
16
17
|
import { getValueInPath } from "@rebasepro/utils";
|
|
17
18
|
|
|
@@ -19,9 +20,16 @@ const DataEnhancementControllerContext = React.createContext<DataEnhancementCont
|
|
|
19
20
|
|
|
20
21
|
type DataEnhancementControllerProviderProps = {
|
|
21
22
|
|
|
23
|
+
/**
|
|
24
|
+
* Kept in step with `DataEnhancementPluginProps.getConfigForPath`, which is
|
|
25
|
+
* the signature the host app actually writes against: the plugin hands this
|
|
26
|
+
* component through as `ComponentType<any>`, so nothing but agreement here
|
|
27
|
+
* makes the two match.
|
|
28
|
+
*/
|
|
22
29
|
getConfigForPath?: (props: {
|
|
23
30
|
path: string,
|
|
24
|
-
collection: CollectionConfig
|
|
31
|
+
collection: CollectionConfig,
|
|
32
|
+
user: User | null
|
|
25
33
|
}) => boolean;
|
|
26
34
|
|
|
27
35
|
endpoint?: string;
|
|
@@ -84,15 +92,26 @@ export function DataEnhancementControllerProvider({
|
|
|
84
92
|
const propertiesRef = useRef(properties);
|
|
85
93
|
propertiesRef.current = properties;
|
|
86
94
|
|
|
87
|
-
/**
|
|
95
|
+
/**
|
|
96
|
+
* The host app's own opt-out.
|
|
97
|
+
*
|
|
98
|
+
* `user` is part of the documented signature and was never passed, so
|
|
99
|
+
* `getConfigForPath: ({ user }) => user?.roles?.includes("editor")` was
|
|
100
|
+
* `Boolean(undefined)` for everyone — an access rule that silently decided
|
|
101
|
+
* nothing, in whichever direction the host had written it.
|
|
102
|
+
*/
|
|
103
|
+
const authController = useAuthController();
|
|
104
|
+
const user: User | null = authController?.user ?? null;
|
|
105
|
+
|
|
88
106
|
useEffect(() => {
|
|
89
107
|
if (!getConfigForPath) {
|
|
90
108
|
setAllowedHere(true);
|
|
91
109
|
return;
|
|
92
110
|
}
|
|
93
111
|
setAllowedHere(Boolean(getConfigForPath({ path,
|
|
94
|
-
collection
|
|
95
|
-
|
|
112
|
+
collection,
|
|
113
|
+
user })));
|
|
114
|
+
}, [getConfigForPath, path, collection, user]);
|
|
96
115
|
|
|
97
116
|
/**
|
|
98
117
|
* The service's own availability.
|
|
@@ -101,15 +120,23 @@ collection })));
|
|
|
101
120
|
* unconfigured provider key or an exhausted daily quota all land here, and
|
|
102
121
|
* all of them mean the same thing to the operator: no Autofill button,
|
|
103
122
|
* rather than a button that fails when clicked.
|
|
123
|
+
*
|
|
124
|
+
* Asked through the session cache: this provider is form-scoped, so an
|
|
125
|
+
* uncached probe is one request to the host per record opened, by an install
|
|
126
|
+
* that may never use the feature. The probe is shared rather than aborted on
|
|
127
|
+
* unmount — cancelling it would cancel it for whatever else is waiting on the
|
|
128
|
+
* same answer — so unmounting only stops this component from reading it.
|
|
104
129
|
*/
|
|
105
130
|
useEffect(() => {
|
|
106
131
|
if (!allowedHere) return;
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
return () =>
|
|
132
|
+
let cancelled = false;
|
|
133
|
+
fetchAiStatusCached({ endpoint })
|
|
134
|
+
.then((status) => {
|
|
135
|
+
if (!cancelled) setServiceAvailable(status.available);
|
|
136
|
+
});
|
|
137
|
+
return () => {
|
|
138
|
+
cancelled = true;
|
|
139
|
+
};
|
|
113
140
|
}, [allowedHere, endpoint]);
|
|
114
141
|
|
|
115
142
|
const enabled = allowedHere && serviceAvailable;
|
|
@@ -131,7 +158,10 @@ fields };
|
|
|
131
158
|
const generate = useCallback(async (params: GenerateParams<Record<string, unknown>>): Promise<void> => {
|
|
132
159
|
|
|
133
160
|
const currentProperties = propertiesRef.current;
|
|
134
|
-
const flatValues =
|
|
161
|
+
const flatValues = omitDisabledValues(
|
|
162
|
+
flatMapEntityValues(params.values ?? {}),
|
|
163
|
+
currentProperties
|
|
164
|
+
);
|
|
135
165
|
|
|
136
166
|
setReview({
|
|
137
167
|
status: "generating",
|
|
@@ -150,6 +180,12 @@ fields };
|
|
|
150
180
|
// Flattened to dotted paths so the keys line up with the
|
|
151
181
|
// property map: the service is told about `seo.title`, so it
|
|
152
182
|
// has to be told the value of `seo.title` too, not of `seo`.
|
|
183
|
+
// Exactly the same rule in both directions — an array or a
|
|
184
|
+
// date is one value under one key here because it is one
|
|
185
|
+
// property under one key there. Where they disagreed, the
|
|
186
|
+
// service read a filled field as empty and offered to
|
|
187
|
+
// rewrite it. Values of properties nobody may edit do not
|
|
188
|
+
// travel at all.
|
|
153
189
|
values: flatValues,
|
|
154
190
|
properties: currentProperties,
|
|
155
191
|
propertyKey: params.propertyKey,
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import React, { useCallback, useEffect, useRef } from "react";
|
|
2
2
|
|
|
3
3
|
import {
|
|
4
|
-
Button,
|
|
5
4
|
CircularProgress,
|
|
6
5
|
cls,
|
|
7
6
|
fieldBackgroundMixin,
|
|
@@ -117,14 +116,18 @@ export function FormEnhanceAction({
|
|
|
117
116
|
// Never full width: this used to stretch to fill the form's
|
|
118
117
|
// `w-80 2xl:w-96` side rail in full screen. That rail is gone, and
|
|
119
118
|
// in the footer a stretched button reads as the primary action.
|
|
120
|
-
|
|
121
|
-
|
|
119
|
+
// Icon only. The label is carried by `aria-label`/`title` — a
|
|
120
|
+
// `Tooltip` here would swallow the menu: both it and
|
|
121
|
+
// `DropdownMenu.Trigger` render `asChild`, and `Tooltip` drops
|
|
122
|
+
// the props Radix clones onto it, so the menu never opens.
|
|
123
|
+
trigger={<IconButton variant={"filled"}
|
|
122
124
|
size={"small"}
|
|
125
|
+
aria-label={"Autofill"}
|
|
126
|
+
title={"Autofill"}
|
|
123
127
|
disabled={loading}>
|
|
124
128
|
{!loading && <AIIcon size={"small"}/>}
|
|
125
129
|
{loading && <CircularProgress size={"small"}/>}
|
|
126
|
-
|
|
127
|
-
</Button>}>
|
|
130
|
+
</IconButton>}>
|
|
128
131
|
|
|
129
132
|
<MenuItem className={"py-4"}
|
|
130
133
|
onClick={() => {
|
package/src/tests/api.test.ts
CHANGED
|
@@ -6,7 +6,9 @@ import {
|
|
|
6
6
|
DEFAULT_AI_ENDPOINT,
|
|
7
7
|
autocompleteStream,
|
|
8
8
|
autofillStream,
|
|
9
|
+
clearAiStatusCache,
|
|
9
10
|
fetchAiStatus,
|
|
11
|
+
fetchAiStatusCached,
|
|
10
12
|
fetchPromptSuggestions
|
|
11
13
|
} from "../api";
|
|
12
14
|
import { AutofillRequest } from "../types/data_enhancement_controller";
|
|
@@ -144,7 +146,8 @@ stock: 42 });
|
|
|
144
146
|
});
|
|
145
147
|
|
|
146
148
|
it("joins a multi-line data field", async () => {
|
|
147
|
-
const body = 'event: suggestion\ndata: {"key":"body",\ndata: "value":"two lines"}\n\n'
|
|
149
|
+
const body = 'event: suggestion\ndata: {"key":"body",\ndata: "value":"two lines"}\n\n'
|
|
150
|
+
+ "event: done\ndata: {}\n\n";
|
|
148
151
|
const { values } = await runAutofill([body]);
|
|
149
152
|
expect(values).toEqual([["body", "two lines"]]);
|
|
150
153
|
});
|
|
@@ -185,6 +188,58 @@ onValue: () => undefined })
|
|
|
185
188
|
).rejects.toThrow(/quota for today/);
|
|
186
189
|
});
|
|
187
190
|
|
|
191
|
+
it("fails a stream that ends without a `done` record", async () => {
|
|
192
|
+
// A rolled pod, a proxy timeout, a dropped connection. The body simply
|
|
193
|
+
// stops. Returning `{ suggestions: {} }` for that is indistinguishable
|
|
194
|
+
// from the service saying there was nothing to fill — and the operator
|
|
195
|
+
// is then told, in a confident sentence, that their empty fields are
|
|
196
|
+
// fields the model would not improve on.
|
|
197
|
+
const truncated = [
|
|
198
|
+
"event: suggestion",
|
|
199
|
+
'data: {"key":"title","value":"Blue widget"}',
|
|
200
|
+
"",
|
|
201
|
+
"event: suggestion_delta",
|
|
202
|
+
'data: {"key":"summary","text":"half a sen'
|
|
203
|
+
].join("\n");
|
|
204
|
+
(global as any).fetch = jest.fn().mockResolvedValue(streamingResponse([truncated]));
|
|
205
|
+
|
|
206
|
+
const values: [string, unknown][] = [];
|
|
207
|
+
await expect(autofillStream({
|
|
208
|
+
request: REQUEST,
|
|
209
|
+
onDelta: () => undefined,
|
|
210
|
+
onValue: (k, v) => values.push([k, v])
|
|
211
|
+
})).rejects.toThrow(/ended before it finished/);
|
|
212
|
+
|
|
213
|
+
// Whatever did arrive was still delivered — the caller keeps the fields
|
|
214
|
+
// that completed and reports the run as failed.
|
|
215
|
+
expect(values).toEqual([["title", "Blue widget"]]);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it("fails a stream whose every record was unreadable", async () => {
|
|
219
|
+
// Zero good fields plus n discarded records is not "nothing to fill".
|
|
220
|
+
const garbage = "event: suggestion\ndata: {not json\n\nevent: suggestion\ndata: {also not\n\n";
|
|
221
|
+
(global as any).fetch = jest.fn().mockResolvedValue(streamingResponse([garbage]));
|
|
222
|
+
await expect(
|
|
223
|
+
autofillStream({ request: REQUEST,
|
|
224
|
+
onDelta: () => undefined,
|
|
225
|
+
onValue: () => undefined })
|
|
226
|
+
).rejects.toThrow(/could not be read|ended before it finished/);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it("accepts the empty run the service sends when there is nothing to fill", async () => {
|
|
230
|
+
// That case is a `done` with no suggestions, not an empty body, so it
|
|
231
|
+
// must stay distinguishable from a truncation.
|
|
232
|
+
(global as any).fetch = jest.fn().mockResolvedValue(
|
|
233
|
+
streamingResponse(['event: done\ndata: {"suggestions":{},"usage":{}}\n\n'])
|
|
234
|
+
);
|
|
235
|
+
await expect(
|
|
236
|
+
autofillStream({ request: REQUEST,
|
|
237
|
+
onDelta: () => undefined,
|
|
238
|
+
onValue: () => undefined })
|
|
239
|
+
).resolves.toEqual({ suggestions: {},
|
|
240
|
+
usage: {} });
|
|
241
|
+
});
|
|
242
|
+
|
|
188
243
|
it("sends no credentials of any kind", async () => {
|
|
189
244
|
// The FireCMS-era client sent the tenant's JWT as `Authorization: Basic`
|
|
190
245
|
// plus a hardcoded `fcms-…` key. No external service could verify the
|
|
@@ -200,7 +255,10 @@ onValue: () => undefined });
|
|
|
200
255
|
});
|
|
201
256
|
|
|
202
257
|
it("posts to the hosted endpoint by default and to an override when given", async () => {
|
|
203
|
-
|
|
258
|
+
// A fresh response per call: one `streamingResponse` is a single reader,
|
|
259
|
+
// and handing the same exhausted one to the second call makes it read an
|
|
260
|
+
// empty body — which is now, correctly, a truncated stream.
|
|
261
|
+
const fetchMock = jest.fn().mockImplementation(async () => streamingResponse([BODY]));
|
|
204
262
|
(global as any).fetch = fetchMock;
|
|
205
263
|
|
|
206
264
|
await autofillStream({ request: REQUEST,
|
|
@@ -263,6 +321,47 @@ features: ["autofill"] })
|
|
|
263
321
|
});
|
|
264
322
|
});
|
|
265
323
|
|
|
324
|
+
describe("fetchAiStatusCached", () => {
|
|
325
|
+
|
|
326
|
+
beforeEach(() => clearAiStatusCache());
|
|
327
|
+
afterEach(() => clearAiStatusCache());
|
|
328
|
+
|
|
329
|
+
it("asks the host once per endpoint, however many callers there are", async () => {
|
|
330
|
+
// The provider is form-scoped, so an uncached probe is one request to
|
|
331
|
+
// the host every time any record is opened — a beacon from an install
|
|
332
|
+
// that may never use the feature, and enough traffic from one office
|
|
333
|
+
// behind one address to spend the host's per-IP limit on nothing.
|
|
334
|
+
const fetchMock = jest.fn().mockResolvedValue(jsonResponse({ available: true }));
|
|
335
|
+
(global as any).fetch = fetchMock;
|
|
336
|
+
|
|
337
|
+
const answers = await Promise.all([
|
|
338
|
+
fetchAiStatusCached({}),
|
|
339
|
+
fetchAiStatusCached({}),
|
|
340
|
+
fetchAiStatusCached({})
|
|
341
|
+
]);
|
|
342
|
+
await fetchAiStatusCached({});
|
|
343
|
+
|
|
344
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
345
|
+
expect(answers.every(a => a.available)).toBe(true);
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
it("keeps one answer per endpoint", async () => {
|
|
349
|
+
const fetchMock = jest.fn().mockResolvedValue(jsonResponse({ available: true }));
|
|
350
|
+
(global as any).fetch = fetchMock;
|
|
351
|
+
await fetchAiStatusCached({});
|
|
352
|
+
await fetchAiStatusCached({ endpoint: "https://ai.example.com" });
|
|
353
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
it("answers unavailable, once, when the host cannot be reached", async () => {
|
|
357
|
+
const fetchMock = jest.fn().mockRejectedValue(new Error("offline"));
|
|
358
|
+
(global as any).fetch = fetchMock;
|
|
359
|
+
await expect(fetchAiStatusCached({})).resolves.toEqual({ available: false });
|
|
360
|
+
await expect(fetchAiStatusCached({})).resolves.toEqual({ available: false });
|
|
361
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
362
|
+
});
|
|
363
|
+
});
|
|
364
|
+
|
|
266
365
|
describe("fetchPromptSuggestions", () => {
|
|
267
366
|
it("maps the service's prompts", async () => {
|
|
268
367
|
(global as any).fetch = jest.fn().mockResolvedValue(jsonResponse({ prompts: ["A blue widget", "A red one"] }));
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import { Properties } from "@rebasepro/types";
|
|
2
|
+
import { getValueInPath } from "@rebasepro/utils";
|
|
3
|
+
|
|
4
|
+
import { getSimplifiedProperties } from "../utils/properties";
|
|
5
|
+
import { flatMapEntityValues, omitDisabledValues } from "../utils/values";
|
|
6
|
+
import { InputProperty } from "../types/data_enhancement_controller";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The two halves of an autofill request have to be keyed the same way.
|
|
10
|
+
*
|
|
11
|
+
* "Autofill only fills blanks" is not enforced anywhere in this package. It is
|
|
12
|
+
* enforced on the service, by omitting already-filled fields from the JSON
|
|
13
|
+
* schema the model answers into — and the service decides a field is filled by
|
|
14
|
+
* looking up `values[key]` for each `key` of `properties`. So the guard is only
|
|
15
|
+
* as true as the agreement between the two maps this file checks.
|
|
16
|
+
*
|
|
17
|
+
* That is why these tests build *both* maps from one record and then replay the
|
|
18
|
+
* service's own decision over them, rather than testing either map alone. Every
|
|
19
|
+
* assertion here passed against a client that flattened `tags: ["a","b"]` into
|
|
20
|
+
* `tags.0`/`tags.1` and dropped `Date` values entirely — as long as it was the
|
|
21
|
+
* flattener, or the property map, that was looked at in isolation.
|
|
22
|
+
*
|
|
23
|
+
* `getFieldId` is mocked deterministically; the real one resolves against the
|
|
24
|
+
* admin field registry, which is not what is under test.
|
|
25
|
+
*/
|
|
26
|
+
jest.mock("@rebasepro/admin", () => ({
|
|
27
|
+
getFieldId: (property: { type?: string }) => {
|
|
28
|
+
if (!property?.type) return undefined;
|
|
29
|
+
if (property.type === "string") return "text_field";
|
|
30
|
+
if (property.type === "number") return "number_field";
|
|
31
|
+
if (property.type === "boolean") return "switch";
|
|
32
|
+
if (property.type === "date") return "date_time";
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
}));
|
|
36
|
+
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
// The service's side of the contract, reproduced.
|
|
39
|
+
//
|
|
40
|
+
// Copied from `saas/backend/functions/ai.ts` (`alreadyFilled`, `scalarSchema`,
|
|
41
|
+
// `propertySchema`, `planFill`). It lives in another repository and cannot be
|
|
42
|
+
// imported, so it is mirrored — and mirrored *including* the JSON round trip,
|
|
43
|
+
// because the difference between a `Date` object and the ISO string a `Date`
|
|
44
|
+
// serialises to is exactly the difference between "this field is empty" and
|
|
45
|
+
// "this field is filled" over there.
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
function alreadyFilled(value: unknown): boolean {
|
|
49
|
+
if (value === null || value === undefined) return false;
|
|
50
|
+
if (typeof value === "string") return value.trim().length > 0;
|
|
51
|
+
if (Array.isArray(value)) return value.length > 0;
|
|
52
|
+
if (typeof value === "object") return Object.keys(value as object).length > 0;
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function scalarSchema(property: InputProperty): object | undefined {
|
|
57
|
+
if (property.enum && property.enum.length > 0) return { type: "string",
|
|
58
|
+
enum: property.enum };
|
|
59
|
+
switch (property.type) {
|
|
60
|
+
case "string":
|
|
61
|
+
return { type: "string" };
|
|
62
|
+
case "number":
|
|
63
|
+
return { type: "number" };
|
|
64
|
+
case "boolean":
|
|
65
|
+
return { type: "boolean" };
|
|
66
|
+
case "date":
|
|
67
|
+
return { type: "string",
|
|
68
|
+
format: "date-time" };
|
|
69
|
+
default:
|
|
70
|
+
return undefined;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function propertySchema(property: InputProperty): object | undefined {
|
|
75
|
+
if (property.disabled) return undefined;
|
|
76
|
+
if (property.type === "array") {
|
|
77
|
+
const items = property.of ? scalarSchema(property.of) : undefined;
|
|
78
|
+
return items ? { type: "array",
|
|
79
|
+
items } : undefined;
|
|
80
|
+
}
|
|
81
|
+
return scalarSchema(property);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** The keys the service would offer the model, given one request body. */
|
|
85
|
+
function plannedFillKeys(request: { properties: Record<string, InputProperty>, values: Record<string, unknown> }): string[] {
|
|
86
|
+
// As the service receives it: JSON, not the client's live objects.
|
|
87
|
+
const values = JSON.parse(JSON.stringify(request.values)) as Record<string, unknown>;
|
|
88
|
+
return Object.entries(request.properties)
|
|
89
|
+
.filter(([key, property]) => {
|
|
90
|
+
if (!property || typeof property !== "object") return false;
|
|
91
|
+
if (alreadyFilled(values[key])) return false;
|
|
92
|
+
return Boolean(propertySchema(property));
|
|
93
|
+
})
|
|
94
|
+
.map(([key]) => key);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** What the client puts on the wire, built the way the provider builds it. */
|
|
98
|
+
function requestFor(properties: Properties, record: Record<string, unknown>) {
|
|
99
|
+
const simplified = getSimplifiedProperties(properties, record);
|
|
100
|
+
return {
|
|
101
|
+
properties: simplified,
|
|
102
|
+
values: omitDisabledValues(flatMapEntityValues(record), simplified)
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const POST_PROPERTIES: Properties = {
|
|
107
|
+
title: { name: "Title",
|
|
108
|
+
type: "string" },
|
|
109
|
+
summary: { name: "Summary",
|
|
110
|
+
type: "string" },
|
|
111
|
+
tags: {
|
|
112
|
+
name: "Tags",
|
|
113
|
+
type: "array",
|
|
114
|
+
of: { name: "Tag",
|
|
115
|
+
type: "string" }
|
|
116
|
+
},
|
|
117
|
+
published_at: { name: "Published at",
|
|
118
|
+
type: "date" },
|
|
119
|
+
seo: {
|
|
120
|
+
name: "SEO",
|
|
121
|
+
type: "map",
|
|
122
|
+
properties: {
|
|
123
|
+
title: { name: "SEO title",
|
|
124
|
+
type: "string" },
|
|
125
|
+
description: { name: "SEO description",
|
|
126
|
+
type: "string" }
|
|
127
|
+
}
|
|
128
|
+
},
|
|
129
|
+
internal_notes: {
|
|
130
|
+
name: "Internal notes",
|
|
131
|
+
type: "string",
|
|
132
|
+
admin: { readOnly: true }
|
|
133
|
+
}
|
|
134
|
+
} as Properties;
|
|
135
|
+
|
|
136
|
+
/** A published post: five tags, a publication date, one genuinely empty field. */
|
|
137
|
+
const PUBLISHED_POST = {
|
|
138
|
+
title: "Hello",
|
|
139
|
+
tags: ["news", "launch"],
|
|
140
|
+
published_at: new Date("2026-01-01T00:00:00.000Z"),
|
|
141
|
+
seo: { title: "Hello — SEO" },
|
|
142
|
+
internal_notes: "written by a backend hook"
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
describe("the request the client sends", () => {
|
|
146
|
+
|
|
147
|
+
it("keys an array and a date exactly as the property map names them", () => {
|
|
148
|
+
const { properties, values } = requestFor(POST_PROPERTIES, PUBLISHED_POST);
|
|
149
|
+
|
|
150
|
+
expect(Object.keys(properties)).toEqual(expect.arrayContaining(["tags", "published_at"]));
|
|
151
|
+
expect(values.tags).toEqual(["news", "launch"]);
|
|
152
|
+
expect(values.published_at).toEqual(new Date("2026-01-01T00:00:00.000Z"));
|
|
153
|
+
// The whole key set, because the old shapes — `tags.0`, `tags.1`, and no
|
|
154
|
+
// `published_at` at all — are absences a per-key assertion would miss.
|
|
155
|
+
expect(Object.keys(values).sort()).toEqual(["published_at", "seo.title", "tags", "title"]);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("still descends into a map, which is the one container with its own fields", () => {
|
|
159
|
+
const { properties, values } = requestFor(POST_PROPERTIES, PUBLISHED_POST);
|
|
160
|
+
expect(Object.keys(properties)).toContain("seo.title");
|
|
161
|
+
expect(values["seo.title"]).toBe("Hello — SEO");
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("offers the model only the fields that are actually empty", () => {
|
|
165
|
+
// The whole invariant, end to end. `tags` and `published_at` used to be
|
|
166
|
+
// in this list, so a populated tag list and a set publication date
|
|
167
|
+
// arrived in the review pre-ticked for replacement.
|
|
168
|
+
expect(plannedFillKeys(requestFor(POST_PROPERTIES, PUBLISHED_POST)))
|
|
169
|
+
.toEqual(["summary", "seo.description"]);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("offers every field of an empty record", () => {
|
|
173
|
+
// The other direction: the agreement must not be bought by hiding
|
|
174
|
+
// fillable fields. `internal_notes` is absent because it is read-only,
|
|
175
|
+
// and `seo` because a map is a container, not a field.
|
|
176
|
+
expect(plannedFillKeys(requestFor(POST_PROPERTIES, {})))
|
|
177
|
+
.toEqual(["title", "summary", "tags", "published_at", "seo.title", "seo.description"]);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("carries a value for every fillable property the record has filled", () => {
|
|
181
|
+
// The drift check, stated as a rule rather than a list: whatever a
|
|
182
|
+
// future property type does to either map, a value the operator can see
|
|
183
|
+
// in the form must reach the service under the key the service will
|
|
184
|
+
// look it up by.
|
|
185
|
+
const { properties, values } = requestFor(POST_PROPERTIES, PUBLISHED_POST);
|
|
186
|
+
const onTheWire = JSON.parse(JSON.stringify(values)) as Record<string, unknown>;
|
|
187
|
+
|
|
188
|
+
for (const [key, property] of Object.entries(properties)) {
|
|
189
|
+
if (!propertySchema(property)) continue; // not a field the service fills
|
|
190
|
+
if (!alreadyFilled(getValueInPath(PUBLISHED_POST, key))) continue;
|
|
191
|
+
expect({ key,
|
|
192
|
+
filled: alreadyFilled(onTheWire[key]) }).toEqual({ key,
|
|
193
|
+
filled: true });
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("does not transmit the value of a read-only property", () => {
|
|
198
|
+
// `disabled` already means "the service may not fill this". It has to
|
|
199
|
+
// mean "and it is not context either": the prompt includes every value
|
|
200
|
+
// it is given, so a field owned by a backend hook was being pasted into
|
|
201
|
+
// it verbatim.
|
|
202
|
+
const { values } = requestFor(POST_PROPERTIES, PUBLISHED_POST);
|
|
203
|
+
expect(values).not.toHaveProperty("internal_notes");
|
|
204
|
+
expect(JSON.stringify(values)).not.toContain("backend hook");
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("takes the children of a disabled map with it", () => {
|
|
208
|
+
const properties = {
|
|
209
|
+
audit: {
|
|
210
|
+
name: "Audit",
|
|
211
|
+
type: "map",
|
|
212
|
+
admin: { disabled: true },
|
|
213
|
+
properties: {
|
|
214
|
+
author: { name: "Author",
|
|
215
|
+
type: "string" }
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
} as Properties;
|
|
219
|
+
const { values } = requestFor(properties, { audit: { author: "root" } });
|
|
220
|
+
expect(values).toEqual({});
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
describe("omitDisabledValues", () => {
|
|
225
|
+
|
|
226
|
+
it("leaves a record alone when nothing is disabled", () => {
|
|
227
|
+
const values = { a: 1,
|
|
228
|
+
b: 2 };
|
|
229
|
+
expect(omitDisabledValues(values, { a: { type: "number",
|
|
230
|
+
fieldConfigId: "number_field" } })).toBe(values);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it("survives a property map carrying a non-object at a path", () => {
|
|
234
|
+
// `getSimplifiedProperty` writes a raw type string at
|
|
235
|
+
// `${path}.${i}.${typeField}` for `oneOf` arrays, and `"disabled" in
|
|
236
|
+
// aString` throws.
|
|
237
|
+
const properties = { "blocks.0.type": "images" } as unknown as Record<string, InputProperty>;
|
|
238
|
+
expect(() => omitDisabledValues({ "blocks.0.type": "images" }, properties)).not.toThrow();
|
|
239
|
+
});
|
|
240
|
+
});
|