datocms-plugin-record-bin 2.0.0 → 3.0.1

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.
Files changed (47) hide show
  1. package/README.md +127 -11
  2. package/build/assets/index-BnrW9Ts8.js +15 -0
  3. package/build/assets/index-aWCW2c0n.css +1 -0
  4. package/build/index.html +13 -1
  5. package/index.html +12 -0
  6. package/package.json +24 -18
  7. package/src/entrypoints/BinOutlet.tsx +262 -37
  8. package/src/entrypoints/ConfigScreen.tsx +939 -38
  9. package/src/entrypoints/ErrorModal.tsx +86 -2
  10. package/src/index.tsx +73 -28
  11. package/src/react-app-env.d.ts +1 -1
  12. package/src/types/types.ts +36 -8
  13. package/src/utils/binCleanup.test.ts +107 -0
  14. package/src/utils/binCleanup.ts +71 -23
  15. package/src/utils/debugLogger.ts +27 -0
  16. package/src/utils/deployProviders.test.ts +33 -0
  17. package/src/utils/deployProviders.ts +28 -0
  18. package/src/utils/getDeploymentUrlFromParameters.test.ts +26 -0
  19. package/src/utils/getDeploymentUrlFromParameters.ts +21 -0
  20. package/src/utils/getRuntimeMode.test.ts +57 -0
  21. package/src/utils/getRuntimeMode.ts +23 -0
  22. package/src/utils/lambdaLessCapture.test.ts +218 -0
  23. package/src/utils/lambdaLessCapture.ts +160 -0
  24. package/src/utils/lambdaLessCleanup.test.ts +125 -0
  25. package/src/utils/lambdaLessCleanup.ts +69 -0
  26. package/src/utils/lambdaLessRestore.test.ts +248 -0
  27. package/src/utils/lambdaLessRestore.ts +159 -0
  28. package/src/utils/recordBinModel.ts +108 -0
  29. package/src/utils/recordBinPayload.test.ts +103 -0
  30. package/src/utils/recordBinPayload.ts +136 -0
  31. package/src/utils/recordBinWebhook.test.ts +253 -0
  32. package/src/utils/recordBinWebhook.ts +305 -0
  33. package/src/utils/render.tsx +17 -8
  34. package/src/utils/restoreError.test.ts +112 -0
  35. package/src/utils/restoreError.ts +221 -0
  36. package/src/utils/verifyLambdaHealth.test.ts +248 -0
  37. package/src/utils/verifyLambdaHealth.ts +422 -0
  38. package/vite.config.ts +11 -0
  39. package/build/asset-manifest.json +0 -13
  40. package/build/static/css/main.10f29737.css +0 -2
  41. package/build/static/css/main.10f29737.css.map +0 -1
  42. package/build/static/js/main.53795e3b.js +0 -3
  43. package/build/static/js/main.53795e3b.js.LICENSE.txt +0 -47
  44. package/build/static/js/main.53795e3b.js.map +0 -1
  45. package/src/entrypoints/InstallationModal.tsx +0 -107
  46. package/src/entrypoints/PreInstallConfig.tsx +0 -28
  47. package/src/utils/attemptVercelInitialization.ts +0 -16
@@ -1,64 +1,965 @@
1
1
  import { RenderConfigScreenCtx } from "datocms-plugin-sdk";
2
- import { Button, Canvas, Form, TextField } from "datocms-react-ui";
3
- import { useState } from "react";
4
- import { automaticBinCleanupObject } from "../types/types";
2
+ import {
3
+ Button,
4
+ Canvas,
5
+ Dropdown,
6
+ DropdownMenu,
7
+ DropdownOption,
8
+ Form,
9
+ Section,
10
+ SwitchField,
11
+ TextField,
12
+ } from "datocms-react-ui";
13
+ import { CSSProperties, useEffect, useState } from "react";
14
+ import {
15
+ automaticBinCleanupObject,
16
+ LambdaConnectionState,
17
+ } from "../types/types";
18
+ import {
19
+ DEPLOY_PROVIDER_OPTIONS,
20
+ DeployProvider,
21
+ PLUGIN_README_URL,
22
+ } from "../utils/deployProviders";
23
+ import { createDebugLogger, isDebugEnabled } from "../utils/debugLogger";
24
+ import { getDeploymentUrlFromParameters } from "../utils/getDeploymentUrlFromParameters";
25
+ import { getRuntimeMode, RuntimeMode } from "../utils/getRuntimeMode";
26
+ import {
27
+ ensureRecordBinWebhook,
28
+ getRecordBinWebhookSyncErrorDetails,
29
+ isRecordBinWebhookSyncError,
30
+ removeRecordBinWebhook,
31
+ RecordBinWebhookSyncError,
32
+ } from "../utils/recordBinWebhook";
33
+ import {
34
+ buildConnectedLambdaConnectionState,
35
+ buildDisconnectedLambdaConnectionState,
36
+ getLambdaConnectionErrorDetails,
37
+ LambdaHealthCheckError,
38
+ verifyLambdaHealth,
39
+ } from "../utils/verifyLambdaHealth";
40
+
41
+ const DEFAULT_CONNECTION_ERROR_SUMMARY =
42
+ "Could not validate the Record Bin lambda deployment.";
43
+
44
+ const getConnectionErrorSummary = (
45
+ connection?: LambdaConnectionState
46
+ ): string => {
47
+ if (!connection || connection.status !== "disconnected") {
48
+ return "";
49
+ }
50
+
51
+ return connection.errorMessage || DEFAULT_CONNECTION_ERROR_SUMMARY;
52
+ };
5
53
 
