@adriansteffan/reactive 0.0.26 → 0.0.28
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/{mod-Dqf5zajq.js → mod-CjZm1Ta9.js} +11254 -10012
- package/dist/mod.d.ts +199 -49
- package/dist/reactive.es.js +27 -15
- package/dist/reactive.umd.js +37 -39
- package/dist/style.css +1 -1
- package/dist/{web-6wmUWZwq.js → web-B_I1xy51.js} +1 -1
- package/dist/{web-CnAMKrLX.js → web-DfpITFBR.js} +1 -1
- package/package.json +7 -2
- package/src/components/canvasblock.tsx +519 -0
- package/src/components/checkdevice.tsx +158 -0
- package/src/components/enterfullscreen.tsx +114 -31
- package/src/components/exitfullscreen.tsx +98 -21
- package/src/components/experimentprovider.tsx +34 -20
- package/src/components/experimentrunner.tsx +387 -0
- package/src/components/index.ts +13 -0
- package/src/components/mobilefilepermission.tsx +12 -19
- package/src/components/plaininput.tsx +7 -8
- package/src/components/prolificending.tsx +10 -4
- package/src/components/quest.tsx +27 -31
- package/src/components/settingsscreen.tsx +770 -0
- package/src/components/text.tsx +48 -3
- package/src/components/upload.tsx +218 -47
- package/src/mod.tsx +3 -12
- package/src/types/array.d.ts +6 -0
- package/src/utils/array.ts +113 -0
- package/src/utils/bytecode.ts +178 -0
- package/src/utils/common.ts +170 -39
- package/template/.env.template +2 -1
- package/template/src/{App.tsx → Experiment.tsx} +4 -4
- package/template/src/main.tsx +4 -4
- package/template/tsconfig.json +1 -0
- package/src/components/experiment.tsx +0 -371
|
@@ -0,0 +1,770 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'react';
|
|
2
|
+
|
|
3
|
+
type CustomParam = { name: string; type: string; value: any };
|
|
4
|
+
type TimelineRepItem = { type: string; name?: string };
|
|
5
|
+
|
|
6
|
+
export const SettingsScreen = ({
|
|
7
|
+
paramRegistry,
|
|
8
|
+
timelineRepresentation,
|
|
9
|
+
}: {
|
|
10
|
+
paramRegistry: any[];
|
|
11
|
+
timelineRepresentation: TimelineRepItem[];
|
|
12
|
+
}) => {
|
|
13
|
+
const [paramValues, setParamValues] = useState<Record<string, any>>({});
|
|
14
|
+
const [customParams, setCustomParams] = useState<CustomParam[]>([]);
|
|
15
|
+
const [generatedUrl, setGeneratedUrl] = useState('');
|
|
16
|
+
const [copyButtonText, setCopyButtonText] = useState('Copy URL');
|
|
17
|
+
const [useBase64, setUseBase64] = useState(false);
|
|
18
|
+
const [isExcludeMode, setIsExcludeMode] = useState(false);
|
|
19
|
+
const [selectedTrials, setSelectedTrials] = useState<string[]>([]);
|
|
20
|
+
const urlInputRef = useRef<HTMLInputElement>(null);
|
|
21
|
+
|
|
22
|
+
const sortedParams = [...paramRegistry]
|
|
23
|
+
.filter((param) => param.name !== 'includeSubset' && param.name !== 'excludeSubset')
|
|
24
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
25
|
+
|
|
26
|
+
// Initialize param values from URL if present
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
const searchParams = new URLSearchParams(window.location.search);
|
|
29
|
+
const initialValues: Record<string, any> = {};
|
|
30
|
+
const extractedCustomParams: CustomParam[] = [];
|
|
31
|
+
|
|
32
|
+
let initialSelectedTrials: string[] = [];
|
|
33
|
+
let initialIsExcludeMode = false;
|
|
34
|
+
|
|
35
|
+
const includeSubset = searchParams.get('includeSubset');
|
|
36
|
+
const excludeSubset = searchParams.get('excludeSubset');
|
|
37
|
+
|
|
38
|
+
const urlEncodedJson = searchParams.get('_b');
|
|
39
|
+
if (urlEncodedJson) {
|
|
40
|
+
try {
|
|
41
|
+
const jsonString = atob(urlEncodedJson);
|
|
42
|
+
const decodedParams = JSON.parse(jsonString);
|
|
43
|
+
|
|
44
|
+
if (decodedParams.excludeSubset) {
|
|
45
|
+
initialIsExcludeMode = true;
|
|
46
|
+
initialSelectedTrials = decodedParams.excludeSubset.split(',');
|
|
47
|
+
} else if (decodedParams.includeSubset) {
|
|
48
|
+
initialIsExcludeMode = false;
|
|
49
|
+
initialSelectedTrials = decodedParams.includeSubset.split(',');
|
|
50
|
+
}
|
|
51
|
+
} catch {
|
|
52
|
+
// Handle fallback to regular params below
|
|
53
|
+
|
|
54
|
+
if (excludeSubset) {
|
|
55
|
+
initialIsExcludeMode = true;
|
|
56
|
+
initialSelectedTrials = excludeSubset.split(',');
|
|
57
|
+
} else if (includeSubset) {
|
|
58
|
+
initialIsExcludeMode = false;
|
|
59
|
+
initialSelectedTrials = includeSubset.split(',');
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
} else {
|
|
63
|
+
if (excludeSubset) {
|
|
64
|
+
initialIsExcludeMode = true;
|
|
65
|
+
initialSelectedTrials = excludeSubset.split(',');
|
|
66
|
+
} else if (includeSubset) {
|
|
67
|
+
initialIsExcludeMode = false;
|
|
68
|
+
initialSelectedTrials = includeSubset.split(',');
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
setIsExcludeMode(initialIsExcludeMode);
|
|
73
|
+
if (excludeSubset || includeSubset) {
|
|
74
|
+
setSelectedTrials(initialSelectedTrials);
|
|
75
|
+
} else if (timelineRepresentation.length > 0) {
|
|
76
|
+
if (initialIsExcludeMode) {
|
|
77
|
+
setSelectedTrials([]);
|
|
78
|
+
} else {
|
|
79
|
+
setSelectedTrials(
|
|
80
|
+
Array.from({ length: timelineRepresentation.length }, (_, index) =>
|
|
81
|
+
(index + 1).toString(),
|
|
82
|
+
),
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const encodedJson = searchParams.get('_b');
|
|
88
|
+
if (encodedJson) {
|
|
89
|
+
try {
|
|
90
|
+
const jsonString = atob(encodedJson);
|
|
91
|
+
const decodedParams = JSON.parse(jsonString);
|
|
92
|
+
|
|
93
|
+
const registeredParamNames = paramRegistry.map((param) => param.name);
|
|
94
|
+
|
|
95
|
+
// Separate registered and custom params
|
|
96
|
+
Object.entries(decodedParams).forEach(([key, value]) => {
|
|
97
|
+
if (registeredParamNames.includes(key)) {
|
|
98
|
+
initialValues[key] = String(value);
|
|
99
|
+
} else if (key !== '_b') {
|
|
100
|
+
// This is a custom param
|
|
101
|
+
extractedCustomParams.push({
|
|
102
|
+
name: key,
|
|
103
|
+
value: typeof value === 'object' ? JSON.stringify(value) : String(value),
|
|
104
|
+
type:
|
|
105
|
+
typeof value === 'boolean'
|
|
106
|
+
? 'boolean'
|
|
107
|
+
: typeof value === 'number'
|
|
108
|
+
? 'number'
|
|
109
|
+
: Array.isArray(value)
|
|
110
|
+
? 'array'
|
|
111
|
+
: typeof value === 'object'
|
|
112
|
+
? 'json'
|
|
113
|
+
: 'string',
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
// Set defaults for registered params that weren't in the URL
|
|
119
|
+
paramRegistry.forEach((param) => {
|
|
120
|
+
if (!(param.name in initialValues)) {
|
|
121
|
+
if (param.type === 'boolean' && param.defaultValue !== undefined) {
|
|
122
|
+
initialValues[param.name] = String(param.defaultValue);
|
|
123
|
+
} else {
|
|
124
|
+
initialValues[param.name] = '';
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
// Since we found base64 params, set the toggle to true
|
|
130
|
+
setUseBase64(true);
|
|
131
|
+
} catch {
|
|
132
|
+
processRegularParams(searchParams);
|
|
133
|
+
}
|
|
134
|
+
} else {
|
|
135
|
+
processRegularParams(searchParams);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function processRegularParams(searchParams: URLSearchParams) {
|
|
139
|
+
// Process registered params
|
|
140
|
+
|
|
141
|
+
paramRegistry.forEach((param) => {
|
|
142
|
+
const value = searchParams.get(param.name);
|
|
143
|
+
if (value !== null) {
|
|
144
|
+
initialValues[param.name] = value;
|
|
145
|
+
} else if (param.type === 'boolean' && param.defaultValue !== undefined) {
|
|
146
|
+
// For boolean params with defaults, explicitly set them
|
|
147
|
+
initialValues[param.name] = String(param.defaultValue);
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
// Extract custom params (any param not in the registry)
|
|
152
|
+
const registeredParamNames = paramRegistry.map((param) => param.name);
|
|
153
|
+
searchParams.forEach((value: string, key: string) => {
|
|
154
|
+
if (!registeredParamNames.includes(key) && key !== '_b') {
|
|
155
|
+
let paramType = 'string';
|
|
156
|
+
|
|
157
|
+
// Try to determine the type
|
|
158
|
+
if (value === 'true' || value === 'false') {
|
|
159
|
+
paramType = 'boolean';
|
|
160
|
+
} else if (!isNaN(Number(value))) {
|
|
161
|
+
paramType = 'number';
|
|
162
|
+
} else {
|
|
163
|
+
try {
|
|
164
|
+
const parsed = JSON.parse(value);
|
|
165
|
+
paramType = Array.isArray(parsed) ? 'array' : 'json';
|
|
166
|
+
} catch {
|
|
167
|
+
// It's a string if we can't parse it as JSON
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
extractedCustomParams.push({
|
|
172
|
+
name: key,
|
|
173
|
+
value: value,
|
|
174
|
+
type: paramType,
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
setParamValues(initialValues);
|
|
181
|
+
setCustomParams(extractedCustomParams);
|
|
182
|
+
}, [paramRegistry]);
|
|
183
|
+
|
|
184
|
+
// Generate URL whenever param values, custom params, trials selection, or encoding type changes
|
|
185
|
+
useEffect(() => {
|
|
186
|
+
const baseUrl = window.location.pathname.replace(/\/settings$/, '');
|
|
187
|
+
let queryString = '';
|
|
188
|
+
|
|
189
|
+
const allParams: Record<string, any> = { ...paramValues };
|
|
190
|
+
|
|
191
|
+
if (selectedTrials.length > 0) {
|
|
192
|
+
if (isExcludeMode) {
|
|
193
|
+
allParams['excludeSubset'] = selectedTrials.sort().join(',');
|
|
194
|
+
delete allParams['includeSubset'];
|
|
195
|
+
} else if (selectedTrials.length != timelineRepresentation.length) {
|
|
196
|
+
allParams['includeSubset'] = selectedTrials.sort().join(',');
|
|
197
|
+
delete allParams['excludeSubset'];
|
|
198
|
+
}
|
|
199
|
+
} else {
|
|
200
|
+
delete allParams['includeSubset'];
|
|
201
|
+
delete allParams['excludeSubset'];
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
customParams.forEach((param) => {
|
|
205
|
+
if (
|
|
206
|
+
param.name &&
|
|
207
|
+
param.value !== '' &&
|
|
208
|
+
param.name !== 'includeSubset' &&
|
|
209
|
+
param.name !== 'excludeSubset'
|
|
210
|
+
) {
|
|
211
|
+
allParams[param.name] = param.value;
|
|
212
|
+
}
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
if (useBase64) {
|
|
216
|
+
const paramsObj: Record<string, any> = {};
|
|
217
|
+
Object.entries(allParams).forEach(([key, value]) => {
|
|
218
|
+
if (value !== '') {
|
|
219
|
+
const param = paramRegistry.find((p) => p.name === key);
|
|
220
|
+
const customParam = customParams.find((p) => p.name === key);
|
|
221
|
+
const paramType = param?.type || customParam?.type || 'string';
|
|
222
|
+
|
|
223
|
+
if (
|
|
224
|
+
paramType === 'boolean' &&
|
|
225
|
+
param?.defaultValue !== undefined &&
|
|
226
|
+
String(param.defaultValue) === value
|
|
227
|
+
) {
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
switch (paramType) {
|
|
232
|
+
case 'number':
|
|
233
|
+
paramsObj[key] = Number(value);
|
|
234
|
+
break;
|
|
235
|
+
case 'boolean':
|
|
236
|
+
paramsObj[key] = value === 'true';
|
|
237
|
+
break;
|
|
238
|
+
case 'array':
|
|
239
|
+
case 'json':
|
|
240
|
+
try {
|
|
241
|
+
paramsObj[key] = JSON.parse(value as string);
|
|
242
|
+
} catch {
|
|
243
|
+
paramsObj[key] = value;
|
|
244
|
+
}
|
|
245
|
+
break;
|
|
246
|
+
default:
|
|
247
|
+
paramsObj[key] = value;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
// Create base64 encoded parameter
|
|
253
|
+
const jsonString = JSON.stringify(paramsObj);
|
|
254
|
+
const encodedParams = btoa(jsonString);
|
|
255
|
+
queryString = `?_b=${encodedParams}`;
|
|
256
|
+
} else {
|
|
257
|
+
const searchParams = new URLSearchParams();
|
|
258
|
+
|
|
259
|
+
Object.entries(allParams).forEach(([key, value]) => {
|
|
260
|
+
if (value !== '') {
|
|
261
|
+
const param = paramRegistry.find((p) => p.name === key);
|
|
262
|
+
// Skip boolean params that match their default value
|
|
263
|
+
if (
|
|
264
|
+
param?.type === 'boolean' &&
|
|
265
|
+
param.defaultValue !== undefined &&
|
|
266
|
+
String(param.defaultValue) === value
|
|
267
|
+
) {
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
searchParams.set(key, String(value));
|
|
271
|
+
}
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
queryString = searchParams.toString() ? `?${searchParams.toString()}` : '';
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const fullUrl = `${window.location.origin}${baseUrl}${queryString}`;
|
|
278
|
+
setGeneratedUrl(fullUrl);
|
|
279
|
+
}, [paramValues, customParams, useBase64, paramRegistry, selectedTrials, isExcludeMode]);
|
|
280
|
+
|
|
281
|
+
const handleParamChange = (name: string, value: any) => {
|
|
282
|
+
setParamValues((prev) => ({
|
|
283
|
+
...prev,
|
|
284
|
+
[name]: value,
|
|
285
|
+
}));
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
const handleCustomParamChange = (index: number, field: string, value: any) => {
|
|
289
|
+
setCustomParams((prev) => {
|
|
290
|
+
const updated = [...prev];
|
|
291
|
+
updated[index] = {
|
|
292
|
+
...updated[index],
|
|
293
|
+
[field]: value,
|
|
294
|
+
};
|
|
295
|
+
return updated;
|
|
296
|
+
});
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
const toggleTrialSelection = (index: number) => {
|
|
300
|
+
const trialIdentifier = `${index + 1}`;
|
|
301
|
+
|
|
302
|
+
setSelectedTrials((prev) => {
|
|
303
|
+
const newSelection = prev.includes(trialIdentifier)
|
|
304
|
+
? prev.filter((id) => id !== trialIdentifier)
|
|
305
|
+
: [...prev, trialIdentifier];
|
|
306
|
+
|
|
307
|
+
return newSelection;
|
|
308
|
+
});
|
|
309
|
+
};
|
|
310
|
+
|
|
311
|
+
const toggleSelectionMode = () => {
|
|
312
|
+
setIsExcludeMode((prev) => !prev);
|
|
313
|
+
};
|
|
314
|
+
|
|
315
|
+
const addCustomParam = () => {
|
|
316
|
+
setCustomParams((prev) => [...prev, { name: '', value: '', type: 'string' }]);
|
|
317
|
+
};
|
|
318
|
+
|
|
319
|
+
// Filter out custom params that match includeSubset or excludeSubset
|
|
320
|
+
const filteredCustomParams = customParams.filter(
|
|
321
|
+
(param) => param.name !== 'includeSubset' && param.name !== 'excludeSubset',
|
|
322
|
+
);
|
|
323
|
+
|
|
324
|
+
const removeCustomParam = (index: number) => {
|
|
325
|
+
setCustomParams((prev) => prev.filter((_, i) => i !== index));
|
|
326
|
+
};
|
|
327
|
+
|
|
328
|
+
const handleCopyUrl = () => {
|
|
329
|
+
if (urlInputRef.current) {
|
|
330
|
+
urlInputRef.current.select();
|
|
331
|
+
document.execCommand('copy');
|
|
332
|
+
setCopyButtonText('Copied!');
|
|
333
|
+
setTimeout(() => setCopyButtonText('Copy URL'), 2000);
|
|
334
|
+
}
|
|
335
|
+
};
|
|
336
|
+
|
|
337
|
+
const toggleUrlFormat = () => {
|
|
338
|
+
setUseBase64(!useBase64);
|
|
339
|
+
};
|
|
340
|
+
|
|
341
|
+
const renderParamInput = (param: {
|
|
342
|
+
name: string;
|
|
343
|
+
type: string | undefined;
|
|
344
|
+
defaultValue: any;
|
|
345
|
+
}) => {
|
|
346
|
+
const { name, type, defaultValue } = param;
|
|
347
|
+
|
|
348
|
+
// Skip rendering for includeSubset and excludeSubset as they're handled separately
|
|
349
|
+
if (name === 'includeSubset' || name === 'excludeSubset') {
|
|
350
|
+
return null;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const value = paramValues[name] || '';
|
|
354
|
+
|
|
355
|
+
switch (type) {
|
|
356
|
+
case 'boolean':
|
|
357
|
+
return (
|
|
358
|
+
<>
|
|
359
|
+
<button
|
|
360
|
+
onClick={() => handleParamChange(name, value === 'true' ? 'false' : 'true')}
|
|
361
|
+
className={`relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 ${value === 'true' ? 'bg-blue-600' : 'bg-gray-200'}`}
|
|
362
|
+
role='switch'
|
|
363
|
+
aria-checked={value === 'true'}
|
|
364
|
+
>
|
|
365
|
+
<span
|
|
366
|
+
className={`pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200 ${value === 'true' ? 'translate-x-5' : 'translate-x-0'}`}
|
|
367
|
+
/>
|
|
368
|
+
</button>
|
|
369
|
+
<span className='ml-4 text-gray-500'>{value === 'true' ? 'True' : 'False'}</span>
|
|
370
|
+
</>
|
|
371
|
+
);
|
|
372
|
+
|
|
373
|
+
case 'number':
|
|
374
|
+
return (
|
|
375
|
+
<input
|
|
376
|
+
type='number'
|
|
377
|
+
className='mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm'
|
|
378
|
+
value={value}
|
|
379
|
+
onChange={(e) => handleParamChange(name, e.target.value)}
|
|
380
|
+
placeholder={
|
|
381
|
+
defaultValue !== undefined ? `Default: ${defaultValue}` : 'Enter number...'
|
|
382
|
+
}
|
|
383
|
+
/>
|
|
384
|
+
);
|
|
385
|
+
|
|
386
|
+
case 'array':
|
|
387
|
+
case 'json':
|
|
388
|
+
return (
|
|
389
|
+
<textarea
|
|
390
|
+
className='mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm'
|
|
391
|
+
value={value}
|
|
392
|
+
onChange={(e) => handleParamChange(name, e.target.value)}
|
|
393
|
+
placeholder={
|
|
394
|
+
type === 'array' ? 'Enter comma-separated values or JSON array' : 'Enter JSON object'
|
|
395
|
+
}
|
|
396
|
+
rows={3}
|
|
397
|
+
/>
|
|
398
|
+
);
|
|
399
|
+
|
|
400
|
+
case 'string':
|
|
401
|
+
default:
|
|
402
|
+
return (
|
|
403
|
+
<input
|
|
404
|
+
type='text'
|
|
405
|
+
className='mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm'
|
|
406
|
+
value={value}
|
|
407
|
+
onChange={(e) => handleParamChange(name, e.target.value)}
|
|
408
|
+
placeholder={
|
|
409
|
+
defaultValue !== undefined && defaultValue !== ''
|
|
410
|
+
? `Default: ${defaultValue}`
|
|
411
|
+
: 'Enter value...'
|
|
412
|
+
}
|
|
413
|
+
/>
|
|
414
|
+
);
|
|
415
|
+
}
|
|
416
|
+
};
|
|
417
|
+
|
|
418
|
+
const renderCustomParamInput = (
|
|
419
|
+
param: { name: string; type: string | undefined; value: any },
|
|
420
|
+
index: number,
|
|
421
|
+
) => {
|
|
422
|
+
// Skip rendering for includeSubset and excludeSubset as they're handled separately
|
|
423
|
+
if (param.name === 'includeSubset' || param.name === 'excludeSubset') {
|
|
424
|
+
return null;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
return (
|
|
428
|
+
<div className='grid grid-cols-12 gap-2 items-center mb-2'>
|
|
429
|
+
<div className='col-span-3'>
|
|
430
|
+
<input
|
|
431
|
+
type='text'
|
|
432
|
+
className='block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm'
|
|
433
|
+
value={param.name}
|
|
434
|
+
onChange={(e) => handleCustomParamChange(index, 'name', e.target.value)}
|
|
435
|
+
placeholder='Parameter name'
|
|
436
|
+
/>
|
|
437
|
+
</div>
|
|
438
|
+
|
|
439
|
+
<div className='col-span-2'>
|
|
440
|
+
<select
|
|
441
|
+
className='block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm rounded-md'
|
|
442
|
+
value={param.type}
|
|
443
|
+
onChange={(e) => handleCustomParamChange(index, 'type', e.target.value)}
|
|
444
|
+
>
|
|
445
|
+
<option value='string'>String</option>
|
|
446
|
+
<option value='number'>Number</option>
|
|
447
|
+
<option value='boolean'>Boolean</option>
|
|
448
|
+
<option value='array'>Array</option>
|
|
449
|
+
<option value='json'>JSON</option>
|
|
450
|
+
</select>
|
|
451
|
+
</div>
|
|
452
|
+
|
|
453
|
+
<div className='col-span-6'>
|
|
454
|
+
{param.type === 'boolean' ? (
|
|
455
|
+
<>
|
|
456
|
+
<button
|
|
457
|
+
onClick={() =>
|
|
458
|
+
handleCustomParamChange(index, 'value', param.value === 'true' ? 'false' : 'true')
|
|
459
|
+
}
|
|
460
|
+
className={`relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 ${param.value === 'true' ? 'bg-blue-600' : 'bg-gray-200'}`}
|
|
461
|
+
role='switch'
|
|
462
|
+
aria-checked={param.value === 'true'}
|
|
463
|
+
>
|
|
464
|
+
<span
|
|
465
|
+
className={`pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200 ${param.value === 'true' ? 'translate-x-5' : 'translate-x-0'}`}
|
|
466
|
+
/>
|
|
467
|
+
</button>
|
|
468
|
+
</>
|
|
469
|
+
) : param.type === 'array' || param.type === 'json' ? (
|
|
470
|
+
<textarea
|
|
471
|
+
className='block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm'
|
|
472
|
+
value={param.value}
|
|
473
|
+
onChange={(e) => handleCustomParamChange(index, 'value', e.target.value)}
|
|
474
|
+
placeholder={
|
|
475
|
+
param.type === 'array'
|
|
476
|
+
? 'Enter JSON array [1,2,3]'
|
|
477
|
+
: 'Enter JSON object {"key":"value"}'
|
|
478
|
+
}
|
|
479
|
+
rows={1}
|
|
480
|
+
/>
|
|
481
|
+
) : param.type === 'number' ? (
|
|
482
|
+
<input
|
|
483
|
+
type='number'
|
|
484
|
+
className='block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm'
|
|
485
|
+
value={param.value}
|
|
486
|
+
onChange={(e) => handleCustomParamChange(index, 'value', e.target.value)}
|
|
487
|
+
placeholder='Enter number...'
|
|
488
|
+
/>
|
|
489
|
+
) : (
|
|
490
|
+
<input
|
|
491
|
+
type='text'
|
|
492
|
+
className='block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm'
|
|
493
|
+
value={param.value}
|
|
494
|
+
onChange={(e) => handleCustomParamChange(index, 'value', e.target.value)}
|
|
495
|
+
placeholder='Enter value...'
|
|
496
|
+
/>
|
|
497
|
+
)}
|
|
498
|
+
</div>
|
|
499
|
+
|
|
500
|
+
<div className='col-span-1'>
|
|
501
|
+
<button
|
|
502
|
+
onClick={() => removeCustomParam(index)}
|
|
503
|
+
className='inline-flex cursor-pointer items-center justify-center p-2 border border-transparent rounded-md text-red-700 bg-red-100 hover:bg-red-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500'
|
|
504
|
+
aria-label='Remove parameter'
|
|
505
|
+
>
|
|
506
|
+
<svg
|
|
507
|
+
xmlns='http://www.w3.org/2000/svg'
|
|
508
|
+
className='h-5 w-5'
|
|
509
|
+
viewBox='0 0 20 20'
|
|
510
|
+
fill='currentColor'
|
|
511
|
+
>
|
|
512
|
+
<path
|
|
513
|
+
fillRule='evenodd'
|
|
514
|
+
d='M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z'
|
|
515
|
+
clipRule='evenodd'
|
|
516
|
+
/>
|
|
517
|
+
</svg>
|
|
518
|
+
</button>
|
|
519
|
+
</div>
|
|
520
|
+
</div>
|
|
521
|
+
);
|
|
522
|
+
};
|
|
523
|
+
|
|
524
|
+
return (
|
|
525
|
+
<div className='max-w-4xl mx-auto p-6'>
|
|
526
|
+
<h1 className='text-3xl font-bold mb-4'>Experiment Parameters</h1>
|
|
527
|
+
<p className='text-gray-700 mb-6'>
|
|
528
|
+
Configure the experiment by setting parameter values below. Fields left empty will be
|
|
529
|
+
assigned their default value. Once configured, you can copy the URL to run the experiment
|
|
530
|
+
with these settings.
|
|
531
|
+
</p>
|
|
532
|
+
|
|
533
|
+
<div className='bg-white shadow overflow-hidden sm:rounded-md mb-8'>
|
|
534
|
+
<div className='px-4 py-5 border-b border-gray-200 sm:px-6'>
|
|
535
|
+
<h2 className='text-lg font-medium text-gray-900'>Registered Parameters</h2>
|
|
536
|
+
<p className='mt-1 text-sm text-gray-500'>
|
|
537
|
+
Parameters defined in the experiment configuration.
|
|
538
|
+
</p>
|
|
539
|
+
</div>
|
|
540
|
+
<ul className='divide-y divide-gray-200'>
|
|
541
|
+
{sortedParams.map((param) => (
|
|
542
|
+
<li key={param.name} className='px-4 py-4 sm:px-6'>
|
|
543
|
+
<div className='grid grid-cols-3 gap-6'>
|
|
544
|
+
<div className='col-span-2'>
|
|
545
|
+
<div className='flex flex-col h-full'>
|
|
546
|
+
<div className='flex items-center'>
|
|
547
|
+
<label className='text-base font-medium text-gray-900'>{param.name}</label>
|
|
548
|
+
<span className='ml-2 inline-flex items-center px-2 py-0.5 rounded-lg text-xs font-medium bg-gray-100 text-gray-600'>
|
|
549
|
+
{param.type}
|
|
550
|
+
</span>
|
|
551
|
+
</div>
|
|
552
|
+
{param.description && (
|
|
553
|
+
<p className='mt-1 text-sm text-gray-500 flex-grow'>{param.description}</p>
|
|
554
|
+
)}
|
|
555
|
+
{param.defaultValue !== undefined && (
|
|
556
|
+
<p className='mt-auto pt-2 text-xs text-gray-500'>
|
|
557
|
+
Default: <code> {JSON.stringify(param.defaultValue)}</code>
|
|
558
|
+
</p>
|
|
559
|
+
)}
|
|
560
|
+
</div>
|
|
561
|
+
</div>
|
|
562
|
+
<div className='col-span-1 flex items-center'>{renderParamInput(param)}</div>
|
|
563
|
+
</div>
|
|
564
|
+
</li>
|
|
565
|
+
))}
|
|
566
|
+
</ul>
|
|
567
|
+
</div>
|
|
568
|
+
|
|
569
|
+
<div className='bg-white shadow overflow-hidden sm:rounded-md mb-8'>
|
|
570
|
+
<div className='px-4 py-5 border-b border-gray-200 sm:px-6'>
|
|
571
|
+
<div className='flex justify-between items-center'>
|
|
572
|
+
<div>
|
|
573
|
+
<h2 className='text-lg font-medium text-gray-900'>Custom Parameters</h2>
|
|
574
|
+
<p className='mt-1 text-sm text-gray-500'>
|
|
575
|
+
Add any additional parameters to be stored in the data for this run.
|
|
576
|
+
</p>
|
|
577
|
+
</div>
|
|
578
|
+
<button
|
|
579
|
+
onClick={addCustomParam}
|
|
580
|
+
className='inline-flex cursor-pointer items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500'
|
|
581
|
+
>
|
|
582
|
+
<svg
|
|
583
|
+
xmlns='http://www.w3.org/2000/svg'
|
|
584
|
+
className='-ml-1 mr-2 h-5 w-5'
|
|
585
|
+
viewBox='0 0 20 20'
|
|
586
|
+
fill='currentColor'
|
|
587
|
+
>
|
|
588
|
+
<path
|
|
589
|
+
fillRule='evenodd'
|
|
590
|
+
d='M10 5a1 1 0 011 1v3h3a1 1 0 110 2h-3v3a1 1 0 11-2 0v-3H6a1 1 0 110-2h3V6a1 1 0 011-1z'
|
|
591
|
+
clipRule='evenodd'
|
|
592
|
+
/>
|
|
593
|
+
</svg>
|
|
594
|
+
Add Parameter
|
|
595
|
+
</button>
|
|
596
|
+
</div>
|
|
597
|
+
</div>
|
|
598
|
+
|
|
599
|
+
<div className='px-4 py-5 sm:px-6'>
|
|
600
|
+
{filteredCustomParams.length === 0 ? (
|
|
601
|
+
<p className='text-sm text-gray-500 italic'>
|
|
602
|
+
No custom parameters added. Click "Add Parameter" to create one.
|
|
603
|
+
</p>
|
|
604
|
+
) : (
|
|
605
|
+
<div>
|
|
606
|
+
<div className='grid grid-cols-12 gap-2 mb-2'>
|
|
607
|
+
<div className='col-span-3'>
|
|
608
|
+
<label className='block text-xs font-medium text-gray-500'>PARAMETER NAME</label>
|
|
609
|
+
</div>
|
|
610
|
+
<div className='col-span-2'>
|
|
611
|
+
<label className='block text-xs font-medium text-gray-500'>TYPE</label>
|
|
612
|
+
</div>
|
|
613
|
+
<div className='col-span-6'>
|
|
614
|
+
<label className='block text-xs font-medium text-gray-500'>VALUE</label>
|
|
615
|
+
</div>
|
|
616
|
+
<div className='col-span-1'></div>
|
|
617
|
+
</div>
|
|
618
|
+
{filteredCustomParams.map((param, index) => (
|
|
619
|
+
<div key={index}>{renderCustomParamInput(param, index)}</div>
|
|
620
|
+
))}
|
|
621
|
+
</div>
|
|
622
|
+
)}
|
|
623
|
+
</div>
|
|
624
|
+
</div>
|
|
625
|
+
|
|
626
|
+
{timelineRepresentation.length > 0 && (
|
|
627
|
+
<div className='bg-white shadow overflow-hidden sm:rounded-md mb-8'>
|
|
628
|
+
<div className='px-4 py-5 border-b border-gray-200 sm:px-6'>
|
|
629
|
+
<div className='flex justify-between items-center'>
|
|
630
|
+
<div>
|
|
631
|
+
<h2 className='text-lg font-medium text-gray-900'>Trial Selection</h2>
|
|
632
|
+
<p className='mt-1 text-sm text-gray-500'>
|
|
633
|
+
Select which trials to include or exclude from the experiment.
|
|
634
|
+
</p>
|
|
635
|
+
</div>
|
|
636
|
+
<div className='flex items-center'>
|
|
637
|
+
<button
|
|
638
|
+
onClick={toggleSelectionMode}
|
|
639
|
+
className={`relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 ${isExcludeMode ? 'bg-red-600' : 'bg-green-600'}`}
|
|
640
|
+
role='switch'
|
|
641
|
+
aria-checked={isExcludeMode}
|
|
642
|
+
>
|
|
643
|
+
<span
|
|
644
|
+
className={`pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200 ${isExcludeMode ? 'translate-x-5' : 'translate-x-0'}`}
|
|
645
|
+
/>
|
|
646
|
+
</button>
|
|
647
|
+
<span className='ml-2 text-sm text-gray-600'>
|
|
648
|
+
{isExcludeMode ? 'Exclude mode' : 'Include mode'}
|
|
649
|
+
</span>
|
|
650
|
+
</div>
|
|
651
|
+
</div>
|
|
652
|
+
</div>
|
|
653
|
+
|
|
654
|
+
<div className='px-4 py-5 sm:px-6'>
|
|
655
|
+
<p className='mb-4 text-sm font-medium text-gray-500'>
|
|
656
|
+
{isExcludeMode
|
|
657
|
+
? 'Check trials you want to EXCLUDE from the experiment'
|
|
658
|
+
: 'Check trials you want to INCLUDE in the experiment'}
|
|
659
|
+
</p>
|
|
660
|
+
|
|
661
|
+
<div className='border border-gray-200 rounded-md overflow-hidden'>
|
|
662
|
+
<ul className='divide-y divide-gray-200'>
|
|
663
|
+
{timelineRepresentation.map(
|
|
664
|
+
(trial: { name?: string; type: string }, index: number) => (
|
|
665
|
+
<li
|
|
666
|
+
key={index}
|
|
667
|
+
className='cursor-pointer px-4 py-3 hover:bg-gray-50'
|
|
668
|
+
onClick={(e) => {
|
|
669
|
+
// Check if the click was on or within the checkbox or label
|
|
670
|
+
if (
|
|
671
|
+
(e.target as any).type !== 'checkbox' &&
|
|
672
|
+
!(e.target as any).closest('label')
|
|
673
|
+
) {
|
|
674
|
+
toggleTrialSelection(index);
|
|
675
|
+
}
|
|
676
|
+
}}
|
|
677
|
+
>
|
|
678
|
+
<div className='flex items-center'>
|
|
679
|
+
<div className='flex items-center flex-grow'>
|
|
680
|
+
<input
|
|
681
|
+
id={`trial-${index}`}
|
|
682
|
+
type='checkbox'
|
|
683
|
+
className='h-4 w-4 text-indigo-600 focus:ring-indigo-500 border-gray-300 rounded cursor-pointer'
|
|
684
|
+
checked={selectedTrials.includes(`${index + 1}`)}
|
|
685
|
+
onChange={() => toggleTrialSelection(index)}
|
|
686
|
+
/>
|
|
687
|
+
<label
|
|
688
|
+
htmlFor={`trial-${index}`}
|
|
689
|
+
className='ml-3 flex-grow flex items-center cursor-pointer'
|
|
690
|
+
>
|
|
691
|
+
<div className='w-8 text-center text-xs text-gray-500 font-medium'>
|
|
692
|
+
#{index + 1}
|
|
693
|
+
</div>
|
|
694
|
+
<div className='flex-grow'>
|
|
695
|
+
{trial.name ? (
|
|
696
|
+
<span className='font-medium text-gray-900'>{trial.name}</span>
|
|
697
|
+
) : (
|
|
698
|
+
<span className='font-medium text-gray-500'>{trial.type}</span>
|
|
699
|
+
)}
|
|
700
|
+
</div>
|
|
701
|
+
<div className='ml-2'>
|
|
702
|
+
<span className='inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800'>
|
|
703
|
+
{trial.type}
|
|
704
|
+
</span>
|
|
705
|
+
</div>
|
|
706
|
+
</label>
|
|
707
|
+
</div>
|
|
708
|
+
</div>
|
|
709
|
+
</li>
|
|
710
|
+
),
|
|
711
|
+
)}
|
|
712
|
+
</ul>
|
|
713
|
+
</div>
|
|
714
|
+
</div>
|
|
715
|
+
</div>
|
|
716
|
+
)}
|
|
717
|
+
|
|
718
|
+
<div className='bg-white shadow sm:rounded-md p-6 mb-8'>
|
|
719
|
+
<div className='flex justify-between items-center mb-4'>
|
|
720
|
+
<h2 className='text-lg font-medium'>Generated URL</h2>
|
|
721
|
+
<div className='flex items-center'>
|
|
722
|
+
<button
|
|
723
|
+
onClick={toggleUrlFormat}
|
|
724
|
+
className={`relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 ${useBase64 ? 'bg-blue-600' : 'bg-gray-200'}`}
|
|
725
|
+
role='switch'
|
|
726
|
+
aria-checked={useBase64}
|
|
727
|
+
>
|
|
728
|
+
<span
|
|
729
|
+
className={`pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200 ${useBase64 ? 'translate-x-5' : 'translate-x-0'}`}
|
|
730
|
+
/>
|
|
731
|
+
</button>
|
|
732
|
+
<span className='ml-2 text-sm text-gray-600'>
|
|
733
|
+
{useBase64 ? 'Base64 Encoded' : 'Plain Parameters'}
|
|
734
|
+
</span>
|
|
735
|
+
</div>
|
|
736
|
+
</div>
|
|
737
|
+
<div className='flex flex-col md:flex-row gap-3'>
|
|
738
|
+
<input
|
|
739
|
+
ref={urlInputRef}
|
|
740
|
+
type='text'
|
|
741
|
+
className='flex-grow px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm'
|
|
742
|
+
value={generatedUrl}
|
|
743
|
+
readOnly
|
|
744
|
+
/>
|
|
745
|
+
<button
|
|
746
|
+
onClick={handleCopyUrl}
|
|
747
|
+
className='inline-flex cursor-pointer justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500'
|
|
748
|
+
>
|
|
749
|
+
{copyButtonText}
|
|
750
|
+
</button>
|
|
751
|
+
</div>
|
|
752
|
+
{useBase64 && (
|
|
753
|
+
<p className='mt-4 text-sm text-gray-600'>
|
|
754
|
+
<span className='font-medium'>Note:</span> Base64 encoding combines all parameters into
|
|
755
|
+
a single "_b" parameter, creating shorter URLs that hide parameter details.
|
|
756
|
+
</p>
|
|
757
|
+
)}
|
|
758
|
+
</div>
|
|
759
|
+
|
|
760
|
+
<div className='flex justify-center'>
|
|
761
|
+
<a
|
|
762
|
+
href={generatedUrl}
|
|
763
|
+
className='inline-flex justify-center py-4 px-5 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500'
|
|
764
|
+
>
|
|
765
|
+
Launch Experiment with these Settings
|
|
766
|
+
</a>
|
|
767
|
+
</div>
|
|
768
|
+
</div>
|
|
769
|
+
);
|
|
770
|
+
};
|