@rebasepro/plugin-ai 0.12.1-canary.gf4240e3 → 0.13.0

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 {
4
4
  Button,
5
5
  CircularProgress,
6
6
  cls,
7
+ fieldBackgroundMixin,
7
8
  focusedDisabled,
8
9
  IconButton,
9
10
  iconSize,
@@ -164,42 +165,61 @@ export function FormEnhanceAction({
164
165
 
165
166
  <Separator orientation={"horizontal"}/>
166
167
 
168
+ {/* `px-4` and `gap-4` are MenuItem's own paddings, so the input
169
+ row lines up with the items above it instead of sitting 8px
170
+ to their left — which is what `mx-2` on the textarea did. */}
171
+ {/* `items-center` so the send button sits on the field's centre
172
+ line rather than pinned to its top edge as the textarea grows. */}
167
173
  <div
168
174
  className={cls(
169
- "my-2 w-[500px] max-w-full flex items-start text-surface-700 dark:text-surface-200"
175
+ "my-2 px-4 py-2 gap-4 w-[500px] max-w-full flex items-center text-surface-700 dark:text-surface-200"
170
176
  )}>
171
177
 
172
- <TextareaAutosize
173
- className={cls("p-4 rounded-lg resize-none bg-surface-100 dark:bg-surface-950 mx-2 w-full grow outline-hidden max-h-[300px] overflow-auto", focusedDisabled)}
174
- value={instructions}
175
- autoFocus={status === "new"}
176
- disabled={loading}
177
- onFocus={(event) => {
178
- event.stopPropagation();
179
- }}
180
- placeholder={"...or provide instructions"}
181
- onKeyDown={(e) => {
182
- e.stopPropagation();
183
- if (e.key === "Enter" && !e.shiftKey) {
184
- e.preventDefault();
185
- submit();
186
- }
187
-
188
- }}
189
- onChange={(e) => {
190
- setInstructions(e.target.value);
191
- }}
192
- />
178
+ <div className={"relative w-full grow"}>
179
+ {/* `fieldBackgroundMixin`, the same surface every other input
180
+ in the codebase uses. It was `dark:bg-surface-950`, which
181
+ theme.css defines as literal `#000000` — a pure black
182
+ rectangle inside an already-dark menu. */}
183
+ <TextareaAutosize
184
+ className={cls("p-3 pr-12 rounded-lg resize-none w-full outline-hidden max-h-[300px] overflow-auto", fieldBackgroundMixin, focusedDisabled)}
185
+ value={instructions}
186
+ autoFocus={status === "new"}
187
+ disabled={loading}
188
+ onFocus={(event) => {
189
+ event.stopPropagation();
190
+ }}
191
+ placeholder={"...or provide instructions"}
192
+ onKeyDown={(e) => {
193
+ e.stopPropagation();
194
+ if (e.key === "Enter" && !e.shiftKey) {
195
+ e.preventDefault();
196
+ submit();
197
+ }
193
198
 
194
- <IconButton
195
- size={"small"}
196
- onClick={() => {
197
- setInstructions("");
198
- }}
199
- color={!instructions ? "primary" : undefined}
200
- disabled={loading || !instructions}>
201
- <XIcon size={iconSize.small}/>
202
- </IconButton>
199
+ }}
200
+ onChange={(e) => {
201
+ setInstructions(e.target.value);
202
+ }}
203
+ />
204
+
205
+ {/* Inside the field and only when there is something to
206
+ clear — a permanently-visible X on an empty box is a
207
+ control that does nothing. Positioned exactly as
208
+ `TextFieldBinding` positions its own clearable X, so
209
+ it stays centred as the textarea grows. */}
210
+ {instructions.length > 0 && !loading && (
211
+ <div
212
+ className={"flex flex-row justify-center items-center absolute h-full right-0 top-0 mr-2"}>
213
+ <IconButton
214
+ size={"small"}
215
+ onClick={() => {
216
+ setInstructions("");
217
+ }}>
218
+ <XIcon size={iconSize.small}/>
219
+ </IconButton>
220
+ </div>
221
+ )}
222
+ </div>
203
223
 
204
224
  <IconButton
205
225
  onClick={() => generate(instructions)}
@@ -208,8 +228,14 @@ export function FormEnhanceAction({
208
228
  disabled={loading || !instructions}>
209
229
  {loading &&
210
230
  <CircularProgress size={"smallest"}/>}
231
+ {/* Sized, and no `color`. These icons are re-exported
232
+ straight from lucide, so `color` lands on the SVG as
233
+ a CSS colour — and `"primary"` is not one, which is
234
+ why this button rendered empty. Every other icon in
235
+ the codebase passes `size` alone and inherits
236
+ `currentColor` from the button. */}
211
237
  {!loading &&
212
- <SendIcon color={"primary"}/>}
238
+ <SendIcon size={iconSize.small}/>}
213
239
  </IconButton>
214
240
 
215
241
  </div>
@@ -0,0 +1,340 @@
1
+ import { TextEncoder, TextDecoder } from "util";
2
+ Object.assign(global, { TextEncoder,
3
+ TextDecoder });
4
+
5
+ if (typeof window !== "undefined") {
6
+ Object.defineProperty(window, "matchMedia", {
7
+ writable: true,
8
+ value: jest.fn().mockImplementation(query => ({
9
+ matches: false,
10
+ media: query,
11
+ onchange: null,
12
+ addEventListener: jest.fn(),
13
+ removeEventListener: jest.fn(),
14
+ dispatchEvent: jest.fn()
15
+ }))
16
+ });
17
+ }
18
+
19
+ import React from "react";
20
+ import { act, render, screen, within } from "@testing-library/react";
21
+ import userEvent from "@testing-library/user-event";
22
+
23
+ import {
24
+ DataEnhancementControllerProvider,
25
+ useDataEnhancementController
26
+ } from "../components/DataEnhancementControllerProvider";
27
+ import { AutofillReviewDialog } from "../components/AutofillReviewDialog";
28
+ import { DataEnhancementController } from "../types/data_enhancement_controller";
29
+
30
+ /**
31
+ * The review surface.
32
+ *
33
+ * The controller's behaviour is covered in `review.test.tsx`; this is about
34
+ * what the operator can actually see and press. The two things worth proving
35
+ * here are that a proposal which would overwrite existing content *says so* —
36
+ * a review that hides what it is about to destroy is worse than no review — and
37
+ * that the Apply button's count matches what Apply will really write.
38
+ */
39
+
40
+ jest.mock("@rebasepro/admin", () => ({
41
+ getFieldId: (property: { type?: string }) => (property?.type === "string" ? "text_field" : "number_field")
42
+ }));
43
+
44
+ const COLLECTION = {
45
+ name: "Products",
46
+ singularName: "Product",
47
+ properties: {
48
+ title: { type: "string",
49
+ name: "Title" },
50
+ stock: { type: "number",
51
+ name: "Stock" }
52
+ }
53
+ } as any;
54
+
55
+ function streamingResponse(body: string): any {
56
+ const encoder = new TextEncoder();
57
+ let sent = false;
58
+ return {
59
+ ok: true,
60
+ status: 200,
61
+ body: {
62
+ getReader: () => ({
63
+ read: async () => {
64
+ if (sent) return { done: true,
65
+ value: undefined };
66
+ sent = true;
67
+ return { done: false,
68
+ value: encoder.encode(body) };
69
+ }
70
+ })
71
+ }
72
+ };
73
+ }
74
+
75
+ const FULL_RUN = [
76
+ 'event: suggestion\ndata: {"key":"title","value":"Blue widget"}',
77
+ 'event: suggestion\ndata: {"key":"stock","value":42}',
78
+ 'event: done\ndata: {"suggestions":{"title":"Blue widget","stock":42}}',
79
+ ""
80
+ ].join("\n\n");
81
+
82
+ function mockService(autofillBody: string | (() => any)) {
83
+ (global as any).fetch = jest.fn((url: string) => {
84
+ if (String(url).endsWith("/status")) {
85
+ return Promise.resolve({ ok: true,
86
+ status: 200,
87
+ json: async () => ({ available: true }) });
88
+ }
89
+ if (String(url).endsWith("/autofill")) {
90
+ return Promise.resolve(typeof autofillBody === "string" ? streamingResponse(autofillBody) : autofillBody());
91
+ }
92
+ return Promise.resolve({ ok: true,
93
+ status: 200,
94
+ json: async () => ({ prompts: [] }) });
95
+ });
96
+ }
97
+
98
+ let controller: DataEnhancementController;
99
+
100
+ function Capture() {
101
+ controller = useDataEnhancementController();
102
+ return null;
103
+ }
104
+
105
+ async function mount(formValues: Record<string, unknown> = {}) {
106
+ const setFieldValue = jest.fn();
107
+ const formContext = { values: formValues,
108
+ setFieldValue } as any;
109
+
110
+ render(
111
+ <DataEnhancementControllerProvider
112
+ path={"products"}
113
+ collection={COLLECTION}
114
+ formContext={formContext}
115
+ {...({} as any)}>
116
+ <Capture/>
117
+ <AutofillReviewDialog/>
118
+ </DataEnhancementControllerProvider>
119
+ );
120
+
121
+ // Let the /status probe resolve so the controller reports enabled.
122
+ await act(async () => {
123
+ await Promise.resolve();
124
+ });
125
+
126
+ return { setFieldValue };
127
+ }
128
+
129
+ async function runAutofill(values: Record<string, unknown> = {}) {
130
+ await act(async () => {
131
+ await controller.generate({ values });
132
+ });
133
+ }
134
+
135
+ afterEach(() => {
136
+ jest.restoreAllMocks();
137
+ });
138
+
139
+ describe("AutofillReviewDialog", () => {
140
+
141
+ it("renders nothing until a run has produced something to review", async () => {
142
+ mockService(FULL_RUN);
143
+ await mount();
144
+ expect(screen.queryByText(/Review autofill/i)).toBeNull();
145
+ });
146
+
147
+ it("lists each proposed field with its label and value", async () => {
148
+ mockService(FULL_RUN);
149
+ await mount();
150
+ await runAutofill();
151
+
152
+ expect(screen.getByText("Review autofill")).toBeTruthy();
153
+ expect(screen.getByText("Title")).toBeTruthy();
154
+ expect(screen.getByText("Blue widget")).toBeTruthy();
155
+ expect(screen.getByText("Stock")).toBeTruthy();
156
+ expect(screen.getByText("42")).toBeTruthy();
157
+ });
158
+
159
+ it("says so when a proposal would overwrite something already written", async () => {
160
+ // The property that makes this a review rather than a preview. Hiding
161
+ // what is about to be destroyed is worse than not offering a review.
162
+ mockService(FULL_RUN);
163
+ await mount({ title: "Hand grinder" });
164
+ await runAutofill({ title: "Hand grinder" });
165
+
166
+ expect(screen.getAllByText(/replaces the current value/i).length).toBe(1);
167
+ expect(screen.getByText("Hand grinder")).toBeTruthy();
168
+ });
169
+
170
+ it("does not claim a replacement for a field that was empty", async () => {
171
+ mockService(FULL_RUN);
172
+ await mount({ title: "" });
173
+ await runAutofill({ title: "" });
174
+
175
+ expect(screen.queryByText(/replaces the current value/i)).toBeNull();
176
+ });
177
+
178
+ it("counts only the fields Apply will actually write", async () => {
179
+ mockService(FULL_RUN);
180
+ await mount();
181
+ await runAutofill();
182
+
183
+ expect(screen.getByRole("button", { name: /Apply 2 fields/i })).toBeTruthy();
184
+
185
+ const user = userEvent.setup();
186
+ const rows = screen.getAllByRole("checkbox");
187
+ // The first checkbox is "select all"; the next two are the fields.
188
+ await act(async () => {
189
+ await user.click(rows[rows.length - 1]);
190
+ });
191
+
192
+ expect(screen.getByRole("button", { name: /Apply 1 field/i })).toBeTruthy();
193
+ });
194
+
195
+ it("writes only the ticked fields when Apply is pressed", async () => {
196
+ mockService(FULL_RUN);
197
+ const { setFieldValue } = await mount();
198
+ await runAutofill();
199
+
200
+ const user = userEvent.setup();
201
+ const boxes = screen.getAllByRole("checkbox");
202
+ await act(async () => {
203
+ await user.click(boxes[boxes.length - 1]);
204
+ });
205
+ await act(async () => {
206
+ await user.click(screen.getByRole("button", { name: /Apply 1 field/i }));
207
+ });
208
+
209
+ expect(setFieldValue).toHaveBeenCalledTimes(1);
210
+ expect(setFieldValue).toHaveBeenCalledWith("title", "Blue widget");
211
+ });
212
+
213
+ it("writes nothing when Discard is pressed, and closes", async () => {
214
+ mockService(FULL_RUN);
215
+ const { setFieldValue } = await mount({ title: "Original" });
216
+ await runAutofill({ title: "Original" });
217
+
218
+ const user = userEvent.setup();
219
+ await act(async () => {
220
+ await user.click(screen.getByRole("button", { name: /Discard/i }));
221
+ });
222
+
223
+ expect(setFieldValue).not.toHaveBeenCalled();
224
+ expect(screen.queryByText("Review autofill")).toBeNull();
225
+ });
226
+
227
+ it("disables Apply when nothing is ticked", async () => {
228
+ mockService(FULL_RUN);
229
+ await mount();
230
+ await runAutofill();
231
+
232
+ const user = userEvent.setup();
233
+ await act(async () => {
234
+ await user.click(screen.getAllByRole("checkbox")[0]);
235
+ });
236
+
237
+ const apply = screen.getByRole("button", { name: /Apply 0 fields/i });
238
+ expect(apply.hasAttribute("disabled")).toBe(true);
239
+ });
240
+
241
+ it("shows the instruction back to the operator while they review", async () => {
242
+ mockService(FULL_RUN);
243
+ await mount();
244
+ await act(async () => {
245
+ await controller.generate({ values: {},
246
+ instructions: "A burr grinder for travel" });
247
+ });
248
+
249
+ expect(screen.getByText(/A burr grinder for travel/)).toBeTruthy();
250
+ });
251
+
252
+ it("keeps what arrived when a run fails part-way, and explains", async () => {
253
+ // A run that produced one good field and then broke should still let
254
+ // the operator take the one.
255
+ mockService([
256
+ 'event: suggestion\ndata: {"key":"title","value":"Blue widget"}',
257
+ 'event: error\ndata: {"message":"quota exhausted"}',
258
+ ""
259
+ ].join("\n\n"));
260
+ await mount();
261
+ await runAutofill();
262
+
263
+ expect(screen.getByText(/quota exhausted/)).toBeTruthy();
264
+ expect(screen.getByText("Blue widget")).toBeTruthy();
265
+ expect(screen.getByRole("button", { name: /Apply 1 field/i })).toBeTruthy();
266
+ });
267
+
268
+ it("explains an empty result rather than showing a blank dialog", async () => {
269
+ mockService('event: done\ndata: {"suggestions":{}}\n\n');
270
+ await mount();
271
+ await runAutofill();
272
+
273
+ expect(screen.getByText(/Nothing to fill in/i)).toBeTruthy();
274
+ expect(screen.getByRole("button", { name: /Apply 0 fields/i })).toBeTruthy();
275
+ });
276
+
277
+ it("offers select-all only when there is more than one field", async () => {
278
+ mockService('event: suggestion\ndata: {"key":"title","value":"Only one"}\n\nevent: done\ndata: {"suggestions":{"title":"Only one"}}\n\n');
279
+ await mount();
280
+ await runAutofill();
281
+
282
+ expect(screen.queryByText(/Select all|Deselect all/i)).toBeNull();
283
+ expect(screen.getAllByRole("checkbox").length).toBe(1);
284
+ });
285
+
286
+ it("toggles every row at once", async () => {
287
+ mockService(FULL_RUN);
288
+ await mount();
289
+ await runAutofill();
290
+
291
+ const user = userEvent.setup();
292
+ await act(async () => {
293
+ await user.click(screen.getByText(/Deselect all/i));
294
+ });
295
+
296
+ expect(screen.getByRole("button", { name: /Apply 0 fields/i })).toBeTruthy();
297
+ expect(screen.getByText(/Select all/i)).toBeTruthy();
298
+ });
299
+
300
+ it("renders a date proposal readably rather than as an ISO string", async () => {
301
+ const collection = {
302
+ name: "Posts",
303
+ singularName: "Post",
304
+ properties: { publishedAt: { type: "date",
305
+ name: "Published at" } }
306
+ } as any;
307
+
308
+ (global as any).fetch = jest.fn((url: string) => {
309
+ if (String(url).endsWith("/status")) return Promise.resolve({ ok: true,
310
+ status: 200,
311
+ json: async () => ({ available: true }) });
312
+ return Promise.resolve(streamingResponse(
313
+ 'event: suggestion\ndata: {"key":"publishedAt","value":"2026-08-03T10:00:00.000Z"}\n\nevent: done\ndata: {"suggestions":{}}\n\n'
314
+ ));
315
+ });
316
+
317
+ const setFieldValue = jest.fn();
318
+ render(
319
+ <DataEnhancementControllerProvider
320
+ path={"posts"}
321
+ collection={collection}
322
+ formContext={{ values: {},
323
+ setFieldValue } as any}
324
+ {...({} as any)}>
325
+ <Capture/>
326
+ <AutofillReviewDialog/>
327
+ </DataEnhancementControllerProvider>
328
+ );
329
+ await act(async () => {
330
+ await Promise.resolve();
331
+ });
332
+ await runAutofill();
333
+
334
+ // Not the raw ISO string — the row shows a locale-formatted date, which
335
+ // is only possible because the controller coerced it to a Date first.
336
+ expect(screen.queryByText("2026-08-03T10:00:00.000Z")).toBeNull();
337
+ const row = screen.getByText("Published at").closest("label[class*=\"flex\"]");
338
+ expect(within(row as HTMLElement).getByText(/2026/)).toBeTruthy();
339
+ });
340
+ });