@adriansteffan/reactive 0.0.44 → 0.1.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/.claude/settings.local.json +8 -1
- package/README.md +126 -0
- package/dist/{mod-D6DlS3ur.js → mod-D9lwPIrH.js} +3480 -3498
- package/dist/mod.d.ts +19 -23
- package/dist/reactive.es.js +12 -11
- package/dist/reactive.umd.js +38 -38
- package/dist/style.css +1 -1
- package/dist/{web-D7VcCd-t.js → web-DUIQX1PV.js} +1 -1
- package/dist/{web-o3I0sgwu.js → web-DXP3LAJm.js} +1 -1
- package/package.json +1 -1
- package/src/components/canvasblock.tsx +14 -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 +106 -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,21 @@ 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
|
+
|
|
9
24
|
export function escapeCsvValue(value: any): string {
|
|
10
25
|
if (value === null || value === undefined) {
|
|
11
26
|
return '';
|
|
@@ -48,137 +63,109 @@ export function convertArrayOfObjectsToCSV(data: DataObject[]): string {
|
|
|
48
63
|
return [headerRow, ...dataRows].join('\n');
|
|
49
64
|
}
|
|
50
65
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
...i,
|
|
61
|
-
}));
|
|
66
|
+
// Extracts experiment parameter values (URL params / registered params) from the initial metadata trial.
|
|
67
|
+
// Each param entry has { value, defaultValue, ... } — we pick value if set, otherwise the default.
|
|
68
|
+
function extractParams(data: any[]): Record<string, any> {
|
|
69
|
+
const paramsSource = data?.[0]?.responseData?.params;
|
|
70
|
+
if (!paramsSource || typeof paramsSource !== 'object') return {};
|
|
71
|
+
const result: Record<string, any> = {};
|
|
72
|
+
for (const [name, details] of Object.entries(paramsSource) as [string, any][]) {
|
|
73
|
+
if (details && typeof details === 'object') {
|
|
74
|
+
result[name] = details.value ?? details.defaultValue;
|
|
62
75
|
}
|
|
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
|
-
);
|
|
76
|
+
}
|
|
77
|
+
return result;
|
|
78
|
+
}
|
|
122
79
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
80
|
+
// Built-in fields on every trial data object — not user data.
|
|
81
|
+
// Used to distinguish user-provided metadata from framework fields when building CSVs.
|
|
82
|
+
const trialBuiltinKeys = new Set(['index', 'trialNumber', 'start', 'end', 'duration', 'type', 'name', 'csv', 'responseData', 'metadata']);
|
|
83
|
+
// Subset of builtins that represent trial identity/timing — included in non-session CSVs with trial_ prefix
|
|
84
|
+
const trialInfoKeys = new Set(['index', 'trialNumber', 'start', 'end', 'duration', 'type', 'name']);
|
|
85
|
+
|
|
86
|
+
// Automatically builds CSV files from trial data using the flattener registry.
|
|
87
|
+
// Each trial is routed to a CSV file based on: item.csv override > registry default > skipped.
|
|
88
|
+
// The 'session' group is special: all trials merge into a single row, with keys namespaced
|
|
89
|
+
// by trial name (e.g. 'nickname_value', 'devicecheck_browser') to avoid collisions.
|
|
90
|
+
// All other groups produce multi-row CSVs, one row per trial (or more if the flattener expands them).
|
|
91
|
+
function autoBuildCSVs(sessionID: string, data: any[], sessionData?: Record<string, any>): FileUpload[] {
|
|
92
|
+
const files: FileUpload[] = [];
|
|
127
93
|
|
|
128
|
-
|
|
129
|
-
|
|
94
|
+
// Group trials by their target CSV file name.
|
|
95
|
+
// A component can register multiple targets, so one trial may appear in multiple groups.
|
|
96
|
+
// A per-item csv override replaces all registered targets.
|
|
97
|
+
const groups: Record<string, { item: any; flatten?: (item: TrialData) => any[] }[]> = {};
|
|
98
|
+
for (const item of data) {
|
|
99
|
+
if (item.index === -1) continue; // skip initial metadata entry
|
|
100
|
+
const csvOverride = item.csv;
|
|
101
|
+
const targets = csvOverride
|
|
102
|
+
? (Array.isArray(csvOverride) ? csvOverride : [csvOverride]).map((csv: string) => ({ csv }))
|
|
103
|
+
: flattenerRegistry[item.type] ?? [];
|
|
104
|
+
for (const target of targets) {
|
|
105
|
+
if (!target.csv) continue;
|
|
106
|
+
if (!groups[target.csv]) groups[target.csv] = [];
|
|
107
|
+
groups[target.csv].push({ item, flatten: 'flatten' in target ? target.flatten : undefined });
|
|
130
108
|
}
|
|
131
|
-
|
|
132
|
-
return {
|
|
133
|
-
filename,
|
|
134
|
-
encoding: 'utf8' as const,
|
|
135
|
-
content: convertArrayOfObjectsToCSV(processedData),
|
|
136
|
-
};
|
|
137
109
|
}
|
|
138
110
|
|
|
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];
|
|
111
|
+
// 'session' group: one row per participant with metadata + responseData namespaced by trial name
|
|
112
|
+
if (groups['session']) {
|
|
113
|
+
const row: Record<string, any> = {
|
|
114
|
+
sessionID,
|
|
115
|
+
userAgent: data?.[0]?.responseData?.userAgent,
|
|
116
|
+
...extractParams(data),
|
|
117
|
+
...sessionData,
|
|
118
|
+
};
|
|
119
|
+
for (const { item } of groups['session']) {
|
|
120
|
+
const prefix = item.name || item.type;
|
|
121
|
+
// Add user-provided metadata (non-builtin top-level keys), namespaced by trial name
|
|
122
|
+
for (const [key, value] of Object.entries(item)) {
|
|
123
|
+
if (!trialBuiltinKeys.has(key)) row[`${prefix}_${key}`] = value;
|
|
124
|
+
}
|
|
125
|
+
// Add responseData fields, namespaced by trial name
|
|
126
|
+
if (item.responseData && typeof item.responseData === 'object' && !Array.isArray(item.responseData)) {
|
|
127
|
+
for (const [key, value] of Object.entries(item.responseData)) {
|
|
128
|
+
row[`${prefix}_${key}`] = value;
|
|
159
129
|
}
|
|
160
|
-
|
|
161
|
-
})
|
|
162
|
-
.map((x) => (fun ? fun(x) : x));
|
|
163
|
-
|
|
164
|
-
if (tableData.length === 0) {
|
|
165
|
-
continue;
|
|
130
|
+
}
|
|
166
131
|
}
|
|
167
|
-
|
|
168
|
-
const baseFilename = filename.replace(/\.csv$/, '');
|
|
169
|
-
|
|
170
132
|
files.push({
|
|
171
|
-
filename:
|
|
133
|
+
filename: `session.${sessionID}.${Date.now()}.csv`,
|
|
134
|
+
content: convertArrayOfObjectsToCSV([row]),
|
|
172
135
|
encoding: 'utf8' as const,
|
|
173
|
-
content: convertArrayOfObjectsToCSV(tableData),
|
|
174
136
|
});
|
|
137
|
+
delete groups['session'];
|
|
175
138
|
}
|
|
176
139
|
|
|
177
|
-
|
|
178
|
-
|
|
140
|
+
// All other groups: multi-row CSVs using registered flatteners (or raw responseData spread).
|
|
141
|
+
// Each row is prepended with standard trial fields (prefixed trial_) plus any extra
|
|
142
|
+
// metadata fields (unprefixed). The flattener's output overwrites these if keys collide.
|
|
143
|
+
for (const [csvName, entries] of Object.entries(groups)) {
|
|
144
|
+
const rows = entries.flatMap(({ item, flatten }) => {
|
|
145
|
+
const base: Record<string, any> = {};
|
|
146
|
+
for (const [key, value] of Object.entries(item)) {
|
|
147
|
+
if (trialBuiltinKeys.has(key)) {
|
|
148
|
+
if (trialInfoKeys.has(key)) base[`trial_${key}`] = value;
|
|
149
|
+
} else {
|
|
150
|
+
base[key] = value;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
// When no flatten function is registered, spread responseData directly.
|
|
154
|
+
const flatRows = flatten
|
|
155
|
+
? flatten(item)
|
|
156
|
+
: [item.responseData && typeof item.responseData === 'object' ? { ...item.responseData } : {}];
|
|
157
|
+
return flatRows.map((row: any) => ({ ...base, ...row }));
|
|
158
|
+
});
|
|
159
|
+
if (rows.length > 0) {
|
|
160
|
+
files.push({
|
|
161
|
+
filename: `${csvName}.${sessionID}.${Date.now()}.csv`,
|
|
162
|
+
content: convertArrayOfObjectsToCSV(rows),
|
|
163
|
+
encoding: 'utf8' as const,
|
|
164
|
+
});
|
|
165
|
+
}
|
|
179
166
|
}
|
|
180
167
|
|
|
181
|
-
return files
|
|
168
|
+
return files;
|
|
182
169
|
}
|
|
183
170
|
|
|
184
171
|
export function buildUploadFiles(config: {
|
|
@@ -186,11 +173,7 @@ export function buildUploadFiles(config: {
|
|
|
186
173
|
data: any[];
|
|
187
174
|
store?: Store;
|
|
188
175
|
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
|
-
};
|
|
176
|
+
sessionData?: Record<string, any>;
|
|
194
177
|
uploadRaw?: boolean;
|
|
195
178
|
}): FileUpload[] {
|
|
196
179
|
const {
|
|
@@ -198,8 +181,7 @@ export function buildUploadFiles(config: {
|
|
|
198
181
|
data,
|
|
199
182
|
store,
|
|
200
183
|
generateFiles,
|
|
201
|
-
|
|
202
|
-
trialCSVBuilder,
|
|
184
|
+
sessionData,
|
|
203
185
|
uploadRaw = true,
|
|
204
186
|
} = config;
|
|
205
187
|
|
|
@@ -213,87 +195,7 @@ export function buildUploadFiles(config: {
|
|
|
213
195
|
});
|
|
214
196
|
}
|
|
215
197
|
|
|
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
|
-
}
|
|
198
|
+
files.push(...autoBuildCSVs(sessionID, data, sessionData));
|
|
297
199
|
|
|
298
200
|
return files;
|
|
299
201
|
}
|