@adriansteffan/reactive 0.0.44 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/settings.local.json +8 -1
- package/README.md +136 -0
- package/dist/{mod-D6DlS3ur.js → mod-Beb0Bz3s.js} +3483 -3499
- package/dist/mod.d.ts +21 -23
- package/dist/reactive.es.js +26 -24
- package/dist/reactive.umd.js +38 -38
- package/dist/style.css +1 -1
- package/dist/{web-o3I0sgwu.js → web-DOXm98lr.js} +1 -1
- package/dist/{web-D7VcCd-t.js → web-aMUVS_EB.js} +1 -1
- package/package.json +1 -1
- package/src/components/canvasblock.tsx +8 -5
- package/src/components/checkdevice.tsx +3 -0
- package/src/components/enterfullscreen.tsx +2 -0
- package/src/components/exitfullscreen.tsx +2 -0
- package/src/components/experimentrunner.tsx +19 -6
- package/src/components/microphonecheck.tsx +3 -1
- package/src/components/plaininput.tsx +3 -0
- package/src/components/prolificending.tsx +2 -0
- package/src/components/quest.tsx +3 -0
- package/src/components/storeui.tsx +3 -0
- package/src/components/text.tsx +3 -0
- package/src/components/upload.tsx +18 -20
- package/src/index.css +0 -20
- package/src/mod.tsx +1 -0
- package/src/utils/bytecode.ts +13 -11
- package/src/utils/common.ts +4 -1
- package/src/utils/simulation.ts +6 -5
- package/src/utils/upload.ts +116 -204
- package/template/backend/package-lock.json +280 -156
- package/template/package-lock.json +1693 -771
- package/template/src/Experiment.tsx +5 -1
package/src/utils/upload.ts
CHANGED
|
@@ -6,6 +6,31 @@ type DataObject = {
|
|
|
6
6
|
[key: string]: string | number | boolean | null | undefined;
|
|
7
7
|
};
|
|
8
8
|
|
|
9
|
+
|
|
10
|
+
const flattenerRegistry: Record<string, {
|
|
11
|
+
csv: string | null;
|
|
12
|
+
flatten?: (item: TrialData) => any[];
|
|
13
|
+
}[]> = {};
|
|
14
|
+
|
|
15
|
+
export function registerFlattener(
|
|
16
|
+
type: string,
|
|
17
|
+
csv: string | null,
|
|
18
|
+
flatten?: (item: TrialData) => any[],
|
|
19
|
+
) {
|
|
20
|
+
if (!flattenerRegistry[type]) flattenerRegistry[type] = [];
|
|
21
|
+
flattenerRegistry[type].push({ csv, flatten });
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Reusable flattener for components whose responseData is an array of objects.
|
|
25
|
+
// Each array element becomes a CSV row. Non-array responseData produces an empty result.
|
|
26
|
+
export function arrayFlattener(item: TrialData): any[] {
|
|
27
|
+
const responseData = item.responseData;
|
|
28
|
+
if (Array.isArray(responseData)) {
|
|
29
|
+
return responseData.map((i) => ({ block: item.name, ...i }));
|
|
30
|
+
}
|
|
31
|
+
return [];
|
|
32
|
+
}
|
|
33
|
+
|
|
9
34
|
export function escapeCsvValue(value: any): string {
|
|
10
35
|
if (value === null || value === undefined) {
|
|
11
36
|
return '';
|
|
@@ -48,137 +73,109 @@ export function convertArrayOfObjectsToCSV(data: DataObject[]): string {
|
|
|
48
73
|
return [headerRow, ...dataRows].join('\n');
|
|
49
74
|
}
|
|
50
75
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
...i,
|
|
61
|
-
}));
|
|
76
|
+
// Extracts experiment parameter values (URL params / registered params) from the initial metadata trial.
|
|
77
|
+
// Each param entry has { value, defaultValue, ... } — we pick value if set, otherwise the default.
|
|
78
|
+
function extractParams(data: any[]): Record<string, any> {
|
|
79
|
+
const paramsSource = data?.[0]?.responseData?.params;
|
|
80
|
+
if (!paramsSource || typeof paramsSource !== 'object') return {};
|
|
81
|
+
const result: Record<string, any> = {};
|
|
82
|
+
for (const [name, details] of Object.entries(paramsSource) as [string, any][]) {
|
|
83
|
+
if (details && typeof details === 'object') {
|
|
84
|
+
result[name] = details.value ?? details.defaultValue;
|
|
62
85
|
}
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
export const transform = ({ responseData, ...obj }: any) => ({
|
|
68
|
-
...obj,
|
|
69
|
-
...Object.entries(responseData || {}).reduce(
|
|
70
|
-
(acc, [k, v]) => ({ ...acc, [`data_${k}`]: v }),
|
|
71
|
-
{},
|
|
72
|
-
),
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
export type CSVBuilder = {
|
|
76
|
-
filename?: string;
|
|
77
|
-
trials?: string[];
|
|
78
|
-
fun?: (row: Record<string, any>) => Record<string, any>;
|
|
79
|
-
};
|
|
80
|
-
|
|
81
|
-
export function combineTrialsToCsv(
|
|
82
|
-
data: any[],
|
|
83
|
-
filename: string,
|
|
84
|
-
names: string[],
|
|
85
|
-
flatteningFunctions: Record<string, (item: any) => any[] | Record<string, any[]>>,
|
|
86
|
-
fun?: (obj: any) => any,
|
|
87
|
-
): FileUpload | FileUpload[] {
|
|
88
|
-
// Collect all flattener results first, filtering out completely empty results
|
|
89
|
-
const allResults: (any[] | Record<string, any[]>)[] = names
|
|
90
|
-
.flatMap((name) => {
|
|
91
|
-
const matchingItems = data.filter((d) => d.name === name);
|
|
92
|
-
|
|
93
|
-
return matchingItems.map((item) => {
|
|
94
|
-
const flattener = item.type && flatteningFunctions[item.type];
|
|
95
|
-
const result = flattener ? flattener(item) : [transform(item)];
|
|
96
|
-
|
|
97
|
-
if (Array.isArray(result) && result.length === 0) {
|
|
98
|
-
return null;
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
if (result && typeof result === 'object' && !Array.isArray(result)) {
|
|
102
|
-
const hasAnyData = Object.values(result).some(
|
|
103
|
-
(val) => Array.isArray(val) && val.length > 0,
|
|
104
|
-
);
|
|
105
|
-
if (!hasAnyData) {
|
|
106
|
-
return null;
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
return result;
|
|
111
|
-
});
|
|
112
|
-
})
|
|
113
|
-
.filter((result) => result !== null);
|
|
114
|
-
|
|
115
|
-
const hasMultiTable = allResults.some(
|
|
116
|
-
(result) =>
|
|
117
|
-
result &&
|
|
118
|
-
typeof result === 'object' &&
|
|
119
|
-
!Array.isArray(result) &&
|
|
120
|
-
Object.keys(result).some((key) => Array.isArray(result[key])),
|
|
121
|
-
);
|
|
86
|
+
}
|
|
87
|
+
return result;
|
|
88
|
+
}
|
|
122
89
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
90
|
+
// Built-in fields on every trial data object — not user data.
|
|
91
|
+
// Used to distinguish user-provided metadata from framework fields when building CSVs.
|
|
92
|
+
const trialBuiltinKeys = new Set(['index', 'trialNumber', 'start', 'end', 'duration', 'type', 'name', 'csv', 'responseData', 'metadata']);
|
|
93
|
+
// Subset of builtins that represent trial identity/timing — included in non-session CSVs with trial_ prefix
|
|
94
|
+
const trialInfoKeys = new Set(['index', 'trialNumber', 'start', 'end', 'duration', 'type', 'name']);
|
|
95
|
+
|
|
96
|
+
// Automatically builds CSV files from trial data using the flattener registry.
|
|
97
|
+
// Each trial is routed to a CSV file based on: item.csv override > registry default > skipped.
|
|
98
|
+
// The 'session' group is special: all trials merge into a single row, with keys namespaced
|
|
99
|
+
// by trial name (e.g. 'nickname_value', 'devicecheck_browser') to avoid collisions.
|
|
100
|
+
// All other groups produce multi-row CSVs, one row per trial (or more if the flattener expands them).
|
|
101
|
+
function autoBuildCSVs(sessionID: string, data: any[], sessionData?: Record<string, any>): FileUpload[] {
|
|
102
|
+
const files: FileUpload[] = [];
|
|
127
103
|
|
|
128
|
-
|
|
129
|
-
|
|
104
|
+
// Group trials by their target CSV file name.
|
|
105
|
+
// A component can register multiple targets, so one trial may appear in multiple groups.
|
|
106
|
+
// A per-item csv override replaces all registered targets.
|
|
107
|
+
const groups: Record<string, { item: any; flatten?: (item: TrialData) => any[] }[]> = {};
|
|
108
|
+
for (const item of data) {
|
|
109
|
+
if (item.index === -1) continue; // skip initial metadata entry
|
|
110
|
+
const csvOverride = item.csv;
|
|
111
|
+
const targets = csvOverride
|
|
112
|
+
? (Array.isArray(csvOverride) ? csvOverride : [csvOverride]).map((csv: string) => ({ csv }))
|
|
113
|
+
: flattenerRegistry[item.type] ?? [];
|
|
114
|
+
for (const target of targets) {
|
|
115
|
+
if (!target.csv) continue;
|
|
116
|
+
if (!groups[target.csv]) groups[target.csv] = [];
|
|
117
|
+
groups[target.csv].push({ item, flatten: 'flatten' in target ? target.flatten : undefined });
|
|
130
118
|
}
|
|
131
|
-
|
|
132
|
-
return {
|
|
133
|
-
filename,
|
|
134
|
-
encoding: 'utf8' as const,
|
|
135
|
-
content: convertArrayOfObjectsToCSV(processedData),
|
|
136
|
-
};
|
|
137
119
|
}
|
|
138
120
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
.
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
} else if (result && typeof result === 'object' && result[tableKey]) {
|
|
158
|
-
return result[tableKey];
|
|
121
|
+
// 'session' group: one row per participant with metadata + responseData namespaced by trial name
|
|
122
|
+
if (groups['session']) {
|
|
123
|
+
const row: Record<string, any> = {
|
|
124
|
+
sessionID,
|
|
125
|
+
userAgent: data?.[0]?.responseData?.userAgent,
|
|
126
|
+
...extractParams(data),
|
|
127
|
+
...sessionData,
|
|
128
|
+
};
|
|
129
|
+
for (const { item } of groups['session']) {
|
|
130
|
+
const prefix = item.name || item.type;
|
|
131
|
+
// Add user-provided metadata (non-builtin top-level keys), namespaced by trial name
|
|
132
|
+
for (const [key, value] of Object.entries(item)) {
|
|
133
|
+
if (!trialBuiltinKeys.has(key)) row[`${prefix}_${key}`] = value;
|
|
134
|
+
}
|
|
135
|
+
// Add responseData fields, namespaced by trial name
|
|
136
|
+
if (item.responseData && typeof item.responseData === 'object' && !Array.isArray(item.responseData)) {
|
|
137
|
+
for (const [key, value] of Object.entries(item.responseData)) {
|
|
138
|
+
row[`${prefix}_${key}`] = value;
|
|
159
139
|
}
|
|
160
|
-
|
|
161
|
-
})
|
|
162
|
-
.map((x) => (fun ? fun(x) : x));
|
|
163
|
-
|
|
164
|
-
if (tableData.length === 0) {
|
|
165
|
-
continue;
|
|
140
|
+
}
|
|
166
141
|
}
|
|
167
|
-
|
|
168
|
-
const baseFilename = filename.replace(/\.csv$/, '');
|
|
169
|
-
|
|
170
142
|
files.push({
|
|
171
|
-
filename:
|
|
143
|
+
filename: `session.${sessionID}.${Date.now()}.csv`,
|
|
144
|
+
content: convertArrayOfObjectsToCSV([row]),
|
|
172
145
|
encoding: 'utf8' as const,
|
|
173
|
-
content: convertArrayOfObjectsToCSV(tableData),
|
|
174
146
|
});
|
|
147
|
+
delete groups['session'];
|
|
175
148
|
}
|
|
176
149
|
|
|
177
|
-
|
|
178
|
-
|
|
150
|
+
// All other groups: multi-row CSVs using registered flatteners (or raw responseData spread).
|
|
151
|
+
// Each row is prepended with standard trial fields (prefixed trial_) plus any extra
|
|
152
|
+
// metadata fields (unprefixed). The flattener's output overwrites these if keys collide.
|
|
153
|
+
for (const [csvName, entries] of Object.entries(groups)) {
|
|
154
|
+
const rows = entries.flatMap(({ item, flatten }) => {
|
|
155
|
+
const base: Record<string, any> = {};
|
|
156
|
+
for (const [key, value] of Object.entries(item)) {
|
|
157
|
+
if (trialBuiltinKeys.has(key)) {
|
|
158
|
+
if (trialInfoKeys.has(key)) base[`trial_${key}`] = value;
|
|
159
|
+
} else {
|
|
160
|
+
base[key] = value;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
// When no flatten function is registered, spread responseData directly.
|
|
164
|
+
const flatRows = flatten
|
|
165
|
+
? flatten(item)
|
|
166
|
+
: [item.responseData && typeof item.responseData === 'object' ? { ...item.responseData } : {}];
|
|
167
|
+
return flatRows.map((row: any) => ({ ...base, ...row }));
|
|
168
|
+
});
|
|
169
|
+
if (rows.length > 0) {
|
|
170
|
+
files.push({
|
|
171
|
+
filename: `${csvName}.${sessionID}.${Date.now()}.csv`,
|
|
172
|
+
content: convertArrayOfObjectsToCSV(rows),
|
|
173
|
+
encoding: 'utf8' as const,
|
|
174
|
+
});
|
|
175
|
+
}
|
|
179
176
|
}
|
|
180
177
|
|
|
181
|
-
return files
|
|
178
|
+
return files;
|
|
182
179
|
}
|
|
183
180
|
|
|
184
181
|
export function buildUploadFiles(config: {
|
|
@@ -186,11 +183,7 @@ export function buildUploadFiles(config: {
|
|
|
186
183
|
data: any[];
|
|
187
184
|
store?: Store;
|
|
188
185
|
generateFiles?: (sessionID: string, data: any[], store?: Store) => FileUpload[];
|
|
189
|
-
|
|
190
|
-
trialCSVBuilder?: {
|
|
191
|
-
flatteners: Record<string, (item: any) => any[] | Record<string, any[]>>;
|
|
192
|
-
builders: CSVBuilder[];
|
|
193
|
-
};
|
|
186
|
+
sessionData?: Record<string, any>;
|
|
194
187
|
uploadRaw?: boolean;
|
|
195
188
|
}): FileUpload[] {
|
|
196
189
|
const {
|
|
@@ -198,8 +191,7 @@ export function buildUploadFiles(config: {
|
|
|
198
191
|
data,
|
|
199
192
|
store,
|
|
200
193
|
generateFiles,
|
|
201
|
-
|
|
202
|
-
trialCSVBuilder,
|
|
194
|
+
sessionData,
|
|
203
195
|
uploadRaw = true,
|
|
204
196
|
} = config;
|
|
205
197
|
|
|
@@ -213,87 +205,7 @@ export function buildUploadFiles(config: {
|
|
|
213
205
|
});
|
|
214
206
|
}
|
|
215
207
|
|
|
216
|
-
|
|
217
|
-
type ParamDetails = {
|
|
218
|
-
value?: any;
|
|
219
|
-
defaultValue: any;
|
|
220
|
-
};
|
|
221
|
-
let paramsDict: Record<string, any> = {};
|
|
222
|
-
const paramsSource: Record<string, ParamDetails | any> | undefined =
|
|
223
|
-
data?.[0]?.responseData?.params;
|
|
224
|
-
if (paramsSource && typeof paramsSource === 'object' && paramsSource !== null) {
|
|
225
|
-
paramsDict = Object.entries(paramsSource).reduce(
|
|
226
|
-
(
|
|
227
|
-
accumulator: Record<string, any>,
|
|
228
|
-
[paramName, paramDetails]: [string, ParamDetails | any],
|
|
229
|
-
) => {
|
|
230
|
-
if (
|
|
231
|
-
paramDetails &&
|
|
232
|
-
typeof paramDetails === 'object' &&
|
|
233
|
-
'defaultValue' in paramDetails
|
|
234
|
-
) {
|
|
235
|
-
const chosenValue = paramDetails.value ?? paramDetails.defaultValue;
|
|
236
|
-
accumulator[paramName] = chosenValue;
|
|
237
|
-
}
|
|
238
|
-
return accumulator;
|
|
239
|
-
},
|
|
240
|
-
{} as Record<string, any>,
|
|
241
|
-
);
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
let content: Record<string, any> = {
|
|
245
|
-
sessionID,
|
|
246
|
-
userAgent: data?.[0]?.responseData?.userAgent,
|
|
247
|
-
...paramsDict,
|
|
248
|
-
};
|
|
249
|
-
|
|
250
|
-
if (
|
|
251
|
-
sessionCSVBuilder.trials &&
|
|
252
|
-
Array.isArray(sessionCSVBuilder.trials) &&
|
|
253
|
-
sessionCSVBuilder.trials.length > 0
|
|
254
|
-
) {
|
|
255
|
-
for (const trialName of sessionCSVBuilder.trials) {
|
|
256
|
-
const matchingDataElement = data.find((element) => element.name === trialName);
|
|
257
|
-
|
|
258
|
-
if (matchingDataElement?.responseData) {
|
|
259
|
-
if (
|
|
260
|
-
typeof matchingDataElement.responseData === 'object' &&
|
|
261
|
-
matchingDataElement.responseData !== null
|
|
262
|
-
) {
|
|
263
|
-
content = { ...content, ...matchingDataElement.responseData };
|
|
264
|
-
}
|
|
265
|
-
}
|
|
266
|
-
}
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
files.push({
|
|
270
|
-
content: convertArrayOfObjectsToCSV([
|
|
271
|
-
sessionCSVBuilder.fun ? sessionCSVBuilder.fun(content) : content,
|
|
272
|
-
]),
|
|
273
|
-
filename: `${sessionID}${sessionCSVBuilder.filename}.csv`,
|
|
274
|
-
encoding: 'utf8' as const,
|
|
275
|
-
});
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
if (trialCSVBuilder) {
|
|
279
|
-
for (const builder of trialCSVBuilder.builders) {
|
|
280
|
-
const result = combineTrialsToCsv(
|
|
281
|
-
data,
|
|
282
|
-
`${sessionID}${builder.filename}.csv`,
|
|
283
|
-
builder.trials ?? [],
|
|
284
|
-
{ ...defaultFlatteningFunctions, ...trialCSVBuilder.flatteners },
|
|
285
|
-
builder.fun,
|
|
286
|
-
);
|
|
287
|
-
|
|
288
|
-
if (Array.isArray(result)) {
|
|
289
|
-
if (result.length > 0) {
|
|
290
|
-
files.push(...result);
|
|
291
|
-
}
|
|
292
|
-
} else {
|
|
293
|
-
files.push(result);
|
|
294
|
-
}
|
|
295
|
-
}
|
|
296
|
-
}
|
|
208
|
+
files.push(...autoBuildCSVs(sessionID, data, sessionData));
|
|
297
209
|
|
|
298
210
|
return files;
|
|
299
211
|
}
|