6
54
  export default function ConfigScreen({ ctx }: { ctx: RenderConfigScreenCtx }) {
55
+ const initialConnectionState = (ctx.plugin.attributes.parameters.lambdaConnection ??
56
+ undefined) as LambdaConnectionState | undefined;
57
+ const initialRuntimeMode = getRuntimeMode(ctx.plugin.attributes.parameters);
58
+ const initialDeploymentUrl = getDeploymentUrlFromParameters(
59
+ ctx.plugin.attributes.parameters
60
+ );
61
+ const initialDebugEnabled = isDebugEnabled(ctx.plugin.attributes.parameters);
62
+ const initialNumberOfDays = String(
63
+ (ctx.plugin.attributes.parameters?.automaticBinCleanup as automaticBinCleanupObject)
64
+ ?.numberOfDays ?? "30"
65
+ );
66
+ const hasInitialConnectionErrorDetails =
67
+ initialDeploymentUrl.trim().length > 0 &&
68
+ initialConnectionState?.status === "disconnected" &&
69
+ Boolean(
70
+ initialConnectionState.errorCode ||
71
+ initialConnectionState.errorMessage ||
72
+ initialConnectionState.httpStatus ||
73
+ initialConnectionState.responseSnippet
74
+ );
75
+
7
76
  const [numberOfDays, setNumberOfDays] = useState(
8
- (
9
- ctx.plugin.attributes.parameters
10
- ?.automaticBinCleanup as automaticBinCleanupObject
11
- )?.numberOfDays || "30"
77
+ initialNumberOfDays
78
+ );
79
+ const [debugEnabled, setDebugEnabled] = useState(initialDebugEnabled);
80
+ const [runtimeModeSelection, setRuntimeModeSelection] = useState<RuntimeMode>(
81
+ initialRuntimeMode
12
82
  );
83
+ const [savedFormValues, setSavedFormValues] = useState({
84
+ numberOfDays: initialNumberOfDays,
85
+ debugEnabled: initialDebugEnabled,
86
+ runtimeMode: initialRuntimeMode,
87
+ });
13
88
  const [isLoading, setLoading] = useState(false);
89
+ const [isDisconnecting, setIsDisconnecting] = useState(false);
90
+ const [isConnecting, setIsConnecting] = useState(false);
14
91
  const [error, setError] = useState("");
92
+ const [isHealthChecking, setIsHealthChecking] = useState(false);
93
+ const [deploymentUrlInput, setDeploymentUrlInput] = useState(
94
+ initialDeploymentUrl
95
+ );
96
+ const [activeDeploymentUrl, setActiveDeploymentUrl] = useState(
97
+ initialDeploymentUrl
98
+ );
99
+ const [connectionState, setConnectionState] = useState<
100
+ LambdaConnectionState | undefined
101
+ >(initialConnectionState);
102
+ const [connectionErrorSummary, setConnectionErrorSummary] = useState(
103
+ hasInitialConnectionErrorDetails
104
+ ? getConnectionErrorSummary(initialConnectionState)
105
+ : ""
106
+ );
107
+ const [connectionErrorDetails, setConnectionErrorDetails] = useState<string[]>(
108
+ hasInitialConnectionErrorDetails
109
+ ? getLambdaConnectionErrorDetails(initialConnectionState)
110
+ : []
111
+ );
112
+ const [showConnectionDetails, setShowConnectionDetails] = useState(false);
113
+ const [showAdvancedSettings, setShowAdvancedSettings] = useState(false);
114
+ const debugLogger = createDebugLogger(debugEnabled, "ConfigScreen");
115
+
116
+ const persistPluginParameters = async (updates: Record<string, unknown>) => {
117
+ await ctx.updatePluginParameters({
118
+ ...ctx.plugin.attributes.parameters,
119
+ ...updates,
120
+ });
121
+ };
122
+
123
+ const clearConnectionErrorState = () => {
124
+ setConnectionErrorSummary("");
125
+ setConnectionErrorDetails([]);
126
+ setShowConnectionDetails(false);
127
+ };
128
+
129
+ const applyDisconnectedState = (state: LambdaConnectionState) => {
130
+ setConnectionState(state);
131
+ setConnectionErrorSummary(getConnectionErrorSummary(state));
132
+ setConnectionErrorDetails(getLambdaConnectionErrorDetails(state));
133
+ setShowConnectionDetails(false);
134
+ };
135
+
136
+ const applyWebhookSyncErrorState = (
137
+ error: RecordBinWebhookSyncError,
138
+ operation: "connect" | "disconnect"
139
+ ) => {
140
+ setConnectionErrorSummary(error.message);
141
+ setConnectionErrorDetails(
142
+ getRecordBinWebhookSyncErrorDetails(error, operation)
143
+ );
144
+ setShowConnectionDetails(false);
145
+ };
146
+
147
+ const canManageWebhooks =
148
+ ctx.currentRole?.meta?.final_permissions?.can_manage_webhooks === true;
149
+
150
+ useEffect(() => {
151
+ let isCancelled = false;
152
+ debugLogger.log("Config screen mounted", {
153
+ initialDebugEnabled,
154
+ hasInitialConnectionState: !!initialConnectionState,
155
+ });
156
+
157
+ const runHealthCheck = async () => {
158
+ setIsHealthChecking(true);
159
+
160
+ if (initialRuntimeMode !== "lambda") {
161
+ debugLogger.log(
162
+ "Skipping lambda health check because Lambda-full mode is not selected"
163
+ );
164
+ if (!isCancelled) {
165
+ setIsHealthChecking(false);
166
+ }
167
+ return;
168
+ }
169
+
170
+ const configuredDeploymentUrl = getDeploymentUrlFromParameters(
171
+ ctx.plugin.attributes.parameters
172
+ );
173
+ if (!isCancelled) {
174
+ setDeploymentUrlInput(configuredDeploymentUrl);
175
+ setActiveDeploymentUrl(configuredDeploymentUrl);
176
+ }
177
+ debugLogger.log("Running lambda health check", {
178
+ phase: "config_mount",
179
+ deploymentUrl: configuredDeploymentUrl,
180
+ });
181
+
182
+ if (!configuredDeploymentUrl.trim()) {
183
+ debugLogger.log(
184
+ "Skipping lambda health check because no deployment URL is configured"
185
+ );
186
+ if (!isCancelled) {
187
+ setConnectionState(undefined);
188
+ clearConnectionErrorState();
189
+ setIsHealthChecking(false);
190
+ }
191
+
192
+ try {
193
+ await persistPluginParameters({
194
+ lambdaConnection: null,
195
+ runtimeMode: runtimeModeSelection,
196
+ lambdaFullMode: runtimeModeSelection === "lambda",
197
+ });
198
+ debugLogger.log("Cleared lambda connection state without URL");
199
+ } catch (persistError) {
200
+ debugLogger.warn(
201
+ "Failed to clear lambda connection state without URL",
202
+ persistError
203
+ );
204
+ }
205
+
206
+ return;
207
+ }
208
+
209
+ try {
210
+ const verificationResult = await verifyLambdaHealth({
211
+ baseUrl: configuredDeploymentUrl,
212
+ environment: ctx.environment,
213
+ phase: "config_mount",
214
+ debug: debugEnabled,
215
+ });
216
+ debugLogger.log("Lambda health check succeeded", verificationResult);
217
+
218
+ const connectedState = buildConnectedLambdaConnectionState(
219
+ verificationResult.endpoint,
220
+ verificationResult.checkedAt,
221
+ "config_mount"
222
+ );
223
+
224
+ if (!isCancelled) {
225
+ setConnectionState(connectedState);
226
+ clearConnectionErrorState();
227
+ setDeploymentUrlInput(verificationResult.normalizedBaseUrl);
228
+ setActiveDeploymentUrl(verificationResult.normalizedBaseUrl);
229
+ }
230
+
231
+ try {
232
+ await persistPluginParameters({
233
+ deploymentURL: verificationResult.normalizedBaseUrl,
234
+ vercelURL: verificationResult.normalizedBaseUrl,
235
+ lambdaConnection: connectedState,
236
+ runtimeMode: runtimeModeSelection,
237
+ lambdaFullMode: runtimeModeSelection === "lambda",
238
+ });
239
+ debugLogger.log("Persisted connected lambda state on mount");
240
+ } catch (persistError) {
241
+ debugLogger.warn(
242
+ "Failed to persist connected lambda state on mount",
243
+ persistError
244
+ );
245
+ }
246
+ } catch (healthCheckError) {
247
+ debugLogger.warn("Lambda health check failed on mount", healthCheckError);
248
+ const disconnectedState = buildDisconnectedLambdaConnectionState(
249
+ healthCheckError,
250
+ configuredDeploymentUrl,
251
+ "config_mount"
252
+ );
253
+
254
+ if (!isCancelled) {
255
+ applyDisconnectedState(disconnectedState);
256
+ }
257
+
258
+ try {
259
+ await persistPluginParameters({
260
+ lambdaConnection: disconnectedState,
261
+ runtimeMode: runtimeModeSelection,
262
+ lambdaFullMode: runtimeModeSelection === "lambda",
263
+ });
264
+ debugLogger.log("Persisted disconnected lambda state on mount");
265
+ } catch (persistError) {
266
+ debugLogger.warn(
267
+ "Failed to persist disconnected lambda state on mount",
268
+ persistError
269
+ );
270
+ }
271
+ } finally {
272
+ if (!isCancelled) {
273
+ setIsHealthChecking(false);
274
+ }
275
+ debugLogger.log("Lambda health check on mount finished");
276
+ }
277
+ };
278
+
279
+ runHealthCheck();
280
+
281
+ return () => {
282
+ isCancelled = true;
283
+ debugLogger.log("Config screen unmounted");
284
+ };
285
+ }, []);
286
+
287
+ const connectLambdaHandler = async () => {
288
+ if (runtimeModeSelection !== "lambda") {
289
+ await ctx.alert(
290
+ "Enable 'Also save records deleted from the API' before connecting a lambda deployment."
291
+ );
292
+ return;
293
+ }
294
+
295
+ const candidateUrl = deploymentUrlInput.trim();
296
+ if (!candidateUrl) {
297
+ setConnectionErrorSummary("Enter your lambda deployment URL.");
298
+ setConnectionErrorDetails([]);
299
+ setShowConnectionDetails(false);
300
+ return;
301
+ }
302
+
303
+ debugLogger.log("Connecting lambda function from config", { candidateUrl });
304
+ setIsConnecting(true);
305
+ clearConnectionErrorState();
306
+
307
+ try {
308
+ const verificationResult = await verifyLambdaHealth({
309
+ baseUrl: candidateUrl,
310
+ environment: ctx.environment,
311
+ phase: "config_connect",
312
+ debug: debugEnabled,
313
+ });
314
+ debugLogger.log("Lambda connect health check succeeded", verificationResult);
315
+
316
+ const webhookSyncResult = await ensureRecordBinWebhook({
317
+ currentUserAccessToken: ctx.currentUserAccessToken,
318
+ canManageWebhooks,
319
+ environment: ctx.environment,
320
+ lambdaBaseUrl: verificationResult.normalizedBaseUrl,
321
+ });
322
+ debugLogger.log("Record Bin webhook synchronized on connect", {
323
+ action: webhookSyncResult.action,
324
+ webhookId: webhookSyncResult.webhookId,
325
+ });
326
+
327
+ const connectedState = buildConnectedLambdaConnectionState(
328
+ verificationResult.endpoint,
329
+ verificationResult.checkedAt,
330
+ "config_connect"
331
+ );
332
+ setConnectionState(connectedState);
333
+ setDeploymentUrlInput(verificationResult.normalizedBaseUrl);
334
+ setActiveDeploymentUrl(verificationResult.normalizedBaseUrl);
335
+ clearConnectionErrorState();
336
+
337
+ await persistPluginParameters({
338
+ deploymentURL: verificationResult.normalizedBaseUrl,
339
+ vercelURL: verificationResult.normalizedBaseUrl,
340
+ lambdaConnection: connectedState,
341
+ runtimeMode: runtimeModeSelection,
342
+ lambdaFullMode: runtimeModeSelection === "lambda",
343
+ });
344
+ debugLogger.log("Persisted connected lambda state from config connect");
345
+ ctx.notice("Lambda function connected successfully.");
346
+ } catch (connectError) {
347
+ if (connectError instanceof LambdaHealthCheckError) {
348
+ debugLogger.warn("Lambda connect health check failed", connectError);
349
+ const disconnectedState = buildDisconnectedLambdaConnectionState(
350
+ connectError,
351
+ candidateUrl,
352
+ "config_connect"
353
+ );
354
+ applyDisconnectedState(disconnectedState);
355
+
356
+ try {
357
+ await persistPluginParameters({
358
+ lambdaConnection: disconnectedState,
359
+ runtimeMode: runtimeModeSelection,
360
+ lambdaFullMode: runtimeModeSelection === "lambda",
361
+ });
362
+ debugLogger.log("Persisted disconnected lambda state from connect");
363
+ } catch (persistError) {
364
+ debugLogger.warn(
365
+ "Failed to persist disconnected lambda state from connect",
366
+ persistError
367
+ );
368
+ }
369
+ } else if (isRecordBinWebhookSyncError(connectError)) {
370
+ debugLogger.warn("Record Bin webhook synchronization failed", connectError);
371
+ applyWebhookSyncErrorState(connectError, "connect");
372
+ } else {
373
+ debugLogger.error(
374
+ "Unexpected error while connecting lambda function",
375
+ connectError
376
+ );
377
+ setConnectionErrorSummary("Unexpected error while connecting lambda.");
378
+ setConnectionErrorDetails([
379
+ "Unexpected error while connecting lambda.",
380
+ `Failure details: ${connectError instanceof Error ? connectError.message : "Unknown error"}`,
381
+ ]);
382
+ setShowConnectionDetails(false);
383
+ }
384
+ } finally {
385
+ setIsConnecting(false);
386
+ }
387
+ };
388
+
389
+ const disconnectCurrentLambdaHandler = async () => {
390
+ const previousActiveDeploymentUrl = activeDeploymentUrl;
391
+
392
+ debugLogger.log("Disconnecting current lambda function", {
393
+ activeDeploymentUrl,
394
+ });
395
+
396
+ setIsDisconnecting(true);
397
+ clearConnectionErrorState();
398
+
399
+ let webhookWasRemoved = false;
400
+
401
+ try {
402
+ const webhookRemovalResult = await removeRecordBinWebhook({
403
+ currentUserAccessToken: ctx.currentUserAccessToken,
404
+ canManageWebhooks,
405
+ environment: ctx.environment,
406
+ });
407
+ webhookWasRemoved = webhookRemovalResult.action === "deleted";
408
+ debugLogger.log("Record Bin webhook synchronized on disconnect", {
409
+ action: webhookRemovalResult.action,
410
+ webhookId: webhookRemovalResult.webhookId,
411
+ });
412
+
413
+ await persistPluginParameters({
414
+ deploymentURL: "",
415
+ vercelURL: "",
416
+ lambdaConnection: null,
417
+ runtimeMode: runtimeModeSelection,
418
+ lambdaFullMode: runtimeModeSelection === "lambda",
419
+ });
420
+ setDeploymentUrlInput("");
421
+ setActiveDeploymentUrl("");
422
+ setConnectionState(undefined);
423
+ clearConnectionErrorState();
424
+ debugLogger.log("Current lambda function disconnected");
425
+ ctx.notice("Current lambda function has been disconnected.");
426
+ } catch (disconnectError) {
427
+ if (webhookWasRemoved && previousActiveDeploymentUrl.trim()) {
428
+ try {
429
+ const webhookRestoreResult = await ensureRecordBinWebhook({
430
+ currentUserAccessToken: ctx.currentUserAccessToken,
431
+ canManageWebhooks,
432
+ environment: ctx.environment,
433
+ lambdaBaseUrl: previousActiveDeploymentUrl,
434
+ });
435
+ debugLogger.warn(
436
+ "Restored Record Bin webhook after disconnect failure",
437
+ webhookRestoreResult
438
+ );
439
+ } catch (restoreError) {
440
+ debugLogger.error(
441
+ "Failed to restore Record Bin webhook after disconnect failure",
442
+ restoreError
443
+ );
444
+ }
445
+ }
446
+
447
+ if (isRecordBinWebhookSyncError(disconnectError)) {
448
+ debugLogger.warn(
449
+ "Failed to synchronize Record Bin webhook on disconnect",
450
+ disconnectError
451
+ );
452
+ applyWebhookSyncErrorState(disconnectError, "disconnect");
453
+ } else {
454
+ debugLogger.warn(
455
+ "Failed to disconnect current lambda function",
456
+ disconnectError
457
+ );
458
+ setConnectionErrorSummary("Could not disconnect the current lambda.");
459
+ setConnectionErrorDetails([
460
+ "Could not disconnect the current lambda function.",
461
+ `Failure details: ${disconnectError instanceof Error ? disconnectError.message : "Unknown error"}`,
462
+ ]);
463
+ setShowConnectionDetails(false);
464
+ }
465
+
466
+ await ctx.alert("Could not disconnect the current lambda function.");
467
+ } finally {
468
+ setIsDisconnecting(false);
469
+ }
470
+ };
471
+
472
+ const handleDeployProviderClick = (provider: DeployProvider) => {
473
+ const option = DEPLOY_PROVIDER_OPTIONS.find(
474
+ (candidate) => candidate.provider === provider
475
+ );
476
+ if (!option) {
477
+ return;
478
+ }
479
+
480
+ debugLogger.log("Opening deploy helper from config", { provider });
481
+ window.open(option.url, "_blank", "noreferrer");
482
+ };
15
483
 
16
484
  const deletionHandler = async () => {
17
- const userInput = parseInt(numberOfDays as string);
485
+ const userInput = parseInt(numberOfDays as string, 10);
486
+ debugLogger.log("Saving plugin settings", {
487
+ numberOfDays,
488
+ parsedNumberOfDays: userInput,
489
+ debugEnabled,
490
+ runtimeModeSelection,
491
+ });
492
+
18
493
  if (isNaN(userInput)) {
19
- setError("Days must be an integerer number");
494
+ setError("Days must be an integer number");
495
+ debugLogger.warn("Cannot save settings: numberOfDays is not a number");
496
+ return;
497
+ }
498
+
499
+ const hasConnectedLambdaForSave =
500
+ runtimeModeSelection !== "lambda" ||
501
+ (activeDeploymentUrl.trim().length > 0 &&
502
+ connectionState?.status === "connected" &&
503
+ !isHealthChecking &&
504
+ !isConnecting);
505
+ if (!hasConnectedLambdaForSave) {
506
+ await ctx.alert(
507
+ "Cannot save while 'Also save records deleted from the API' is enabled unless the Lambda URL is connected and ping status is Connected."
508
+ );
20
509
  return;
21
510
  }
22
511
 
23
512
  setLoading(true);
24
513
 
25
- await ctx.updatePluginParameters({
26
- ...ctx.plugin.attributes.parameters,
27
- automaticBinCleanup: { numberOfDays: userInput, timeStamp: "" },
28
- });
514
+ try {
515
+ let persistedDeploymentUrl = activeDeploymentUrl.trim();
516
+ let persistedConnectionState = connectionState ?? null;
29
517
 
30
- ctx.notice(
31
- `All records older than ${numberOfDays} days in the bin will be daily deleted.`
32
- );
518
+ if (runtimeModeSelection === "lambdaless" && persistedDeploymentUrl) {
519
+ debugLogger.log(
520
+ "Switching to Lambda-less mode: attempting to remove managed webhook and clear lambda URL"
521
+ );
522
+ try {
523
+ const webhookRemovalResult = await removeRecordBinWebhook({
524
+ currentUserAccessToken: ctx.currentUserAccessToken,
525
+ canManageWebhooks,
526
+ environment: ctx.environment,
527
+ });
528
+ debugLogger.log(
529
+ "Managed Record Bin webhook synchronized while switching to Lambda-less mode",
530
+ webhookRemovalResult
531
+ );
532
+ } catch (webhookRemovalError) {
533
+ debugLogger.warn(
534
+ "Could not remove managed webhook while switching to Lambda-less mode",
535
+ webhookRemovalError
536
+ );
537
+ await ctx.notice(
538
+ "Runtime was switched to Lambda-less, but the managed webhook could not be removed automatically. If you see duplicate captures, remove the webhook manually."
539
+ );
540
+ }
33
541
 
34
- setLoading(false);
542
+ persistedDeploymentUrl = "";
543
+ persistedConnectionState = null;
544
+ setDeploymentUrlInput("");
545
+ setActiveDeploymentUrl("");
546
+ setConnectionState(undefined);
547
+ clearConnectionErrorState();
548
+ }
549
+
550
+ await persistPluginParameters({
551
+ debug: debugEnabled,
552
+ automaticBinCleanup: { numberOfDays: userInput, timeStamp: "" },
553
+ runtimeMode: runtimeModeSelection,
554
+ lambdaFullMode: runtimeModeSelection === "lambda",
555
+ deploymentURL: persistedDeploymentUrl,
556
+ vercelURL: persistedDeploymentUrl,
557
+ lambdaConnection: persistedConnectionState,
558
+ });
559
+ debugLogger.log("Plugin settings saved", {
560
+ numberOfDays: userInput,
561
+ runtimeModeSelection,
562
+ });
563
+
564
+ ctx.notice(
565
+ `Settings saved. Runtime mode: ${runtimeModeSelection === "lambda" ? "Lambda-full" : "Lambda-less"}. All records older than ${numberOfDays} days in the bin will be daily deleted. Debug logging is ${debugEnabled ? "enabled" : "disabled"}.`
566
+ );
567
+ setSavedFormValues({
568
+ numberOfDays: String(userInput),
569
+ debugEnabled,
570
+ runtimeMode: runtimeModeSelection,
571
+ });
572
+ } catch (saveError) {
573
+ debugLogger.warn("Failed to save plugin settings", saveError);
574
+ await ctx.alert("Could not save plugin settings.");
575
+ } finally {
576
+ setLoading(false);
577
+ }
35
578
  };
36
579
 
580
+ const isLambdaFullModeEnabled = runtimeModeSelection === "lambda";
581
+ const pingIndicator = isHealthChecking || isConnecting
582
+ ? {
583
+ label: "Checking ping...",
584
+ color: "var(--warning-color)",
585
+ }
586
+ : connectionState?.status === "connected"
587
+ ? {
588
+ label: "Connected (ping successful)",
589
+ color: "var(--notice-color)",
590
+ }
591
+ : connectionState?.status === "disconnected"
592
+ ? {
593
+ label: "Disconnected (ping failed)",
594
+ color: "var(--alert-color)",
595
+ }
596
+ : activeDeploymentUrl
597
+ ? {
598
+ label: "Connection pending",
599
+ color: "var(--light-body-color)",
600
+ }
601
+ : {
602
+ label: "Disconnected (no lambda URL configured)",
603
+ color: "var(--light-body-color)",
604
+ };
605
+ const hasActiveDeploymentUrl = activeDeploymentUrl.trim().length > 0;
606
+ const connectButtonLabel = isConnecting
607
+ ? hasActiveDeploymentUrl
608
+ ? "Changing Lambda URL..."
609
+ : "Connecting..."
610
+ : hasActiveDeploymentUrl
611
+ ? "Change Lambda URL"
612
+ : "Connect";
613
+ const disconnectButtonLabel = isDisconnecting ? "Disconnecting..." : "Disconnect";
614
+ const lambdaActionButtonStyle = {
615
+ width: "100%",
616
+ height: "40px",
617
+ fontSize: "var(--font-size-m)",
618
+ fontWeight: 500,
619
+ lineHeight: "1",
620
+ padding: "0 var(--spacing-m)",
621
+ display: "inline-flex",
622
+ alignItems: "center",
623
+ justifyContent: "center",
624
+ boxSizing: "border-box",
625
+ flex: "1 1 0",
626
+ whiteSpace: "nowrap",
627
+ };
628
+ const cardStyle = {
629
+ border: "1px solid var(--border-color)",
630
+ borderRadius: "6px",
631
+ background: "#fff",
632
+ padding: "var(--spacing-l)",
633
+ marginBottom: "var(--spacing-l)",
634
+ textAlign: "left",
635
+ };
636
+ const subtleTextStyle = {
637
+ margin: 0,
638
+ color: "var(--light-body-color)",
639
+ fontSize: "var(--font-size-xs)",
640
+ };
641
+ const infoTextStyle = {
642
+ marginTop: 0,
643
+ marginBottom: "var(--spacing-s)",
644
+ color: "var(--base-body-color)",
645
+ fontSize: "var(--font-size-s)",
646
+ };
647
+ const advancedSettingsStyle = {
648
+ display: "flex",
649
+ flexDirection: "column",
650
+ gap: "var(--spacing-m)",
651
+ };
652
+ const switchFieldNoHintGapStyle = {
653
+ "--spacing-s": "0",
654
+ } as CSSProperties;
655
+ const switchFieldNoHintGapStyleWithExtraSpacing = {
656
+ ...switchFieldNoHintGapStyle,
657
+ marginBottom: "0.25rem",
658
+ } as CSSProperties;
659
+ const lambdaSetupDisabled =
660
+ isConnecting || isDisconnecting || isHealthChecking || isLoading;
661
+ const hasUnsavedChanges =
662
+ numberOfDays !== savedFormValues.numberOfDays ||
663
+ debugEnabled !== savedFormValues.debugEnabled ||
664
+ runtimeModeSelection !== savedFormValues.runtimeMode;
665
+ const canSaveWithLambdaMode =
666
+ !isLambdaFullModeEnabled ||
667
+ (hasActiveDeploymentUrl &&
668
+ connectionState?.status === "connected" &&
669
+ !isHealthChecking &&
670
+ !isConnecting);
671
+ const lambdaSaveBlockReason = !isLambdaFullModeEnabled
672
+ ? ""
673
+ : !hasActiveDeploymentUrl
674
+ ? "To save with API capture enabled, connect a Lambda URL first."
675
+ : isHealthChecking || isConnecting
676
+ ? "Wait for the Lambda ping check to finish."
677
+ : connectionState?.status !== "connected"
678
+ ? "To save with API capture enabled, Lambda status must be Connected."
679
+ : "";
680
+
37
681
  return (
38
682
  <Canvas ctx={ctx}>
39
- <h2>Always delete all trashed records older than </h2>{" "}
40
- <Form>
41
- <TextField
42
- error={error}
43
- required
44
- name="numberOfDays"
45
- id="numberOfDays"
46
- label="Days"
47
- value={numberOfDays}
48
- onChange={(event) => {
49
- setNumberOfDays(event);
50
- setError("");
683
+ <div
684
+ style={{
685
+ maxWidth: "760px",
686
+ margin: "0 auto",
687
+ }}
688
+ >
689
+ {isLambdaFullModeEnabled && (
690
+ <div style={cardStyle}>
691
+ <h2
692
+ style={{
693
+ marginTop: 0,
694
+ marginBottom: "var(--spacing-s)",
695
+ fontSize: "var(--font-size-l)",
696
+ }}
697
+ >
698
+ Lambda setup
699
+ </h2>
700
+ <p style={infoTextStyle}>
701
+ <strong>Current URL:</strong>{" "}
702
+ <span style={{ wordBreak: "break-all" }}>
703
+ {activeDeploymentUrl || "No lambda function connected."}
704
+ </span>
705
+ </p>
706
+ <p
707
+ style={{
708
+ display: "flex",
709
+ justifyContent: "flex-start",
710
+ alignItems: "center",
711
+ gap: "var(--spacing-s)",
712
+ marginTop: 0,
713
+ marginBottom: "var(--spacing-s)",
714
+ fontSize: "var(--font-size-s)",
715
+ color: "var(--light-body-color)",
716
+ }}
717
+ >
718
+ <span
719
+ aria-hidden="true"
720
+ style={{
721
+ display: "inline-block",
722
+ width: "10px",
723
+ height: "10px",
724
+ borderRadius: "999px",
725
+ background: pingIndicator.color,
726
+ }}
727
+ />
728
+ <span>{pingIndicator.label}</span>
729
+ </p>
730
+ <p style={{ ...subtleTextStyle, marginBottom: "var(--spacing-l)" }}>
731
+ Status is based on the `/api/datocms/plugin-health` ping.
732
+ </p>
733
+
734
+ <TextField
735
+ name="deploymentURL"
736
+ id="deploymentURL"
737
+ label="Lambda URL"
738
+ value={deploymentUrlInput}
739
+ placeholder="https://record-bin.example.com/"
740
+ onChange={(newValue) => {
741
+ setDeploymentUrlInput(newValue);
742
+ clearConnectionErrorState();
743
+ }}
744
+ />
745
+
746
+ <div
747
+ style={{
748
+ display: "flex",
749
+ alignItems: "center",
750
+ flexWrap: "nowrap",
751
+ width: "100%",
752
+ gap: "var(--spacing-s)",
753
+ marginTop: "var(--spacing-l)",
754
+ }}
755
+ >
756
+ <Dropdown
757
+ style={{ flex: "1 1 0" }}
758
+ renderTrigger={({ onClick }) => (
759
+ <Button
760
+ buttonType="muted"
761
+ onClick={onClick}
762
+ disabled={
763
+ isConnecting || isHealthChecking || isDisconnecting
764
+ }
765
+ style={lambdaActionButtonStyle}
766
+ >
767
+ Deploy lambda
768
+ </Button>
769
+ )}
770
+ >
771
+ <DropdownMenu alignment="left">
772
+ {DEPLOY_PROVIDER_OPTIONS.map((option) => (
773
+ <DropdownOption
774
+ key={option.provider}
775
+ onClick={() => handleDeployProviderClick(option.provider)}
776
+ >
777
+ {option.label}
778
+ </DropdownOption>
779
+ ))}
780
+ </DropdownMenu>
781
+ </Dropdown>
782
+ <Button
783
+ onClick={disconnectCurrentLambdaHandler}
784
+ buttonType="negative"
785
+ disabled={
786
+ isDisconnecting || isHealthChecking || !activeDeploymentUrl.trim()
787
+ }
788
+ style={lambdaActionButtonStyle}
789
+ >
790
+ {disconnectButtonLabel}
791
+ </Button>
792
+ <Button
793
+ buttonType="primary"
794
+ onClick={connectLambdaHandler}
795
+ disabled={isConnecting || isHealthChecking || isDisconnecting}
796
+ style={lambdaActionButtonStyle}
797
+ >
798
+ {connectButtonLabel}
799
+ </Button>
800
+ </div>
801
+ </div>
802
+ )}
803
+
804
+ {isLambdaFullModeEnabled && connectionErrorSummary && (
805
+ <div
806
+ style={{
807
+ border: "1px solid rgba(var(--alert-color-rgb-components), 0.5)",
808
+ borderRadius: "6px",
809
+ background: "rgba(var(--alert-color-rgb-components), 0.08)",
810
+ padding: "var(--spacing-m)",
811
+ marginBottom: "var(--spacing-m)",
812
+ }}
813
+ >
814
+ <p style={{ marginTop: 0, marginBottom: "var(--spacing-s)" }}>
815
+ {connectionErrorSummary}
816
+ </p>
817
+ {connectionErrorDetails.length > 0 && (
818
+ <Button
819
+ buttonType="muted"
820
+ buttonSize="s"
821
+ onClick={() => setShowConnectionDetails((current) => !current)}
822
+ >
823
+ {showConnectionDetails ? "Hide details" : "Show details"}
824
+ </Button>
825
+ )}
826
+ </div>
827
+ )}
828
+
829
+ {isLambdaFullModeEnabled &&
830
+ showConnectionDetails &&
831
+ connectionErrorDetails.length > 0 && (
832
+ <div
833
+ style={{
834
+ border: "1px solid rgba(var(--alert-color-rgb-components), 0.5)",
835
+ borderRadius: "6px",
836
+ background: "#fff",
837
+ padding: "var(--spacing-m)",
838
+ marginBottom: "var(--spacing-l)",
839
+ textAlign: "left",
840
+ }}
841
+ >
842
+ {connectionErrorDetails.map((detail, index) => (
843
+ <p key={`config-health-error-${index}`}>{detail}</p>
844
+ ))}
845
+ </div>
846
+ )}
847
+
848
+ <h2
849
+ style={{
850
+ marginTop: 0,
851
+ marginBottom: "var(--spacing-s)",
852
+ fontSize: "var(--font-size-l)",
51
853
  }}
52
- />
53
- <Button
54
- onClick={deletionHandler}
55
- fullWidth
56
- buttonType={isLoading ? "muted" : "primary"}
57
- disabled={isLoading}
58
854
  >
59
- Save
60
- </Button>
61
- </Form>
855
+ Bin cleanup settings
856
+ </h2>
857
+ <Form>
858
+ <TextField
859
+ error={error}
860
+ required
861
+ name="numberOfDays"
862
+ id="numberOfDays"
863
+ label="Delete trashed records older than (days)"
864
+ value={numberOfDays}
865
+ onChange={(event) => {
866
+ setNumberOfDays(event);
867
+ setError("");
868
+ }}
869
+ />
870
+ <Section
871
+ title="Advanced settings"
872
+ collapsible={{
873
+ isOpen: showAdvancedSettings,
874
+ onToggle: () => setShowAdvancedSettings((current) => !current),
875
+ }}
876
+ >
877
+ <div style={advancedSettingsStyle}>
878
+ <div style={switchFieldNoHintGapStyleWithExtraSpacing}>
879
+ <SwitchField
880
+ name="debug"
881
+ id="debug"
882
+ label="Enable debug logs"
883
+ hint="When enabled, plugin events and requests are logged to the browser console."
884
+ value={debugEnabled}
885
+ onChange={(newValue) => setDebugEnabled(newValue)}
886
+ />
887
+ </div>
888
+ <div style={switchFieldNoHintGapStyle}>
889
+ <SwitchField
890
+ name="lambdaMode"
891
+ id="lambdaMode"
892
+ label="Also save records deleted from the API"
893
+ hint="If you do not know what Serverless Functions are, keep this disabled"
894
+ value={isLambdaFullModeEnabled}
895
+ switchInputProps={{
896
+ disabled: lambdaSetupDisabled,
897
+ }}
898
+ onChange={(newValue) => {
899
+ setRuntimeModeSelection(newValue ? "lambda" : "lambdaless");
900
+ clearConnectionErrorState();
901
+ }}
902
+ />
903
+ </div>
904
+ <p style={subtleTextStyle}>
905
+ <a
906
+ href={`${PLUGIN_README_URL}#runtime-modes`}
907
+ target="_blank"
908
+ rel="noreferrer"
909
+ >
910
+ Runtime mode guide and differences
911
+ </a>
912
+ </p>
913
+ {isLambdaFullModeEnabled && (
914
+ <p style={subtleTextStyle}>
915
+ To capture API deletions, connect a Lambda function above.
916
+ </p>
917
+ )}
918
+ {runtimeModeSelection === "lambdaless" && hasActiveDeploymentUrl && (
919
+ <p style={subtleTextStyle}>
920
+ Lambda is currently connected. Click Save to complete the switch to
921
+ Lambda-less and remove the managed webhook.
922
+ </p>
923
+ )}
924
+ {lambdaSaveBlockReason && (
925
+ <p
926
+ style={{
927
+ ...subtleTextStyle,
928
+ color: "var(--alert-color)",
929
+ }}
930
+ >
931
+ {lambdaSaveBlockReason}
932
+ </p>
933
+ )}
934
+ </div>
935
+ </Section>
936
+ {!showAdvancedSettings && lambdaSaveBlockReason && (
937
+ <p
938
+ style={{
939
+ ...subtleTextStyle,
940
+ marginTop: "var(--spacing-s)",
941
+ color: "var(--alert-color)",
942
+ }}
943
+ >
944
+ Open Advanced settings to configure API capture before saving.
945
+ </p>
946
+ )}
947
+ <Button
948
+ onClick={deletionHandler}
949
+ fullWidth
950
+ buttonType={isLoading ? "muted" : "primary"}
951
+ disabled={
952
+ isLoading ||
953
+ isDisconnecting ||
954
+ isConnecting ||
955
+ !canSaveWithLambdaMode ||
956
+ !hasUnsavedChanges
957
+ }
958
+ >
959
+ Save
960
+ </Button>
961
+ </Form>
962
+ </div>
62
963
  </Canvas>
63
964
  );
64
965
  }