@agent-native/core 0.120.0 → 0.120.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.
- package/corpus/README.md +1 -1
- package/corpus/core/CHANGELOG.md +7 -0
- package/corpus/core/package.json +1 -1
- package/corpus/core/src/client/MultiTabAssistantChat.tsx +45 -0
- package/corpus/core/src/client/use-chat-models.ts +45 -0
- package/corpus/core/src/server/analytics.ts +15 -0
- package/corpus/core/src/server/auth.ts +17 -13
- package/corpus/core/src/server/better-auth-instance.ts +13 -1
- package/corpus/templates/assets/app/components/layout/Sidebar.tsx +1 -1
- package/corpus/templates/assets/changelog/2026-07-23-new-chat-aligns-with-sidebar-controls.md +6 -0
- package/corpus/templates/forms/.agents/skills/form-publishing/SKILL.md +9 -3
- package/corpus/templates/forms/AGENTS.md +11 -1
- package/corpus/templates/forms/README.md +24 -1
- package/corpus/templates/forms/actions/update-form.ts +12 -5
- package/corpus/templates/forms/app/components/builder/FieldPropertiesPanel.tsx +199 -1
- package/corpus/templates/forms/app/i18n/en-US.ts +13 -1
- package/corpus/templates/forms/app/pages/FormBuilderPage.tsx +35 -4
- package/corpus/templates/forms/app/pages/FormFillPage.tsx +7 -16
- package/corpus/templates/forms/changelog/2026-07-23-forms-can-branch-into-follow-up-questions-and-route-response.md +6 -0
- package/corpus/templates/forms/server/handlers/submissions.ts +22 -31
- package/corpus/templates/forms/server/lib/integrations.ts +11 -2
- package/corpus/templates/forms/server/lib/public-form-ssr.ts +26 -5
- package/corpus/templates/forms/server/lib/validate-fields.ts +48 -11
- package/corpus/templates/forms/server/plugins/agent-chat.ts +1 -0
- package/corpus/templates/forms/shared/conditional.ts +67 -0
- package/dist/client/MultiTabAssistantChat.d.ts.map +1 -1
- package/dist/client/MultiTabAssistantChat.js +35 -0
- package/dist/client/MultiTabAssistantChat.js.map +1 -1
- package/dist/client/use-chat-models.d.ts +1 -0
- package/dist/client/use-chat-models.d.ts.map +1 -1
- package/dist/client/use-chat-models.js +33 -0
- package/dist/client/use-chat-models.js.map +1 -1
- package/dist/file-upload/actions/upload-image.d.ts +1 -1
- package/dist/observability/routes.d.ts +2 -2
- package/dist/progress/routes.d.ts +1 -1
- package/dist/provider-api/actions/custom-provider-registration.d.ts +6 -6
- package/dist/provider-api/actions/provider-api.d.ts +9 -9
- package/dist/provider-api/corpus-jobs.d.ts +2 -2
- package/dist/server/analytics.d.ts +8 -0
- package/dist/server/analytics.d.ts.map +1 -1
- package/dist/server/analytics.js +16 -0
- package/dist/server/analytics.js.map +1 -1
- package/dist/server/auth.d.ts.map +1 -1
- package/dist/server/auth.js +5 -4
- package/dist/server/auth.js.map +1 -1
- package/dist/server/better-auth-instance.d.ts +8 -0
- package/dist/server/better-auth-instance.d.ts.map +1 -1
- package/dist/server/better-auth-instance.js +10 -1
- package/dist/server/better-auth-instance.js.map +1 -1
- package/package.json +1 -1
- package/src/client/MultiTabAssistantChat.tsx +45 -0
- package/src/client/use-chat-models.ts +45 -0
- package/src/server/analytics.ts +15 -0
- package/src/server/auth.ts +17 -13
- package/src/server/better-auth-instance.ts +13 -1
package/corpus/README.md
CHANGED
package/corpus/core/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# @agent-native/core
|
|
2
2
|
|
|
3
|
+
## 0.120.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 5477352: Keep Dispatch's overview composer, full-page chat, and side chat on the same selected model.
|
|
8
|
+
- 5477352: Inject the configured Google Analytics tag into framework-owned login and signup pages.
|
|
9
|
+
|
|
3
10
|
## 0.120.0
|
|
4
11
|
|
|
5
12
|
### Minor Changes
|
package/corpus/core/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agent-native/core",
|
|
3
|
-
"version": "0.120.
|
|
3
|
+
"version": "0.120.1",
|
|
4
4
|
"description": "Framework for agent-native application development — where AI agents and UI share SQL state, actions, and context",
|
|
5
5
|
"homepage": "https://github.com/BuilderIO/agent-native#readme",
|
|
6
6
|
"bugs": {
|
|
@@ -56,6 +56,7 @@ import { isTrustedFrameMessage } from "./frame.js";
|
|
|
56
56
|
import { RunStuckBanner } from "./RunStuckBanner.js";
|
|
57
57
|
import { callAction } from "./use-action.js";
|
|
58
58
|
import { useChangeVersion } from "./use-change-version.js";
|
|
59
|
+
import { CHAT_MODEL_SELECTION_CHANGED_EVENT } from "./use-chat-models.js";
|
|
59
60
|
import {
|
|
60
61
|
useChatThreads,
|
|
61
62
|
type ChatThreadScope,
|
|
@@ -138,6 +139,13 @@ function writeStoredModelSelection(key: string, selection: ModelSelection) {
|
|
|
138
139
|
if (typeof window === "undefined") return;
|
|
139
140
|
try {
|
|
140
141
|
window.localStorage.setItem(key, JSON.stringify(selection));
|
|
142
|
+
queueMicrotask(() => {
|
|
143
|
+
window.dispatchEvent(
|
|
144
|
+
new CustomEvent(CHAT_MODEL_SELECTION_CHANGED_EVENT, {
|
|
145
|
+
detail: { key },
|
|
146
|
+
}),
|
|
147
|
+
);
|
|
148
|
+
});
|
|
141
149
|
} catch {}
|
|
142
150
|
}
|
|
143
151
|
|
|
@@ -954,6 +962,43 @@ export function MultiTabAssistantChat({
|
|
|
954
962
|
const bumpModelSelectionVersion = useCallback(() => {
|
|
955
963
|
setModelSelectionVersion((version) => version + 1);
|
|
956
964
|
}, []);
|
|
965
|
+
|
|
966
|
+
useEffect(() => {
|
|
967
|
+
if (typeof window === "undefined") return;
|
|
968
|
+
|
|
969
|
+
const syncPersistedSelection = (event?: Event) => {
|
|
970
|
+
const detail = (event as CustomEvent<{ key?: string }> | undefined)
|
|
971
|
+
?.detail;
|
|
972
|
+
if (detail?.key && detail.key !== modelSelectionKey) return;
|
|
973
|
+
|
|
974
|
+
const next = readStoredModelSelection(modelSelectionKey);
|
|
975
|
+
if (!next) return;
|
|
976
|
+
|
|
977
|
+
const activeThreadId = activeThreadIdRef.current;
|
|
978
|
+
if (activeThreadId) {
|
|
979
|
+
threadModelRef.current.set(activeThreadId, next);
|
|
980
|
+
}
|
|
981
|
+
setPersistedModelSelection(next);
|
|
982
|
+
bumpModelSelectionVersion();
|
|
983
|
+
};
|
|
984
|
+
const handleStorage = (event: StorageEvent) => {
|
|
985
|
+
if (event.key === modelSelectionKey) syncPersistedSelection();
|
|
986
|
+
};
|
|
987
|
+
|
|
988
|
+
window.addEventListener(
|
|
989
|
+
CHAT_MODEL_SELECTION_CHANGED_EVENT,
|
|
990
|
+
syncPersistedSelection,
|
|
991
|
+
);
|
|
992
|
+
window.addEventListener("storage", handleStorage);
|
|
993
|
+
return () => {
|
|
994
|
+
window.removeEventListener(
|
|
995
|
+
CHAT_MODEL_SELECTION_CHANGED_EVENT,
|
|
996
|
+
syncPersistedSelection,
|
|
997
|
+
);
|
|
998
|
+
window.removeEventListener("storage", handleStorage);
|
|
999
|
+
};
|
|
1000
|
+
}, [bumpModelSelectionVersion, modelSelectionKey]);
|
|
1001
|
+
|
|
957
1002
|
const postMessageSubmissionsDisabled = props.composerDisabled === true;
|
|
958
1003
|
|
|
959
1004
|
const setContextInTab = useCallback(
|
|
@@ -42,6 +42,8 @@ interface Options {
|
|
|
42
42
|
}
|
|
43
43
|
|
|
44
44
|
const DEFAULT_STORAGE_KEY = "agent-native:chat-models:selection";
|
|
45
|
+
export const CHAT_MODEL_SELECTION_CHANGED_EVENT =
|
|
46
|
+
"agent-native:chat-model-selection-changed";
|
|
45
47
|
|
|
46
48
|
interface PersistedSelection {
|
|
47
49
|
model?: string;
|
|
@@ -63,6 +65,13 @@ function writePersisted(key: string | null, value: PersistedSelection) {
|
|
|
63
65
|
if (!key || typeof window === "undefined") return;
|
|
64
66
|
try {
|
|
65
67
|
window.localStorage.setItem(key, JSON.stringify(value));
|
|
68
|
+
queueMicrotask(() => {
|
|
69
|
+
window.dispatchEvent(
|
|
70
|
+
new CustomEvent(CHAT_MODEL_SELECTION_CHANGED_EVENT, {
|
|
71
|
+
detail: { key },
|
|
72
|
+
}),
|
|
73
|
+
);
|
|
74
|
+
});
|
|
66
75
|
} catch {}
|
|
67
76
|
}
|
|
68
77
|
|
|
@@ -110,6 +119,42 @@ export function useChatModels({
|
|
|
110
119
|
};
|
|
111
120
|
}, [selectedEffort, selectedEngine, selectedModel]);
|
|
112
121
|
|
|
122
|
+
useEffect(() => {
|
|
123
|
+
if (!storageKey || typeof window === "undefined") return;
|
|
124
|
+
|
|
125
|
+
const syncPersistedSelection = (event?: Event) => {
|
|
126
|
+
const detail = (event as CustomEvent<{ key?: string }> | undefined)
|
|
127
|
+
?.detail;
|
|
128
|
+
if (detail?.key && detail.key !== storageKey) return;
|
|
129
|
+
|
|
130
|
+
const next = readPersisted(storageKey);
|
|
131
|
+
if (!next.model) return;
|
|
132
|
+
|
|
133
|
+
hasExplicitSelectionRef.current = true;
|
|
134
|
+
setSelectedModel(next.model);
|
|
135
|
+
setSelectedEngine(next.engine ?? "");
|
|
136
|
+
setSelectedEffort(
|
|
137
|
+
resolveReasoningEffortSelection(next.model, next.effort),
|
|
138
|
+
);
|
|
139
|
+
};
|
|
140
|
+
const handleStorage = (event: StorageEvent) => {
|
|
141
|
+
if (event.key === storageKey) syncPersistedSelection();
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
window.addEventListener(
|
|
145
|
+
CHAT_MODEL_SELECTION_CHANGED_EVENT,
|
|
146
|
+
syncPersistedSelection,
|
|
147
|
+
);
|
|
148
|
+
window.addEventListener("storage", handleStorage);
|
|
149
|
+
return () => {
|
|
150
|
+
window.removeEventListener(
|
|
151
|
+
CHAT_MODEL_SELECTION_CHANGED_EVENT,
|
|
152
|
+
syncPersistedSelection,
|
|
153
|
+
);
|
|
154
|
+
window.removeEventListener("storage", handleStorage);
|
|
155
|
+
};
|
|
156
|
+
}, [storageKey]);
|
|
157
|
+
|
|
113
158
|
const onModelChange = useCallback(
|
|
114
159
|
(model: string, engine: string) => {
|
|
115
160
|
hasExplicitSelectionRef.current = true;
|
|
@@ -74,6 +74,21 @@ function getGaScript(): string | null {
|
|
|
74
74
|
);
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
+
/**
|
|
78
|
+
* Add the configured analytics scripts to a complete HTML document.
|
|
79
|
+
*
|
|
80
|
+
* The normal app document is streamed through `wrapWithAnalytics`, but
|
|
81
|
+
* framework-owned documents such as `/signup` are returned as strings by the
|
|
82
|
+
* auth guard and need the same injection path.
|
|
83
|
+
*/
|
|
84
|
+
export function injectAnalyticsIntoHtml(html: string): string {
|
|
85
|
+
const script = getGaScript();
|
|
86
|
+
if (!script) return html;
|
|
87
|
+
const headCloseIdx = html.indexOf("</head>");
|
|
88
|
+
if (headCloseIdx === -1) return html;
|
|
89
|
+
return html.slice(0, headCloseIdx) + script + html.slice(headCloseIdx);
|
|
90
|
+
}
|
|
91
|
+
|
|
77
92
|
export function wrapWithAnalytics(body: ReadableStream): ReadableStream {
|
|
78
93
|
const scripts = [getGaScript()].filter(Boolean).join("");
|
|
79
94
|
if (!scripts) return body;
|
|
@@ -98,6 +98,7 @@ import {
|
|
|
98
98
|
type WorkspaceAppAudience,
|
|
99
99
|
} from "../shared/workspace-app-audience.js";
|
|
100
100
|
import { isValidWorkspaceAppIdFormat } from "../shared/workspace-app-id.js";
|
|
101
|
+
import { injectAnalyticsIntoHtml } from "./analytics.js";
|
|
101
102
|
import { signupAttributionFromCookieHeader } from "./attribution.js";
|
|
102
103
|
import { getBetterAuth, getBetterAuthSync } from "./better-auth-instance.js";
|
|
103
104
|
import type { BetterAuthConfig } from "./better-auth-instance.js";
|
|
@@ -1646,20 +1647,23 @@ function injectLoginSocialImageMeta(loginHtml: string, event: H3Event): string {
|
|
|
1646
1647
|
}
|
|
1647
1648
|
|
|
1648
1649
|
function loginHtmlResponse(loginHtml: string, event: H3Event): Response {
|
|
1649
|
-
return new Response(
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1650
|
+
return new Response(
|
|
1651
|
+
injectAnalyticsIntoHtml(injectLoginSocialImageMeta(loginHtml, event)),
|
|
1652
|
+
{
|
|
1653
|
+
status: 200,
|
|
1654
|
+
headers: {
|
|
1655
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
1656
|
+
// The sign-in document is part of the public server shell. Keep it on the
|
|
1657
|
+
// same long-fresh/long-SWR CDN policy as React Router SSR so hosted
|
|
1658
|
+
// template roots do not invoke origin just to render anonymous login UI.
|
|
1659
|
+
// The login markup is env-INDEPENDENT (a Google-only app always renders
|
|
1660
|
+
// a working button); the analytics script is public build configuration,
|
|
1661
|
+
// not user/session state. Never downgrade this to private/no-store.
|
|
1662
|
+
...DEFAULT_SSR_CACHE_HEADERS,
|
|
1663
|
+
"X-Robots-Tag": "noindex, nofollow",
|
|
1664
|
+
},
|
|
1661
1665
|
},
|
|
1662
|
-
|
|
1666
|
+
);
|
|
1663
1667
|
}
|
|
1664
1668
|
|
|
1665
1669
|
function isHtmlDocumentRequest(event: H3Event, pathname: string): boolean {
|
|
@@ -1109,6 +1109,18 @@ async function createBetterAuthInstance(
|
|
|
1109
1109
|
return auth as unknown as BetterAuthInstance;
|
|
1110
1110
|
}
|
|
1111
1111
|
|
|
1112
|
+
/**
|
|
1113
|
+
* Configure the local auth connection with the same write contention settings
|
|
1114
|
+
* as the shared app connection. Better Auth uses its own SQLite handle, so the
|
|
1115
|
+
* app connection's busy timeout does not protect first-run account creation.
|
|
1116
|
+
*/
|
|
1117
|
+
export function configureLocalSqlite(sqlite: {
|
|
1118
|
+
pragma(statement: string): unknown;
|
|
1119
|
+
}): void {
|
|
1120
|
+
sqlite.pragma("busy_timeout = 10000");
|
|
1121
|
+
sqlite.pragma("journal_mode = WAL");
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1112
1124
|
async function buildDatabaseConfig(
|
|
1113
1125
|
dialect: string,
|
|
1114
1126
|
): Promise<BetterAuthOptions["database"]> {
|
|
@@ -1182,7 +1194,7 @@ async function buildDatabaseConfig(
|
|
|
1182
1194
|
const { default: Database } = await import("better-sqlite3");
|
|
1183
1195
|
const filePath = url.replace(/^file:/, "");
|
|
1184
1196
|
const sqlite = new Database(filePath);
|
|
1185
|
-
sqlite
|
|
1197
|
+
configureLocalSqlite(sqlite);
|
|
1186
1198
|
const { drizzle } = await import("drizzle-orm/better-sqlite3");
|
|
1187
1199
|
const db = drizzle(sqlite, { schema: sqliteAuthSchema });
|
|
1188
1200
|
const { drizzleAdapter } = await import("better-auth/adapters/drizzle");
|
|
@@ -77,7 +77,7 @@ Each form has a `settings` JSON object:
|
|
|
77
77
|
| `showProgressBar` | boolean | Show progress bar for multi-section forms |
|
|
78
78
|
| `emailOnNewResponses` | boolean | Email the form owner's account when someone submits a response |
|
|
79
79
|
| `anonymous` | boolean | Suppress IP, submitter identity, chat/run ids, page URL, and client-surface metadata for every response |
|
|
80
|
-
| `integrations` | array | Webhook/Slack/Discord notification configs |
|
|
80
|
+
| `integrations` | array | Webhook/Slack/Discord/Google Sheets notification configs |
|
|
81
81
|
|
|
82
82
|
For a genuinely anonymous form, set `anonymous: true` when creating the form.
|
|
83
83
|
Do not describe an ordinary published form as anonymous: published forms accept
|
|
@@ -91,9 +91,15 @@ Forms can notify external services on submission:
|
|
|
91
91
|
| Type | Description |
|
|
92
92
|
| --------------- | ------------------------------- |
|
|
93
93
|
| `webhook` | POST JSON to any URL |
|
|
94
|
-
| `slack` | Send to a Slack channel
|
|
94
|
+
| `slack` | Send to a Slack channel through an Incoming Webhook URL |
|
|
95
95
|
| `discord` | Send to a Discord webhook |
|
|
96
|
-
| `google-sheets` |
|
|
96
|
+
| `google-sheets` | Send response JSON to a deployed Apps Script `/exec` URL |
|
|
97
|
+
|
|
98
|
+
These are outbound form destinations configured in the form builder's
|
|
99
|
+
**Integrations** tab. They are separate from the managed Slack/Messaging
|
|
100
|
+
connection. Google Sheets Apps Script handlers should parse
|
|
101
|
+
`JSON.parse(e.postData.contents)`; a spreadsheet URL or `/dev` URL will not
|
|
102
|
+
receive submissions.
|
|
97
103
|
|
|
98
104
|
## Related Skills
|
|
99
105
|
|
|
@@ -40,7 +40,17 @@ ladder.
|
|
|
40
40
|
- To email the form owner when someone submits a response, set
|
|
41
41
|
`settings.emailOnNewResponses: true` through `create-form` or `update-form`.
|
|
42
42
|
Delivery uses the configured framework email provider (`RESEND_API_KEY` or
|
|
43
|
-
`SENDGRID_API_KEY`)
|
|
43
|
+
`SENDGRID_API_KEY`) in the form owner's request context and sends to the form
|
|
44
|
+
owner's account email. A public submission can succeed even when delivery
|
|
45
|
+
fails, so check the server logs and provider configuration when debugging.
|
|
46
|
+
- Conditional fields use `conditional: { fieldId, operator, value }`. The
|
|
47
|
+
`fieldId` must reference an earlier field; supported operators are `equals`,
|
|
48
|
+
`not_equals`, and `contains`. Hidden fields and their stale values are not
|
|
49
|
+
persisted or delivered to integrations.
|
|
50
|
+
- Form integrations are outbound webhooks. Slack requires an Incoming Webhook
|
|
51
|
+
URL, and Google Sheets requires a deployed Google Apps Script `/exec` URL that
|
|
52
|
+
parses `JSON.parse(e.postData.contents)` and appends the received values.
|
|
53
|
+
They are separate from the managed Slack/Messaging connection.
|
|
44
54
|
- Form UX should stay focused: clear labels, sensible validation, minimal
|
|
45
55
|
required fields, and progressive disclosure for advanced settings.
|
|
46
56
|
- Public form submission endpoints must be intentionally public; keep management
|
|
@@ -17,7 +17,8 @@ them.
|
|
|
17
17
|
- Shipped field types: text, email, number, long text, select, multi-select,
|
|
18
18
|
checkbox, radio, date, rating, and scale.
|
|
19
19
|
- Submissions stored in SQL with a per-response detail view and dashboard.
|
|
20
|
-
- Route submissions to webhooks, Slack, Discord, or Google Sheets
|
|
20
|
+
- Route submissions to webhooks, Slack, Discord, or Google Sheets via Apps
|
|
21
|
+
Script.
|
|
21
22
|
- Publish public form URLs with a thank-you message.
|
|
22
23
|
|
|
23
24
|
## Develop locally
|
|
@@ -32,3 +33,25 @@ pnpm dev
|
|
|
32
33
|
```
|
|
33
34
|
|
|
34
35
|
Full docs: [agent-native.com/docs/template-forms](https://agent-native.com/docs/template-forms).
|
|
36
|
+
|
|
37
|
+
## Submission destinations
|
|
38
|
+
|
|
39
|
+
Open a form's **Integrations** tab and save a destination before publishing.
|
|
40
|
+
Slack uses an Incoming Webhook URL. Google Sheets uses a deployed Apps Script
|
|
41
|
+
web app URL ending in `/exec` (not a spreadsheet URL or `/dev` URL). The script
|
|
42
|
+
should parse the JSON body and append the values, for example:
|
|
43
|
+
|
|
44
|
+
```ts
|
|
45
|
+
function doPost(e) {
|
|
46
|
+
const payload = JSON.parse(e.postData.contents);
|
|
47
|
+
SpreadsheetApp.getActiveSpreadsheet()
|
|
48
|
+
.getSheets()[0]
|
|
49
|
+
.appendRow([payload.submittedAt, payload.formTitle, payload.responseId]);
|
|
50
|
+
return ContentService.createTextOutput(JSON.stringify({ ok: true }));
|
|
51
|
+
}
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Deploy it as a web app that executes as you and allows anyone with the URL to
|
|
55
|
+
access it, then paste the deployed `/exec` URL into Forms. The destination
|
|
56
|
+
receives the form metadata plus one property per field label; duplicate labels
|
|
57
|
+
are disambiguated with the field ID.
|
|
@@ -85,22 +85,29 @@ export default defineAction({
|
|
|
85
85
|
updates.fields = JSON.stringify(parsedFields);
|
|
86
86
|
}
|
|
87
87
|
if (args.settings !== undefined) {
|
|
88
|
-
let
|
|
88
|
+
let incomingSettings: FormSettings;
|
|
89
89
|
if (typeof args.settings === "string") {
|
|
90
90
|
try {
|
|
91
|
-
|
|
92
|
-
updates.settings = args.settings;
|
|
91
|
+
incomingSettings = JSON.parse(args.settings) as FormSettings;
|
|
93
92
|
} catch {
|
|
94
93
|
throw new Error("--settings must be valid JSON");
|
|
95
94
|
}
|
|
96
95
|
} else {
|
|
97
|
-
|
|
98
|
-
updates.settings = JSON.stringify(args.settings);
|
|
96
|
+
incomingSettings = args.settings as unknown as FormSettings;
|
|
99
97
|
}
|
|
98
|
+
let existingSettings: FormSettings = {};
|
|
99
|
+
try {
|
|
100
|
+
existingSettings = JSON.parse(existing.settings) as FormSettings;
|
|
101
|
+
} catch {
|
|
102
|
+
// Keep malformed legacy settings recoverable by replacing them with
|
|
103
|
+
// the valid settings supplied by this update.
|
|
104
|
+
}
|
|
105
|
+
const parsedSettings = { ...existingSettings, ...incomingSettings };
|
|
100
106
|
// Reject blocked integration URLs at save time (private IPs,
|
|
101
107
|
// cloud-metadata, non-http(s) schemes). fireIntegrations also
|
|
102
108
|
// re-checks at runtime as defense-in-depth.
|
|
103
109
|
assertIntegrationUrlsAllowed(parsedSettings);
|
|
110
|
+
updates.settings = JSON.stringify(parsedSettings);
|
|
104
111
|
}
|
|
105
112
|
if (args.status !== undefined) updates.status = args.status;
|
|
106
113
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useT } from "@agent-native/core/client/i18n";
|
|
2
|
-
import type { FormField, FormFieldType } from "@shared/types";
|
|
2
|
+
import type { ConditionalRule, FormField, FormFieldType } from "@shared/types";
|
|
3
3
|
import { IconPlus, IconX } from "@tabler/icons-react";
|
|
4
4
|
import { useState } from "react";
|
|
5
5
|
|
|
@@ -19,6 +19,7 @@ import { Textarea } from "@/components/ui/textarea";
|
|
|
19
19
|
|
|
20
20
|
interface FieldPropertiesPanelProps {
|
|
21
21
|
field: FormField;
|
|
22
|
+
fields: FormField[];
|
|
22
23
|
onChange: (field: FormField) => void;
|
|
23
24
|
onDelete: () => void;
|
|
24
25
|
}
|
|
@@ -39,13 +40,48 @@ const fieldTypeLabels: Record<FormFieldType, string> = {
|
|
|
39
40
|
|
|
40
41
|
const hasOptions: FormFieldType[] = ["select", "multiselect", "radio"];
|
|
41
42
|
|
|
43
|
+
function conditionValueOptions(source: FormField | undefined): string[] {
|
|
44
|
+
if (!source) return [];
|
|
45
|
+
if (hasOptions.includes(source.type)) {
|
|
46
|
+
return (source.options || []).filter(
|
|
47
|
+
(option, index, options) =>
|
|
48
|
+
Boolean(option.trim()) && options.indexOf(option) === index,
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
if (source.type === "checkbox") return ["true", "false"];
|
|
52
|
+
if (source.type === "rating") return ["1", "2", "3", "4", "5"];
|
|
53
|
+
return [];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function defaultConditionOperator(
|
|
57
|
+
source: FormField,
|
|
58
|
+
): ConditionalRule["operator"] {
|
|
59
|
+
return source.type === "multiselect" ? "contains" : "equals";
|
|
60
|
+
}
|
|
61
|
+
|
|
42
62
|
export function FieldPropertiesPanel({
|
|
43
63
|
field,
|
|
64
|
+
fields,
|
|
44
65
|
onChange,
|
|
45
66
|
onDelete,
|
|
46
67
|
}: FieldPropertiesPanelProps) {
|
|
47
68
|
const t = useT();
|
|
48
69
|
const [newOption, setNewOption] = useState("");
|
|
70
|
+
const fieldIndex = fields.findIndex((candidate) => candidate.id === field.id);
|
|
71
|
+
const availableFields =
|
|
72
|
+
fieldIndex >= 0
|
|
73
|
+
? fields.slice(0, fieldIndex)
|
|
74
|
+
: fields.filter((candidate) => candidate.id !== field.id);
|
|
75
|
+
const conditionSource =
|
|
76
|
+
availableFields.find(
|
|
77
|
+
(candidate) => candidate.id === field.conditional?.fieldId,
|
|
78
|
+
) || availableFields[0];
|
|
79
|
+
const conditionOptions = conditionValueOptions(conditionSource);
|
|
80
|
+
const conditionFieldId = conditionSource?.id || "";
|
|
81
|
+
const conditionOperator =
|
|
82
|
+
field.conditional?.operator ||
|
|
83
|
+
(conditionSource ? defaultConditionOperator(conditionSource) : "equals");
|
|
84
|
+
const conditionValue = field.conditional?.value || conditionOptions[0] || "";
|
|
49
85
|
|
|
50
86
|
function update(partial: Partial<FormField>) {
|
|
51
87
|
onChange({ ...field, ...partial });
|
|
@@ -161,6 +197,168 @@ export function FieldPropertiesPanel({
|
|
|
161
197
|
</Select>
|
|
162
198
|
</div>
|
|
163
199
|
|
|
200
|
+
{availableFields.length > 0 && (
|
|
201
|
+
<>
|
|
202
|
+
<Separator />
|
|
203
|
+
<div className="space-y-3">
|
|
204
|
+
<div className="flex items-start justify-between gap-3">
|
|
205
|
+
<div className="space-y-0.5">
|
|
206
|
+
<Label className="text-xs">
|
|
207
|
+
{t("fieldProperties.conditionalVisibility")}
|
|
208
|
+
</Label>
|
|
209
|
+
<p className="text-[11px] leading-snug text-muted-foreground">
|
|
210
|
+
{t("fieldProperties.conditionalVisibilityDescription")}
|
|
211
|
+
</p>
|
|
212
|
+
</div>
|
|
213
|
+
<Switch
|
|
214
|
+
checked={Boolean(field.conditional)}
|
|
215
|
+
onCheckedChange={(checked) => {
|
|
216
|
+
if (!checked) {
|
|
217
|
+
update({ conditional: undefined });
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
if (!conditionSource) return;
|
|
221
|
+
update({
|
|
222
|
+
conditional: {
|
|
223
|
+
fieldId: conditionSource.id,
|
|
224
|
+
operator: defaultConditionOperator(conditionSource),
|
|
225
|
+
value: conditionOptions[0] || "",
|
|
226
|
+
},
|
|
227
|
+
});
|
|
228
|
+
}}
|
|
229
|
+
/>
|
|
230
|
+
</div>
|
|
231
|
+
|
|
232
|
+
{field.conditional && conditionSource && (
|
|
233
|
+
<div className="space-y-2 rounded-md border border-border/70 bg-muted/20 p-2.5">
|
|
234
|
+
<div className="space-y-1.5">
|
|
235
|
+
<Label className="text-xs">
|
|
236
|
+
{t("fieldProperties.conditionField")}
|
|
237
|
+
</Label>
|
|
238
|
+
<Select
|
|
239
|
+
value={conditionFieldId}
|
|
240
|
+
onValueChange={(value) => {
|
|
241
|
+
const nextSource = availableFields.find(
|
|
242
|
+
(candidate) => candidate.id === value,
|
|
243
|
+
);
|
|
244
|
+
if (!nextSource) return;
|
|
245
|
+
const nextOptions = conditionValueOptions(nextSource);
|
|
246
|
+
update({
|
|
247
|
+
conditional: {
|
|
248
|
+
fieldId: nextSource.id,
|
|
249
|
+
operator: defaultConditionOperator(nextSource),
|
|
250
|
+
value: nextOptions[0] || "",
|
|
251
|
+
},
|
|
252
|
+
});
|
|
253
|
+
}}
|
|
254
|
+
>
|
|
255
|
+
<SelectTrigger className="h-8 text-xs">
|
|
256
|
+
<SelectValue />
|
|
257
|
+
</SelectTrigger>
|
|
258
|
+
<SelectContent>
|
|
259
|
+
{availableFields.map((candidate) => (
|
|
260
|
+
<SelectItem
|
|
261
|
+
key={candidate.id}
|
|
262
|
+
value={candidate.id}
|
|
263
|
+
className="text-xs"
|
|
264
|
+
>
|
|
265
|
+
{candidate.label || candidate.id}
|
|
266
|
+
</SelectItem>
|
|
267
|
+
))}
|
|
268
|
+
</SelectContent>
|
|
269
|
+
</Select>
|
|
270
|
+
</div>
|
|
271
|
+
|
|
272
|
+
<div className="space-y-1.5">
|
|
273
|
+
<Label className="text-xs">
|
|
274
|
+
{t("fieldProperties.conditionOperator")}
|
|
275
|
+
</Label>
|
|
276
|
+
<Select
|
|
277
|
+
value={conditionOperator}
|
|
278
|
+
onValueChange={(value) =>
|
|
279
|
+
update({
|
|
280
|
+
conditional: {
|
|
281
|
+
fieldId: conditionFieldId,
|
|
282
|
+
operator: value as ConditionalRule["operator"],
|
|
283
|
+
value: conditionValue,
|
|
284
|
+
},
|
|
285
|
+
})
|
|
286
|
+
}
|
|
287
|
+
>
|
|
288
|
+
<SelectTrigger className="h-8 text-xs">
|
|
289
|
+
<SelectValue />
|
|
290
|
+
</SelectTrigger>
|
|
291
|
+
<SelectContent>
|
|
292
|
+
<SelectItem value="equals" className="text-xs">
|
|
293
|
+
{t("fieldProperties.conditionEquals")}
|
|
294
|
+
</SelectItem>
|
|
295
|
+
<SelectItem value="not_equals" className="text-xs">
|
|
296
|
+
{t("fieldProperties.conditionNotEquals")}
|
|
297
|
+
</SelectItem>
|
|
298
|
+
<SelectItem value="contains" className="text-xs">
|
|
299
|
+
{t("fieldProperties.conditionContains")}
|
|
300
|
+
</SelectItem>
|
|
301
|
+
</SelectContent>
|
|
302
|
+
</Select>
|
|
303
|
+
</div>
|
|
304
|
+
|
|
305
|
+
<div className="space-y-1.5">
|
|
306
|
+
<Label className="text-xs">
|
|
307
|
+
{t("fieldProperties.conditionValue")}
|
|
308
|
+
</Label>
|
|
309
|
+
{conditionOptions.length > 0 ? (
|
|
310
|
+
<Select
|
|
311
|
+
value={conditionValue}
|
|
312
|
+
onValueChange={(value) =>
|
|
313
|
+
update({
|
|
314
|
+
conditional: {
|
|
315
|
+
fieldId: conditionFieldId,
|
|
316
|
+
operator: conditionOperator,
|
|
317
|
+
value,
|
|
318
|
+
},
|
|
319
|
+
})
|
|
320
|
+
}
|
|
321
|
+
>
|
|
322
|
+
<SelectTrigger className="h-8 text-xs">
|
|
323
|
+
<SelectValue />
|
|
324
|
+
</SelectTrigger>
|
|
325
|
+
<SelectContent>
|
|
326
|
+
{conditionOptions.map((option) => (
|
|
327
|
+
<SelectItem
|
|
328
|
+
key={option}
|
|
329
|
+
value={option}
|
|
330
|
+
className="text-xs"
|
|
331
|
+
>
|
|
332
|
+
{option}
|
|
333
|
+
</SelectItem>
|
|
334
|
+
))}
|
|
335
|
+
</SelectContent>
|
|
336
|
+
</Select>
|
|
337
|
+
) : (
|
|
338
|
+
<Input
|
|
339
|
+
value={conditionValue}
|
|
340
|
+
onChange={(event) =>
|
|
341
|
+
update({
|
|
342
|
+
conditional: {
|
|
343
|
+
fieldId: conditionFieldId,
|
|
344
|
+
operator: conditionOperator,
|
|
345
|
+
value: event.target.value,
|
|
346
|
+
},
|
|
347
|
+
})
|
|
348
|
+
}
|
|
349
|
+
placeholder={t(
|
|
350
|
+
"fieldProperties.conditionValuePlaceholder",
|
|
351
|
+
)}
|
|
352
|
+
className="h-8 text-xs"
|
|
353
|
+
/>
|
|
354
|
+
)}
|
|
355
|
+
</div>
|
|
356
|
+
</div>
|
|
357
|
+
)}
|
|
358
|
+
</div>
|
|
359
|
+
</>
|
|
360
|
+
)}
|
|
361
|
+
|
|
164
362
|
{/* Options (for select/radio/multiselect) */}
|
|
165
363
|
{hasOptions.includes(field.type) && (
|
|
166
364
|
<>
|
|
@@ -88,6 +88,16 @@ const messages = {
|
|
|
88
88
|
addOption: "Add option",
|
|
89
89
|
min: "Min",
|
|
90
90
|
max: "Max",
|
|
91
|
+
conditionalVisibility: "Conditional visibility",
|
|
92
|
+
conditionalVisibilityDescription:
|
|
93
|
+
"Only show this field when an earlier answer matches.",
|
|
94
|
+
conditionField: "Earlier answer",
|
|
95
|
+
conditionOperator: "Condition",
|
|
96
|
+
conditionEquals: "is",
|
|
97
|
+
conditionNotEquals: "is not",
|
|
98
|
+
conditionContains: "contains",
|
|
99
|
+
conditionValue: "Answer",
|
|
100
|
+
conditionValuePlaceholder: "Enter an answer...",
|
|
91
101
|
fieldTypes: {
|
|
92
102
|
text: "Short Text",
|
|
93
103
|
email: "Email",
|
|
@@ -144,6 +154,7 @@ const messages = {
|
|
|
144
154
|
integrationsTab: "Integrations",
|
|
145
155
|
settingsSaved: "Settings saved",
|
|
146
156
|
integrationsSaved: "Integrations saved",
|
|
157
|
+
saveFailed: "Failed to save changes",
|
|
147
158
|
formTitlePlaceholder: "Form Title",
|
|
148
159
|
addDescriptionPlaceholder: "Add a description...",
|
|
149
160
|
dragToReorder: "Drag to reorder",
|
|
@@ -210,7 +221,8 @@ const messages = {
|
|
|
210
221
|
"Sends a JSON POST with submission data. Works with Zapier, Make, n8n, etc.",
|
|
211
222
|
googleSheetsBlurb:
|
|
212
223
|
"Mirror every response into a spreadsheet your team can share.",
|
|
213
|
-
googleSheetsHelp:
|
|
224
|
+
googleSheetsHelp:
|
|
225
|
+
"Use a deployed Apps Script /exec URL. It must parse JSON from e.postData.contents and append the row.",
|
|
214
226
|
slackLabel: "Slack",
|
|
215
227
|
discordLabel: "Discord",
|
|
216
228
|
webhookLabel: "Webhook",
|