@selfhelp/sh2-shp-survey-js-mobile 0.3.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/index.d.mts +30 -0
- package/dist/index.d.ts +30 -0
- package/dist/index.js +641 -0
- package/dist/index.mjs +630 -0
- package/package.json +70 -0
- package/src/api/surveys.ts +205 -0
- package/src/bridge/messages.ts +264 -0
- package/src/css.d.ts +13 -0
- package/src/index.ts +57 -0
- package/src/runtime/controller.ts +166 -0
- package/src/runtime/lifecycle.ts +118 -0
- package/src/styles/SurveyJsStyle.tsx +347 -0
- package/src/styles/section.ts +131 -0
- package/src/styles/transport/SurveyWebViewNative.tsx +53 -0
- package/src/styles/transport/SurveyWebViewWeb.tsx +63 -0
- package/src/styles/transport/contract.ts +33 -0
- package/src/webview/generated/runtimeHtml.ts +26 -0
- package/src/webview/htmlAsset.ts +14 -0
- package/src/webview/index.html +20 -0
- package/src/webview/runtime/SurveyWebviewApp.tsx +183 -0
- package/src/webview/runtime/bridge.ts +76 -0
- package/src/webview/runtime/main.tsx +24 -0
- package/src/webview/runtime/runtime.css +27 -0
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/*
|
|
2
|
+
SPDX-FileCopyrightText: 2026 Humdek, University of Bern
|
|
3
|
+
SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
*/
|
|
5
|
+
/**
|
|
6
|
+
* Section-field extraction for the mobile SurveyJS runtime.
|
|
7
|
+
*
|
|
8
|
+
* Mirrors the frontend `buildRuntimeConfigFromSection` (and its field
|
|
9
|
+
* readers) so the mobile renderer interprets the SAME CMS style fields
|
|
10
|
+
* the web runtime does — `redirect_at_end`, `auto_save_interval`,
|
|
11
|
+
* `restart_on_refresh`, `timeout`, the once-per-* gates, the schedule
|
|
12
|
+
* window, and the status labels. Keeping the readers identical is what
|
|
13
|
+
* makes "exactly the same functionality" hold across platforms.
|
|
14
|
+
*
|
|
15
|
+
* `extractSurveyId` reads the `survey-js` field (the selected published
|
|
16
|
+
* survey key) and is the single source of truth for both the host shell and
|
|
17
|
+
* the WebView load handshake.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export interface ISectionLike {
|
|
21
|
+
id?: number;
|
|
22
|
+
fields?: Record<string, unknown>;
|
|
23
|
+
style_name?: string;
|
|
24
|
+
[key: string]: unknown;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Read the selected published-survey key from the section's `survey-js` field. */
|
|
28
|
+
export function extractSurveyId(section: ISectionLike | undefined): string | null {
|
|
29
|
+
return extractFieldString(section, 'survey-js');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface IRuntimeSectionConfig {
|
|
33
|
+
restartOnRefresh: boolean;
|
|
34
|
+
autoSaveIntervalSeconds: number;
|
|
35
|
+
timeoutMinutes: number;
|
|
36
|
+
savePdf: boolean;
|
|
37
|
+
closeModalAtEnd: boolean;
|
|
38
|
+
redirectAtEnd: string | null;
|
|
39
|
+
urlParams: boolean;
|
|
40
|
+
startTime: string | null;
|
|
41
|
+
endTime: string | null;
|
|
42
|
+
oncePerUser: boolean;
|
|
43
|
+
oncePerSchedule: boolean;
|
|
44
|
+
ownEntriesOnly: boolean;
|
|
45
|
+
allowAnonymous: boolean;
|
|
46
|
+
labelSurveyDone: string | null;
|
|
47
|
+
labelSurveyNotActive: string | null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function extractFieldString(section: ISectionLike | undefined, key: string): string | null {
|
|
51
|
+
const value = section?.fields?.[key] ?? section?.[key];
|
|
52
|
+
if (typeof value === 'string' && value.trim() !== '') return value.trim();
|
|
53
|
+
if (typeof value === 'number' && Number.isFinite(value)) return String(value);
|
|
54
|
+
if (
|
|
55
|
+
value &&
|
|
56
|
+
typeof value === 'object' &&
|
|
57
|
+
'content' in (value as Record<string, unknown>) &&
|
|
58
|
+
(typeof (value as { content?: unknown }).content === 'string' ||
|
|
59
|
+
typeof (value as { content?: unknown }).content === 'number')
|
|
60
|
+
) {
|
|
61
|
+
const content = (value as { content: string | number }).content;
|
|
62
|
+
return String(content).trim() || null;
|
|
63
|
+
}
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function extractFieldBoolean(
|
|
68
|
+
section: ISectionLike | undefined,
|
|
69
|
+
key: string,
|
|
70
|
+
fallback: boolean,
|
|
71
|
+
): boolean {
|
|
72
|
+
const raw = extractFieldString(section, key);
|
|
73
|
+
if (raw === null) return fallback;
|
|
74
|
+
return raw === '1' || raw.toLowerCase() === 'true';
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function extractFieldNumber(
|
|
78
|
+
section: ISectionLike | undefined,
|
|
79
|
+
key: string,
|
|
80
|
+
fallback: number,
|
|
81
|
+
): number {
|
|
82
|
+
const raw = extractFieldString(section, key);
|
|
83
|
+
if (raw === null) return fallback;
|
|
84
|
+
const parsed = Number.parseInt(raw, 10);
|
|
85
|
+
return Number.isFinite(parsed) ? parsed : fallback;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function buildRuntimeConfigFromSection(section?: ISectionLike): IRuntimeSectionConfig {
|
|
89
|
+
return {
|
|
90
|
+
restartOnRefresh: extractFieldBoolean(section, 'restart_on_refresh', false),
|
|
91
|
+
autoSaveIntervalSeconds: extractFieldNumber(section, 'auto_save_interval', 0),
|
|
92
|
+
timeoutMinutes: extractFieldNumber(section, 'timeout', 0),
|
|
93
|
+
savePdf: extractFieldBoolean(section, 'save_pdf', false),
|
|
94
|
+
closeModalAtEnd: extractFieldBoolean(section, 'close_modal_at_end', false),
|
|
95
|
+
redirectAtEnd: extractFieldString(section, 'redirect_at_end'),
|
|
96
|
+
urlParams: extractFieldBoolean(section, 'url_params', false),
|
|
97
|
+
startTime: extractFieldString(section, 'start_time'),
|
|
98
|
+
endTime: extractFieldString(section, 'end_time'),
|
|
99
|
+
oncePerUser: extractFieldBoolean(section, 'once_per_user', false),
|
|
100
|
+
oncePerSchedule: extractFieldBoolean(section, 'once_per_schedule', false),
|
|
101
|
+
ownEntriesOnly: extractFieldBoolean(section, 'own_entries_only', false),
|
|
102
|
+
allowAnonymous: extractFieldBoolean(section, 'allow_anonymous', true),
|
|
103
|
+
labelSurveyDone: extractFieldString(section, 'label_survey_done'),
|
|
104
|
+
labelSurveyNotActive: extractFieldString(section, 'label_survey_not_active'),
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Server-side runtime-config echo (sent as `X-SurveyJs-Runtime-Config`)
|
|
110
|
+
* so the backend re-validates the submission against the SAME config the
|
|
111
|
+
* section declares. Mirrors the frontend `configToServerConfig`.
|
|
112
|
+
*/
|
|
113
|
+
export function configToServerConfig(config: IRuntimeSectionConfig): Record<string, unknown> {
|
|
114
|
+
return {
|
|
115
|
+
restartOnRefresh: config.restartOnRefresh,
|
|
116
|
+
autoSaveIntervalSeconds: config.autoSaveIntervalSeconds,
|
|
117
|
+
timeoutMinutes: config.timeoutMinutes,
|
|
118
|
+
savePdf: config.savePdf,
|
|
119
|
+
closeModalAtEnd: config.closeModalAtEnd,
|
|
120
|
+
redirectAtEnd: config.redirectAtEnd,
|
|
121
|
+
urlParams: config.urlParams,
|
|
122
|
+
startTime: config.startTime,
|
|
123
|
+
endTime: config.endTime,
|
|
124
|
+
oncePerUser: config.oncePerUser,
|
|
125
|
+
oncePerSchedule: config.oncePerSchedule,
|
|
126
|
+
ownEntriesOnly: config.ownEntriesOnly,
|
|
127
|
+
allowAnonymous: config.allowAnonymous,
|
|
128
|
+
labelSurveyDone: config.labelSurveyDone,
|
|
129
|
+
labelSurveyNotActive: config.labelSurveyNotActive,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/*
|
|
2
|
+
SPDX-FileCopyrightText: 2026 Humdek, University of Bern
|
|
3
|
+
SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
*/
|
|
5
|
+
/**
|
|
6
|
+
* Native WebView transport (iOS / Android) backed by `react-native-webview`.
|
|
7
|
+
*
|
|
8
|
+
* Lazy-`require`d by `SurveyJsStyle` ONLY on native, so the native module is
|
|
9
|
+
* never evaluated on the web export. Security posture:
|
|
10
|
+
* - loads the self-contained HTML via `source={{ html }}` (no remote URL),
|
|
11
|
+
* - `originWhitelist` is scoped to `about:blank` (not `*`),
|
|
12
|
+
* - `onShouldStartLoadWithRequest` blocks every navigation the shell did not
|
|
13
|
+
* explicitly allow (real redirects go through the native host, not the
|
|
14
|
+
* WebView), and
|
|
15
|
+
* - DOM storage is left off; the runtime needs no persistent storage.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { useEffect, useRef } from 'react';
|
|
19
|
+
import { WebView } from 'react-native-webview';
|
|
20
|
+
import type { WebViewMessageEvent } from 'react-native-webview';
|
|
21
|
+
|
|
22
|
+
import type { IWebViewTransportProps } from './contract';
|
|
23
|
+
|
|
24
|
+
export function SurveyWebViewNative({
|
|
25
|
+
html,
|
|
26
|
+
height,
|
|
27
|
+
onMessage,
|
|
28
|
+
setPost,
|
|
29
|
+
isAllowedUrl,
|
|
30
|
+
}: IWebViewTransportProps): React.ReactElement {
|
|
31
|
+
const ref = useRef<WebView>(null);
|
|
32
|
+
|
|
33
|
+
useEffect(() => {
|
|
34
|
+
setPost((json: string) => {
|
|
35
|
+
ref.current?.postMessage(json);
|
|
36
|
+
});
|
|
37
|
+
}, [setPost]);
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<WebView
|
|
41
|
+
ref={ref}
|
|
42
|
+
originWhitelist={['about:blank']}
|
|
43
|
+
source={{ html }}
|
|
44
|
+
onMessage={(event: WebViewMessageEvent) => onMessage(event.nativeEvent.data)}
|
|
45
|
+
onShouldStartLoadWithRequest={(request) => isAllowedUrl(request.url)}
|
|
46
|
+
javaScriptEnabled
|
|
47
|
+
domStorageEnabled={false}
|
|
48
|
+
setSupportMultipleWindows={false}
|
|
49
|
+
allowsInlineMediaPlayback
|
|
50
|
+
style={{ height, backgroundColor: 'transparent' }}
|
|
51
|
+
/>
|
|
52
|
+
);
|
|
53
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/*
|
|
2
|
+
SPDX-FileCopyrightText: 2026 Humdek, University of Bern
|
|
3
|
+
SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
*/
|
|
5
|
+
/**
|
|
6
|
+
* Web-export WebView transport backed by an `<iframe srcDoc>`.
|
|
7
|
+
*
|
|
8
|
+
* `react-native-webview` has no web implementation, so the Expo web export
|
|
9
|
+
* (react-native-web → react-dom) renders the SAME self-contained runtime HTML
|
|
10
|
+
* in a sandboxed iframe and uses `window.postMessage` for the bridge.
|
|
11
|
+
*
|
|
12
|
+
* This file uses the DOM, so it is EXCLUDED from the package `tsc` (the mobile
|
|
13
|
+
* tsconfig has no DOM lib) and compiled only by tsup/esbuild + Metro web. It
|
|
14
|
+
* is lazy-`require`d by `SurveyJsStyle` ONLY on web. The iframe is sandboxed
|
|
15
|
+
* (`allow-scripts allow-same-origin`) and the runtime HTML carries a strict
|
|
16
|
+
* CSP (`connect-src 'none'`), so the runtime cannot reach the network — every
|
|
17
|
+
* authenticated call goes through the native host.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { useEffect, useRef } from 'react';
|
|
21
|
+
|
|
22
|
+
import type { IWebViewTransportProps } from './contract';
|
|
23
|
+
|
|
24
|
+
export function SurveyWebViewWeb({
|
|
25
|
+
html,
|
|
26
|
+
height,
|
|
27
|
+
onMessage,
|
|
28
|
+
setPost,
|
|
29
|
+
isAllowedUrl,
|
|
30
|
+
}: IWebViewTransportProps): React.ReactElement {
|
|
31
|
+
const ref = useRef<HTMLIFrameElement | null>(null);
|
|
32
|
+
|
|
33
|
+
useEffect(() => {
|
|
34
|
+
setPost((json: string) => {
|
|
35
|
+
const win = ref.current?.contentWindow;
|
|
36
|
+
if (win) win.postMessage(JSON.parse(json), '*');
|
|
37
|
+
});
|
|
38
|
+
}, [setPost]);
|
|
39
|
+
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
const handler = (event: MessageEvent): void => {
|
|
42
|
+
if (ref.current && event.source === ref.current.contentWindow) {
|
|
43
|
+
onMessage(event.data);
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
window.addEventListener('message', handler);
|
|
47
|
+
return () => window.removeEventListener('message', handler);
|
|
48
|
+
}, [onMessage]);
|
|
49
|
+
|
|
50
|
+
// `isAllowedUrl` is enforced by the iframe CSP + sandbox on web; referenced
|
|
51
|
+
// here to keep the transport contract identical across platforms.
|
|
52
|
+
void isAllowedUrl;
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<iframe
|
|
56
|
+
ref={ref}
|
|
57
|
+
srcDoc={html}
|
|
58
|
+
title="SurveyJS"
|
|
59
|
+
sandbox="allow-scripts allow-same-origin allow-forms"
|
|
60
|
+
style={{ width: '100%', height, border: '0', background: 'transparent' }}
|
|
61
|
+
/>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/*
|
|
2
|
+
SPDX-FileCopyrightText: 2026 Humdek, University of Bern
|
|
3
|
+
SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
*/
|
|
5
|
+
/**
|
|
6
|
+
* Platform-agnostic contract between the SurveyJS RN shell and its WebView
|
|
7
|
+
* transport. Two implementations exist — `SurveyWebViewNative` (uses
|
|
8
|
+
* `react-native-webview`) and `SurveyWebViewWeb` (uses an `<iframe>`); the
|
|
9
|
+
* shell picks one at runtime by `Platform.OS` and lazy-`require`s it so the
|
|
10
|
+
* native module is never evaluated on web (and vice-versa).
|
|
11
|
+
*
|
|
12
|
+
* Both transports do the SAME two jobs: load the self-contained runtime HTML
|
|
13
|
+
* and shuttle bridge messages. They expose `setPost` so the shell can send
|
|
14
|
+
* host->webview messages, and call `onMessage` for every webview->host
|
|
15
|
+
* message (string on native, object on web — the shell normalises both).
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export interface IWebViewTransportProps {
|
|
19
|
+
/** Self-contained runtime HTML (survey-core + survey-react-ui inlined). */
|
|
20
|
+
html: string;
|
|
21
|
+
/** Measured content height (px) reported by the runtime via `RESIZE`. */
|
|
22
|
+
height: number;
|
|
23
|
+
/** Called with each inbound webview->host payload (string or object). */
|
|
24
|
+
onMessage: (raw: unknown) => void;
|
|
25
|
+
/** Hands the shell the function that posts a JSON string INTO the runtime. */
|
|
26
|
+
setPost: (post: (json: string) => void) => void;
|
|
27
|
+
/**
|
|
28
|
+
* Security gate: returns true only for URLs the WebView may load itself
|
|
29
|
+
* (the srcdoc/about:blank bootstrap). Everything else is blocked — real
|
|
30
|
+
* redirects go through the native host (`REQUEST_REDIRECT`).
|
|
31
|
+
*/
|
|
32
|
+
isAllowedUrl: (url: string) => boolean;
|
|
33
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/*
|
|
2
|
+
SPDX-FileCopyrightText: 2026 Humdek, University of Bern
|
|
3
|
+
SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
*/
|
|
5
|
+
/* eslint-disable */
|
|
6
|
+
/**
|
|
7
|
+
* GENERATED FILE — do not edit by hand.
|
|
8
|
+
*
|
|
9
|
+
* `npm run build:webview` compiles `src/webview` with Vite into one
|
|
10
|
+
* self-contained HTML (SurveyJS JS + CSS inlined, no CDN), then
|
|
11
|
+
* `scripts/wrap-webview-html.mjs` rewrites this file with that HTML as a string
|
|
12
|
+
* constant. This committed PLACEHOLDER is intentionally tiny so the repo stays
|
|
13
|
+
* light and so `tsc`, vitest, and the tsup bundle resolve before the WebView is
|
|
14
|
+
* built; `npm run build` always runs `build:webview` before `tsup`, so the
|
|
15
|
+
* published `dist` carries the real runtime. Do NOT commit the multi-MB built
|
|
16
|
+
* version of this file. If this placeholder ever reaches a device it posts a
|
|
17
|
+
* RUNTIME_ERROR instead of rendering.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export const SURVEYJS_WEBVIEW_HTML =
|
|
21
|
+
'<!doctype html><html><head><meta charset="utf-8" /><title>SurveyJS</title></head>' +
|
|
22
|
+
'<body><div id="root"></div><script>' +
|
|
23
|
+
"var m={source:'sh2-surveyjs',type:'RUNTIME_ERROR',message:'SurveyJS WebView runtime asset was not built (run build:webview).'};" +
|
|
24
|
+
'try{if(window.ReactNativeWebView){window.ReactNativeWebView.postMessage(JSON.stringify(m));}' +
|
|
25
|
+
"else if(window.parent&&window.parent!==window){window.parent.postMessage(m,'*');}}catch(e){}" +
|
|
26
|
+
'</script></body></html>';
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/*
|
|
2
|
+
SPDX-FileCopyrightText: 2026 Humdek, University of Bern
|
|
3
|
+
SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
*/
|
|
5
|
+
/**
|
|
6
|
+
* Public accessor for the self-contained SurveyJS WebView runtime HTML.
|
|
7
|
+
*
|
|
8
|
+
* The native RN shell loads this string into `react-native-webview`
|
|
9
|
+
* (`source={{ html }}`) on native and into a `srcdoc` iframe on web export.
|
|
10
|
+
* The string is the Vite single-file bundle (SurveyJS JS + CSS inlined, no
|
|
11
|
+
* CDN/network for the runtime itself). See `generated/runtimeHtml.ts`.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export { SURVEYJS_WEBVIEW_HTML } from './generated/runtimeHtml';
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
SPDX-FileCopyrightText: 2026 Humdek, University of Bern
|
|
3
|
+
SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
-->
|
|
5
|
+
<!doctype html>
|
|
6
|
+
<html lang="en">
|
|
7
|
+
<head>
|
|
8
|
+
<meta charset="utf-8" />
|
|
9
|
+
<meta
|
|
10
|
+
name="viewport"
|
|
11
|
+
content="width=device-width, initial-scale=1, maximum-scale=1, viewport-fit=cover"
|
|
12
|
+
/>
|
|
13
|
+
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; img-src data: blob:; connect-src 'none'; font-src data:;" />
|
|
14
|
+
<title>SurveyJS</title>
|
|
15
|
+
</head>
|
|
16
|
+
<body>
|
|
17
|
+
<div id="root"></div>
|
|
18
|
+
<script type="module" src="./runtime/main.tsx"></script>
|
|
19
|
+
</body>
|
|
20
|
+
</html>
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/*
|
|
2
|
+
SPDX-FileCopyrightText: 2026 Humdek, University of Bern
|
|
3
|
+
SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
*/
|
|
5
|
+
/**
|
|
6
|
+
* Isolated SurveyJS WebView runtime (browser side).
|
|
7
|
+
*
|
|
8
|
+
* Renders the OFFICIAL SurveyJS React UI (`survey-react-ui` + `survey-core`)
|
|
9
|
+
* — the same library the web frontend uses — so mobile gets full parity
|
|
10
|
+
* (same JSON, question types, validation, conditional logic, completion).
|
|
11
|
+
* It owns the UI lifecycle but NOT the network: it emits typed intents over
|
|
12
|
+
* the bridge and the native host performs every authenticated call and
|
|
13
|
+
* returns the result.
|
|
14
|
+
*
|
|
15
|
+
* Built ONLY by the WebView Vite bundle (`vite.webview.config.ts`) into a
|
|
16
|
+
* single self-contained HTML (survey-core + survey-react-ui JS/CSS inlined,
|
|
17
|
+
* no CDN). Excluded from the package `tsc`.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { useEffect, useRef, useState } from 'react';
|
|
21
|
+
import { Model } from 'survey-core';
|
|
22
|
+
import { Survey } from 'survey-react-ui';
|
|
23
|
+
|
|
24
|
+
import { BRIDGE_SOURCE, BRIDGE_PROTOCOL_VERSION } from '../../bridge/messages';
|
|
25
|
+
import type { THostToWebviewMessage } from '../../bridge/messages';
|
|
26
|
+
import {
|
|
27
|
+
createSurveyRuntimeController,
|
|
28
|
+
type ISurveyRuntimeController,
|
|
29
|
+
type TRuntimeLifecycle,
|
|
30
|
+
} from '../../runtime/controller';
|
|
31
|
+
import { isOutsideSchedule, stripHtml } from '../../runtime/lifecycle';
|
|
32
|
+
import type { IRuntimeSectionConfig } from '../../styles/section';
|
|
33
|
+
import { createRuntimeBridge, type IRuntimeBridge } from './bridge';
|
|
34
|
+
|
|
35
|
+
interface IRuntimeLabels {
|
|
36
|
+
surveyDone: string | null;
|
|
37
|
+
surveyNotActive: string | null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function SurveyWebviewApp(): React.ReactElement {
|
|
41
|
+
const [lifecycle, setLifecycle] = useState<TRuntimeLifecycle>('booting');
|
|
42
|
+
const [model, setModel] = useState<Model | null>(null);
|
|
43
|
+
const [statusText, setStatusText] = useState<string | null>(null);
|
|
44
|
+
|
|
45
|
+
const bridgeRef = useRef<IRuntimeBridge | null>(null);
|
|
46
|
+
const controllerRef = useRef<ISurveyRuntimeController | null>(null);
|
|
47
|
+
const configRef = useRef<IRuntimeSectionConfig | null>(null);
|
|
48
|
+
const localeRef = useRef<string | null>(null);
|
|
49
|
+
const labelsRef = useRef<IRuntimeLabels>({ surveyDone: null, surveyNotActive: null });
|
|
50
|
+
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
const bridge = createRuntimeBridge((message) => handleHostMessage(message));
|
|
53
|
+
bridgeRef.current = bridge;
|
|
54
|
+
setLifecycle('loading');
|
|
55
|
+
bridge.post({ source: BRIDGE_SOURCE, type: 'READY', protocolVersion: BRIDGE_PROTOCOL_VERSION });
|
|
56
|
+
return () => bridge.dispose();
|
|
57
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
58
|
+
}, []);
|
|
59
|
+
|
|
60
|
+
// Report content height to the native host so it can size the WebView.
|
|
61
|
+
useEffect(() => {
|
|
62
|
+
const report = (): void => {
|
|
63
|
+
const height = document.documentElement.scrollHeight;
|
|
64
|
+
bridgeRef.current?.post({ source: BRIDGE_SOURCE, type: 'RESIZE', height });
|
|
65
|
+
};
|
|
66
|
+
report();
|
|
67
|
+
const observer = new ResizeObserver(report);
|
|
68
|
+
observer.observe(document.documentElement);
|
|
69
|
+
return () => observer.disconnect();
|
|
70
|
+
}, [lifecycle, model]);
|
|
71
|
+
|
|
72
|
+
function handleHostMessage(message: THostToWebviewMessage): void {
|
|
73
|
+
switch (message.type) {
|
|
74
|
+
case 'INIT': {
|
|
75
|
+
configRef.current = message.config;
|
|
76
|
+
localeRef.current = message.locale;
|
|
77
|
+
if (isOutsideSchedule(message.config)) {
|
|
78
|
+
setStatusText(stripHtml(message.config.labelSurveyNotActive));
|
|
79
|
+
setLifecycle('locked');
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
bridgeRef.current?.post({
|
|
83
|
+
source: BRIDGE_SOURCE,
|
|
84
|
+
type: 'LOAD_SURVEY',
|
|
85
|
+
surveyKey: message.surveyKey,
|
|
86
|
+
});
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
case 'SURVEY_LOADED': {
|
|
90
|
+
labelsRef.current = {
|
|
91
|
+
surveyDone: message.runtimeConfig.labelSurveyDone,
|
|
92
|
+
surveyNotActive: message.runtimeConfig.labelSurveyNotActive,
|
|
93
|
+
};
|
|
94
|
+
if (message.state.lockoutReason) {
|
|
95
|
+
setStatusText(stripHtml(message.runtimeConfig.labelSurveyDone));
|
|
96
|
+
setLifecycle('locked');
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
buildModel(message);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
default:
|
|
103
|
+
controllerRef.current?.handleHostMessage(message);
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function buildModel(message: Extract<THostToWebviewMessage, { type: 'SURVEY_LOADED' }>): void {
|
|
109
|
+
const config = configRef.current;
|
|
110
|
+
if (!config || !bridgeRef.current) return;
|
|
111
|
+
|
|
112
|
+
const nextModel = new Model(message.definition);
|
|
113
|
+
const variables = { ...message.tokens, ...message.extraParams };
|
|
114
|
+
for (const [key, value] of Object.entries(variables)) {
|
|
115
|
+
nextModel.setVariable(key, value);
|
|
116
|
+
}
|
|
117
|
+
if (localeRef.current) nextModel.locale = localeRef.current;
|
|
118
|
+
|
|
119
|
+
let initialResponseId: string | null = null;
|
|
120
|
+
if (message.draft) {
|
|
121
|
+
nextModel.data = { ...message.draft.data };
|
|
122
|
+
if (Number.isInteger(message.draft.pageNo) && message.draft.pageNo >= 0) {
|
|
123
|
+
nextModel.currentPageNo = message.draft.pageNo;
|
|
124
|
+
}
|
|
125
|
+
initialResponseId = message.draft.responseId;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const controller = createSurveyRuntimeController({
|
|
129
|
+
config,
|
|
130
|
+
post: bridgeRef.current.post,
|
|
131
|
+
initialResponseId,
|
|
132
|
+
onLifecycle: (next, detail) => {
|
|
133
|
+
if (next === 'locked') setStatusText(stripHtml(labelsRef.current.surveyDone));
|
|
134
|
+
setLifecycle(next);
|
|
135
|
+
if (detail?.message) setStatusText(detail.message);
|
|
136
|
+
},
|
|
137
|
+
});
|
|
138
|
+
controllerRef.current = controller;
|
|
139
|
+
controller.attachModel(nextModel);
|
|
140
|
+
setModel(nextModel);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (lifecycle === 'ready' && model) {
|
|
144
|
+
return <Survey model={model} />;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return <StatusView lifecycle={lifecycle} statusText={statusText} />;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function StatusView({
|
|
151
|
+
lifecycle,
|
|
152
|
+
statusText,
|
|
153
|
+
}: {
|
|
154
|
+
lifecycle: TRuntimeLifecycle;
|
|
155
|
+
statusText: string | null;
|
|
156
|
+
}): React.ReactElement {
|
|
157
|
+
const message = resolveStatusMessage(lifecycle, statusText);
|
|
158
|
+
return (
|
|
159
|
+
<div className="sh2-survey-status" role="status" aria-live="polite">
|
|
160
|
+
{message}
|
|
161
|
+
</div>
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function resolveStatusMessage(lifecycle: TRuntimeLifecycle, statusText: string | null): string {
|
|
166
|
+
switch (lifecycle) {
|
|
167
|
+
case 'booting':
|
|
168
|
+
case 'loading':
|
|
169
|
+
return 'Loading survey…';
|
|
170
|
+
case 'submitting':
|
|
171
|
+
return 'Submitting your response…';
|
|
172
|
+
case 'submitted':
|
|
173
|
+
return 'Thank you — your response was recorded.';
|
|
174
|
+
case 'locked':
|
|
175
|
+
return statusText ?? 'You have already completed this survey.';
|
|
176
|
+
case 'session-expired':
|
|
177
|
+
return 'Your session has expired. Please reopen this page to continue.';
|
|
178
|
+
case 'error':
|
|
179
|
+
return statusText ?? 'The survey could not be displayed.';
|
|
180
|
+
default:
|
|
181
|
+
return statusText ?? 'Loading survey…';
|
|
182
|
+
}
|
|
183
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/*
|
|
2
|
+
SPDX-FileCopyrightText: 2026 Humdek, University of Bern
|
|
3
|
+
SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
*/
|
|
5
|
+
/**
|
|
6
|
+
* Bridge transport for the isolated WebView runtime (browser side).
|
|
7
|
+
*
|
|
8
|
+
* Outbound (runtime -> host):
|
|
9
|
+
* - native: `window.ReactNativeWebView.postMessage(JSON.stringify(msg))`
|
|
10
|
+
* - web/iframe: `window.parent.postMessage(msg, '*')`
|
|
11
|
+
*
|
|
12
|
+
* Inbound (host -> runtime): both `react-native-webview` (which dispatches a
|
|
13
|
+
* `document`/`window` `message` event with a string `data`) and the iframe
|
|
14
|
+
* `postMessage` (object `data`) are accepted, parsed, and validated against
|
|
15
|
+
* the typed host->webview contract. Anything that fails the guard is dropped.
|
|
16
|
+
*
|
|
17
|
+
* This file is built ONLY by the WebView Vite bundle (it touches `window` /
|
|
18
|
+
* `document`), so it is excluded from the package's `tsc` (no DOM lib) and
|
|
19
|
+
* relies on esbuild for transpilation.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import {
|
|
23
|
+
type THostToWebviewMessage,
|
|
24
|
+
type TWebviewToHostMessage,
|
|
25
|
+
isHostToWebviewMessage,
|
|
26
|
+
} from '../../bridge/messages';
|
|
27
|
+
|
|
28
|
+
interface IReactNativeWebView {
|
|
29
|
+
postMessage(payload: string): void;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface IRuntimeBridge {
|
|
33
|
+
post(message: TWebviewToHostMessage): void;
|
|
34
|
+
dispose(): void;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function createRuntimeBridge(onMessage: (message: THostToWebviewMessage) => void): IRuntimeBridge {
|
|
38
|
+
const rnWebView = (window as unknown as { ReactNativeWebView?: IReactNativeWebView }).ReactNativeWebView;
|
|
39
|
+
|
|
40
|
+
const post = (message: TWebviewToHostMessage): void => {
|
|
41
|
+
if (rnWebView && typeof rnWebView.postMessage === 'function') {
|
|
42
|
+
rnWebView.postMessage(JSON.stringify(message));
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
if (window.parent && window.parent !== window) {
|
|
46
|
+
window.parent.postMessage(message, '*');
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const handleEvent = (event: Event): void => {
|
|
51
|
+
const raw = (event as MessageEvent).data;
|
|
52
|
+
let parsed: unknown = raw;
|
|
53
|
+
if (typeof raw === 'string') {
|
|
54
|
+
try {
|
|
55
|
+
parsed = JSON.parse(raw);
|
|
56
|
+
} catch {
|
|
57
|
+
return; // not our protocol — ignore
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
if (isHostToWebviewMessage(parsed)) {
|
|
61
|
+
onMessage(parsed);
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
// react-native-webview injects to `document`; iframe posts to `window`.
|
|
66
|
+
window.addEventListener('message', handleEvent);
|
|
67
|
+
document.addEventListener('message', handleEvent as EventListener);
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
post,
|
|
71
|
+
dispose() {
|
|
72
|
+
window.removeEventListener('message', handleEvent);
|
|
73
|
+
document.removeEventListener('message', handleEvent as EventListener);
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/*
|
|
2
|
+
SPDX-FileCopyrightText: 2026 Humdek, University of Bern
|
|
3
|
+
SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
*/
|
|
5
|
+
/**
|
|
6
|
+
* WebView runtime entry point. Bundled by `vite.webview.config.ts` together
|
|
7
|
+
* with SurveyJS JS + CSS into a single self-contained HTML asset (no CDN).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { StrictMode } from 'react';
|
|
11
|
+
import { createRoot } from 'react-dom/client';
|
|
12
|
+
|
|
13
|
+
import 'survey-core/survey-core.css';
|
|
14
|
+
import './runtime.css';
|
|
15
|
+
import { SurveyWebviewApp } from './SurveyWebviewApp';
|
|
16
|
+
|
|
17
|
+
const container = document.getElementById('root');
|
|
18
|
+
if (container) {
|
|
19
|
+
createRoot(container).render(
|
|
20
|
+
<StrictMode>
|
|
21
|
+
<SurveyWebviewApp />
|
|
22
|
+
</StrictMode>,
|
|
23
|
+
);
|
|
24
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/*
|
|
2
|
+
SPDX-FileCopyrightText: 2026 Humdek, University of Bern
|
|
3
|
+
SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
*/
|
|
5
|
+
/* Minimal runtime chrome. SurveyJS owns the survey UI; this only styles the
|
|
6
|
+
transient status messages and resets margins so RESIZE height is accurate. */
|
|
7
|
+
|
|
8
|
+
html,
|
|
9
|
+
body {
|
|
10
|
+
margin: 0;
|
|
11
|
+
padding: 0;
|
|
12
|
+
background: transparent;
|
|
13
|
+
-webkit-text-size-adjust: 100%;
|
|
14
|
+
font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
#root {
|
|
18
|
+
padding: 0;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
.sh2-survey-status {
|
|
22
|
+
padding: 24px 16px;
|
|
23
|
+
text-align: center;
|
|
24
|
+
color: #4a4a4a;
|
|
25
|
+
font-size: 15px;
|
|
26
|
+
line-height: 1.5;
|
|
27
|
+
}
|