@tangle-network/agent-app 0.30.0 → 0.32.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.
- package/dist/IntakeInterview-WHVKMNQW.js +8 -0
- package/dist/IntakeInterview-WHVKMNQW.js.map +1 -0
- package/dist/VaultPane-2BRLHKEB.js +7 -0
- package/dist/VaultPane-2BRLHKEB.js.map +1 -0
- package/dist/VaultPane-B8ipCqkB.d.ts +118 -0
- package/dist/chunk-4K7BZYO7.js +124 -0
- package/dist/chunk-4K7BZYO7.js.map +1 -0
- package/dist/chunk-5I72FRZY.js +212 -0
- package/dist/chunk-5I72FRZY.js.map +1 -0
- package/dist/{chunk-WOVCWPFF.js → chunk-FJUOBNIY.js} +1 -1
- package/dist/chunk-ND3XEGBE.js +119 -0
- package/dist/chunk-ND3XEGBE.js.map +1 -0
- package/dist/chunk-SMVD76VC.js +584 -0
- package/dist/chunk-SMVD76VC.js.map +1 -0
- package/dist/chunk-USYXHDKE.js +30 -0
- package/dist/chunk-USYXHDKE.js.map +1 -0
- package/dist/design-canvas-react/index.js +1 -1
- package/dist/design-canvas-react/lazy.js +1 -1
- package/dist/intakes/api.d.ts +59 -0
- package/dist/intakes/api.js +63 -0
- package/dist/intakes/api.js.map +1 -0
- package/dist/intakes/drizzle.d.ts +385 -0
- package/dist/intakes/drizzle.js +61 -0
- package/dist/intakes/drizzle.js.map +1 -0
- package/dist/intakes/index.d.ts +42 -0
- package/dist/intakes/index.js +31 -0
- package/dist/intakes/index.js.map +1 -0
- package/dist/intakes-react/index.d.ts +48 -0
- package/dist/intakes-react/index.js +8 -0
- package/dist/intakes-react/index.js.map +1 -0
- package/dist/intakes-react/lazy.d.ts +8 -0
- package/dist/intakes-react/lazy.js +9 -0
- package/dist/intakes-react/lazy.js.map +1 -0
- package/dist/model-Cuj7d-xT.d.ts +116 -0
- package/dist/vault/index.d.ts +22 -0
- package/dist/vault/index.js +9 -0
- package/dist/vault/index.js.map +1 -0
- package/dist/vault/lazy.d.ts +7 -0
- package/dist/vault/lazy.js +9 -0
- package/dist/vault/lazy.js.map +1 -0
- package/package.json +36 -1
- /package/dist/{chunk-WOVCWPFF.js.map → chunk-FJUOBNIY.js.map} +0 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":[],"sourcesContent":[],"mappings":"","names":[]}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":[],"sourcesContent":[],"mappings":"","names":[]}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import * as react from 'react';
|
|
2
|
+
import { ReactNode } from 'react';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Seams between the shared VaultPane and the host product. Everything here is
|
|
6
|
+
* interface-only: the pane never imports a file tree, an artifact viewer, a
|
|
7
|
+
* dialog library, or a product's data client. The product supplies the data
|
|
8
|
+
* (`VaultDataPort`) and the renderers (`renderTree`/`renderArtifact`/`renderDock`),
|
|
9
|
+
* so the same 3-pane vault mounts in any Tangle agent product.
|
|
10
|
+
*
|
|
11
|
+
* `VaultTreeNode` BYTE-MATCHES sandbox-ui's `FileNode` so a product's tree
|
|
12
|
+
* passes straight through both the data port and `renderTree` with zero mapping.
|
|
13
|
+
*
|
|
14
|
+
* The only import is React's `ReactNode` type — pure type, no runtime, so this
|
|
15
|
+
* file stays server-safe and the contracts are usable without React mounted.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* One node in the vault tree. Field-for-field identical to sandbox-ui's
|
|
20
|
+
* `FileNode`, so a `VaultTreeNode[]` IS a `FileNode[]` — the product hands
|
|
21
|
+
* sandbox-ui's `RichFileTree` the `root` from `renderTree` with no conversion.
|
|
22
|
+
*/
|
|
23
|
+
interface VaultTreeNode {
|
|
24
|
+
name: string;
|
|
25
|
+
path: string;
|
|
26
|
+
type: 'file' | 'directory';
|
|
27
|
+
children?: VaultTreeNode[];
|
|
28
|
+
size?: number;
|
|
29
|
+
mimeType?: string;
|
|
30
|
+
}
|
|
31
|
+
/** A loaded vault file: its text content plus optional preview hints. */
|
|
32
|
+
interface VaultFile {
|
|
33
|
+
path: string;
|
|
34
|
+
content: string;
|
|
35
|
+
mimeType?: string;
|
|
36
|
+
/** Object URL for binary/media previews the artifact renderer streams. */
|
|
37
|
+
blobUrl?: string;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* The data seam the product owns. Every method is async and product-backed —
|
|
41
|
+
* over fetch, a sandbox session, a worker RPC, whatever the product uses. The
|
|
42
|
+
* pane calls these; it never knows the transport.
|
|
43
|
+
*/
|
|
44
|
+
interface VaultDataPort {
|
|
45
|
+
/** List the full vault tree. The returned nodes feed `renderTree` directly. */
|
|
46
|
+
listTree(): Promise<VaultTreeNode[]>;
|
|
47
|
+
/** Read a single file by path. */
|
|
48
|
+
readFile(path: string): Promise<VaultFile>;
|
|
49
|
+
/** Persist `content` to `path`. */
|
|
50
|
+
writeFile(path: string, content: string): Promise<void>;
|
|
51
|
+
/** Create a new (empty or scaffolded) file at `path`. */
|
|
52
|
+
createFile(path: string): Promise<void>;
|
|
53
|
+
/** Delete the file at `path`. */
|
|
54
|
+
deleteFile(path: string): Promise<void>;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* The parsed form of a file's rich content. The pane stays agnostic about the
|
|
58
|
+
* shape — it round-trips `serialize(parse(raw))` through the codec to compute
|
|
59
|
+
* dirtiness, so any structure (frontmatter + body, AST, etc.) works as long as
|
|
60
|
+
* `parse` and `serialize` are inverses for unchanged content.
|
|
61
|
+
*/
|
|
62
|
+
type VaultRichParts = unknown;
|
|
63
|
+
/**
|
|
64
|
+
* Optional rich/source codec. When supplied, the pane's rich mode edits
|
|
65
|
+
* `parse(raw)` and the source mode edits `raw`; switching modes recomputes
|
|
66
|
+
* dirtiness against the saved content via `serialize`. Default: identity
|
|
67
|
+
* passthrough (the rich draft IS the raw string) so source mode works without a
|
|
68
|
+
* codec and there's no markdown dependency in the shell.
|
|
69
|
+
*/
|
|
70
|
+
interface VaultMarkdownCodec {
|
|
71
|
+
parse(raw: string): VaultRichParts;
|
|
72
|
+
serialize(parts: VaultRichParts): string;
|
|
73
|
+
}
|
|
74
|
+
/** Props the pane passes to the product's tree renderer (e.g. RichFileTree). */
|
|
75
|
+
interface VaultTreeRenderProps {
|
|
76
|
+
root: VaultTreeNode;
|
|
77
|
+
selectedPath?: string;
|
|
78
|
+
onSelect: (path: string) => void;
|
|
79
|
+
}
|
|
80
|
+
/** Props the pane passes to the product's artifact renderer (e.g. FileArtifactPane). */
|
|
81
|
+
interface VaultArtifactRenderProps {
|
|
82
|
+
file: VaultFile | null;
|
|
83
|
+
loading: boolean;
|
|
84
|
+
}
|
|
85
|
+
/** Props the pane passes to the product's optional dock renderer (e.g. an agent dock). */
|
|
86
|
+
interface VaultDockRenderProps {
|
|
87
|
+
file: VaultFile | null;
|
|
88
|
+
open: boolean;
|
|
89
|
+
onClose: () => void;
|
|
90
|
+
}
|
|
91
|
+
/** The two editor surfaces the pane switches between for editable text files. */
|
|
92
|
+
type VaultEditorMode = 'rich' | 'source';
|
|
93
|
+
interface VaultPaneProps {
|
|
94
|
+
/** Product-owned data access. */
|
|
95
|
+
port: VaultDataPort;
|
|
96
|
+
/** Renders the left tree pane. The product passes sandbox-ui's RichFileTree. */
|
|
97
|
+
renderTree: (props: VaultTreeRenderProps) => ReactNode;
|
|
98
|
+
/** Renders the center artifact pane. The product passes sandbox-ui's FileArtifactPane. */
|
|
99
|
+
renderArtifact: (props: VaultArtifactRenderProps) => ReactNode;
|
|
100
|
+
/** Renders an optional right dock (agent chat, metadata, …). Omit to hide. */
|
|
101
|
+
renderDock?: (props: VaultDockRenderProps) => ReactNode;
|
|
102
|
+
/**
|
|
103
|
+
* When false, all write affordances (create / delete / save / source editor)
|
|
104
|
+
* are hidden and the pane is read-only. Defaults to true.
|
|
105
|
+
*/
|
|
106
|
+
canWrite?: boolean;
|
|
107
|
+
/** Controlled selection. Pair with `onSelectedPathChange`. */
|
|
108
|
+
selectedPath?: string | null;
|
|
109
|
+
/** Notified whenever the selected path changes (including clear → null). */
|
|
110
|
+
onSelectedPathChange?: (path: string | null) => void;
|
|
111
|
+
/** Optional rich/source codec. Defaults to identity passthrough. */
|
|
112
|
+
codec?: VaultMarkdownCodec;
|
|
113
|
+
className?: string;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
declare function VaultPane(props: VaultPaneProps): react.JSX.Element;
|
|
117
|
+
|
|
118
|
+
export { type VaultArtifactRenderProps as V, type VaultDataPort as a, type VaultDockRenderProps as b, type VaultEditorMode as c, type VaultFile as d, type VaultMarkdownCodec as e, VaultPane as f, type VaultPaneProps as g, type VaultRichParts as h, type VaultTreeNode as i, type VaultTreeRenderProps as j };
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import {
|
|
2
|
+
emptyPayload,
|
|
3
|
+
markComplete,
|
|
4
|
+
payloadComplete,
|
|
5
|
+
withAnswer
|
|
6
|
+
} from "./chunk-USYXHDKE.js";
|
|
7
|
+
import {
|
|
8
|
+
getQuestion,
|
|
9
|
+
validateAnswer
|
|
10
|
+
} from "./chunk-ND3XEGBE.js";
|
|
11
|
+
|
|
12
|
+
// src/intakes/drizzle/store.ts
|
|
13
|
+
import { eq } from "drizzle-orm";
|
|
14
|
+
var IntakeError = class extends Error {
|
|
15
|
+
code;
|
|
16
|
+
constructor(code, message) {
|
|
17
|
+
super(message);
|
|
18
|
+
this.name = "IntakeError";
|
|
19
|
+
this.code = code;
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
function createScopedStore(opts) {
|
|
23
|
+
const { db, graph, table, scopeColumn, scopeValue, insertScope } = opts;
|
|
24
|
+
async function loadRow() {
|
|
25
|
+
const [row] = await db.select().from(table).where(eq(scopeColumn, scopeValue)).limit(1);
|
|
26
|
+
return row ?? null;
|
|
27
|
+
}
|
|
28
|
+
function toState(row) {
|
|
29
|
+
if (!row) {
|
|
30
|
+
return { payload: emptyPayload(graph), completed: false, completedAt: null };
|
|
31
|
+
}
|
|
32
|
+
const payload = normalizePayload(row.payload, row.graphId);
|
|
33
|
+
const completedAt = toDate(row.completedAt);
|
|
34
|
+
return {
|
|
35
|
+
payload,
|
|
36
|
+
completed: completedAt != null && payloadComplete(graph, payload),
|
|
37
|
+
completedAt
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
async function get() {
|
|
41
|
+
return toState(await loadRow());
|
|
42
|
+
}
|
|
43
|
+
async function upsertPayload(payload, completedAt) {
|
|
44
|
+
const existing = await loadRow();
|
|
45
|
+
if (existing) {
|
|
46
|
+
await db.update(table).set({ graphId: payload.graphId, payload, completedAt, updatedAt: /* @__PURE__ */ new Date() }).where(eq(scopeColumn, scopeValue));
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
await db.insert(table).values({
|
|
50
|
+
...insertScope,
|
|
51
|
+
graphId: payload.graphId,
|
|
52
|
+
payload,
|
|
53
|
+
completedAt
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
async function save(questionId, value) {
|
|
57
|
+
const question = getQuestion(graph, questionId);
|
|
58
|
+
if (!question) throw new IntakeError("unknown-question", `No question '${questionId}' in intake '${graph.id}'`);
|
|
59
|
+
const validity = validateAnswer(question, value);
|
|
60
|
+
if (!validity.ok) {
|
|
61
|
+
throw new IntakeError("invalid-answer", `Answer to '${questionId}' rejected: ${validity.reason}`);
|
|
62
|
+
}
|
|
63
|
+
const current = await get();
|
|
64
|
+
const next = withAnswer(current.payload, questionId, value);
|
|
65
|
+
await upsertPayload(next, current.completedAt);
|
|
66
|
+
return toState({ graphId: next.graphId, payload: next, completedAt: current.completedAt });
|
|
67
|
+
}
|
|
68
|
+
async function complete() {
|
|
69
|
+
const current = await get();
|
|
70
|
+
if (current.payload.graphId !== graph.id) {
|
|
71
|
+
throw new IntakeError("stale-graph", `Intake payload was collected against '${current.payload.graphId}', not '${graph.id}'`);
|
|
72
|
+
}
|
|
73
|
+
if (!payloadComplete(graph, current.payload)) {
|
|
74
|
+
throw new IntakeError("incomplete", `Intake '${graph.id}' has unanswered required questions`);
|
|
75
|
+
}
|
|
76
|
+
const completedPayload = markComplete(current.payload);
|
|
77
|
+
const completedAt = new Date(completedPayload.completedAt);
|
|
78
|
+
await upsertPayload(completedPayload, completedAt);
|
|
79
|
+
return { payload: completedPayload, completed: true, completedAt };
|
|
80
|
+
}
|
|
81
|
+
return { get, save, complete };
|
|
82
|
+
}
|
|
83
|
+
function createUserIntakeStore(opts) {
|
|
84
|
+
return createScopedStore({
|
|
85
|
+
db: opts.db,
|
|
86
|
+
graph: opts.graph,
|
|
87
|
+
table: opts.table,
|
|
88
|
+
scopeColumn: opts.table.userId,
|
|
89
|
+
scopeValue: opts.userId,
|
|
90
|
+
insertScope: { userId: opts.userId }
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
function createProjectIntakeStore(opts) {
|
|
94
|
+
return createScopedStore({
|
|
95
|
+
db: opts.db,
|
|
96
|
+
graph: opts.graph,
|
|
97
|
+
table: opts.table,
|
|
98
|
+
scopeColumn: opts.table.workspaceId,
|
|
99
|
+
scopeValue: opts.workspaceId,
|
|
100
|
+
insertScope: { workspaceId: opts.workspaceId }
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
function normalizePayload(raw, graphId) {
|
|
104
|
+
if (raw && typeof raw === "object" && "answers" in raw) {
|
|
105
|
+
const candidate = raw;
|
|
106
|
+
return {
|
|
107
|
+
graphId: candidate.graphId ?? graphId,
|
|
108
|
+
answers: candidate.answers ?? {},
|
|
109
|
+
...candidate.completedAt ? { completedAt: candidate.completedAt } : {}
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
return { graphId, answers: {} };
|
|
113
|
+
}
|
|
114
|
+
function toDate(value) {
|
|
115
|
+
if (value == null) return null;
|
|
116
|
+
return value instanceof Date ? value : new Date(Number(value) * (Number(value) < 1e12 ? 1e3 : 1));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export {
|
|
120
|
+
IntakeError,
|
|
121
|
+
createUserIntakeStore,
|
|
122
|
+
createProjectIntakeStore
|
|
123
|
+
};
|
|
124
|
+
//# sourceMappingURL=chunk-4K7BZYO7.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/intakes/drizzle/store.ts"],"sourcesContent":["/**\n * Drizzle-backed store over the tables from `createIntakeTables`. One store\n * builder per scope: `createUserIntakeStore` (keyed on userId) and\n * `createProjectIntakeStore` (keyed on workspaceId). Each returns the same\n * three-method surface — `get` / `save` / `complete` — so the api handlers and\n * the UI talk to one shape regardless of scope.\n *\n * Works against any SQLite drizzle driver (better-sqlite3, D1, libsql): the\n * builders are awaited, never `.run()`/`.all()`, so sync and async drivers\n * behave identically.\n *\n * Validation is fail-loud and pure (from the `./intakes` leaf). `save` runs\n * `validateAnswer` before writing, so an invalid answer can never enter the\n * payload; `complete` runs `payloadComplete` and refuses to stamp an\n * incomplete (or stale-graph) intake — it throws a typed `IntakeError` rather\n * than silently writing a half-finished onboarding state.\n *\n * `getProjectIntakeStore` pins `workspaceId` in every WHERE clause; RBAC runs\n * in the route before the store is built, but the scope key is enforced here\n * too, so a leaked id can never read or write across the boundary.\n *\n * Imports `drizzle-orm`, so this is a subpath, never re-exported from root.\n */\n\nimport { eq } from 'drizzle-orm'\nimport type { BaseSQLiteDatabase } from 'drizzle-orm/sqlite-core'\nimport {\n type IntakeAnswerValue,\n type IntakeGraph,\n getQuestion,\n validateAnswer,\n} from '../model'\nimport {\n type IntakePayload,\n emptyPayload,\n markComplete,\n payloadComplete,\n withAnswer,\n} from '../completion'\nimport type { ProjectIntakeTable, UserIntakeTable } from './schema'\n\n/** Any SQLite drizzle database — `any` erases driver-specific generics so\n * better-sqlite3, D1, and libsql handles all fit. */\nexport type IntakeDatabase = BaseSQLiteDatabase<'sync' | 'async', any, any>\n\n/** A loaded intake: the payload plus whether it is complete against the graph. */\nexport interface IntakeState {\n payload: IntakePayload\n completed: boolean\n completedAt: Date | null\n}\n\nexport type IntakeErrorCode = 'invalid-answer' | 'unknown-question' | 'incomplete' | 'stale-graph'\n\n/** Thrown by store mutations on a refused write — callers map it to a 4xx. */\nexport class IntakeError extends Error {\n readonly code: IntakeErrorCode\n constructor(code: IntakeErrorCode, message: string) {\n super(message)\n this.name = 'IntakeError'\n this.code = code\n }\n}\n\n/** The three-method store surface, identical for both scopes. */\nexport interface IntakeStore {\n /** Load the current intake, or seed an empty payload when none exists yet. */\n get(): Promise<IntakeState>\n /** Validate and persist one answer; returns the updated state. */\n save(questionId: string, value: IntakeAnswerValue): Promise<IntakeState>\n /** Stamp the intake complete; throws IntakeError when not yet completable. */\n complete(): Promise<IntakeState>\n}\n\ninterface IntakeTableShape {\n id: any\n graphId: any\n payload: any\n completedAt: any\n updatedAt: any\n}\n\ninterface ScopedStoreOptions {\n db: IntakeDatabase\n graph: IntakeGraph\n table: IntakeTableShape\n /** The scope column (userIntake.userId or projectIntake.workspaceId). */\n scopeColumn: any\n scopeValue: string\n /** Extra columns to set on insert (the scope key). */\n insertScope: Record<string, unknown>\n}\n\nfunction createScopedStore(opts: ScopedStoreOptions): IntakeStore {\n const { db, graph, table, scopeColumn, scopeValue, insertScope } = opts\n\n async function loadRow() {\n const [row] = await db\n .select()\n .from(table as any)\n .where(eq(scopeColumn, scopeValue))\n .limit(1)\n return (row ?? null) as\n | { id: string; graphId: string; payload: unknown; completedAt: Date | number | null }\n | null\n }\n\n function toState(\n row: { graphId: string; payload: unknown; completedAt: Date | number | null } | null,\n ): IntakeState {\n if (!row) {\n return { payload: emptyPayload(graph), completed: false, completedAt: null }\n }\n const payload = normalizePayload(row.payload, row.graphId)\n const completedAt = toDate(row.completedAt)\n return {\n payload,\n completed: completedAt != null && payloadComplete(graph, payload),\n completedAt,\n }\n }\n\n async function get(): Promise<IntakeState> {\n return toState(await loadRow())\n }\n\n async function upsertPayload(payload: IntakePayload, completedAt: Date | null) {\n const existing = await loadRow()\n if (existing) {\n await db\n .update(table as any)\n .set({ graphId: payload.graphId, payload, completedAt, updatedAt: new Date() })\n .where(eq(scopeColumn, scopeValue))\n return\n }\n await db.insert(table as any).values({\n ...insertScope,\n graphId: payload.graphId,\n payload,\n completedAt,\n })\n }\n\n async function save(questionId: string, value: IntakeAnswerValue): Promise<IntakeState> {\n const question = getQuestion(graph, questionId)\n if (!question) throw new IntakeError('unknown-question', `No question '${questionId}' in intake '${graph.id}'`)\n const validity = validateAnswer(question, value)\n if (!validity.ok) {\n throw new IntakeError('invalid-answer', `Answer to '${questionId}' rejected: ${validity.reason}`)\n }\n\n const current = await get()\n const next = withAnswer(current.payload, questionId, value)\n await upsertPayload(next, current.completedAt)\n return toState({ graphId: next.graphId, payload: next, completedAt: current.completedAt })\n }\n\n async function complete(): Promise<IntakeState> {\n const current = await get()\n if (current.payload.graphId !== graph.id) {\n throw new IntakeError('stale-graph', `Intake payload was collected against '${current.payload.graphId}', not '${graph.id}'`)\n }\n if (!payloadComplete(graph, current.payload)) {\n throw new IntakeError('incomplete', `Intake '${graph.id}' has unanswered required questions`)\n }\n const completedPayload = markComplete(current.payload)\n const completedAt = new Date(completedPayload.completedAt!)\n await upsertPayload(completedPayload, completedAt)\n return { payload: completedPayload, completed: true, completedAt }\n }\n\n return { get, save, complete }\n}\n\nexport interface CreateUserIntakeStoreOptions {\n db: IntakeDatabase\n /** The user-intake table from createIntakeTables. */\n table: UserIntakeTable\n /** The intake definition this store collects answers against. */\n graph: IntakeGraph\n /** The user whose onboarding this is. */\n userId: string\n}\n\n/** Build the per-user onboarding store, scoped to one userId. */\nexport function createUserIntakeStore(opts: CreateUserIntakeStoreOptions): IntakeStore {\n return createScopedStore({\n db: opts.db,\n graph: opts.graph,\n table: opts.table as unknown as IntakeTableShape,\n scopeColumn: opts.table.userId,\n scopeValue: opts.userId,\n insertScope: { userId: opts.userId },\n })\n}\n\nexport interface CreateProjectIntakeStoreOptions {\n db: IntakeDatabase\n /** The project-intake table from createIntakeTables (workspace scope). */\n table: ProjectIntakeTable\n graph: IntakeGraph\n /** The workspace this intake is attached to. */\n workspaceId: string\n}\n\n/** Build the per-project store, scoped to one workspaceId. */\nexport function createProjectIntakeStore(opts: CreateProjectIntakeStoreOptions): IntakeStore {\n return createScopedStore({\n db: opts.db,\n graph: opts.graph,\n table: opts.table as unknown as IntakeTableShape,\n scopeColumn: opts.table.workspaceId,\n scopeValue: opts.workspaceId,\n insertScope: { workspaceId: opts.workspaceId },\n })\n}\n\nfunction normalizePayload(raw: unknown, graphId: string): IntakePayload {\n if (raw && typeof raw === 'object' && 'answers' in (raw as Record<string, unknown>)) {\n const candidate = raw as Partial<IntakePayload>\n return {\n graphId: candidate.graphId ?? graphId,\n answers: candidate.answers ?? {},\n ...(candidate.completedAt ? { completedAt: candidate.completedAt } : {}),\n }\n }\n return { graphId, answers: {} }\n}\n\nfunction toDate(value: Date | number | null | undefined): Date | null {\n if (value == null) return null\n return value instanceof Date ? value : new Date(Number(value) * (Number(value) < 1e12 ? 1000 : 1))\n}\n"],"mappings":"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","names":[]}
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import {
|
|
2
|
+
validateAnswer
|
|
3
|
+
} from "./chunk-ND3XEGBE.js";
|
|
4
|
+
|
|
5
|
+
// src/intakes-react/components/IntakeInterview.tsx
|
|
6
|
+
import { useEffect, useState } from "react";
|
|
7
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
8
|
+
function IntakeInterview({
|
|
9
|
+
view: initialView,
|
|
10
|
+
onAnswer,
|
|
11
|
+
onComplete,
|
|
12
|
+
onDone,
|
|
13
|
+
onNotice
|
|
14
|
+
}) {
|
|
15
|
+
const [view, setView] = useState(initialView);
|
|
16
|
+
const [draft, setDraft] = useState(currentAnswer(initialView));
|
|
17
|
+
const [busy, setBusy] = useState(false);
|
|
18
|
+
const [doneFired, setDoneFired] = useState(false);
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
setView(initialView);
|
|
21
|
+
setDraft(currentAnswer(initialView));
|
|
22
|
+
}, [initialView]);
|
|
23
|
+
useEffect(() => {
|
|
24
|
+
if (view.completed && !doneFired) {
|
|
25
|
+
setDoneFired(true);
|
|
26
|
+
onDone?.();
|
|
27
|
+
}
|
|
28
|
+
}, [view.completed, doneFired, onDone]);
|
|
29
|
+
function notify(kind, message) {
|
|
30
|
+
onNotice?.({ kind, message });
|
|
31
|
+
}
|
|
32
|
+
const question = view.nextQuestion;
|
|
33
|
+
async function submit() {
|
|
34
|
+
if (!question || busy) return;
|
|
35
|
+
const validity = validateAnswer(question, draft);
|
|
36
|
+
if (!validity.ok) {
|
|
37
|
+
notify("error", `Please answer: ${validity.reason}`);
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
setBusy(true);
|
|
41
|
+
try {
|
|
42
|
+
const next = await onAnswer({ questionId: question.id, value: normalize(question, draft) });
|
|
43
|
+
setView(next);
|
|
44
|
+
setDraft(currentAnswer(next));
|
|
45
|
+
} catch (err) {
|
|
46
|
+
notify("error", err instanceof Error ? err.message : "Failed to save answer");
|
|
47
|
+
} finally {
|
|
48
|
+
setBusy(false);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
async function finish() {
|
|
52
|
+
if (busy) return;
|
|
53
|
+
setBusy(true);
|
|
54
|
+
try {
|
|
55
|
+
const next = await onComplete();
|
|
56
|
+
setView(next);
|
|
57
|
+
notify("success", "All set.");
|
|
58
|
+
} catch (err) {
|
|
59
|
+
notify("error", err instanceof Error ? err.message : "Failed to finish");
|
|
60
|
+
} finally {
|
|
61
|
+
setBusy(false);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return /* @__PURE__ */ jsxs("section", { className: "flex flex-col gap-5", children: [
|
|
65
|
+
/* @__PURE__ */ jsxs("header", { className: "flex flex-col gap-1", children: [
|
|
66
|
+
/* @__PURE__ */ jsx("h2", { className: "text-lg font-semibold text-[var(--text-primary)]", children: view.title }),
|
|
67
|
+
view.description && /* @__PURE__ */ jsx("p", { className: "text-sm text-[var(--text-muted)]", children: view.description }),
|
|
68
|
+
/* @__PURE__ */ jsx(ProgressBar, { answered: view.progress.answered, total: view.progress.total })
|
|
69
|
+
] }),
|
|
70
|
+
view.completed ? /* @__PURE__ */ jsx("p", { className: "text-sm text-[var(--text-secondary)]", children: "Thanks \u2014 your intake is complete." }) : question ? /* @__PURE__ */ jsxs("div", { className: "flex flex-col gap-3", children: [
|
|
71
|
+
/* @__PURE__ */ jsxs("div", { className: "flex flex-col gap-1", children: [
|
|
72
|
+
/* @__PURE__ */ jsx("p", { className: "text-base font-medium text-[var(--text-primary)]", children: question.prompt }),
|
|
73
|
+
question.help && /* @__PURE__ */ jsx("p", { className: "text-xs text-[var(--text-muted)]", children: question.help })
|
|
74
|
+
] }),
|
|
75
|
+
/* @__PURE__ */ jsx(AnswerField, { question, value: draft, onChange: setDraft, onSubmit: () => void submit() }),
|
|
76
|
+
/* @__PURE__ */ jsx("div", { className: "flex justify-end", children: /* @__PURE__ */ jsx(
|
|
77
|
+
"button",
|
|
78
|
+
{
|
|
79
|
+
type: "button",
|
|
80
|
+
onClick: () => void submit(),
|
|
81
|
+
disabled: busy,
|
|
82
|
+
className: "rounded bg-[var(--brand-primary)] px-4 py-1.5 text-sm text-white disabled:opacity-50",
|
|
83
|
+
children: busy ? "Saving\u2026" : "Continue"
|
|
84
|
+
}
|
|
85
|
+
) })
|
|
86
|
+
] }) : /* @__PURE__ */ jsxs("div", { className: "flex flex-col gap-3", children: [
|
|
87
|
+
/* @__PURE__ */ jsx("p", { className: "text-sm text-[var(--text-secondary)]", children: "That's everything. Ready to finish?" }),
|
|
88
|
+
/* @__PURE__ */ jsx("div", { className: "flex justify-end", children: /* @__PURE__ */ jsx(
|
|
89
|
+
"button",
|
|
90
|
+
{
|
|
91
|
+
type: "button",
|
|
92
|
+
onClick: () => void finish(),
|
|
93
|
+
disabled: busy,
|
|
94
|
+
className: "rounded bg-[var(--brand-primary)] px-4 py-1.5 text-sm text-white disabled:opacity-50",
|
|
95
|
+
children: busy ? "Finishing\u2026" : "Finish"
|
|
96
|
+
}
|
|
97
|
+
) })
|
|
98
|
+
] })
|
|
99
|
+
] });
|
|
100
|
+
}
|
|
101
|
+
function AnswerField({ question, value, onChange, onSubmit }) {
|
|
102
|
+
const inputClass = "rounded border border-[var(--border-default)] bg-[var(--bg-input)] px-3 py-2 text-sm text-[var(--text-primary)] placeholder:text-[var(--text-muted)]";
|
|
103
|
+
switch (question.type) {
|
|
104
|
+
case "long-text":
|
|
105
|
+
return /* @__PURE__ */ jsx(
|
|
106
|
+
"textarea",
|
|
107
|
+
{
|
|
108
|
+
rows: 4,
|
|
109
|
+
"aria-label": question.prompt,
|
|
110
|
+
value: typeof value === "string" ? value : "",
|
|
111
|
+
onChange: (event) => onChange(event.target.value),
|
|
112
|
+
className: inputClass
|
|
113
|
+
}
|
|
114
|
+
);
|
|
115
|
+
case "boolean":
|
|
116
|
+
return /* @__PURE__ */ jsx("div", { className: "flex gap-2", children: [{ v: true, l: "Yes" }, { v: false, l: "No" }].map((opt) => /* @__PURE__ */ jsx(
|
|
117
|
+
"button",
|
|
118
|
+
{
|
|
119
|
+
type: "button",
|
|
120
|
+
onClick: () => onChange(opt.v),
|
|
121
|
+
className: `rounded border px-4 py-1.5 text-sm ${value === opt.v ? "border-[var(--brand-primary)] bg-[var(--brand-primary)] text-white" : "border-[var(--border-default)] bg-[var(--bg-input)] text-[var(--text-secondary)]"}`,
|
|
122
|
+
children: opt.l
|
|
123
|
+
},
|
|
124
|
+
opt.l
|
|
125
|
+
)) });
|
|
126
|
+
case "number":
|
|
127
|
+
return /* @__PURE__ */ jsx(
|
|
128
|
+
"input",
|
|
129
|
+
{
|
|
130
|
+
type: "number",
|
|
131
|
+
"aria-label": question.prompt,
|
|
132
|
+
value: typeof value === "number" ? value : "",
|
|
133
|
+
onChange: (event) => onChange(event.target.value === "" ? null : Number(event.target.value)),
|
|
134
|
+
onKeyDown: (event) => {
|
|
135
|
+
if (event.key === "Enter") onSubmit();
|
|
136
|
+
},
|
|
137
|
+
className: inputClass
|
|
138
|
+
}
|
|
139
|
+
);
|
|
140
|
+
case "single-select":
|
|
141
|
+
return /* @__PURE__ */ jsx("div", { className: "flex flex-col gap-1.5", children: (question.options ?? []).map((option) => /* @__PURE__ */ jsx(
|
|
142
|
+
"button",
|
|
143
|
+
{
|
|
144
|
+
type: "button",
|
|
145
|
+
onClick: () => onChange(option.value),
|
|
146
|
+
className: `rounded border px-3 py-2 text-left text-sm ${value === option.value ? "border-[var(--brand-primary)] bg-[var(--bg-input)] text-[var(--text-primary)]" : "border-[var(--border-default)] bg-[var(--bg-input)] text-[var(--text-secondary)]"}`,
|
|
147
|
+
children: option.label
|
|
148
|
+
},
|
|
149
|
+
option.value
|
|
150
|
+
)) });
|
|
151
|
+
case "multi-select": {
|
|
152
|
+
const selected = Array.isArray(value) ? value : [];
|
|
153
|
+
return /* @__PURE__ */ jsx("div", { className: "flex flex-col gap-1.5", children: (question.options ?? []).map((option) => {
|
|
154
|
+
const on = selected.includes(option.value);
|
|
155
|
+
return /* @__PURE__ */ jsx(
|
|
156
|
+
"button",
|
|
157
|
+
{
|
|
158
|
+
type: "button",
|
|
159
|
+
onClick: () => onChange(on ? selected.filter((v) => v !== option.value) : [...selected, option.value]),
|
|
160
|
+
className: `rounded border px-3 py-2 text-left text-sm ${on ? "border-[var(--brand-primary)] bg-[var(--bg-input)] text-[var(--text-primary)]" : "border-[var(--border-default)] bg-[var(--bg-input)] text-[var(--text-secondary)]"}`,
|
|
161
|
+
children: option.label
|
|
162
|
+
},
|
|
163
|
+
option.value
|
|
164
|
+
);
|
|
165
|
+
}) });
|
|
166
|
+
}
|
|
167
|
+
default:
|
|
168
|
+
return /* @__PURE__ */ jsx(
|
|
169
|
+
"input",
|
|
170
|
+
{
|
|
171
|
+
type: question.type === "email" ? "email" : question.type === "url" ? "url" : "text",
|
|
172
|
+
"aria-label": question.prompt,
|
|
173
|
+
value: typeof value === "string" ? value : "",
|
|
174
|
+
onChange: (event) => onChange(event.target.value),
|
|
175
|
+
onKeyDown: (event) => {
|
|
176
|
+
if (event.key === "Enter") onSubmit();
|
|
177
|
+
},
|
|
178
|
+
className: inputClass
|
|
179
|
+
}
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
function ProgressBar({ answered, total }) {
|
|
184
|
+
const pct = total > 0 ? Math.round(answered / total * 100) : 0;
|
|
185
|
+
return /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2", children: [
|
|
186
|
+
/* @__PURE__ */ jsx("div", { className: "h-1.5 flex-1 overflow-hidden rounded-full bg-[var(--bg-input)]", children: /* @__PURE__ */ jsx("div", { className: "h-full rounded-full bg-[var(--brand-primary)] transition-all", style: { width: `${pct}%` } }) }),
|
|
187
|
+
/* @__PURE__ */ jsxs("span", { className: "text-xs text-[var(--text-muted)]", children: [
|
|
188
|
+
answered,
|
|
189
|
+
"/",
|
|
190
|
+
total
|
|
191
|
+
] })
|
|
192
|
+
] });
|
|
193
|
+
}
|
|
194
|
+
function currentAnswer(view) {
|
|
195
|
+
const id = view.nextQuestion?.id;
|
|
196
|
+
if (!id) return null;
|
|
197
|
+
return view.answers[id] ?? defaultDraft(view.nextQuestion);
|
|
198
|
+
}
|
|
199
|
+
function defaultDraft(question) {
|
|
200
|
+
return question.type === "multi-select" ? [] : null;
|
|
201
|
+
}
|
|
202
|
+
function normalize(question, value) {
|
|
203
|
+
if (typeof value === "string" && (question.type === "text" || question.type === "long-text" || question.type === "url" || question.type === "email")) {
|
|
204
|
+
return value.trim();
|
|
205
|
+
}
|
|
206
|
+
return value;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export {
|
|
210
|
+
IntakeInterview
|
|
211
|
+
};
|
|
212
|
+
//# sourceMappingURL=chunk-5I72FRZY.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/intakes-react/components/IntakeInterview.tsx"],"sourcesContent":["/**\n * Agent-led intake interview: asks one question at a time, advancing as each\n * answer is saved. Fully callback-driven — the host supplies the loaded `view`\n * and the async `onAnswer` / `onComplete` callbacks (backed by `./intakes/api`),\n * so this imports no app router, fetch client, or toast. Styled with the\n * shipped Tangle Quiet tokens (`var(--*)`).\n *\n * The server owns traversal: each `onAnswer` resolves with the next view (next\n * question + progress), so the component never re-derives the graph — it renders\n * `view.nextQuestion`, and when that is null and the view is completable it\n * shows the finish action. Local validation only gates the submit button; the\n * authoritative validation runs in the store.\n */\n\nimport { useEffect, useState } from 'react'\nimport type { IntakeAnswerValue, IntakeQuestion } from '../../intakes/model'\nimport { validateAnswer } from '../../intakes/model'\nimport type { IntakeInterviewProps, IntakeView } from '../contracts'\n\nexport function IntakeInterview({\n view: initialView,\n onAnswer,\n onComplete,\n onDone,\n onNotice,\n}: IntakeInterviewProps) {\n const [view, setView] = useState<IntakeView>(initialView)\n const [draft, setDraft] = useState<IntakeAnswerValue>(currentAnswer(initialView))\n const [busy, setBusy] = useState(false)\n const [doneFired, setDoneFired] = useState(false)\n\n useEffect(() => {\n setView(initialView)\n setDraft(currentAnswer(initialView))\n }, [initialView])\n\n useEffect(() => {\n if (view.completed && !doneFired) {\n setDoneFired(true)\n onDone?.()\n }\n }, [view.completed, doneFired, onDone])\n\n function notify(kind: 'success' | 'error', message: string) {\n onNotice?.({ kind, message })\n }\n\n const question = view.nextQuestion\n\n async function submit() {\n if (!question || busy) return\n const validity = validateAnswer(question, draft)\n if (!validity.ok) {\n notify('error', `Please answer: ${validity.reason}`)\n return\n }\n setBusy(true)\n try {\n const next = await onAnswer({ questionId: question.id, value: normalize(question, draft) })\n setView(next)\n setDraft(currentAnswer(next))\n } catch (err) {\n notify('error', err instanceof Error ? err.message : 'Failed to save answer')\n } finally {\n setBusy(false)\n }\n }\n\n async function finish() {\n if (busy) return\n setBusy(true)\n try {\n const next = await onComplete()\n setView(next)\n notify('success', 'All set.')\n } catch (err) {\n notify('error', err instanceof Error ? err.message : 'Failed to finish')\n } finally {\n setBusy(false)\n }\n }\n\n return (\n <section className=\"flex flex-col gap-5\">\n <header className=\"flex flex-col gap-1\">\n <h2 className=\"text-lg font-semibold text-[var(--text-primary)]\">{view.title}</h2>\n {view.description && <p className=\"text-sm text-[var(--text-muted)]\">{view.description}</p>}\n <ProgressBar answered={view.progress.answered} total={view.progress.total} />\n </header>\n\n {view.completed ? (\n <p className=\"text-sm text-[var(--text-secondary)]\">Thanks — your intake is complete.</p>\n ) : question ? (\n <div className=\"flex flex-col gap-3\">\n <div className=\"flex flex-col gap-1\">\n <p className=\"text-base font-medium text-[var(--text-primary)]\">{question.prompt}</p>\n {question.help && <p className=\"text-xs text-[var(--text-muted)]\">{question.help}</p>}\n </div>\n\n <AnswerField question={question} value={draft} onChange={setDraft} onSubmit={() => void submit()} />\n\n <div className=\"flex justify-end\">\n <button\n type=\"button\"\n onClick={() => void submit()}\n disabled={busy}\n className=\"rounded bg-[var(--brand-primary)] px-4 py-1.5 text-sm text-white disabled:opacity-50\"\n >\n {busy ? 'Saving…' : 'Continue'}\n </button>\n </div>\n </div>\n ) : (\n <div className=\"flex flex-col gap-3\">\n <p className=\"text-sm text-[var(--text-secondary)]\">That's everything. Ready to finish?</p>\n <div className=\"flex justify-end\">\n <button\n type=\"button\"\n onClick={() => void finish()}\n disabled={busy}\n className=\"rounded bg-[var(--brand-primary)] px-4 py-1.5 text-sm text-white disabled:opacity-50\"\n >\n {busy ? 'Finishing…' : 'Finish'}\n </button>\n </div>\n </div>\n )}\n </section>\n )\n}\n\ninterface AnswerFieldProps {\n question: IntakeQuestion\n value: IntakeAnswerValue\n onChange(value: IntakeAnswerValue): void\n onSubmit(): void\n}\n\nfunction AnswerField({ question, value, onChange, onSubmit }: AnswerFieldProps) {\n const inputClass =\n 'rounded border border-[var(--border-default)] bg-[var(--bg-input)] px-3 py-2 text-sm text-[var(--text-primary)] placeholder:text-[var(--text-muted)]'\n\n switch (question.type) {\n case 'long-text':\n return (\n <textarea\n rows={4}\n aria-label={question.prompt}\n value={typeof value === 'string' ? value : ''}\n onChange={(event) => onChange(event.target.value)}\n className={inputClass}\n />\n )\n case 'boolean':\n return (\n <div className=\"flex gap-2\">\n {[{ v: true, l: 'Yes' }, { v: false, l: 'No' }].map((opt) => (\n <button\n key={opt.l}\n type=\"button\"\n onClick={() => onChange(opt.v)}\n className={`rounded border px-4 py-1.5 text-sm ${\n value === opt.v\n ? 'border-[var(--brand-primary)] bg-[var(--brand-primary)] text-white'\n : 'border-[var(--border-default)] bg-[var(--bg-input)] text-[var(--text-secondary)]'\n }`}\n >\n {opt.l}\n </button>\n ))}\n </div>\n )\n case 'number':\n return (\n <input\n type=\"number\"\n aria-label={question.prompt}\n value={typeof value === 'number' ? value : ''}\n onChange={(event) => onChange(event.target.value === '' ? null : Number(event.target.value))}\n onKeyDown={(event) => { if (event.key === 'Enter') onSubmit() }}\n className={inputClass}\n />\n )\n case 'single-select':\n return (\n <div className=\"flex flex-col gap-1.5\">\n {(question.options ?? []).map((option) => (\n <button\n key={option.value}\n type=\"button\"\n onClick={() => onChange(option.value)}\n className={`rounded border px-3 py-2 text-left text-sm ${\n value === option.value\n ? 'border-[var(--brand-primary)] bg-[var(--bg-input)] text-[var(--text-primary)]'\n : 'border-[var(--border-default)] bg-[var(--bg-input)] text-[var(--text-secondary)]'\n }`}\n >\n {option.label}\n </button>\n ))}\n </div>\n )\n case 'multi-select': {\n const selected = Array.isArray(value) ? value : []\n return (\n <div className=\"flex flex-col gap-1.5\">\n {(question.options ?? []).map((option) => {\n const on = selected.includes(option.value)\n return (\n <button\n key={option.value}\n type=\"button\"\n onClick={() =>\n onChange(on ? selected.filter((v) => v !== option.value) : [...selected, option.value])\n }\n className={`rounded border px-3 py-2 text-left text-sm ${\n on\n ? 'border-[var(--brand-primary)] bg-[var(--bg-input)] text-[var(--text-primary)]'\n : 'border-[var(--border-default)] bg-[var(--bg-input)] text-[var(--text-secondary)]'\n }`}\n >\n {option.label}\n </button>\n )\n })}\n </div>\n )\n }\n default:\n return (\n <input\n type={question.type === 'email' ? 'email' : question.type === 'url' ? 'url' : 'text'}\n aria-label={question.prompt}\n value={typeof value === 'string' ? value : ''}\n onChange={(event) => onChange(event.target.value)}\n onKeyDown={(event) => { if (event.key === 'Enter') onSubmit() }}\n className={inputClass}\n />\n )\n }\n}\n\nfunction ProgressBar({ answered, total }: { answered: number; total: number }) {\n const pct = total > 0 ? Math.round((answered / total) * 100) : 0\n return (\n <div className=\"flex items-center gap-2\">\n <div className=\"h-1.5 flex-1 overflow-hidden rounded-full bg-[var(--bg-input)]\">\n <div className=\"h-full rounded-full bg-[var(--brand-primary)] transition-all\" style={{ width: `${pct}%` }} />\n </div>\n <span className=\"text-xs text-[var(--text-muted)]\">{answered}/{total}</span>\n </div>\n )\n}\n\nfunction currentAnswer(view: IntakeView): IntakeAnswerValue {\n const id = view.nextQuestion?.id\n if (!id) return null\n return view.answers[id] ?? defaultDraft(view.nextQuestion!)\n}\n\nfunction defaultDraft(question: IntakeQuestion): IntakeAnswerValue {\n return question.type === 'multi-select' ? [] : null\n}\n\n/** Trim text answers before submit; pass others through unchanged. */\nfunction normalize(question: IntakeQuestion, value: IntakeAnswerValue): IntakeAnswerValue {\n if (typeof value === 'string' && (question.type === 'text' || question.type === 'long-text' || question.type === 'url' || question.type === 'email')) {\n return value.trim()\n }\n return value\n}\n"],"mappings":"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","names":[]}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// src/intakes/model.ts
|
|
2
|
+
var URL_PATTERN = /^https?:\/\/[^\s/$.?#].[^\s]*$/i;
|
|
3
|
+
var EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
4
|
+
function getQuestion(graph, questionId) {
|
|
5
|
+
return graph.questions.find((q) => q.id === questionId) ?? null;
|
|
6
|
+
}
|
|
7
|
+
function hasAnswer(value) {
|
|
8
|
+
if (value == null) return false;
|
|
9
|
+
if (typeof value === "string") return value.trim().length > 0;
|
|
10
|
+
if (Array.isArray(value)) return value.length > 0;
|
|
11
|
+
return true;
|
|
12
|
+
}
|
|
13
|
+
function validateAnswer(question, value) {
|
|
14
|
+
if (!hasAnswer(value)) {
|
|
15
|
+
return question.required ? { ok: false, reason: "required" } : { ok: true };
|
|
16
|
+
}
|
|
17
|
+
switch (question.type) {
|
|
18
|
+
case "text":
|
|
19
|
+
case "long-text": {
|
|
20
|
+
if (typeof value !== "string") return { ok: false, reason: "wrong-type" };
|
|
21
|
+
const len = value.trim().length;
|
|
22
|
+
if (question.min != null && len < question.min) return { ok: false, reason: "too-short" };
|
|
23
|
+
if (question.max != null && len > question.max) return { ok: false, reason: "too-long" };
|
|
24
|
+
return { ok: true };
|
|
25
|
+
}
|
|
26
|
+
case "url": {
|
|
27
|
+
if (typeof value !== "string") return { ok: false, reason: "wrong-type" };
|
|
28
|
+
return URL_PATTERN.test(value.trim()) ? { ok: true } : { ok: false, reason: "invalid-url" };
|
|
29
|
+
}
|
|
30
|
+
case "email": {
|
|
31
|
+
if (typeof value !== "string") return { ok: false, reason: "wrong-type" };
|
|
32
|
+
return EMAIL_PATTERN.test(value.trim()) ? { ok: true } : { ok: false, reason: "invalid-email" };
|
|
33
|
+
}
|
|
34
|
+
case "number": {
|
|
35
|
+
if (typeof value !== "number" || !Number.isFinite(value)) return { ok: false, reason: "wrong-type" };
|
|
36
|
+
if (question.min != null && value < question.min) return { ok: false, reason: "too-small" };
|
|
37
|
+
if (question.max != null && value > question.max) return { ok: false, reason: "too-large" };
|
|
38
|
+
return { ok: true };
|
|
39
|
+
}
|
|
40
|
+
case "boolean": {
|
|
41
|
+
return typeof value === "boolean" ? { ok: true } : { ok: false, reason: "wrong-type" };
|
|
42
|
+
}
|
|
43
|
+
case "single-select": {
|
|
44
|
+
if (typeof value !== "string") return { ok: false, reason: "wrong-type" };
|
|
45
|
+
return optionExists(question, value) ? { ok: true } : { ok: false, reason: "not-an-option" };
|
|
46
|
+
}
|
|
47
|
+
case "multi-select": {
|
|
48
|
+
if (!Array.isArray(value)) return { ok: false, reason: "wrong-type" };
|
|
49
|
+
if (!value.every((v) => typeof v === "string" && optionExists(question, v))) {
|
|
50
|
+
return { ok: false, reason: "not-an-option" };
|
|
51
|
+
}
|
|
52
|
+
if (question.min != null && value.length < question.min) return { ok: false, reason: "too-short" };
|
|
53
|
+
if (question.max != null && value.length > question.max) return { ok: false, reason: "too-long" };
|
|
54
|
+
return { ok: true };
|
|
55
|
+
}
|
|
56
|
+
default:
|
|
57
|
+
return { ok: false, reason: "wrong-type" };
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
function nextQuestion(graph, answers) {
|
|
61
|
+
if (graph.questions.length === 0) return null;
|
|
62
|
+
const visited = /* @__PURE__ */ new Set();
|
|
63
|
+
let current = graph.questions[0] ?? null;
|
|
64
|
+
while (current) {
|
|
65
|
+
if (visited.has(current.id)) return null;
|
|
66
|
+
visited.add(current.id);
|
|
67
|
+
const answer = answers[current.id];
|
|
68
|
+
const validity = validateAnswer(current, answer);
|
|
69
|
+
if (!validity.ok || current.required && !hasAnswer(answer)) {
|
|
70
|
+
return current;
|
|
71
|
+
}
|
|
72
|
+
current = advance(graph, current, answers);
|
|
73
|
+
}
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
function isComplete(graph, answers) {
|
|
77
|
+
return nextQuestion(graph, answers) === null;
|
|
78
|
+
}
|
|
79
|
+
function intakeProgress(graph, answers) {
|
|
80
|
+
const reachable = reachableQuestions(graph, answers);
|
|
81
|
+
const required = reachable.filter((q) => q.required);
|
|
82
|
+
const answered = required.filter((q) => validateAnswer(q, answers[q.id]).ok && hasAnswer(answers[q.id])).length;
|
|
83
|
+
return { answered, total: required.length };
|
|
84
|
+
}
|
|
85
|
+
function reachableQuestions(graph, answers) {
|
|
86
|
+
if (graph.questions.length === 0) return [];
|
|
87
|
+
const visited = /* @__PURE__ */ new Set();
|
|
88
|
+
const out = [];
|
|
89
|
+
let current = graph.questions[0] ?? null;
|
|
90
|
+
while (current) {
|
|
91
|
+
if (visited.has(current.id)) break;
|
|
92
|
+
visited.add(current.id);
|
|
93
|
+
out.push(current);
|
|
94
|
+
current = advance(graph, current, answers);
|
|
95
|
+
}
|
|
96
|
+
return out;
|
|
97
|
+
}
|
|
98
|
+
function advance(graph, current, answers) {
|
|
99
|
+
if (current.next) {
|
|
100
|
+
const nextId = current.next(answers);
|
|
101
|
+
return nextId == null ? null : getQuestion(graph, nextId);
|
|
102
|
+
}
|
|
103
|
+
const index = graph.questions.indexOf(current);
|
|
104
|
+
return graph.questions[index + 1] ?? null;
|
|
105
|
+
}
|
|
106
|
+
function optionExists(question, value) {
|
|
107
|
+
return (question.options ?? []).some((option) => option.value === value);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export {
|
|
111
|
+
getQuestion,
|
|
112
|
+
hasAnswer,
|
|
113
|
+
validateAnswer,
|
|
114
|
+
nextQuestion,
|
|
115
|
+
isComplete,
|
|
116
|
+
intakeProgress,
|
|
117
|
+
reachableQuestions
|
|
118
|
+
};
|
|
119
|
+
//# sourceMappingURL=chunk-ND3XEGBE.js.map
|