@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.
@@ -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
- export const defaultFlatteningFunctions: Record<
52
- string,
53
- (item: TrialData) => any[] | Record<string, any[]>
54
- > = {
55
- CanvasBlock: (item: TrialData) => {
56
- const responseData = item.responseData;
57
- if (Array.isArray(responseData)) {
58
- return responseData.map((i) => ({
59
- block: item.name,
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
- return [];
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
- if (!hasMultiTable) {
124
- const processedData = allResults
125
- .flatMap((result) => (Array.isArray(result) ? result : []))
126
- .map((x) => (fun ? fun(x) : x));
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
- if (processedData.length === 0) {
129
- return [];
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
- const allTableKeys = new Set<string>();
140
- allResults.forEach((result) => {
141
- if (result && typeof result === 'object' && !Array.isArray(result)) {
142
- Object.keys(result).forEach((key) => {
143
- if (Array.isArray(result[key])) {
144
- allTableKeys.add(key);
145
- }
146
- });
147
- }
148
- });
149
-
150
- const files: FileUpload[] = [];
151
-
152
- for (const tableKey of allTableKeys) {
153
- const tableData = allResults
154
- .flatMap((result) => {
155
- if (Array.isArray(result)) {
156
- return result;
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
- return [];
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: `${baseFilename}_${tableKey}.csv`,
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
- if (files.length === 0) {
178
- return [];
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.length === 1 ? files[0] : 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
- sessionCSVBuilder?: CSVBuilder;
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
- sessionCSVBuilder,
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
- if (sessionCSVBuilder) {
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
  }