@lotics/app-sdk 0.15.0 → 0.17.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/src/analytics.d.ts +6 -0
- package/dist/src/analytics.js +106 -53
- package/dist/src/hooks.js +15 -2
- package/dist/src/index.d.ts +3 -0
- package/dist/src/index.js +2 -0
- package/dist/src/mount.js +4 -4
- package/dist/src/open_external.d.ts +16 -0
- package/dist/src/open_external.js +19 -0
- package/dist/src/row.d.ts +8 -4
- package/dist/src/row.js +12 -5
- package/dist/src/rpc.d.ts +8 -10
- package/dist/src/rpc.js +23 -2
- package/dist/src/select.d.ts +26 -0
- package/dist/src/select.js +40 -0
- package/package.json +1 -1
package/dist/src/analytics.d.ts
CHANGED
|
@@ -1 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Capture an app event. Buffers until PostHog is ready, then emits directly;
|
|
3
|
+
* no-ops permanently once tracking is known to be off, so the hooks can call it
|
|
4
|
+
* unconditionally.
|
|
5
|
+
*/
|
|
6
|
+
export declare function captureAppEvent(event: string, props?: Record<string, unknown>): void;
|
|
1
7
|
export declare function bootstrapAnalytics(): Promise<void>;
|
package/dist/src/analytics.js
CHANGED
|
@@ -1,75 +1,128 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* PostHog analytics for custom-code apps.
|
|
3
3
|
*
|
|
4
|
-
* Apps
|
|
5
|
-
* product's PostHog
|
|
6
|
-
* was previously dark. `bootstrapAnalytics` runs once from `mount()` and gives
|
|
7
|
-
* every app tracking for free, with no per-app wiring: PostHog **autocapture**
|
|
8
|
-
* (clicks, pageview, pageleave) records the user actions, plus exception
|
|
9
|
-
* capture for error visibility. Every event is tagged with app identity so it's
|
|
10
|
-
* attributable per app/workspace/org and rolls up under the existing
|
|
11
|
-
* `organization` group; embedded apps `identify` the authenticated member the
|
|
12
|
-
* host passes down, so app + product events share one person. See `rpc.ts`
|
|
13
|
-
* `AppContext` for how identity is resolved per transport.
|
|
4
|
+
* Apps are a separate cross-origin bundle at `<slug>.lotics.app`, invisible to
|
|
5
|
+
* the product's PostHog — so `mount()` boots a PostHog instance per app.
|
|
14
6
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
7
|
+
* **Why explicit events, not autocapture:** the Lotics PostHog project runs
|
|
8
|
+
* with autocapture OFF (the product captures explicit events, not DOM
|
|
9
|
+
* autocapture), and that project setting overrides any client `autocapture`
|
|
10
|
+
* flag — so apps can't rely on autocapture. The SDK instead emits explicit
|
|
11
|
+
* events for genuine user actions: `app_opened`, plus `app_workflow_run` /
|
|
12
|
+
* `app_file_uploaded` from the hooks. Data-read mechanics (a `useQuery`
|
|
13
|
+
* refetch) are a system signal, not a user action, and are deliberately not
|
|
14
|
+
* events.
|
|
18
15
|
*
|
|
19
|
-
*
|
|
20
|
-
* `
|
|
21
|
-
*
|
|
16
|
+
* Every event is tagged with app identity and rolls up under the existing
|
|
17
|
+
* `organization` group; embedded apps `identify` the member the host passes
|
|
18
|
+
* down so app + product events share one person. Exception capture is on (the
|
|
19
|
+
* project opts in); session replay is off (on by default project-wide, but
|
|
20
|
+
* apps — including anonymous public visitors — were never scoped for it).
|
|
21
|
+
*
|
|
22
|
+
* The key is the public, write-only project key (already shipped in every
|
|
23
|
+
* browser), hardcoded here. Tracking is gated to the deployed app host
|
|
24
|
+
* (`*.lotics.app`); `lotics app dev` runs on localhost and stays untracked.
|
|
25
|
+
* Best-effort: any failure leaves the app fully functional and untracked.
|
|
22
26
|
*/
|
|
23
27
|
import posthog from "posthog-js";
|
|
24
28
|
import { rpc } from "./rpc.js";
|
|
25
29
|
import { hasMockFlag } from "./mock.js";
|
|
30
|
+
const POSTHOG_KEY = "phc_N1nyqSRdo9XMK3DODxrxX2Y9jG3dppybruOuMznbz62";
|
|
31
|
+
const POSTHOG_HOST = "https://us.i.posthog.com";
|
|
32
|
+
const APP_HOST_SUFFIX = ".lotics.app";
|
|
33
|
+
let loaded = false;
|
|
34
|
+
let disabled = false;
|
|
35
|
+
/**
|
|
36
|
+
* Events fired before init completes (a hook can settle during the `context`
|
|
37
|
+
* round-trip). Drained on init; capped so a never-initializing session can't
|
|
38
|
+
* grow it unbounded.
|
|
39
|
+
*/
|
|
40
|
+
const preInitQueue = [];
|
|
41
|
+
const MAX_QUEUE = 100;
|
|
42
|
+
function disable() {
|
|
43
|
+
disabled = true;
|
|
44
|
+
preInitQueue.length = 0;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Capture an app event. Buffers until PostHog is ready, then emits directly;
|
|
48
|
+
* no-ops permanently once tracking is known to be off, so the hooks can call it
|
|
49
|
+
* unconditionally.
|
|
50
|
+
*/
|
|
51
|
+
export function captureAppEvent(event, props) {
|
|
52
|
+
if (disabled)
|
|
53
|
+
return;
|
|
54
|
+
if (loaded) {
|
|
55
|
+
posthog.capture(event, props);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
if (preInitQueue.length < MAX_QUEUE)
|
|
59
|
+
preInitQueue.push({ event, props });
|
|
60
|
+
}
|
|
61
|
+
function onDeployedAppHost() {
|
|
62
|
+
try {
|
|
63
|
+
return window.location.hostname.endsWith(APP_HOST_SUFFIX);
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
26
69
|
export async function bootstrapAnalytics() {
|
|
27
|
-
//
|
|
28
|
-
|
|
70
|
+
// Only the deployed app host tracks: a design-time/screenshot load
|
|
71
|
+
// (?__mock=1) and `lotics app dev` (localhost) emit nothing. This replaces
|
|
72
|
+
// the old "no key off-prod" gate now that the key is hardcoded.
|
|
73
|
+
if (hasMockFlag() || !onDeployedAppHost()) {
|
|
74
|
+
disable();
|
|
29
75
|
return;
|
|
76
|
+
}
|
|
30
77
|
let ctx;
|
|
31
78
|
try {
|
|
32
79
|
ctx = await rpc("context", {});
|
|
33
80
|
}
|
|
34
81
|
catch {
|
|
35
|
-
// Best-effort: a context-resolution failure
|
|
36
|
-
|
|
82
|
+
// Best-effort: a context-resolution failure must never break the app.
|
|
83
|
+
disable();
|
|
37
84
|
return;
|
|
38
85
|
}
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
//
|
|
50
|
-
//
|
|
86
|
+
posthog.init(POSTHOG_KEY, {
|
|
87
|
+
// Pin the modern posthog-js init contract (the product uses the same).
|
|
88
|
+
defaults: "2025-05-24",
|
|
89
|
+
api_host: POSTHOG_HOST,
|
|
90
|
+
// Autocapture is disabled project-wide (the project setting overrides this
|
|
91
|
+
// flag), so app analytics is explicit events — not DOM autocapture or
|
|
92
|
+
// pageviews.
|
|
93
|
+
autocapture: false,
|
|
94
|
+
capture_pageview: false,
|
|
95
|
+
capture_pageleave: false,
|
|
96
|
+
// Error tracking (the project opts in). mount() only renders a local
|
|
97
|
+
// banner, so this is the app's one exception channel.
|
|
51
98
|
capture_exceptions: true,
|
|
52
|
-
// Session replay is on by default
|
|
53
|
-
// §11.4), but apps were never scoped for it and it would silently record
|
|
54
|
-
// anonymous public-app visitors. Keep it off on this surface.
|
|
99
|
+
// Session replay is on by default project-wide; never on the app surface.
|
|
55
100
|
disable_session_recording: true,
|
|
101
|
+
// posthog-js keeps its default bot/user-agent filter, so headless/bot
|
|
102
|
+
// traffic is never tracked (real users are unaffected). One consequence:
|
|
103
|
+
// analytics can't be verified through headless Playwright — it's filtered.
|
|
104
|
+
//
|
|
105
|
+
// Tag + capture from `loaded` (once PostHog has initialized) — the robust
|
|
106
|
+
// point to register super-properties and emit the first event.
|
|
107
|
+
loaded: (ph) => {
|
|
108
|
+
ph.register({
|
|
109
|
+
app_id: ctx.app_id,
|
|
110
|
+
app_name: ctx.app_name,
|
|
111
|
+
workspace_id: ctx.workspace_id,
|
|
112
|
+
organization_id: ctx.organization_id,
|
|
113
|
+
});
|
|
114
|
+
// Guarded: a bridged host that answered `context` before its auth member
|
|
115
|
+
// loaded sends an empty org — never group on an empty key.
|
|
116
|
+
if (ctx.organization_id)
|
|
117
|
+
ph.group("organization", ctx.organization_id);
|
|
118
|
+
// Embedded apps attach to the same person as the product; standalone
|
|
119
|
+
// visitors stay anonymous (member_id null).
|
|
120
|
+
if (ctx.member_id)
|
|
121
|
+
ph.identify(ctx.member_id);
|
|
122
|
+
loaded = true;
|
|
123
|
+
ph.capture("app_opened");
|
|
124
|
+
for (const e of preInitQueue.splice(0))
|
|
125
|
+
ph.capture(e.event, e.props);
|
|
126
|
+
},
|
|
56
127
|
});
|
|
57
|
-
// Tag every event (autocapture included) so app traffic is attributable and
|
|
58
|
-
// filterable — any event carrying `app_id` is an app event. `app_name` spares
|
|
59
|
-
// an id lookup when debugging; the org's name lives on the `organization`
|
|
60
|
-
// group, not denormalized here.
|
|
61
|
-
posthog.register({
|
|
62
|
-
app_id: ctx.app_id,
|
|
63
|
-
app_name: ctx.app_name,
|
|
64
|
-
workspace_id: ctx.workspace_id,
|
|
65
|
-
organization_id: ctx.organization_id,
|
|
66
|
-
});
|
|
67
|
-
// Guarded: a bridged host that answered `context` before its auth member
|
|
68
|
-
// loaded sends an empty org — never group on an empty key.
|
|
69
|
-
if (ctx.organization_id)
|
|
70
|
-
posthog.group("organization", ctx.organization_id);
|
|
71
|
-
// Embedded apps attach to the same person as the product; standalone
|
|
72
|
-
// visitors stay anonymous (member_id null).
|
|
73
|
-
if (ctx.member_id)
|
|
74
|
-
posthog.identify(ctx.member_id);
|
|
75
128
|
}
|
package/dist/src/hooks.js
CHANGED
|
@@ -16,8 +16,19 @@
|
|
|
16
16
|
import { useCallback, useEffect, useState } from "react";
|
|
17
17
|
import { rpc } from "./rpc.js";
|
|
18
18
|
import { getMockRows } from "./mock.js";
|
|
19
|
+
import { captureAppEvent } from "./analytics.js";
|
|
19
20
|
export function useWorkflow(alias) {
|
|
20
|
-
return useCallback((inputs) =>
|
|
21
|
+
return useCallback(async (inputs) => {
|
|
22
|
+
try {
|
|
23
|
+
const result = await rpc("workflow", { alias, inputs: inputs ?? {} });
|
|
24
|
+
captureAppEvent("app_workflow_run", { alias, ok: true });
|
|
25
|
+
return result;
|
|
26
|
+
}
|
|
27
|
+
catch (err) {
|
|
28
|
+
captureAppEvent("app_workflow_run", { alias, ok: false });
|
|
29
|
+
throw err;
|
|
30
|
+
}
|
|
31
|
+
}, [alias]);
|
|
21
32
|
}
|
|
22
33
|
export function useQuery(alias, params) {
|
|
23
34
|
// Stringified params key — structurally-equal-but-new param objects don't
|
|
@@ -88,7 +99,9 @@ export function useFileUpload() {
|
|
|
88
99
|
setInFlight((n) => n + 1);
|
|
89
100
|
setError(null);
|
|
90
101
|
try {
|
|
91
|
-
|
|
102
|
+
const uploaded = await rpc("upload", { file });
|
|
103
|
+
captureAppEvent("app_file_uploaded", { mime_type: file.type });
|
|
104
|
+
return uploaded;
|
|
92
105
|
}
|
|
93
106
|
catch (err) {
|
|
94
107
|
const message = err instanceof Error ? err.message : "Upload failed";
|
package/dist/src/index.d.ts
CHANGED
|
@@ -20,8 +20,11 @@ export { useWorkflow, useQuery, useFileUpload } from "./hooks.js";
|
|
|
20
20
|
export type { UploadedFile } from "./hooks.js";
|
|
21
21
|
export { rpc } from "./rpc.js";
|
|
22
22
|
export type { RpcOp } from "./rpc.js";
|
|
23
|
+
export { openExternal } from "./open_external.js";
|
|
23
24
|
export { readMembers } from "./members.js";
|
|
24
25
|
export type { ResolvedMember } from "./members.js";
|
|
26
|
+
export { readSelect } from "./select.js";
|
|
27
|
+
export type { ResolvedOption } from "./select.js";
|
|
25
28
|
export type { AppFixture } from "./mock.js";
|
|
26
29
|
export type { AppWorkflows, AppQueries } from "./types.js";
|
|
27
30
|
export { row } from "./row.js";
|
package/dist/src/index.js
CHANGED
|
@@ -17,6 +17,8 @@
|
|
|
17
17
|
export { mount } from "./mount.js";
|
|
18
18
|
export { useWorkflow, useQuery, useFileUpload } from "./hooks.js";
|
|
19
19
|
export { rpc } from "./rpc.js";
|
|
20
|
+
export { openExternal } from "./open_external.js";
|
|
20
21
|
export { readMembers } from "./members.js";
|
|
22
|
+
export { readSelect } from "./select.js";
|
|
21
23
|
export { row } from "./row.js";
|
|
22
24
|
export { useOptimistic } from "./use_optimistic.js";
|
package/dist/src/mount.js
CHANGED
|
@@ -14,10 +14,10 @@ export function mount(element, options = {}) {
|
|
|
14
14
|
// *something* even before the parent's debug telemetry is wired up.
|
|
15
15
|
installVisibleErrorHandlers(container);
|
|
16
16
|
createRoot(container).render(element);
|
|
17
|
-
// Fire-and-forget: resolve the app's identity
|
|
18
|
-
//
|
|
19
|
-
// and is best-effort so its failure can't break
|
|
20
|
-
//
|
|
17
|
+
// Fire-and-forget: resolve the app's identity and start PostHog (explicit
|
|
18
|
+
// events — autocapture is disabled project-wide). Never awaited — analytics
|
|
19
|
+
// must not delay first paint, and is best-effort so its failure can't break
|
|
20
|
+
// the app. No-ops in mock mode and off the deployed app host.
|
|
21
21
|
void bootstrapAnalytics();
|
|
22
22
|
}
|
|
23
23
|
function installVisibleErrorHandlers(container) {
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Open an external URL in a new tab.
|
|
3
|
+
*
|
|
4
|
+
* The embedded app iframe is sandboxed without `allow-popups`, so a direct
|
|
5
|
+
* `window.open` from app code is silently dropped. This routes the open to
|
|
6
|
+
* whoever can actually perform it: the un-sandboxed host frame (embedded) or
|
|
7
|
+
* the app's own top-level page (public). The URL is scheme-validated
|
|
8
|
+
* (`http`/`https` only) at the point it opens — a non-string or disallowed
|
|
9
|
+
* scheme rejects.
|
|
10
|
+
*
|
|
11
|
+
* ```tsx
|
|
12
|
+
* import { openExternal } from "@lotics/app-sdk";
|
|
13
|
+
* await openExternal(invoiceUrl);
|
|
14
|
+
* ```
|
|
15
|
+
*/
|
|
16
|
+
export declare function openExternal(url: string): Promise<void>;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { rpc } from "./rpc.js";
|
|
2
|
+
/**
|
|
3
|
+
* Open an external URL in a new tab.
|
|
4
|
+
*
|
|
5
|
+
* The embedded app iframe is sandboxed without `allow-popups`, so a direct
|
|
6
|
+
* `window.open` from app code is silently dropped. This routes the open to
|
|
7
|
+
* whoever can actually perform it: the un-sandboxed host frame (embedded) or
|
|
8
|
+
* the app's own top-level page (public). The URL is scheme-validated
|
|
9
|
+
* (`http`/`https` only) at the point it opens — a non-string or disallowed
|
|
10
|
+
* scheme rejects.
|
|
11
|
+
*
|
|
12
|
+
* ```tsx
|
|
13
|
+
* import { openExternal } from "@lotics/app-sdk";
|
|
14
|
+
* await openExternal(invoiceUrl);
|
|
15
|
+
* ```
|
|
16
|
+
*/
|
|
17
|
+
export function openExternal(url) {
|
|
18
|
+
return rpc("openExternal", { url });
|
|
19
|
+
}
|
package/dist/src/row.d.ts
CHANGED
|
@@ -1,15 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Accessors that coerce raw `useQuery` row values into typed values. Each cell
|
|
3
|
-
* of a query row is `unknown`: the query layer serializes a select column as
|
|
4
|
-
* `
|
|
5
|
-
*
|
|
3
|
+
* of a query row is `unknown`: the query layer serializes a select column as a
|
|
4
|
+
* `{ key, label }` array (one entry per selected option), a date/datetime as a
|
|
5
|
+
* `YYYY-MM-DD[THH:mm…]` string, a number/boolean as itself.
|
|
6
6
|
*
|
|
7
7
|
* These live in app-sdk — next to `useQuery`, whose serialization contract they
|
|
8
8
|
* decode — so a change to that contract updates them in one place, and so apps
|
|
9
9
|
* stop re-implementing the same coercions. They are pure (`unknown` in, value
|
|
10
10
|
* out) and never throw.
|
|
11
11
|
*/
|
|
12
|
-
/**
|
|
12
|
+
/**
|
|
13
|
+
* Select → first option key. Reads the enriched `{ key, label }` shape (and the
|
|
14
|
+
* legacy bare id / single-element array / `{ id }` shapes for back-compat).
|
|
15
|
+
* Use `readSelect` for the full `{ key, label }[]` on multi-select cells.
|
|
16
|
+
*/
|
|
13
17
|
declare function opt(v: unknown): string | null;
|
|
14
18
|
/** Text/markdown/autonumber → string (numbers stringified; everything else ""). */
|
|
15
19
|
declare function text(v: unknown): string;
|
package/dist/src/row.js
CHANGED
|
@@ -1,23 +1,30 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Accessors that coerce raw `useQuery` row values into typed values. Each cell
|
|
3
|
-
* of a query row is `unknown`: the query layer serializes a select column as
|
|
4
|
-
* `
|
|
5
|
-
*
|
|
3
|
+
* of a query row is `unknown`: the query layer serializes a select column as a
|
|
4
|
+
* `{ key, label }` array (one entry per selected option), a date/datetime as a
|
|
5
|
+
* `YYYY-MM-DD[THH:mm…]` string, a number/boolean as itself.
|
|
6
6
|
*
|
|
7
7
|
* These live in app-sdk — next to `useQuery`, whose serialization contract they
|
|
8
8
|
* decode — so a change to that contract updates them in one place, and so apps
|
|
9
9
|
* stop re-implementing the same coercions. They are pure (`unknown` in, value
|
|
10
10
|
* out) and never throw.
|
|
11
11
|
*/
|
|
12
|
-
/**
|
|
12
|
+
/**
|
|
13
|
+
* Select → first option key. Reads the enriched `{ key, label }` shape (and the
|
|
14
|
+
* legacy bare id / single-element array / `{ id }` shapes for back-compat).
|
|
15
|
+
* Use `readSelect` for the full `{ key, label }[]` on multi-select cells.
|
|
16
|
+
*/
|
|
13
17
|
function opt(v) {
|
|
14
18
|
if (typeof v === "string")
|
|
15
19
|
return v || null;
|
|
16
20
|
if (Array.isArray(v))
|
|
17
21
|
return v.length ? opt(v[0]) : null;
|
|
18
22
|
if (v && typeof v === "object") {
|
|
23
|
+
const key = v.key;
|
|
24
|
+
if (typeof key === "string")
|
|
25
|
+
return key || null;
|
|
19
26
|
const id = v.id;
|
|
20
|
-
return typeof id === "string" ? id : null;
|
|
27
|
+
return typeof id === "string" ? id || null : null;
|
|
21
28
|
}
|
|
22
29
|
return null;
|
|
23
30
|
}
|
package/dist/src/rpc.d.ts
CHANGED
|
@@ -18,19 +18,19 @@
|
|
|
18
18
|
* app → host: { id, op, payload }
|
|
19
19
|
* host → app: { id, type: "result", data } | { id, type: "error", message }
|
|
20
20
|
*/
|
|
21
|
-
export type RpcOp = "query" | "workflow" | "upload" | "context";
|
|
21
|
+
export type RpcOp = "query" | "workflow" | "upload" | "context" | "openExternal";
|
|
22
22
|
/**
|
|
23
|
-
* The app's identity
|
|
24
|
-
*
|
|
23
|
+
* The app's identity, resolved once at startup to tag PostHog events.
|
|
24
|
+
* Assembled by whichever transport is active:
|
|
25
25
|
*
|
|
26
26
|
* - **Bridged** — the host (authenticated) supplies `member_id` so app events
|
|
27
27
|
* attach to the same PostHog person as the product, plus org/workspace/app
|
|
28
|
-
* from its own context
|
|
29
|
-
* - **Standalone** — the public `/by-subdomain` endpoint returns identity
|
|
30
|
-
*
|
|
28
|
+
* from its own context.
|
|
29
|
+
* - **Standalone** — the public `/by-subdomain` endpoint returns identity;
|
|
30
|
+
* `member_id` is null (anonymous visitor).
|
|
31
31
|
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
32
|
+
* The PostHog key/host are not here — they're the public project key, hardcoded
|
|
33
|
+
* in `analytics.ts`.
|
|
34
34
|
*/
|
|
35
35
|
export interface AppContext {
|
|
36
36
|
app_id: string;
|
|
@@ -38,7 +38,5 @@ export interface AppContext {
|
|
|
38
38
|
workspace_id: string;
|
|
39
39
|
organization_id: string;
|
|
40
40
|
member_id: string | null;
|
|
41
|
-
posthog_key: string | null;
|
|
42
|
-
posthog_host: string;
|
|
43
41
|
}
|
|
44
42
|
export declare function rpc<T = unknown>(op: RpcOp, payload: unknown): Promise<T>;
|
package/dist/src/rpc.js
CHANGED
|
@@ -219,8 +219,31 @@ function rpcStandalone(op, payload) {
|
|
|
219
219
|
return standaloneUpload(payload.file);
|
|
220
220
|
case "context":
|
|
221
221
|
return standaloneContext();
|
|
222
|
+
case "openExternal":
|
|
223
|
+
return standaloneOpenExternal(payload);
|
|
222
224
|
}
|
|
223
225
|
}
|
|
226
|
+
/**
|
|
227
|
+
* Open an external URL in a new tab, scheme-validated. In standalone mode the
|
|
228
|
+
* app is a normal top-level page (`<slug>.lotics.app`), so `window.open` is not
|
|
229
|
+
* sandbox-blocked — open directly. (Bridged apps route this op to the host,
|
|
230
|
+
* which opens it in the un-sandboxed parent frame; see `app_iframe_host`.)
|
|
231
|
+
* The scheme is re-validated wherever the open actually happens — never trust a
|
|
232
|
+
* URL handed across the bridge.
|
|
233
|
+
*/
|
|
234
|
+
function openValidatedUrl(url) {
|
|
235
|
+
if (typeof url !== "string")
|
|
236
|
+
throw new Error("openExternal requires a url string");
|
|
237
|
+
const parsed = new URL(url);
|
|
238
|
+
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
|
|
239
|
+
throw new Error(`openExternal: unsupported URL scheme "${parsed.protocol}"`);
|
|
240
|
+
}
|
|
241
|
+
window.open(parsed.href, "_blank", "noopener,noreferrer");
|
|
242
|
+
}
|
|
243
|
+
// `async` so a synchronous validation throw surfaces as a rejected Promise.
|
|
244
|
+
async function standaloneOpenExternal(p) {
|
|
245
|
+
openValidatedUrl(p.url);
|
|
246
|
+
}
|
|
224
247
|
async function standaloneContext() {
|
|
225
248
|
const info = await resolveAppInfo();
|
|
226
249
|
return {
|
|
@@ -230,8 +253,6 @@ async function standaloneContext() {
|
|
|
230
253
|
organization_id: info.organization_id,
|
|
231
254
|
// No host session in standalone mode — the visitor is anonymous.
|
|
232
255
|
member_id: null,
|
|
233
|
-
posthog_key: info.posthog_key,
|
|
234
|
-
posthog_host: info.posthog_host,
|
|
235
256
|
};
|
|
236
257
|
}
|
|
237
258
|
async function standaloneQuery(p) {
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reader for `select` cells in `useQuery` rows.
|
|
3
|
+
*
|
|
4
|
+
* The server (`backend/lib/select_option_resolver.ts`) rewrites every
|
|
5
|
+
* `select` column from its storage shape (`string[]` of bare `opt_*` keys)
|
|
6
|
+
* into `ResolvedOption[]` before the row reaches the app. Apps used to
|
|
7
|
+
* hardcode an `opt_* → label` map because the SDK didn't expose the resolved
|
|
8
|
+
* shape; this helper makes the right shape the obvious one.
|
|
9
|
+
*
|
|
10
|
+
* If the wire format changes, the resolver and this reader move together.
|
|
11
|
+
*/
|
|
12
|
+
export interface ResolvedOption {
|
|
13
|
+
key: string;
|
|
14
|
+
/** Option display name. Falls back to the key when the option was deleted
|
|
15
|
+
* after the cell was written — surfaces the stale state explicitly. */
|
|
16
|
+
label: string;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Parse a `useQuery` cell value into `ResolvedOption[]`. Returns `[]` for
|
|
20
|
+
* null/undefined/empty cells and for any unexpected shape — callers iterate
|
|
21
|
+
* uniformly without null-checks. A bare-string entry (a column whose source
|
|
22
|
+
* options couldn't be resolved server-side) becomes `{ key, label: key }` so
|
|
23
|
+
* single-value reads still work. Entries that fail the shape check are
|
|
24
|
+
* dropped silently rather than corrupting the array with partial data.
|
|
25
|
+
*/
|
|
26
|
+
export declare function readSelect(value: unknown): ResolvedOption[];
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reader for `select` cells in `useQuery` rows.
|
|
3
|
+
*
|
|
4
|
+
* The server (`backend/lib/select_option_resolver.ts`) rewrites every
|
|
5
|
+
* `select` column from its storage shape (`string[]` of bare `opt_*` keys)
|
|
6
|
+
* into `ResolvedOption[]` before the row reaches the app. Apps used to
|
|
7
|
+
* hardcode an `opt_* → label` map because the SDK didn't expose the resolved
|
|
8
|
+
* shape; this helper makes the right shape the obvious one.
|
|
9
|
+
*
|
|
10
|
+
* If the wire format changes, the resolver and this reader move together.
|
|
11
|
+
*/
|
|
12
|
+
/**
|
|
13
|
+
* Parse a `useQuery` cell value into `ResolvedOption[]`. Returns `[]` for
|
|
14
|
+
* null/undefined/empty cells and for any unexpected shape — callers iterate
|
|
15
|
+
* uniformly without null-checks. A bare-string entry (a column whose source
|
|
16
|
+
* options couldn't be resolved server-side) becomes `{ key, label: key }` so
|
|
17
|
+
* single-value reads still work. Entries that fail the shape check are
|
|
18
|
+
* dropped silently rather than corrupting the array with partial data.
|
|
19
|
+
*/
|
|
20
|
+
export function readSelect(value) {
|
|
21
|
+
if (!Array.isArray(value))
|
|
22
|
+
return [];
|
|
23
|
+
const out = [];
|
|
24
|
+
for (const entry of value) {
|
|
25
|
+
if (typeof entry === "string") {
|
|
26
|
+
if (entry !== "")
|
|
27
|
+
out.push({ key: entry, label: entry });
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
if (!entry || typeof entry !== "object")
|
|
31
|
+
continue;
|
|
32
|
+
const obj = entry;
|
|
33
|
+
const key = obj.key;
|
|
34
|
+
if (typeof key !== "string" || key === "")
|
|
35
|
+
continue;
|
|
36
|
+
const label = typeof obj.label === "string" ? obj.label : key;
|
|
37
|
+
out.push({ key, label });
|
|
38
|
+
}
|
|
39
|
+
return out;
|
|
40
|
+
}
|