@adriansteffan/reactive 0.0.42 → 0.0.44

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/.claude/settings.local.json +11 -2
  2. package/README.md +106 -3
  3. package/dist/{mod-UqYdghJl.js → mod-D6DlS3ur.js} +6483 -6120
  4. package/dist/mod.d.ts +54 -2
  5. package/dist/reactive.es.js +42 -33
  6. package/dist/reactive.umd.js +38 -36
  7. package/dist/style.css +1 -1
  8. package/dist/{web-pL-YTTVv.js → web-D7VcCd-t.js} +1 -1
  9. package/dist/{web-eGzX65_f.js → web-o3I0sgwu.js} +1 -1
  10. package/package.json +1 -1
  11. package/src/components/canvasblock.tsx +112 -70
  12. package/src/components/checkdevice.tsx +15 -0
  13. package/src/components/enterfullscreen.tsx +5 -3
  14. package/src/components/exitfullscreen.tsx +4 -1
  15. package/src/components/experimentprovider.tsx +7 -2
  16. package/src/components/experimentrunner.tsx +66 -52
  17. package/src/components/microphonecheck.tsx +3 -0
  18. package/src/components/mobilefilepermission.tsx +3 -0
  19. package/src/components/plaininput.tsx +17 -0
  20. package/src/components/prolificending.tsx +3 -0
  21. package/src/components/quest.tsx +58 -8
  22. package/src/components/storeui.tsx +15 -11
  23. package/src/components/text.tsx +11 -0
  24. package/src/components/upload.tsx +56 -271
  25. package/src/mod.tsx +1 -0
  26. package/src/utils/bytecode.ts +50 -0
  27. package/src/utils/simulation.ts +268 -0
  28. package/src/utils/upload.ts +299 -0
  29. package/template/README.md +59 -0
  30. package/template/backend/src/backend.ts +1 -0
  31. package/template/package.json +2 -0
  32. package/template/simulate.ts +15 -0
  33. package/template/src/Experiment.tsx +58 -5
  34. package/template/src/main.tsx +1 -1
  35. package/template/tsconfig.json +2 -3
  36. package/tsconfig.json +1 -0
  37. package/vite.config.ts +1 -1
@@ -1,5 +1,6 @@
1
1
  import { BaseComponentProps } from '../mod';
2
2
  import React, { useState, useEffect } from 'react';
3
+ import { registerSimulation } from '../utils/simulation';
3
4
 
4
5
  interface BaseFieldConfig {
5
6
  type: 'string' | 'integer' | 'float' | 'boolean';
@@ -33,6 +34,19 @@ interface BooleanFieldConfig extends BaseFieldConfig {
33
34
 
34
35
  type FieldConfig = StringFieldConfig | NumberFieldConfig | BooleanFieldConfig;
35
36
 
37
+ function buildFieldValues(fields: FieldConfig[], store?: Record<string, any>): Record<string, any> {
38
+ const values: Record<string, any> = {};
39
+ fields.forEach(field => {
40
+ values[field.storeKey] = store?.[field.storeKey] !== undefined ? store[field.storeKey] : field.defaultValue;
41
+ });
42
+ return values;
43
+ }
44
+
45
+ registerSimulation('StoreUI', (trialProps, experimentState, _simulators, participant) => {
46
+ const values = buildFieldValues(trialProps.fields || [], experimentState.store);
47
+ return { responseData: values, participantState: participant, storeUpdates: values };
48
+ }, {});
49
+
36
50
  interface StoreUIProps extends BaseComponentProps {
37
51
  title?: string;
38
52
  description?: string;
@@ -56,17 +70,7 @@ function StoreUI({
56
70
 
57
71
 
58
72
  useEffect(() => {
59
- const initialValues: Record<string, any> = {};
60
-
61
- fields.forEach(field => {
62
-
63
- initialValues[field.storeKey] =
64
- store?.[field.storeKey] !== undefined
65
- ? store[field.storeKey]
66
- : field.defaultValue;
67
- });
68
-
69
- setValues(initialValues);
73
+ setValues(buildFieldValues(fields, store));
70
74
 
71
75
  const initialTouched: Record<string, boolean> = {};
72
76
  fields.forEach(field => {
@@ -1,5 +1,16 @@
1
1
  import React, { useEffect, useRef } from 'react';
2
2
  import { BaseComponentProps, now } from '../mod';
3
+ import { registerSimulation } from '../utils/simulation';
4
+
5
+ registerSimulation('Text', (trialProps, _experimentState, simulators, participant) => {
6
+ const result = simulators.respond(trialProps, participant);
7
+ return { responseData: result.value, participantState: result.participantState, duration: result.value.reactionTime };
8
+ }, {
9
+ respond: (_input: any, participant: any) => ({
10
+ value: { key: 'button', reactionTime: 500 + Math.random() * 1500 },
11
+ participantState: participant,
12
+ }),
13
+ });
3
14
 
4
15
  function Text({
5
16
  content,
@@ -13,8 +13,54 @@ import {
13
13
  TrialData,
14
14
  } from '../utils/common';
15
15
  import { BlobWriter, TextReader, ZipWriter } from '@zip.js/zip.js';
16
-
17
16
  import { Filesystem, Directory, Encoding } from '@capacitor/filesystem';
17
+ import { buildUploadFiles, convertArrayOfObjectsToCSV, CSVBuilder } from '../utils/upload';
18
+ import { registerSimulation, getBackendUrl, getInitialParticipant } from '../utils/simulation';
19
+
20
+ registerSimulation('Upload', async (trialProps, experimentState, _simulators, participant) => {
21
+ const sessionID = trialProps.sessionID || `sim_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
22
+ const files = buildUploadFiles({
23
+ sessionID,
24
+ data: experimentState.data || [],
25
+ store: experimentState.store,
26
+ generateFiles: trialProps.generateFiles,
27
+ sessionCSVBuilder: trialProps.sessionCSVBuilder,
28
+ trialCSVBuilder: trialProps.trialCSVBuilder,
29
+ uploadRaw: trialProps.uploadRaw ?? true,
30
+ });
31
+
32
+ const initialParticipant = getInitialParticipant();
33
+ if (initialParticipant) {
34
+ files.push({
35
+ filename: `${sessionID}_participant_initial.csv`,
36
+ content: convertArrayOfObjectsToCSV([initialParticipant]),
37
+ encoding: 'utf8',
38
+ });
39
+ files.push({
40
+ filename: `${sessionID}_participant_final.csv`,
41
+ content: convertArrayOfObjectsToCSV([participant]),
42
+ encoding: 'utf8',
43
+ });
44
+ }
45
+
46
+ const backendUrl = getBackendUrl();
47
+ if (backendUrl) {
48
+ const payload = {
49
+ sessionId: sessionID,
50
+ files: files.map((f) => ({ ...f, encoding: f.encoding ?? 'utf8' })),
51
+ };
52
+ const res = await fetch(`${backendUrl}/data`, {
53
+ method: 'POST',
54
+ headers: { 'Content-Type': 'application/json' },
55
+ body: JSON.stringify(payload),
56
+ });
57
+ if (!res.ok) {
58
+ console.error(`Simulation upload failed: ${res.status} ${res.statusText}`);
59
+ }
60
+ }
61
+
62
+ return { responseData: { files }, participantState: participant };
63
+ }, {});
18
64
 
19
65
  interface UploadPayload {
20
66
  sessionId: string;
@@ -42,179 +88,6 @@ registerComponentParams('Upload', [
42
88
  },
43
89
  ]);
44
90
 
45
- type DataObject = {
46
- [key: string]: string | number | boolean | null | undefined;
47
- };
48
-
49
- function escapeCsvValue(value: any): string {
50
- if (value === null || value === undefined) {
51
- return '';
52
- }
53
-
54
- const stringValue = String(value);
55
-
56
- if (stringValue.includes(',') || stringValue.includes('"') || stringValue.includes('\n')) {
57
- const escapedValue = stringValue.replace(/"/g, '""');
58
- return `"${escapedValue}"`;
59
- }
60
-
61
- return stringValue;
62
- }
63
-
64
- function convertArrayOfObjectsToCSV(data: DataObject[]): string {
65
- if (!data || data.length === 0) {
66
- return '';
67
- }
68
-
69
- const headerSet = new Set<string>();
70
- data.forEach((obj) => {
71
- Object.keys(obj).forEach((key) => {
72
- headerSet.add(key);
73
- });
74
- });
75
- const headers = Array.from(headerSet);
76
-
77
- const headerRow = headers.map((header) => escapeCsvValue(header)).join(',');
78
-
79
- const dataRows = data.map((obj) => {
80
- return headers
81
- .map((header) => {
82
- const value = obj[header];
83
- return escapeCsvValue(value);
84
- })
85
- .join(',');
86
- });
87
-
88
- return [headerRow, ...dataRows].join('\n');
89
- }
90
-
91
- // TODO: for better cohesion move this into the components on registration
92
- const defaultFlatteningFunctions = {
93
- 'CanvasBlock': (item: TrialData) => {
94
- const responseData = item.responseData;
95
- if (Array.isArray(responseData)) {
96
- return responseData.map((i) => ({
97
- block: item.name,
98
- ...i,
99
- }));
100
- }
101
- return [];
102
- }
103
- }
104
-
105
- const transform = ({responseData, ...obj}: any) => ({ ...obj, ...Object.entries(responseData || {}).reduce((acc, [k, v]) => ({...acc, [`data_${k}`]: v}), {}) });
106
-
107
- function combineTrialsToCsv(
108
- data: any[],
109
- filename: string,
110
- names: string[],
111
- flatteningFunctions: Record<string, (item: any) => any[] | Record<string, any[]>>,
112
- fun?: (obj: any) => any,
113
- ): FileUpload | FileUpload[] {
114
-
115
- // Collect all flattener results first, filtering out completely empty results
116
- const allResults: (any[] | Record<string, any[]>)[] = names.flatMap((name) => {
117
- const matchingItems = data.filter((d) => d.name === name);
118
-
119
- return matchingItems.map((item) => {
120
- const flattener = item.type && flatteningFunctions[item.type];
121
- const result = flattener ? flattener(item) : [transform(item)];
122
-
123
- // Filter out completely empty results
124
- if (Array.isArray(result) && result.length === 0) {
125
- return null; // Signal this trial should be completely skipped
126
- }
127
-
128
- if (result && typeof result === 'object' && !Array.isArray(result)) {
129
- // Check if all arrays in the object are empty
130
- const hasAnyData = Object.values(result).some(val =>
131
- Array.isArray(val) && val.length > 0
132
- );
133
- if (!hasAnyData) {
134
- return null; // Signal this trial should be completely skipped
135
- }
136
- }
137
-
138
- return result;
139
- });
140
- }).filter(result => result !== null);
141
-
142
- // Check if any result is a multi-table object (has string keys with array values)
143
- const hasMultiTable = allResults.some((result) =>
144
- result &&
145
- typeof result === 'object' &&
146
- !Array.isArray(result) &&
147
- Object.keys(result).some(key => Array.isArray(result[key]))
148
- );
149
-
150
- if (!hasMultiTable) {
151
- // all results are arrays, combine them into one CSV
152
- const processedData = allResults
153
- .flatMap((result) => Array.isArray(result) ? result : [])
154
- .map((x) => (fun ? fun(x) : x));
155
-
156
- // Skip creating CSV if all flatteners returned empty arrays
157
- if (processedData.length === 0) {
158
- return [];
159
- }
160
-
161
- return {
162
- filename,
163
- encoding: 'utf8' as const,
164
- content: convertArrayOfObjectsToCSV(processedData),
165
- };
166
- }
167
-
168
- // handle multi-table results
169
- // Collect all table keys from all results
170
- const allTableKeys = new Set<string>();
171
- allResults.forEach((result) => {
172
- if (result && typeof result === 'object' && !Array.isArray(result)) {
173
- Object.keys(result).forEach(key => {
174
- if (Array.isArray(result[key])) {
175
- allTableKeys.add(key);
176
- }
177
- });
178
- }
179
- });
180
-
181
- // Create separate CSV files for each table key
182
- const files: FileUpload[] = [];
183
-
184
- for (const tableKey of allTableKeys) {
185
- const tableData = allResults.flatMap((result) => {
186
- if (Array.isArray(result)) {
187
- // If this result is a simple array, include it in all tables for backward compatibility
188
- return result;
189
- } else if (result && typeof result === 'object' && result[tableKey]) {
190
- // If this result has data for this table key, include it
191
- return result[tableKey];
192
- }
193
- return [];
194
- }).map((x) => (fun ? fun(x) : x));
195
-
196
- // Skip creating CSV if all flatteners returned empty arrays for this table
197
- if (tableData.length === 0) {
198
- continue;
199
- }
200
-
201
- // Remove file extension from filename and add table key
202
- const baseFilename = filename.replace(/\.csv$/, '');
203
-
204
- files.push({
205
- filename: `${baseFilename}_${tableKey}.csv`,
206
- encoding: 'utf8' as const,
207
- content: convertArrayOfObjectsToCSV(tableData),
208
- });
209
- }
210
-
211
- // Return empty array if no files were created
212
- if (files.length === 0) {
213
- return [];
214
- }
215
-
216
- return files.length === 1 ? files[0] : files;
217
- }
218
91
 
219
92
  interface FileBackend {
220
93
  directoryExists(path: string): Promise<boolean>;
@@ -345,12 +218,6 @@ const getUniqueDirectoryName = async (
345
218
  return uniqueSessionID;
346
219
  };
347
220
 
348
- type CSVBuilder = {
349
- filename?: string;
350
- trials?: string[];
351
- fun?: (row: Record<string, any>) => Record<string, any>;
352
- };
353
-
354
221
  export default function Upload({
355
222
  data,
356
223
  next,
@@ -427,97 +294,15 @@ export default function Upload({
427
294
 
428
295
  const sessionIDUpload = sessionID ?? uuidv4();
429
296
 
430
- const files: FileUpload[] = generateFiles ? generateFiles(sessionIDUpload, data, store) : [];
431
- if (uploadRaw) {
432
- files.push({
433
- filename: `${sessionIDUpload}.raw.json`,
434
- content: JSON.stringify(data),
435
- encoding: 'utf8',
436
- });
437
- }
438
-
439
- if (sessionCSVBuilder) {
440
- type ParamDetails = {
441
- value?: any;
442
- defaultValue: any;
443
- };
444
- let paramsDict: Record<string, any> = {};
445
- const paramsSource: Record<string, ParamDetails | any> | undefined =
446
- data?.[0]?.responseData?.params;
447
- if (paramsSource && typeof paramsSource === 'object' && paramsSource !== null) {
448
- paramsDict = Object.entries(paramsSource).reduce(
449
- (
450
- accumulator: Record<string, any>,
451
- [paramName, paramDetails]: [string, ParamDetails | any],
452
- ) => {
453
- if (
454
- paramDetails &&
455
- typeof paramDetails === 'object' &&
456
- 'defaultValue' in paramDetails
457
- ) {
458
- const chosenValue = paramDetails.value ?? paramDetails.defaultValue;
459
- accumulator[paramName] = chosenValue;
460
- }
461
- return accumulator;
462
- },
463
- {} as Record<string, any>,
464
- );
465
- }
466
-
467
- let content = {
468
- sessionID: sessionIDUpload,
469
- userAgent: data[0].responseData.userAgent,
470
- ...paramsDict,
471
- };
472
-
473
- if (
474
- sessionCSVBuilder.trials &&
475
- Array.isArray(sessionCSVBuilder.trials) &&
476
- sessionCSVBuilder.trials.length > 0
477
- ) {
478
- for (const trialName of sessionCSVBuilder.trials) {
479
- const matchingDataElement = data.find((element) => element.name === trialName);
480
-
481
- if (matchingDataElement?.responseData) {
482
- if (
483
- typeof matchingDataElement.responseData === 'object' &&
484
- matchingDataElement.responseData !== null
485
- ) {
486
- content = { ...content, ...matchingDataElement.responseData };
487
- }
488
- }
489
- }
490
- }
491
-
492
- files.push({
493
- content: convertArrayOfObjectsToCSV([
494
- sessionCSVBuilder.fun ? sessionCSVBuilder.fun(content) : content,
495
- ]),
496
- filename: `${sessionIDUpload}${sessionCSVBuilder.filename}.csv`,
497
- encoding: 'utf8' as const,
498
- });
499
- }
500
-
501
- if (trialCSVBuilder) {
502
- for (const builder of trialCSVBuilder.builders) {
503
- const result = combineTrialsToCsv(
504
- data,
505
- `${sessionIDUpload}${builder.filename}.csv`,
506
- builder.trials ?? [],
507
- {...defaultFlatteningFunctions, ...trialCSVBuilder.flatteners},
508
- builder.fun,
509
- );
510
-
511
- if (Array.isArray(result)) {
512
- // Only push files if the array is not empty
513
- if (result.length > 0) {
514
- files.push(...result);
515
- }
516
- } else {
517
- files.push(result);
518
- }
519
- }
520
- }
297
+ const files = buildUploadFiles({
298
+ sessionID: sessionIDUpload,
299
+ data,
300
+ store,
301
+ generateFiles,
302
+ sessionCSVBuilder,
303
+ trialCSVBuilder,
304
+ uploadRaw,
305
+ });
521
306
 
522
307
  try {
523
308
  const payload: UploadPayload = {
package/src/mod.tsx CHANGED
@@ -4,6 +4,7 @@ export type { BaseComponentProps, ExperimentConfig };
4
4
 
5
5
  export * from './utils/array';
6
6
  export * from './utils/common';
7
+ export * from './utils/simulation';
7
8
  export * from './components';
8
9
 
9
10
  export * from 'react-toastify';
@@ -207,3 +207,53 @@ export function compileTimeline(timeline: TimelineItem[]): {
207
207
 
208
208
  return { instructions, markers };
209
209
  }
210
+
211
+ /**
212
+ * Walks bytecode from `fromPointer`, processing IfGoto and UpdateStore instructions,
213
+ * and returns the pointer of the next ExecuteContent (or past-end if none found).
214
+ */
215
+ export function applyMetadata<T extends Record<string, any>>(
216
+ trialData: T,
217
+ content: { metadata?: any; nestMetadata?: boolean },
218
+ data: RefinedTrialData[],
219
+ store: Store,
220
+ ): T {
221
+ const metadata = typeof content.metadata === 'function' ? content.metadata(data, store) : content.metadata;
222
+ if (content.nestMetadata) return { ...trialData, metadata };
223
+ if (metadata) return { ...metadata, ...trialData };
224
+ return trialData;
225
+ }
226
+
227
+ export function advanceToNextContent(
228
+ bytecode: { instructions: UnifiedBytecodeInstruction[]; markers: Record<string, number> },
229
+ fromPointer: number,
230
+ getStore: () => Store,
231
+ getData: () => RefinedTrialData[],
232
+ onUpdateStore: (newStore: Store) => void,
233
+ ): number {
234
+ let pointer = fromPointer;
235
+ while (pointer < bytecode.instructions.length) {
236
+ const instr = bytecode.instructions[pointer];
237
+ switch (instr.type) {
238
+ case 'ExecuteContent':
239
+ return pointer;
240
+ case 'IfGoto':
241
+ if (instr.cond(getStore(), getData())) {
242
+ const target = bytecode.markers[instr.marker];
243
+ if (target !== undefined) {
244
+ pointer = target;
245
+ continue;
246
+ }
247
+ }
248
+ pointer++;
249
+ break;
250
+ case 'UpdateStore':
251
+ onUpdateStore(instr.fun(getStore(), getData()));
252
+ pointer++;
253
+ break;
254
+ default:
255
+ pointer++;
256
+ }
257
+ }
258
+ return pointer;
259
+ }