@01-edu/shared 2.0.5 → 2.0.7

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 (46) hide show
  1. package/dist/attrs-defs.js +1 -0
  2. package/dist/attrs.js +1 -0
  3. package/dist/bin/check-definitions.js +3 -0
  4. package/dist/chunk-727IHWVW.js +1 -0
  5. package/dist/chunk-7ZTI6THC.js +1 -0
  6. package/dist/chunk-CBWHFDVB.js +1 -0
  7. package/dist/chunk-EI7MMDWY.js +1 -0
  8. package/dist/chunk-EOY6G4KE.js +1 -0
  9. package/dist/chunk-K5Z4W5GV.js +1 -0
  10. package/dist/chunk-LDUPRVV4.js +1 -0
  11. package/dist/chunk-NQCTSDJE.js +1 -0
  12. package/dist/chunk-V47RDOUO.js +1 -0
  13. package/dist/chunk-XYW3ROIR.js +1 -0
  14. package/dist/definitions-checker.js +1 -0
  15. package/dist/event-utils.js +1 -0
  16. package/dist/games-utils.js +1 -0
  17. package/dist/graph.js +1 -0
  18. package/dist/hasura-core.js +1 -0
  19. package/dist/hasura-model.js +34 -0
  20. package/dist/hasura-prepare.js +1 -0
  21. package/dist/modular-steps-utils.js +1 -0
  22. package/dist/object-structure.js +1 -0
  23. package/dist/onboarding.js +1 -0
  24. package/dist/path.js +1 -0
  25. package/dist/qa-utils.js +1 -0
  26. package/dist/score.js +1 -0
  27. package/dist/skill-definitions.js +1 -0
  28. package/dist/toolbox.js +1 -0
  29. package/package.json +17 -6
  30. package/attrs-defs.js +0 -4273
  31. package/attrs.js +0 -423
  32. package/bin/check-definitions.js +0 -74
  33. package/definitions-checker.js +0 -233
  34. package/event-utils.js +0 -58
  35. package/graph.js +0 -96
  36. package/hasura-core.js +0 -217
  37. package/hasura-model.js +0 -138
  38. package/hasura-prepare.js +0 -44
  39. package/languages.js +0 -147
  40. package/onboarding.js +0 -25
  41. package/path.js +0 -73
  42. package/programming-languages.js +0 -21
  43. package/qa-utils.js +0 -13
  44. package/score.js +0 -80
  45. package/skill-definitions.js +0 -359
  46. package/toolbox.js +0 -532
package/attrs-defs.js DELETED
@@ -1,4273 +0,0 @@
1
- import {
2
- DAY,
3
- HOUR,
4
- MIN,
5
- WEEK,
6
- getQuestExtraEndAt,
7
- getQuestStartAt,
8
- numTime,
9
- } from './event-utils.js'
10
- import { flatGraphContents, getCoreOfSattelite, limitations } from './graph.js'
11
- import { onboardingTypes } from './object-structure.ts'
12
- import { getObjectFromRelativePath } from './path.js'
13
- import { gamesScoring } from './score.js'
14
- import { hasRequiredSkills, skillsSet } from './skill-definitions.js'
15
- import {
16
- arrayOf,
17
- breath,
18
- formatedMS,
19
- getChildByType,
20
- isNotCamel,
21
- mapValues,
22
- toCamelCase,
23
- } from './toolbox.js'
24
-
25
- export const MAX_LEVEL = 128
26
- export const LEVELS = Array(MAX_LEVEL + 1)
27
-
28
- let i = -1
29
- let xpIndex = 0
30
- let cumul = 0
31
- while (++i < MAX_LEVEL + 1) {
32
- const req = i * 0.66 + 1
33
- const base = (i + 2) * 150 + 50
34
- const total = Math.round(req * base)
35
- cumul += total
36
- xpIndex += req
37
- LEVELS[i] = { level: i, base, cumul, total, xpIndex: Math.floor(xpIndex) }
38
- }
39
-
40
- const _children = obj => (obj?.children && Object.values(obj.children)) || []
41
- const find = (object, by, key) =>
42
- object && (by(object) ? object : find(object[key], by, key))
43
- const findParent = (object, by) => find(object, by, 'parent')
44
- const findPrev = (object, by) => find(object, by, 'prev')
45
- const hasEvent = ({ event }) => event
46
- const isQuest = object => object.type === 'quest'
47
- const isRequired = object => object.attrs.category === 'required'
48
- const numOrOne = x => (typeof x === 'number' && !Number.isNaN(x) ? x : 1)
49
- const _difficultyXpCoef = o =>
50
- o.attrs.difficulty * numOrOne(o.attrs.difficultyMod)
51
- const add = (a, b) => a + b
52
- const byValueIdx = ([_ak, av], [_bk, bv]) => av.index - bv.index
53
-
54
- const translate = (object, user, key) =>
55
- object.attrs[`${key}-${user?.attrs?.language}`] ||
56
- object.attrs[`${key}-en`] ||
57
- key
58
-
59
- const firstOfGroup = (a, _i, children) => {
60
- const { group } = a.attrs
61
- if (!group) return true
62
- return children.find(b => b.attrs.group === group) === a
63
- }
64
-
65
- const getObjectPath = object => {
66
- const [core] = getCoreOfSattelite(object) || []
67
- if (!core) return object.name
68
- // special case only for projects with a core requirement set
69
- // const coreName = getCoreName(object)
70
- const path = `${core.name}/${getProjectName(object)}`
71
- return path
72
- }
73
-
74
- // Generate default value from the first function and the map of fn.name -> fn
75
- const Functions = functions => ({
76
- value: Object.values(functions)[0],
77
- functions,
78
- functionsByName: Object.fromEntries(
79
- Object.values(functions).map(fn => [fn.name, fn]),
80
- ),
81
- })
82
-
83
- const translatable = {
84
- functions: { translate },
85
- functionsByName: { translate },
86
- }
87
-
88
- // generate attribute type according to its value
89
- const Literal = (value, options) => ({
90
- type: typeof value,
91
- value,
92
- ...options,
93
- })
94
-
95
- // used to generate default value for object
96
- const getDefaultValue = (typeDef, ...args) => {
97
- if (!typeDef.required) return
98
- return typeof typeDef.value === 'function'
99
- ? typeDef.value(...args)
100
- : typeDef.value
101
- }
102
- // generate default value of objects according to the type described
103
- const TypeObject = def => ({
104
- value: (...args) =>
105
- mapValues(def.type, subDef => getDefaultValue(subDef, ...args)),
106
- ...def,
107
- })
108
-
109
- // ✱ how to declare a new attribute:
110
- // NB: by front end here, we mean the front end of the admin side of the app
111
- // attrs.myAttribute = {
112
- // objectType: {
113
- // type*: // string, number, boolean, etc. Complex types (objects, array) are described after
114
- // value*: // defaultValue ONLY IF the attribute is required, or when it is added,
115
- // functions: // if the attribute is associated to function(s) (if just one - it's this by default; if several, by default the user can choose in between them),
116
- // label: // text displayed in front end, instead of attribute name (client friendly),
117
- // restrictive: // if true, the attribute will be defined only on the object itself and cannot be overridden in the relation to the parent object
118
- // instruction: // mention displayed under the label in the front end - to give precision that need to be seen,
119
- // description: complete explanation of what the attributes does - description is displayed only when the used hover the i icon in the front end,
120
- // required: // if the attribute is always there by default,
121
- // editable: // if it can be override by schools in the db (=/= choice in between several functions),
122
- // private: // if the attribute should not be seen in front end,
123
- // hidden: // if the attribute should be hidden to admins but needed in front end,
124
- // primary: // only used objects in arrays, allow to indicate what key must have a unique value
125
- // options: // array containing literal possible values
126
- // acceptDuplicates: // when array type can have duplicates
127
- // maxElements: // when array has a maximum elements accepted (indicative - has to be handled in the code)
128
- // check: // function to check if the value of the attribute is valid - used to check refs but also to validate value in the front end when saving
129
- // },
130
- // }
131
- // * properties with the * are absolutely needed
132
- // By default, required, editable, and private are set to false
133
- // So it should be added only to set it to true
134
-
135
- // ✱ Literal(defaultValue, properties) use
136
- // Can be used to describe simple types. It will generate the type
137
- // of the attribute from defaultValue (added as value)
138
- // The argument defaultValue is the default value of the attribute
139
- // Example: Literal('available', { required: true, ...restOfProperties })
140
- // will add {type: 'string'} and {value: 'available'} to the properties
141
-
142
- // ✱ ...Functions({}) use
143
- // Describe the associated functions of the attribute
144
- // it generate the `value` (to use only if the default value is a function)
145
- // and a `functionsByName` property to find matching functions from DB
146
- // example: {...Functions({'friendly name of the function': myFunction})}
147
-
148
- // ✱ ...TypeObject({}) use
149
- // Can be use to describe the type of the attribute if it's a complicated object
150
- // it will generate the default `value`. It resolves values of each key,
151
- // only if the key is defined as required
152
-
153
- // ✱ array and object complex types
154
- // Complex types can be described directly in `type` property
155
- // instead of writing 'object' or 'array', the type can be defined in the {} or []
156
- // For arrays, each item of type object MUST have one of its property
157
- // tagged with a `primary` key, which value must be unique.
158
- // That helps the system identify each item when rendering them
159
- // If several object types of items are defined, then each one of them
160
- // also needs to have a `type` property of type string with a unique value
161
-
162
- // ✱ new option: types
163
- // It is used to define types inside complicated attributes. types are not attributes
164
- // but can be defined as attributes
165
- // example: if an attribute is an array of strings, it can be described like this:
166
- // types.myStringType = { type: 'string', ...restOfTypeProperties }
167
- // attrs.myArrayOfStrings = { type: [types.myStringType], ...restOfAttrProperties }
168
-
169
- // list of available attributes and function to validate them
170
- export const attrs = {}
171
- export const relationAttrs = {}
172
- // not exported because all types will be accessed from attributes
173
- export const types = {}
174
-
175
- // TODO: add a placeholder to default? propose an address
176
- // how can we propose an address?
177
- attrs.address = {
178
- 'contact-validation-step': Literal('', {
179
- editable: true,
180
- required: true,
181
- label: 'Phone validation URL',
182
- }),
183
- }
184
-
185
- types.allowedFunctions = {
186
- value: '', // we cannot have an exhaustive list of the fn, as we can restrict from functions to regex
187
- type: 'string',
188
- editable: true,
189
- label: 'Function',
190
- }
191
- // not required - we handle the undefined status in the code
192
- const sharedAllowedFunctions = {
193
- type: [types.allowedFunctions],
194
- value: [],
195
- label: 'Allowed functions',
196
- instruction: 'Functions authorized to the student',
197
- editable: true,
198
- restrictive: true, // could also be constraining or exclusive
199
- }
200
- // allowedFunctions unit: array of string
201
- attrs.allowedFunctions = {
202
- exercise: sharedAllowedFunctions,
203
- raid: sharedAllowedFunctions,
204
- }
205
-
206
- // this is for error handling on module graph display
207
- const sharedInvalidRequirements = {
208
- type: 'boolean',
209
- required: true,
210
- private: true,
211
- label: 'Show invalid requirements',
212
- }
213
- attrs.showInvalidRequirements = {
214
- project: sharedInvalidRequirements,
215
- piscine: sharedInvalidRequirements,
216
- }
217
-
218
- const autoValidateLabel = 'Automatic success'
219
- const autoValidateWhereLabel = `${autoValidateLabel} if`
220
- const autoRejectWhereLabel = `Automatic reject if`
221
- const sharedAutoValidationOperator = action => ({
222
- type: 'string',
223
- restrictive: true,
224
- editable: true,
225
- label: `${action} - number of conditions to fulfil`,
226
- ...Functions({ all: () => 'and', one: () => 'or' }),
227
- })
228
- attrs.autoRejectOperator = { games: sharedAutoValidationOperator('Reject') }
229
-
230
- const games = Object.entries(gamesScoring)
231
- const gamesParameters = games.flatMap(([game, params]) => [
232
- [
233
- `${game}Score`,
234
- {
235
- shouldFailUnder: params.shouldFailUnderScore,
236
- shouldSucceedFrom: params.shouldSucceedFromScore,
237
- },
238
- ],
239
- [
240
- `${game}Level`,
241
- {
242
- shouldFailUnder: params.shouldFailUnderLevel,
243
- shouldSucceedFrom: params.shouldSucceedFromLevel,
244
- },
245
- ],
246
- ])
247
- const allParameters = [
248
- ['score', { shouldFailUnder: 25, shouldSucceedFrom: 50 }],
249
- ['level', { shouldFailUnder: 10, shouldSucceedFrom: 25 }],
250
- ...gamesParameters,
251
- ]
252
-
253
- // TODO: mv to a attrs-utils file
254
- const isntObjectOrIsEmpty = elem =>
255
- Array.isArray(elem) || typeof elem !== 'object' || !Object.keys(elem).length
256
- const checkNotEmpty = (value, _, key) => {
257
- if (!value.length) {
258
- const err = Error('Must be a non empty value')
259
- err.label = key
260
- throw err
261
- }
262
- }
263
- const checkNumberInBetween = (value, min, max) => {
264
- if (value < min || value > max) {
265
- throw Error(`Expected to be a number range ${min} to ${max}`)
266
- }
267
- }
268
- const checkIntegerInBetween = (value, min, max) => {
269
- if (value < min || value > max || !Number.isInteger(value)) {
270
- throw Error(`Expected to be an integer range ${min} to ${max}`)
271
- }
272
- }
273
- const checkDurationInBetween = (value, min, max) => {
274
- if (value < min || value > max) {
275
- throw Error(
276
- `Expected to be a duration between ${formatedMS(min)} and ${formatedMS(
277
- max,
278
- )}`,
279
- )
280
- }
281
- }
282
- const checkTextLength = (str, length) => {
283
- if (str.length > length) {
284
- throw Error(`Max ${length} characters (spaces included).`)
285
- }
286
- }
287
- const checkValidURL = value => {
288
- try {
289
- return Boolean(new URL(value))
290
- } catch {
291
- throw Error('Invalid URL.')
292
- }
293
- }
294
- const sharedComparisonIntegerTypes = {
295
- editable: true,
296
- options: arrayOf(100, 1),
297
- check: limit => checkIntegerInBetween(limit, 1, 100),
298
- }
299
- // none of these conditions are required
300
- types.autoRejectWhereConditions = Object.fromEntries(
301
- allParameters.map(([parameter, { shouldFailUnder }]) => [
302
- parameter,
303
- {
304
- value: {},
305
- editable: true,
306
- description: `Condition relative to ${breath(
307
- parameter,
308
- )} of the candidate.`,
309
- type: {
310
- '<': Literal(shouldFailUnder, {
311
- label: 'is lower than',
312
- description: 'Under this number, condition is fulfilled.',
313
- ...sharedComparisonIntegerTypes,
314
- }),
315
- '<=': Literal(shouldFailUnder, {
316
- label: 'is lower than or equal',
317
- description: 'Until this number, condition is fulfilled.',
318
- ...sharedComparisonIntegerTypes,
319
- }),
320
- },
321
- },
322
- ]),
323
- )
324
- attrs.autoRejectWhere = {
325
- games: {
326
- value: {},
327
- editable: true,
328
- restrictive: true,
329
- label: autoRejectWhereLabel,
330
- instruction: 'List of conditions to automatically reject an application',
331
- type: types.autoRejectWhereConditions,
332
- },
333
- }
334
-
335
- attrs.autoValidate = {
336
- administration: Literal(false, {
337
- label: autoValidateLabel,
338
- instruction: 'Validate all applications when they are submitted',
339
- restrictive: true,
340
- editable: true,
341
- }),
342
- }
343
-
344
- attrs.autoValidateOperator = { games: sharedAutoValidationOperator('Success') }
345
-
346
- // none of these conditions are required
347
- types.autoValidateWhereConditions = Object.fromEntries(
348
- allParameters.map(([parameter, { shouldSucceedFrom }]) => [
349
- parameter,
350
- {
351
- value: {},
352
- editable: true,
353
- description: `Condition relative to ${breath(
354
- parameter,
355
- )} of the candidate.`,
356
- type: {
357
- '>': Literal(shouldSucceedFrom, {
358
- label: 'is bigger than',
359
- description: 'Above this number, condition is fulfilled.',
360
- ...sharedComparisonIntegerTypes,
361
- }),
362
- '>=': Literal(shouldSucceedFrom, {
363
- label: 'is bigger than or equal',
364
- description: 'From this number, condition is fulfilled.',
365
- ...sharedComparisonIntegerTypes,
366
- }),
367
- },
368
- },
369
- ]),
370
- )
371
- attrs.autoValidateWhere = {
372
- games: {
373
- value: {},
374
- editable: true,
375
- restrictive: true,
376
- label: autoValidateWhereLabel,
377
- instruction: 'List of conditions to automatically accept an application',
378
- type: types.autoValidateWhereConditions,
379
- },
380
- }
381
-
382
- const skillsList = {
383
- value: {},
384
- editable: true,
385
- label: 'Skills rewarded',
386
- type: mapValues(skillsSet, ({ name, type: instruction, description }) => ({
387
- label: name,
388
- instruction,
389
- description,
390
- editable: true,
391
- type: 'number',
392
- value: 1,
393
- options: arrayOf(100, 1),
394
- check: amount => checkIntegerInBetween(amount, 1, 100),
395
- })),
396
- }
397
- const sharedBasedSkills = {
398
- ...skillsList,
399
- instruction:
400
- 'Skills rewarded to users when they succeed in the current content',
401
- }
402
- relationAttrs.baseSkills = {
403
- // only if result associated directly to the object
404
- exam: { exercise: sharedBasedSkills },
405
- quest: { exercise: sharedBasedSkills },
406
- module: { piscine: sharedBasedSkills, project: sharedBasedSkills },
407
- campus: { piscine: sharedBasedSkills },
408
- piscine: { raid: sharedBasedSkills, project: sharedBasedSkills },
409
- }
410
-
411
- const getBaseXpByLevelAndDifficulty = object => {
412
- if (Object.keys(object.children || {}).length && object.type !== 'raid') {
413
- return 0 // rm type 'raid' condition after clean alem db
414
- }
415
- const level = LEVELS[object.attrs.level] || LEVELS[0]
416
- return Math.round(level.base * numOrOne(_difficultyXpCoef(object)))
417
- }
418
- const sharedBaseXp = {
419
- type: 'number',
420
- required: true,
421
- // TODO: we should not manually set base-xp but play with level and difficulty attribute
422
- // then this attribute should not be editable
423
- editable: true,
424
- label: 'XP rewards',
425
- instruction:
426
- 'How much experience points the user gets by succeeding on this content',
427
- ...Functions({ 'by level and difficulty': getBaseXpByLevelAndDifficulty }),
428
- check: amount => {
429
- if (!Number.isInteger(amount)) throw Error('Must be a whole number')
430
- if (amount < 0) throw Error('Must not be negative')
431
- },
432
- }
433
- relationAttrs.baseXp = {
434
- // only if result associated directly to the object
435
- exam: { exercise: sharedBaseXp },
436
- quest: { exercise: sharedBaseXp },
437
- module: { piscine: sharedBaseXp, project: sharedBaseXp },
438
- piscine: { raid: sharedBaseXp, project: sharedBaseXp },
439
- // TODO: TO RM - HERE ONLY TO COMPARE CAMPUS BEFORE AND AFTER REL ATTRS IMPLEM
440
- campus: { piscine: sharedBaseXp },
441
- }
442
-
443
- attrs.blockStartDelay = {
444
- exam: Literal(15 * MIN, {
445
- label: 'Time to join the exam',
446
- editable: true,
447
- }),
448
- }
449
-
450
- const sharedButtonText = Literal('Save', {
451
- label: 'Submit button text',
452
- editable: true,
453
- ...translatable,
454
- })
455
- attrs.buttonText = {
456
- 'sign-step': sharedButtonText,
457
- 'form-step': sharedButtonText,
458
- }
459
-
460
- // const getGamesObjectsAndCampaigns = graphql(`
461
- // query toad_campaign_ids($id: Int!) {
462
- // object(
463
- // where: {
464
- // _and: [
465
- // { attrs: { _has_key: "campaignId" } }
466
- // { _not: { id: { _eq: $id } } }
467
- // ]
468
- // }
469
- // ) { attrs }
470
- // toad_campaigns { id }
471
- // }
472
- // `)
473
- attrs.campaignId = {
474
- games: Literal('', {
475
- label: 'Games campaign related',
476
- instruction: 'Id required',
477
- required: true,
478
- editable: true,
479
- restrictive: true,
480
- // TODO: check if we use the check here to add some security on the campaignId
481
- // but we would need to do some query (which is not something we planned to do)
482
- // check: (value, { id }) => {
483
- // // get all toad campaigns + all campaignIds used in campus
484
- // const { object, toad_campaigns } = await getGamesObjectsAndCampaigns({
485
- // id,
486
- // })
487
- // // compare with toad_campaigns and check if it's one of them
488
- // if (!toad_campaigns.some(({ id }) => id === value)) {
489
- // throw Error('Campaign not found.')
490
- // }
491
- // // compare with campaignIds already used and check it's not used in another object
492
- // if (object.map(({ attrs }) => attrs.campaignId).includes(value)) {
493
- // throw Error('Must be unique')
494
- // }
495
- // },
496
- }),
497
- }
498
-
499
- const getCampus = object => object.path.split('/')[1]
500
- const sharedCampus = {
501
- label: 'Campus',
502
- type: 'string',
503
- required: true,
504
- private: true,
505
- ...Functions({ 'from campus name': getCampus }),
506
- }
507
- attrs.campus = {
508
- exercise: sharedCampus,
509
- module: sharedCampus,
510
- piscine: sharedCampus,
511
- project: sharedCampus,
512
- raid: sharedCampus,
513
- exam: sharedCampus,
514
- quest: sharedCampus,
515
- ...Object.fromEntries([...onboardingTypes].map(type => [type, sharedCampus])),
516
- }
517
-
518
- const sharedCapacity = Literal(400, {
519
- label: 'Event capacity',
520
- instruction: 'From 1 to 1000.',
521
- editable: true,
522
- required: true,
523
- check: amount => {
524
- if (!Number.isInteger(amount)) throw Error('Must be a whole number')
525
- if (amount > 1000) throw Error('Cannot be more than 1000.')
526
- if (amount < 1) throw Error('Must be one or more')
527
- },
528
- })
529
- attrs.capacity = {
530
- exam: sharedCapacity,
531
- module: sharedCapacity,
532
- piscine: sharedCapacity,
533
- raid: sharedCapacity,
534
- interview: sharedCapacity,
535
- }
536
-
537
- const exerciseCategories = new Set(['required', 'optional', 'bonus'])
538
- relationAttrs.category = {
539
- // exercises in exams need the category for expected xp calculation
540
- exam: {
541
- exercise: Literal('required', {
542
- label: 'Category of the exercise',
543
- required: true,
544
- private: true,
545
- check: value => {
546
- const exerciseCategoriesInExam = new Set(['required', 'optional'])
547
- if (!exerciseCategoriesInExam.has(value)) {
548
- throw Error('Must be "required" or "optional"')
549
- }
550
- },
551
- }),
552
- },
553
- quest: {
554
- exercise: Literal('required', {
555
- label: 'Category of the exercise',
556
- required: true,
557
- editable: true,
558
- options: ['required', 'optional', 'bonus'],
559
- check: value => {
560
- if (!exerciseCategories.has(value)) {
561
- throw Error('Must be "required", "optional" or "bonus"')
562
- }
563
- },
564
- }),
565
- },
566
- piscine: { raid: Literal('required', { required: true, private: true }) },
567
- }
568
-
569
- // checkbox is not required - sign step can be used without, just to display a text
570
- // for example: a text to ask if a user authorize to share personal info to partners
571
- attrs.checkbox = {
572
- 'sign-step': {
573
- label: 'Display a checkbox',
574
- instruction: 'Minimum options: "label" and "required"',
575
- value: {},
576
- editable: true,
577
- type: 'object',
578
- check: value => {
579
- if (!value.label || typeof value.label !== 'string') {
580
- throw Error(
581
- '"label" must be defined as a text to be displayed on the right of the checkbox.',
582
- )
583
- }
584
- // NB: required has to be defined, even if false
585
- // (to help admins not forget to put it if it is mandatory)
586
- if (typeof value.required !== 'boolean') {
587
- throw Error(
588
- '"required" must be defined as true or false (if the checkbox is mandatory or not).',
589
- )
590
- }
591
- },
592
- ...translatable,
593
- },
594
- }
595
-
596
- types.codeEditorFile = TypeObject({
597
- label: 'File',
598
- editable: true,
599
- type: {
600
- giteaPath: Literal('root/public/README.md', {
601
- label: 'Gitea file path',
602
- instruction: 'Example: "{user}/{repo}/{path-to-file}"',
603
- editable: true,
604
- check: value => {
605
- checkTextLength(value, 200)
606
- checkNotEmpty(value)
607
- },
608
- }),
609
- editorPath: Literal('provided/README.md', {
610
- label: 'Path in Code editor',
611
- required: true,
612
- editable: true,
613
- check: value => {
614
- checkTextLength(value, 200)
615
- checkNotEmpty(value)
616
- },
617
- }),
618
- },
619
- })
620
-
621
- types.providedFiles = {
622
- editable: true,
623
- label: 'Provided files',
624
- type: [types.codeEditorFile],
625
- }
626
- types.enableCodeEditor = (enabled, options) => {
627
- return Literal(enabled, {
628
- editable: true,
629
- required: true,
630
- label: 'Enable',
631
- ...options,
632
- })
633
- }
634
-
635
- const sharedCodeEditor = {
636
- label: 'Code editor settings',
637
- editable: true,
638
- // code editor will not be available for shell exercises
639
- // shell exercises need to commit files that are not supported, for example ".tar"
640
- check: (_, { attrs }) => {
641
- if (attrs.language === 'sh') {
642
- throw Error('The code editor does not support shell exercises')
643
- }
644
- },
645
- type: {
646
- // If checked, will enable the code editor in the platform for the exercise.
647
- enabled: types.enableCodeEditor(false),
648
- providedFiles: types.providedFiles,
649
- },
650
- }
651
-
652
- relationAttrs.codeEditor = {
653
- quest: {
654
- exercise: TypeObject({ ...sharedCodeEditor }),
655
- },
656
- exam: {
657
- exercise: TypeObject({
658
- ...sharedCodeEditor,
659
- required: true,
660
- type: {
661
- enabled: types.enableCodeEditor(true, { editable: false }),
662
- providedFiles: types.providedFiles,
663
- },
664
- }),
665
- },
666
- }
667
-
668
- const checkCRMvalues = value => {
669
- const reg = /^[^a-z](\d+)|[^a-z0-9_]/g
670
- const i = value.search(reg)
671
- if (i !== -1) {
672
- const wrongCharacter = value[i]
673
- const suggestion = value.normalize('NFD').toLowerCase().replace(reg, '')
674
- const suffix = suggestion ? `Did you mean ${suggestion}?` : ''
675
- throw Error(
676
- `Property names should start with a letter, only contain lowercase letters, numbers, and underscores (${i}:"${wrongCharacter}"). ${suffix}`,
677
- )
678
- }
679
- }
680
-
681
- const ENABLE_CRM = process.env.ENABLE_CRM
682
- const CRM_NAME = process.env.CRM_NAME
683
- const CRM_ENDPOINT = process.env.CRM_ENDPOINT
684
- const CRM_PRIVATE_KEY = process.env.CRM_PRIVATE_KEY
685
-
686
- // types.crmOnRegistration = Literal('event_registration', {
687
- // editable: true,
688
- // check: value => {
689
- // checkNotEmpty(value)
690
- // checkCRMvalues(value)
691
- // },
692
- // label: 'Notify on registration',
693
- // })
694
-
695
- types.crmOnValidation = Literal('event_status', {
696
- editable: true,
697
- label: 'Validation status',
698
-
699
- check: value => {
700
- checkNotEmpty(value)
701
- checkCRMvalues(value)
702
- },
703
- })
704
-
705
- const sharedCRM = TypeObject({
706
- label: 'Customer Relationship Management (CRM)',
707
- editable: true,
708
- restrictive: true,
709
- private: !(
710
- ENABLE_CRM &&
711
- JSON.parse(ENABLE_CRM) &&
712
- CRM_NAME &&
713
- CRM_ENDPOINT &&
714
- CRM_PRIVATE_KEY
715
- ),
716
- type: {
717
- // TODO : add type for registration process "onRegistration"
718
- // onRegistration: types.crmOnRegistration,
719
- onValidation: types.crmOnValidation,
720
- },
721
- })
722
-
723
- types.crmGamesOnAttempts = Literal('number_of_attempts_remaining', {
724
- label: 'Remaining attempts',
725
- editable: true,
726
-
727
- check: value => {
728
- checkNotEmpty(value)
729
- checkCRMvalues(value)
730
- },
731
- })
732
-
733
- // RootEvents
734
- types.crmOnProgress = Literal('last_progress', {
735
- label: 'Currently in progress',
736
- editable: true,
737
-
738
- check: value => {
739
- checkNotEmpty(value)
740
- checkCRMvalues(value)
741
- },
742
- })
743
-
744
- types.gamesCRM = {
745
- // how many attempts remain
746
- onAttempts: types.crmGamesOnAttempts,
747
- }
748
-
749
- types.crmDueDate = Literal('audit_due_date', {
750
- label: 'Due date',
751
- editable: true,
752
-
753
- check: value => {
754
- checkNotEmpty(value)
755
- checkCRMvalues(value)
756
- },
757
- })
758
-
759
- const crmRootEvents = {
760
- ...sharedCRM,
761
- type: { ...sharedCRM.type, onProgress: types.crmOnProgress },
762
- }
763
-
764
- attrs.crm = {
765
- games: {
766
- ...sharedCRM,
767
- type: { ...types.gamesCRM, onValidation: sharedCRM.type.onValidation },
768
- },
769
- interview: sharedCRM,
770
- piscine: crmRootEvents,
771
- module: crmRootEvents,
772
- exam: sharedCRM,
773
- raid: sharedCRM,
774
- project: {
775
- ...sharedCRM,
776
- type: { dueDate: types.crmDueDate },
777
- },
778
- }
779
-
780
- const getDelay = ({ type, parent, index, attrs }) => {
781
- if (parent && type === 'exercise' && attrs.special) {
782
- // duration is expressed in days
783
- const prevExercisesDuration = Object.values(parent.children)
784
- .filter(ex => ex.index < index)
785
- .map(({ attrs }) => attrs.duration)
786
- .reduce(add, 0)
787
- // now return in millisecond
788
- return prevExercisesDuration * DAY
789
- }
790
- return attrs.startDay ? (attrs.startDay - 1) * DAY : 0
791
- }
792
- const sharedDelay = {
793
- type: 'number',
794
- required: true,
795
- private: true,
796
- }
797
- relationAttrs.delay = {
798
- piscine: {
799
- quest: {
800
- ...sharedDelay,
801
- label: 'Time before the quest starts',
802
- // NB: 'startDay' should be rename if one day it becomes a public attribute
803
- ...Functions({ 'based on previous content temporal-windows': getDelay }),
804
- },
805
- // needed for project to calculate hasStarted, and then status
806
- // TODO: figure out how tron is handled
807
- project: {
808
- ...sharedDelay,
809
- label: 'Time before the project starts',
810
- ...Functions({ 'based on previous content temporal-windows': getDelay }),
811
- },
812
- },
813
- // for the hackathon
814
- quest: {
815
- exercise: {
816
- ...sharedDelay,
817
- label: 'Time before the exercise starts',
818
- ...Functions({
819
- 'based on previous exercises temporal-windows': getDelay,
820
- }),
821
- },
822
- },
823
- // TODO: TO RM - HERE ONLY TO COMPARE CAMPUS BEFORE AND AFTER REL ATTRS IMPLEM
824
- exam: {
825
- exercise: {
826
- ...sharedDelay,
827
- label: 'Time before the exercise starts',
828
- ...Functions({
829
- 'based on previous exercises temporal-windows': getDelay,
830
- }),
831
- },
832
- },
833
- module: { project: Literal(0, sharedDelay) },
834
- }
835
-
836
- const exerciseDifficulty = ({ attrs, prev }) => {
837
- if (attrs.parentType !== 'exam') return 1
838
- if (attrs.group) return Math.round(attrs.group * 2)
839
- return Math.round(((prev?.attrs.group || 0) + 1) * 2)
840
- }
841
-
842
- const sumOfChildrenDifficulty = object =>
843
- _children(object)
844
- .filter(firstOfGroup)
845
- .filter(isRequired)
846
- .map(_difficultyXpCoef)
847
- .reduce(add, 0)
848
-
849
- // TODO: update with level - still in progress
850
- const sharedDifficulty = Literal(1, {
851
- required: true,
852
- label: 'Difficulty',
853
- // options: [0.75, 1, 1.25, 1.5, 1.75, 2], // TODO: uncomment when content had fixed the difficulties
854
- instruction: 'From 0.75 to 2 (in increments of 0.25)',
855
- // TODO: uncomment when refs are fixed
856
- // check: value => checkNumberInBetween(value, 0.75, 2),
857
- })
858
- const privateDifficulty = {
859
- ...sharedDifficulty,
860
- private: true,
861
- ...Functions({
862
- 'Sum of required children exercises difficulties': sumOfChildrenDifficulty,
863
- }),
864
- }
865
- relationAttrs.difficulty = {
866
- module: {
867
- project: { ...sharedDifficulty, editable: true },
868
- exam: privateDifficulty,
869
- },
870
- piscine: {
871
- project: { ...sharedDifficulty, editable: true },
872
- raid: { ...sharedDifficulty, editable: true },
873
- exam: privateDifficulty,
874
- quest: privateDifficulty,
875
- },
876
- quest: { exercise: Literal(1, { ...sharedDifficulty, editable: true }) },
877
- exam: {
878
- exercise: {
879
- ...sharedDifficulty,
880
- editable: true,
881
- editableDefaultValue: 1,
882
- ...Functions({ 'Based on exercise group or 1': exerciseDifficulty }),
883
- },
884
- },
885
- }
886
-
887
- // TODO: rename this attribute by 'xpCoefficient' for example
888
- // but it is in db so it would need to do a migration
889
- const getDifficultyMod = ({ parent }) => parent?.attrs.difficultyMod
890
- const sharedDifficultyMod = Literal(1, {
891
- label: 'XP Coefficient',
892
- required: true,
893
- editable: true,
894
- editableDefaultValue: 1,
895
- ...Functions({ 'from parent': getDifficultyMod }),
896
- check: value => checkNumberInBetween(value, 0, 64),
897
- })
898
- relationAttrs.difficultyMod = {
899
- campus: { piscine: sharedDifficultyMod }, // ??
900
- module: {
901
- project: sharedDifficultyMod,
902
- piscine: sharedDifficultyMod,
903
- exam: sharedDifficultyMod, // ??
904
- },
905
- piscine: {
906
- raid: sharedDifficultyMod,
907
- exam: sharedDifficultyMod,
908
- quest: sharedDifficultyMod,
909
- project: sharedDifficultyMod,
910
- },
911
- quest: { exercise: sharedDifficultyMod },
912
- exam: { exercise: sharedDifficultyMod },
913
- }
914
-
915
- const getName = ({ name }) => name
916
- const getProjectName = object => {
917
- const [core] = getCoreOfSattelite(object) || []
918
- if (!core) return object.name
919
- // only for projects with a core, and which name starts by the core name:
920
- // remove the core name from the name to avoid repetition
921
- return core.name && object.name.startsWith(core.name)
922
- ? object.name.slice(core.name.length + 1)
923
- : object.name
924
- }
925
- const sharedDisplayedName = Literal('', {
926
- editable: true,
927
- label: 'Displayed name',
928
- check: title => checkTextLength(title, 60),
929
- })
930
- // TODO: mv onboarding name attribute to displayedName
931
- attrs.displayedName = {
932
- raid: {
933
- ...sharedDisplayedName,
934
- ...Functions({ 'from name': getName }),
935
- },
936
- project: {
937
- ...sharedDisplayedName,
938
- ...Functions({ 'from name (or light version)': getProjectName }),
939
- required: true,
940
- },
941
- // TODO: maybe rm it here and simply use name
942
- piscine: sharedDisplayedName,
943
- exercise: sharedDisplayedName,
944
- quest: sharedDisplayedName, // not required
945
- }
946
-
947
- const isHackathon = object => object.attrs.special
948
- const getExerciseDuration = ({ parent }) =>
949
- isHackathon(parent)
950
- ? parent.attrs.duration / Object.keys(parent.children).length
951
- : 0
952
- const oneDayDuration = () => 1
953
- const twoDaysDuration = () => 2
954
- const sharedDuration = Literal(1, {
955
- label: 'Temporal-window duration in the event',
956
- required: true,
957
- editable: true,
958
- check: duration => checkDurationInBetween(duration * DAY, 0, 50 * DAY), // TODO: maybe max should be another int
959
- editableDefaultValue: 1,
960
- ...Functions({ 'one day': oneDayDuration }),
961
- })
962
- // duration unit: positive integer
963
- relationAttrs.duration = {
964
- // TODO: rm, here just for campus comparison
965
- module: {
966
- exam: { ...sharedDuration, hidden: true },
967
- },
968
- piscine: {
969
- quest: sharedDuration,
970
- // exam and raid duration are used to calculate start day of the next quests
971
- // it is useful only for the temporal-window - it has no impact on their scope/start/end
972
- exam: sharedDuration, // used anyway to calculate the temporal-window - 1 by default
973
- raid: {
974
- ...sharedDuration,
975
- ...Functions({ 'two days': twoDaysDuration }),
976
- }, // used anyway to calculate the temporal-window - override of 2 in db
977
- project: sharedDuration,
978
- },
979
- quest: {
980
- exercise: {
981
- ...sharedDuration,
982
- ...Functions({
983
- // TODO: maybe find a better fn name (too long) -
984
- // 'handled if special' could work but is not descriptive
985
- 'proportional to the others (in hackathon mode)': getExerciseDuration,
986
- }),
987
- },
988
- },
989
- // TODO: TO RM - HERE ONLY TO COMPARE CAMPUS BEFORE AND AFTER REL ATTRS IMPLEM
990
- exam: {
991
- exercise: {
992
- ...sharedDuration,
993
- ...Functions({
994
- // TODO: maybe find a better fn name (too long) -
995
- // 'handled if special' could work but is not descriptive
996
- 'proportional to the others (in hackathon mode)': getExerciseDuration,
997
- }),
998
- },
999
- },
1000
- }
1001
-
1002
- const sharedEventDuration = {
1003
- label: 'Event duration',
1004
- required: true,
1005
- editable: true,
1006
- check: duration =>
1007
- checkDurationInBetween(duration * MIN, 1 * MIN, 365 * DAY * 3), // 1min to 3 years (+1 extra day)
1008
- }
1009
- attrs.eventDuration = {
1010
- exam: Literal((4 * HOUR) / MIN, sharedEventDuration),
1011
- module: Literal((365 * DAY * 2) / MIN, sharedEventDuration),
1012
- interview: Literal((DAY * 1) / MIN, sharedEventDuration),
1013
- piscine: Literal((4 * WEEK) / MIN, sharedEventDuration),
1014
- raid: Literal((DAY * 2) / MIN, sharedEventDuration),
1015
- }
1016
-
1017
- const getParentEventId = ({ parent }) => parent?.attrs.eventId
1018
- const getEventId = ({ event }) => event?.id
1019
- const sharedEventId = {
1020
- type: 'number',
1021
- label: 'Event id',
1022
- required: true,
1023
- private: true,
1024
- ...Functions({ 'own event': getEventId }),
1025
- }
1026
- const parentEventId = {
1027
- ...sharedEventId,
1028
- ...Functions({ 'parent event': getParentEventId }),
1029
- }
1030
- // eventId unit: positive integer
1031
- relationAttrs.eventId = {
1032
- campus: { module: sharedEventId, piscine: sharedEventId },
1033
- piscine: {
1034
- quest: parentEventId,
1035
- project: parentEventId,
1036
- exam: sharedEventId,
1037
- raid: sharedEventId,
1038
- },
1039
- quest: { exercise: parentEventId },
1040
- exam: { exercise: parentEventId },
1041
- module: {
1042
- piscine: sharedEventId,
1043
- project: parentEventId,
1044
- exam: sharedEventId,
1045
- },
1046
- }
1047
-
1048
- const sharedEventStartDelay = Literal(0, {
1049
- label: 'Time between registration and event',
1050
- editable: true,
1051
- })
1052
- attrs.eventStartDelay = {
1053
- exam: sharedEventStartDelay,
1054
- module: sharedEventStartDelay,
1055
- interview: sharedEventStartDelay,
1056
- piscine: sharedEventStartDelay,
1057
- raid: sharedEventStartDelay,
1058
- }
1059
-
1060
- // TODO: add a fake name by default or rm check not empty
1061
- // because when the value is added in front end, it saves the default value,
1062
- // which is '' but the save is blocked by check
1063
- // or create a state in array type
1064
- types.filesName = Literal('', {
1065
- label: 'Expected file name',
1066
- instruction: 'Examples: "main.go", or "exerciseName/main.go"',
1067
- editable: true,
1068
- check: value => {
1069
- // checkNotEmpty(value)
1070
- checkTextLength(value, 65)
1071
- // TODO: check that there is a .extension
1072
- },
1073
- })
1074
- const sharedExpectedFiles = {
1075
- type: [types.filesName],
1076
- value: [],
1077
- editable: true,
1078
- restrictive: true,
1079
- label: 'Expected files',
1080
- check: checkNotEmpty,
1081
- }
1082
- // expectedFiles unit: array of strings
1083
- attrs.expectedFiles = {
1084
- exercise: sharedExpectedFiles,
1085
- raid: sharedExpectedFiles,
1086
- }
1087
-
1088
- const totalRequiredXp = (acc, c) => acc + (isRequired(c) ? c.attrs.baseXp : 0)
1089
- const getRequiredExpectedXp = object =>
1090
- _children(object).filter(firstOfGroup).reduce(totalRequiredXp, 0)
1091
- const totalExpectedXp = (acc, c) => acc + (c.attrs.expectedXp || 0)
1092
- const getTotalExpectedXp = object =>
1093
- _children(object).reduce(totalExpectedXp, 0)
1094
- const sharedExpectedXp = {
1095
- type: 'number',
1096
- label: 'XP',
1097
- instruction: 'Expertise(s) that this content is worth.',
1098
- required: true,
1099
- check: amount => {
1100
- if (!Number.isInteger(amount)) throw Error('Must be a whole number')
1101
- if (amount < 0) throw Error('Must not be negative')
1102
- },
1103
- }
1104
- // expectedXp unit: integer
1105
- attrs.expectedXp = {
1106
- piscine: {
1107
- ...sharedExpectedXp,
1108
- private: true,
1109
- label: 'XP (for stats)', // expected from quests and exams & for module
1110
- ...Functions({ 'Sum of children expected XP': getTotalExpectedXp }),
1111
- }, // used in event service and for stats
1112
- module: {
1113
- ...sharedExpectedXp,
1114
- ...Functions({ 'Sum of children expected XP': getTotalExpectedXp }),
1115
- }, // TODO: check if really used??
1116
- quest: {
1117
- ...sharedExpectedXp,
1118
- ...Functions({ 'Sum of required children XP': getRequiredExpectedXp }),
1119
- },
1120
- exam: {
1121
- ...sharedExpectedXp,
1122
- ...Functions({ 'Sum of required children XP': getRequiredExpectedXp }),
1123
- },
1124
- }
1125
-
1126
- const primaryKeyDef = Literal('', {
1127
- primary: true,
1128
- editable: true,
1129
- required: true,
1130
- })
1131
-
1132
- const inputKeyAndRequiredProps = {
1133
- key: {
1134
- ...primaryKeyDef,
1135
- label: 'Key (must be unique)',
1136
- instruction: 'Associated keyword in the database and data exports',
1137
- check: (value, object) => {
1138
- checkNotEmpty(value.trim())
1139
-
1140
- const isNotFormatted = isNotCamel(value)
1141
- if (isNotFormatted) {
1142
- const camelKey = toCamelCase(value)
1143
- throw Error(`Must be written in camelCase. Suggestion: "${camelKey}"`)
1144
- }
1145
-
1146
- // TODO: as key is defined like `primary`, this check should be done
1147
- // by check-refs/object service/expand attrs (to be confirmed)
1148
- const sameInputKeys = (
1149
- object.attrs['form-en'] ||
1150
- object.attrs.form ||
1151
- // TODO: rm when add object is fixed with required attrs saved in db
1152
- []
1153
- )
1154
- .flatMap(section => section.inputs.map(input => input.key))
1155
- .filter(inputKey => inputKey === value)
1156
- if (sameInputKeys?.length > 1) {
1157
- throw Error(
1158
- `${value} is already used for another input. Please choose a unique key.`,
1159
- )
1160
- }
1161
- },
1162
- },
1163
- required: Literal(true, { label: 'Must be fulfilled', editable: true }),
1164
- }
1165
-
1166
- // few flags in generatedFlags from ../design/createCssClass.js
1167
- const generatedFlags = new Set(['w100p', 'w18p'])
1168
- const keyStyleAndRequiredProps = {
1169
- ...inputKeyAndRequiredProps,
1170
- styleProps: TypeObject({
1171
- // TODO: change to array, and define instruction for each selected available design flags
1172
- label: 'Input style',
1173
- type: {
1174
- w100p: Literal(false, {
1175
- label: 'Large input style',
1176
- editable: true,
1177
- }),
1178
- w18p: Literal(false, {
1179
- label: 'Small input style',
1180
- editable: true,
1181
- }),
1182
- },
1183
- editable: true,
1184
- check: value => {
1185
- const invalidProp = Object.keys(value).find(
1186
- prop => !generatedFlags.has(prop),
1187
- )
1188
- if (invalidProp) throw Error(`${invalidProp} is not a valid css property`)
1189
- },
1190
- }),
1191
- }
1192
- const nameAndIdProps = {
1193
- name: Literal('', {
1194
- label: 'Input HTML name',
1195
- editable: true,
1196
- check: checkNotEmpty,
1197
- }),
1198
- id: Literal('', {
1199
- label: 'Input HTML id',
1200
- editable: true,
1201
- check: checkNotEmpty,
1202
- }), // id could be key by default?
1203
- }
1204
- const labelAndPLaceholder = (defaultLabel, defaultPlaceholder) => ({
1205
- placeholder: Literal(defaultPlaceholder || 'Your answer...', {
1206
- label: 'Placeholder',
1207
- editable: true,
1208
- }),
1209
- label: Literal(defaultLabel || '', {
1210
- label: 'Label / title',
1211
- instruction: 'Displayed on top of the input',
1212
- editable: true,
1213
- // TODO: add this!!
1214
- // required: true
1215
- }),
1216
- })
1217
- const sharedTextProps = {
1218
- ...labelAndPLaceholder(),
1219
- ...nameAndIdProps,
1220
- minLength: Literal(1, { label: 'Minimum length', editable: true }),
1221
- maxLength: Literal(60, { label: 'Maximum length', editable: true }),
1222
- }
1223
-
1224
- types.textInput = TypeObject({
1225
- label: 'Text input',
1226
- type: {
1227
- type: Literal('text', { label: 'Input type', required: true }), // private: true?
1228
- ...keyStyleAndRequiredProps,
1229
- ...sharedTextProps,
1230
- size: Literal(20, { label: 'Size', editable: true }),
1231
- },
1232
- })
1233
- types.textareaInput = TypeObject({
1234
- label: 'Textarea input',
1235
- type: {
1236
- type: Literal('textarea', { label: 'Input type', required: true }), // private: true?
1237
- ...keyStyleAndRequiredProps,
1238
- ...sharedTextProps,
1239
- rows: Literal(5, { label: 'Rows', editable: true }),
1240
- cols: Literal(20, { label: 'Columns', editable: true }),
1241
- },
1242
- })
1243
-
1244
- types.telInputPattern = Literal(`/^\\s?\\+?[0-9 ()_-]+$/`, {
1245
- label: 'Pattern to match',
1246
- instruction:
1247
- "Regular expression (in javascript) that the input's value must match.",
1248
- editable: true,
1249
- })
1250
-
1251
- types.telInput = TypeObject({
1252
- label: 'Tel input',
1253
- type: {
1254
- type: Literal('tel', { label: 'Input type', required: true }), // private: true?
1255
- ...keyStyleAndRequiredProps,
1256
- ...nameAndIdProps,
1257
- ...labelAndPLaceholder('Telephone number', '+333 33 33 33 33'),
1258
- pattern: types.telInputPattern,
1259
- format: Literal('Format: +7 777 123 00 00', {
1260
- label: 'Format to respect',
1261
- editable: true,
1262
- }),
1263
- },
1264
- })
1265
- types.checkboxInput = TypeObject({
1266
- label: 'Checkbox input',
1267
- type: {
1268
- type: Literal('checkbox', { label: 'Input type', required: true }), // private: true?
1269
- ...inputKeyAndRequiredProps,
1270
- ...nameAndIdProps,
1271
- label: Literal('I agree', {
1272
- label: 'Text associated to the checkbox',
1273
- instruction: 'Displayed on the right of the checkbox',
1274
- editable: true,
1275
- required: true,
1276
- check: checkNotEmpty,
1277
- }),
1278
- value: Literal(false, {
1279
- label: 'Is initially checked',
1280
- editable: true,
1281
- required: true,
1282
- }),
1283
- // TODO: add items??
1284
- },
1285
- })
1286
-
1287
- types.switchInput = TypeObject({
1288
- label: 'Switch input',
1289
- type: {
1290
- type: Literal('switch', { label: 'Input type', required: true }), // private: true?
1291
- ...inputKeyAndRequiredProps,
1292
- ...nameAndIdProps,
1293
- label: Literal('I choose...', {
1294
- label: 'Text associated to the switch input',
1295
- instruction: 'Displayed on the left of the checkbox',
1296
- editable: true,
1297
- required: true,
1298
- check: checkNotEmpty,
1299
- }),
1300
- value: Literal(false, {
1301
- label: 'Is initially set to true',
1302
- editable: true,
1303
- required: true,
1304
- }),
1305
- },
1306
- })
1307
-
1308
- types.numberInputsSteps = Literal(10, {
1309
- label: 'Step value up and down by',
1310
- editable: true,
1311
- })
1312
-
1313
- types.numberInput = TypeObject({
1314
- label: 'Number input',
1315
- type: {
1316
- type: Literal('number', { label: 'Input type', required: true }), // private: true?
1317
- ...keyStyleAndRequiredProps,
1318
- ...nameAndIdProps,
1319
- ...labelAndPLaceholder('How many/much...', 'Multiple of...'),
1320
- min: Literal(1, { label: 'Minimum', editable: true }),
1321
- max: Literal(100, { label: 'Maximum', editable: true }),
1322
- step: types.numberInputsSteps,
1323
- },
1324
- })
1325
-
1326
- types.dateInput = TypeObject({
1327
- label: 'Date input',
1328
- type: {
1329
- type: Literal('date', { label: 'Input type', required: true }), // private: true?
1330
- ...keyStyleAndRequiredProps,
1331
- ...nameAndIdProps,
1332
- ...labelAndPLaceholder('Date', '2000-01-01'),
1333
- value: Literal('2000-01-01', {
1334
- label: 'Default date value',
1335
- editable: true,
1336
- }),
1337
- min: Literal('2000-01-01', {
1338
- label: 'Lowest date allowed',
1339
- editable: true,
1340
- }),
1341
- max: Literal('2000-01-01', {
1342
- label: 'Highest date allowed',
1343
- editable: true,
1344
- }),
1345
- },
1346
- })
1347
- types.datetimeLocalInput = TypeObject({
1348
- label: 'Date with time input',
1349
- type: {
1350
- type: Literal('datetime-local', { label: 'Input type', required: true }), // private: true?
1351
- ...keyStyleAndRequiredProps,
1352
- ...nameAndIdProps,
1353
- ...labelAndPLaceholder('Date and time', '2000-01-01T00:00'),
1354
- value: Literal('2000-01-01T00:00', {
1355
- label: 'Default date and time value',
1356
- editable: true,
1357
- }),
1358
- min: Literal('2000-01-01T00:00', {
1359
- label: 'Lowest date and time allowed',
1360
- editable: true,
1361
- }),
1362
- max: Literal('2000-01-01T00:00', {
1363
- label: 'Highest date and time allowed',
1364
- editable: true,
1365
- }),
1366
- },
1367
- })
1368
-
1369
- types.countriesInput = TypeObject({
1370
- label: 'Countries input',
1371
- type: {
1372
- type: Literal('countries', { label: 'Input type', required: true }), // private: true?
1373
- ...inputKeyAndRequiredProps,
1374
- ...nameAndIdProps,
1375
- label: Literal('Country', {
1376
- label: 'Label / title',
1377
- instruction: 'Displayed on top of the input',
1378
- editable: true,
1379
- // required: true,
1380
- }),
1381
- emptyItem: TypeObject({
1382
- label: 'Empty selection text',
1383
- editable: true,
1384
- instruction: 'Displayed as a placeholder before selection',
1385
- type: {
1386
- label: Literal('Choose your country...', {
1387
- label: 'Label',
1388
- editable: true,
1389
- }),
1390
- },
1391
- }),
1392
- },
1393
- })
1394
- types.languagesInput = TypeObject({
1395
- label: 'Languages input',
1396
- type: {
1397
- type: Literal('languages', { label: 'Input type', required: true }), // private: true?
1398
- ...inputKeyAndRequiredProps,
1399
- ...nameAndIdProps,
1400
- label: Literal('Language', {
1401
- label: 'Label / title',
1402
- instruction: 'Displayed on top of the input',
1403
- editable: true,
1404
- // required: true,
1405
- }),
1406
- emptyItem: TypeObject({
1407
- label: 'Empty selection text',
1408
- editable: true,
1409
- instruction: 'Displayed as a placeholder before selection',
1410
- type: {
1411
- label: Literal('Choose your language...', {
1412
- label: 'Label',
1413
- editable: true,
1414
- }),
1415
- },
1416
- }),
1417
- },
1418
- })
1419
-
1420
- // TODO: handle input type file? until now, handled by upload step
1421
- // accept, capture, and multiple properties to add
1422
- // types.fileInput = TypeObject({})
1423
-
1424
- // radio must have data property
1425
- types.radioItems = TypeObject({
1426
- label: 'Radio item',
1427
- type: {
1428
- label: Literal('', {
1429
- label: 'Label (text displayed)',
1430
- editable: true,
1431
- }),
1432
- data: Literal('', {
1433
- label: 'Data (value saved in database)',
1434
- editable: true,
1435
- required: true,
1436
- primary: true,
1437
- check: checkNotEmpty, // TODO: rm?
1438
- }),
1439
- },
1440
- })
1441
- types.radioInput = TypeObject({
1442
- label: 'Radio input',
1443
- type: {
1444
- type: Literal('radio', { label: 'Input type', required: true }), // private: true?
1445
- ...inputKeyAndRequiredProps,
1446
- ...nameAndIdProps,
1447
- label: Literal('Select one...', {
1448
- label: 'Label / title',
1449
- instruction: 'Displayed on top of the input',
1450
- editable: true,
1451
- required: true,
1452
- }),
1453
- inlineBlock: Literal(true, {
1454
- label: 'Displays options under the label',
1455
- instruction: 'If set to false, options are right to the label',
1456
- editable: true,
1457
- }),
1458
- items: {
1459
- value: [],
1460
- type: [types.radioItems],
1461
- label: 'Items',
1462
- required: true,
1463
- editable: true,
1464
- },
1465
- },
1466
- })
1467
-
1468
- // select must have label property
1469
- types.selectItems = TypeObject({
1470
- label: 'Select item',
1471
- type: {
1472
- label: Literal('', {
1473
- label: 'Label',
1474
- instruction: 'Text displayed and saved in database', // rm?
1475
- editable: true,
1476
- required: true,
1477
- primary: true,
1478
- check: checkNotEmpty, // TODO: rm?
1479
- }),
1480
- data: Literal('', { label: 'Data', editable: true }),
1481
- },
1482
- })
1483
- types.selectInput = TypeObject({
1484
- label: 'Select input',
1485
- type: {
1486
- type: Literal('select', { label: 'Input type', required: true }), // private: true?
1487
- ...keyStyleAndRequiredProps, // style???
1488
- ...nameAndIdProps,
1489
- label: Literal('Select one...', {
1490
- label: 'Label / title',
1491
- instruction: 'Displayed on top of the input',
1492
- editable: true,
1493
- // required: true, // TODO: add this?
1494
- }),
1495
- searchable: Literal(true, {
1496
- label: 'Searchable',
1497
- instruction: 'Allow search, and sort the input options',
1498
- editable: true,
1499
- }),
1500
- emptyItem: TypeObject({
1501
- label: 'Empty selection text',
1502
- editable: true,
1503
- instruction: 'Displayed as a placeholder before selection',
1504
- type: {
1505
- label: Literal('Choose...', {
1506
- label: 'Label',
1507
- editable: true,
1508
- }),
1509
- },
1510
- }),
1511
- items: {
1512
- type: [types.selectItems],
1513
- value: [],
1514
- label: 'Items',
1515
- required: true,
1516
- editable: true,
1517
- },
1518
- },
1519
- })
1520
-
1521
- types.formSectionResume = Literal('', {
1522
- label: 'Resume',
1523
- editable: true,
1524
- })
1525
-
1526
- types.formSection = TypeObject({
1527
- type: {
1528
- key: {
1529
- ...primaryKeyDef,
1530
- label: 'Section id',
1531
- instruction: 'Private info (for admins)',
1532
- check: (value, object) => {
1533
- checkNotEmpty(value.trim())
1534
- const isNotFormatted = isNotCamel(value)
1535
- if (isNotFormatted) {
1536
- const camelKey = toCamelCase(value)
1537
- throw Error(`Must be written in camelCase. Suggestion: "${camelKey}"`)
1538
- }
1539
-
1540
- // TODO: as key is defined like `primary`, this check should be done
1541
- // by check-refs/object service/expand attrs
1542
- const sameSectionKeys = (
1543
- object.attrs['form-en'] ||
1544
- object.attrs.form ||
1545
- // TODO: rm when add object is fixed with required attrs saved in db
1546
- []
1547
- )
1548
- .map(section => section.key)
1549
- .filter(sectionKey => sectionKey === value)
1550
- if (sameSectionKeys?.length > 1) {
1551
- throw Error(
1552
- `${value} is already used for another section. Please choose a unique key.`,
1553
- )
1554
- }
1555
- },
1556
- },
1557
- title: Literal('', { label: 'Section title', editable: true }),
1558
- resume: types.formSectionResume,
1559
- inputs: {
1560
- label: 'Inputs',
1561
- instruction: 'Questions or instruction to answer to',
1562
- required: true,
1563
- editable: true,
1564
- // check: checkNotEmpty, // TODO: add modal to force putting first item?
1565
- value: [],
1566
- // TODO: handle one or any in the arrays
1567
- type: [
1568
- types.textInput,
1569
- types.textareaInput,
1570
- types.telInput,
1571
- types.checkboxInput,
1572
- types.switchInput,
1573
- types.dateInput,
1574
- types.datetimeLocalInput,
1575
- types.numberInput,
1576
- // types.fileInput, - supposed to be in upload step, not form
1577
- types.countriesInput,
1578
- types.languagesInput,
1579
- types.radioInput,
1580
- types.selectInput,
1581
- ],
1582
- },
1583
- },
1584
- })
1585
-
1586
- attrs.form = {
1587
- 'form-step': {
1588
- label: 'Form to fill',
1589
- required: true,
1590
- editable: true,
1591
- value: [{ key: 'firstSection', inputs: [] }],
1592
- type: [types.formSection],
1593
- ...translatable,
1594
- },
1595
- }
1596
-
1597
- const questGrade = object => {
1598
- if (!object.children) return
1599
- const children = _children(object)
1600
- const requiredCount = children.filter(isRequired).length
1601
- const count = children.filter(
1602
- child => child.attrs.status === 'succeeded',
1603
- ).length
1604
-
1605
- return count && count / requiredCount
1606
- }
1607
- attrs.grade = {
1608
- quest: Literal(0, {
1609
- required: true,
1610
- private: true,
1611
- label: 'Quest grade',
1612
- ...Functions({ 'succeeded exercises / required': questGrade }),
1613
- }),
1614
- }
1615
-
1616
- /* MODULE GRAPH ATTRIBUTE */
1617
- types.graphArcName = TypeObject({
1618
- label: 'Name of the arc',
1619
- type: {
1620
- text: Literal('', {
1621
- label: 'The text name of the arc',
1622
- type: 'string',
1623
- }),
1624
- hidden: Literal(true, {
1625
- label: 'Display the text name on the graph',
1626
- }),
1627
- },
1628
- })
1629
-
1630
- const checkGraphArcContentIsValid = (contentName, object) => {
1631
- const matchingObject = object.children[contentName]
1632
- if (!matchingObject) {
1633
- throw Error(
1634
- `Invalid object - no object found in the module for the following key name: ${contentName}`,
1635
- )
1636
- }
1637
- }
1638
-
1639
- types.graphArcContentName = Literal('', {
1640
- label: 'The text name of a content placed on an arc',
1641
- check: (contentName, object) =>
1642
- checkGraphArcContentIsValid(contentName, object),
1643
- })
1644
-
1645
- types.graphArcContentWithSubContents = {
1646
- value: {},
1647
- label: 'Content with sub-contents placed on an arc',
1648
- type: 'object',
1649
- check: (value, object) => {
1650
- if (isntObjectOrIsEmpty(value)) {
1651
- throw Error('Should be a non empty object.')
1652
- }
1653
-
1654
- const entries = Object.entries(value)
1655
- if (entries.length > 1) {
1656
- throw Error(
1657
- "Should be an object with a single key-value pair; the key being the content's name, and the value being an array of sub-contents's names.",
1658
- )
1659
- }
1660
-
1661
- const [contentName, subContentsList] = entries[0]
1662
-
1663
- // check content name key matches an existing object in the module
1664
- checkGraphArcContentIsValid(contentName, object)
1665
-
1666
- // check sub-contents' list is a non empty array
1667
- if (!Array.isArray(subContentsList) || !subContentsList.length) {
1668
- throw Error('Must be a non empty array')
1669
- }
1670
-
1671
- // check there's no duplicates in sub-contents' list
1672
- const uniques = new Set(subContentsList)
1673
- if (subContentsList.length !== uniques.size) {
1674
- throw Error('Duplicates are not allowed.')
1675
- }
1676
-
1677
- const maxSattelitesAllowed =
1678
- limitations.SLICE.innerCircle.maxSubContentsCount
1679
- if (subContentsList.length > maxSattelitesAllowed) {
1680
- throw Error('Max sattelites reached')
1681
- }
1682
-
1683
- // check sub-contents' name keys match existing objects in the module
1684
- for (const keyName of subContentsList) {
1685
- checkGraphArcContentIsValid(keyName, object)
1686
- }
1687
- },
1688
- }
1689
-
1690
- const graphArcBasicChecks = value => {
1691
- const { name: _name, contents, type: _type, id: _id, ...rest } = value
1692
-
1693
- if (Object.keys(rest).length) {
1694
- throw Error(`Unsupported attribute "${Object.keys(rest)[0]}"`)
1695
- }
1696
-
1697
- if (!Array.isArray(contents) || !contents?.length) {
1698
- throw Error('Must be a non empty array')
1699
- }
1700
- }
1701
-
1702
- types.graphArc = TypeObject({
1703
- label: 'Name & contents of an arc',
1704
- type: {
1705
- id: Literal('', {
1706
- label: 'A randomly-generated id to identify the arc',
1707
- }),
1708
- name: types.graphArcName,
1709
- contents: {
1710
- label: 'The list of contents to be placed on an arc',
1711
- type: [types.graphArcContentName],
1712
- },
1713
- },
1714
- check: graphArcBasicChecks,
1715
- })
1716
-
1717
- types.innerCircleSlice = TypeObject({
1718
- label: 'Slice on the inner circle',
1719
- type: {
1720
- id: Literal('', {
1721
- label: 'A randomly-generated id to identify the slice',
1722
- }),
1723
- name: types.graphArcName,
1724
-
1725
- type: Literal('slice', {
1726
- label: 'The slice type',
1727
- }),
1728
-
1729
- entryPoint: types.graphArcContentName,
1730
-
1731
- innerArc: TypeObject({
1732
- label: 'Inner arc of an inner circle slice',
1733
- type: {
1734
- ...types.graphArc.type,
1735
- contents: {
1736
- label:
1737
- 'The list of contents to be placed on an inner arc of an inner circle slice',
1738
- type: [
1739
- types.graphArcContentName,
1740
- types.graphArcContentWithSubContents, // for now, sub-contents are only displayed on the inner arc of an inner circle slice
1741
- ],
1742
- },
1743
- },
1744
- check: graphArcBasicChecks,
1745
- }),
1746
-
1747
- outerArcs: {
1748
- label: 'Outer arcs of an inner circle slice',
1749
- type: [types.graphArc],
1750
- check: outerArcs => {
1751
- const { maxArcsCount, maxContentsCount } = limitations.SLICE.outerArc
1752
- if (outerArcs.length > maxArcsCount) {
1753
- throw Error('Max outerArcs reached')
1754
- }
1755
- if (
1756
- outerArcs.reduce(
1757
- (total, arc) => total + (arc.contents?.length || 0),
1758
- 0,
1759
- ) > maxContentsCount
1760
- ) {
1761
- throw Error('Max contents spread over outerArcs of slice reached')
1762
- }
1763
- },
1764
- },
1765
- },
1766
-
1767
- check: innerCircle => {
1768
- const { name, entryPoint, type, innerArc, outerArcs, id, ...rest } =
1769
- innerCircle
1770
-
1771
- if (Object.keys(rest).length) {
1772
- throw Error(`Unsupported attribute "${Object.keys(rest)[0]}"`)
1773
- }
1774
-
1775
- if (!name && !entryPoint && !type && !innerArc && !outerArcs && !id) {
1776
- throw Error('Empty inner circle, should be removed')
1777
- }
1778
- },
1779
- })
1780
-
1781
- types.innerCircleLine = {
1782
- ...types.graphArc,
1783
- label: 'Line on the inner circle',
1784
- type: {
1785
- ...types.graphArc.type,
1786
- type: Literal('line', {
1787
- label: 'The line type',
1788
- }),
1789
- },
1790
- check: line => {
1791
- if (line.contents.length > limitations.LINE.maxContentsCount) {
1792
- throw Error('Max contents reached for a line')
1793
- }
1794
- },
1795
- }
1796
-
1797
- const graphStructure = TypeObject({
1798
- private: true, // not displayed in the admin settings; could use hidden too but private more efficient
1799
- required: true,
1800
- editable: true, // TODO: check if should be here?
1801
- value: { innerCircle: [], middleCircle: [], outerCircle: [] },
1802
- label: 'Graph structure',
1803
- instruction: 'Structure of the visual graph',
1804
- description:
1805
- 'Sets the visual structure & hierarchy of the graph of a module.',
1806
- type: {
1807
- centralPoint: types.graphArcContentName,
1808
-
1809
- innerCircle: {
1810
- value: [],
1811
- required: true,
1812
- editable: true,
1813
- label: 'List of slices and/or lines spread on the inner circle',
1814
- type: [types.innerCircleSlice, types.innerCircleLine],
1815
- check: innerCircle => {
1816
- const slices = innerCircle.filter(section => section.type === 'slice')
1817
- const lines = innerCircle.filter(section => section.type === 'line')
1818
- const { SLICE, LINE } = limitations
1819
- if (slices.length > SLICE.maxSlicesCount) {
1820
- throw Error('Max slices sections reached')
1821
- }
1822
- if (lines.length > LINE.maxLinesCount) {
1823
- throw Error('Max lines sections reached')
1824
- }
1825
- if (
1826
- slices.flatMap(({ innerArc }) =>
1827
- innerArc.contents.flatMap(c =>
1828
- typeof c === 'string' ? c : Object.keys(c)[0],
1829
- ),
1830
- ).length > SLICE.innerCircle.maxContentsCount
1831
- ) {
1832
- throw Error('Max contents spread over innerArcs of slices reached')
1833
- }
1834
- },
1835
- },
1836
-
1837
- middleCircle: {
1838
- value: [],
1839
- required: true,
1840
- editable: true,
1841
- label: 'List of arcs spread on the middle circle',
1842
- type: [types.graphArc],
1843
- check: middleCircle => {
1844
- const { maxArcsCount, maxContentsCount } = limitations.MIDDLE_CIRCLE
1845
- if (middleCircle.length > maxArcsCount) {
1846
- throw Error('Max arches reach on middleCircle')
1847
- }
1848
- if (
1849
- middleCircle.reduce(
1850
- (total, arc) => total + (arc.contents?.length || 0),
1851
- 0,
1852
- ) > maxContentsCount
1853
- ) {
1854
- throw Error('Max contents reach on middleCircle')
1855
- }
1856
- },
1857
- },
1858
-
1859
- outerCircle: {
1860
- value: [],
1861
- required: true,
1862
- editable: true,
1863
- label: 'List of arcs spread on the outer circle',
1864
- type: [types.graphArc],
1865
- check: outerCircle => {
1866
- const { maxArcsCount, maxContentsCount } = limitations.OUTER_CIRCLE
1867
- if (outerCircle.length > maxArcsCount) {
1868
- throw Error('Max arches reach on outerCircle')
1869
- }
1870
- if (
1871
- outerCircle.reduce(
1872
- (total, arc) => total + (arc.contents?.length || 0),
1873
- 0,
1874
- ) > maxContentsCount
1875
- ) {
1876
- throw Error('Max contents reach on outerCircle')
1877
- }
1878
- },
1879
- },
1880
- },
1881
- check: (graph, object) => {
1882
- const { centralPoint, innerCircle, middleCircle, outerCircle, ...rest } =
1883
- graph
1884
-
1885
- if (Object.keys(rest).length) {
1886
- throw Error(`Unsupported attribute "${Object.keys(rest)[0]}"`)
1887
- }
1888
-
1889
- if (
1890
- !centralPoint &&
1891
- !innerCircle?.length &&
1892
- !middleCircle?.length &&
1893
- !outerCircle?.length
1894
- ) {
1895
- throw Error('Empty graph, should be removed')
1896
- }
1897
- const flattenContents = flatGraphContents(graph)
1898
- const flattenContentsSet = new Set(flattenContents)
1899
- if (flattenContents.length !== flattenContentsSet.size) {
1900
- throw Error('Graph should not contain duplicate contents keys')
1901
- }
1902
- const childrenKeys = Object.keys(object.children)
1903
- if (flattenContentsSet.size !== childrenKeys.length) {
1904
- // console.log(flattenContents.filter(c => !childrenKeys.some(k => k === c)))
1905
- // console.log(childrenKeys.filter(c => !flattenContents.some(k => k === c)))
1906
- // TODO: update for throw Error when fixed with content
1907
- console.error(
1908
- `Inconsistancy in between graph and children: different size (${flattenContentsSet.size} vs ${childrenKeys.length})`,
1909
- )
1910
- }
1911
- },
1912
- })
1913
-
1914
- attrs.graph = {
1915
- module: graphStructure,
1916
- }
1917
-
1918
- // about to be refactored to define group base on exercise level?
1919
- relationAttrs.group = {
1920
- exam: {
1921
- exercise: Literal(1, {
1922
- label: 'Exercise group',
1923
- editable: true,
1924
- required: true,
1925
- hidden: true,
1926
- options: arrayOf(100, 1),
1927
- check: value => checkIntegerInBetween(value, 1, 100),
1928
- }),
1929
- },
1930
- }
1931
-
1932
- const getGroupId = ({ group }) => group?.id
1933
- const sharedGroupId = {
1934
- label: 'Group id of the user',
1935
- type: 'number',
1936
- required: true,
1937
- private: true,
1938
- ...Functions({ 'last confirmed group for this project': getGroupId }),
1939
- }
1940
- attrs.groupId = {
1941
- project: sharedGroupId,
1942
- raid: sharedGroupId,
1943
- }
1944
-
1945
- attrs.groupMax = {
1946
- project: Literal(1, {
1947
- label: 'Max. group number',
1948
- required: true,
1949
- editable: true,
1950
- options: arrayOf(20, 1),
1951
- check: (number, object) => {
1952
- checkIntegerInBetween(number, 1, 20) // TODO: validate the max allowed
1953
- if (object.attrs.groupMin > number) {
1954
- throw Error('Must be bigger or equal to "Min. group number"')
1955
- }
1956
- },
1957
- }),
1958
- }
1959
-
1960
- attrs.groupMin = {
1961
- project: Literal(1, {
1962
- label: 'Min. group number',
1963
- required: true,
1964
- editable: true,
1965
- options: arrayOf(20, 1),
1966
- check: (number, object) => {
1967
- checkIntegerInBetween(number, 1, 20) // TODO: validate the max allowed
1968
- if (object.attrs.groupMax < number) {
1969
- throw Error('Must be smaller or equal to "Max. group number"')
1970
- }
1971
- },
1972
- }),
1973
- }
1974
-
1975
- // TODO: write a doc on the group system? not sure it's needed
1976
- attrs.groupSize = {
1977
- raid: Literal(3, {
1978
- label: 'Average group size',
1979
- required: true,
1980
- editable: true,
1981
- // put literal value first, as in admin front end the first value of options
1982
- // is considered as default value. This is for dynamic options to be handled
1983
- options: [3, 1, 2, ...arrayOf(17, 4)],
1984
- check: number => checkIntegerInBetween(number, 1, 20), // TODO: validate the max allowed
1985
- }),
1986
- }
1987
-
1988
- const getHasStarted = ({ attrs }) => Date.now() > attrs.start
1989
- const always = () => true
1990
- const never = () => false
1991
- const sharedPrivateHasStarted = {
1992
- type: 'boolean',
1993
- required: true,
1994
- private: true,
1995
- ...Functions({ 'from beginning of event': getHasStarted }),
1996
- }
1997
- const sharedPublicHasStared = {
1998
- label: 'Starts when',
1999
- type: 'boolean',
2000
- hidden: true,
2001
- required: true,
2002
- ...Functions({
2003
- 'temporal-window has started (in hackathon mode)': getHasStarted,
2004
- 'always started': always,
2005
- 'never starts': never,
2006
- }),
2007
- }
2008
- // NB: it's a boolean but it is more understandable if it's described as a moment
2009
- // to do so, we just add 2 fn: always and never instead of add editable: true
2010
- relationAttrs.hasStarted = {
2011
- campus: { module: sharedPrivateHasStarted, piscine: sharedPrivateHasStarted },
2012
- module: {
2013
- piscine: sharedPrivateHasStarted,
2014
- exam: sharedPrivateHasStarted,
2015
- // TODO: rm this for project? it is now at true as soon as the module started
2016
- // (except for tron)
2017
- // depends if we want to keep the option of using the temporal-window
2018
- // then if we define a delay for projects, start and hasStarted would change
2019
- // NB: this attribute is actually used right now to define inScope attr for projects
2020
- project: sharedPublicHasStared,
2021
- },
2022
- piscine: {
2023
- exam: sharedPrivateHasStarted,
2024
- raid: sharedPrivateHasStarted,
2025
- quest: sharedPublicHasStared,
2026
- project: sharedPublicHasStared,
2027
- },
2028
- quest: {
2029
- exercise: sharedPublicHasStared,
2030
- },
2031
- exam: {
2032
- exercise: sharedPublicHasStared,
2033
- },
2034
- }
2035
-
2036
- // TODO: rm? used only in the Calendar (that is not used anymore)
2037
- // and only for mention of `Group 1,2 or 3` for raids...
2038
- attrs.info = {
2039
- raid: Literal('Group 1', {
2040
- editable: true,
2041
- label: 'Calendar information tag',
2042
- check: value => checkTextLength(value, 50),
2043
- }),
2044
- }
2045
-
2046
- const sharedInput = {
2047
- label: 'Upload input',
2048
- required: true,
2049
- editable: true,
2050
- type: 'object',
2051
- value: {},
2052
- ...translatable,
2053
- instruction: 'Required option: "type"',
2054
- }
2055
- const uploadInput = {
2056
- ...sharedInput,
2057
- editable: false,
2058
- check: value => {
2059
- const [inputValues] = Object.values(value)
2060
- isntObjectOrIsEmpty(inputValues)
2061
- if (!inputValues.type || inputValues.type !== 'file') {
2062
- throw Error('"type":"file" property must be defined in the upload input.')
2063
- }
2064
- if (
2065
- inputValues.accept !== undefined &&
2066
- typeof inputValues.accept !== 'string'
2067
- ) {
2068
- throw Error(
2069
- '"accept" property (if added) must be a text. Example: "image/png, image/jpeg"',
2070
- )
2071
- }
2072
- if (
2073
- inputValues.required !== undefined &&
2074
- typeof inputValues.required !== 'boolean'
2075
- ) {
2076
- throw Error('"required" property (if added) must be a true or false.')
2077
- }
2078
- },
2079
- }
2080
-
2081
- const avatarInput = TypeObject({
2082
- label: 'Avatar input',
2083
- required: true,
2084
- editable: false,
2085
- value: {
2086
- type: 'file',
2087
- accept: 'image/png, image/jpeg',
2088
- required: true,
2089
- },
2090
- type: {
2091
- type: Literal('file', {
2092
- label: 'Input type',
2093
- required: true,
2094
- editable: false,
2095
- }),
2096
- accept: Literal('image/png, image/jpeg', {
2097
- label: 'Accepted file types',
2098
- editable: false,
2099
- required: true,
2100
- }),
2101
- required: Literal(true, {
2102
- label: 'Required',
2103
- editable: false,
2104
- required: true,
2105
- }),
2106
- },
2107
- })
2108
-
2109
- attrs.input = {
2110
- 'upload-step': uploadInput,
2111
- 'avatar-step': avatarInput,
2112
- 'contact-validation-step': {
2113
- ...sharedInput,
2114
- check: value => {
2115
- const [inputValues] = Object.values(value)
2116
- isntObjectOrIsEmpty(inputValues)
2117
- if (!inputValues.type || inputValues.type !== 'tel') {
2118
- throw Error(
2119
- '"type":"tel" property must be defined in the contact validation input.',
2120
- )
2121
- }
2122
- if (
2123
- inputValues.required !== undefined &&
2124
- typeof inputValues.required !== 'boolean'
2125
- ) {
2126
- throw Error('"required" property (if added) must be a true or false.')
2127
- }
2128
- if (
2129
- inputValues.label !== undefined &&
2130
- typeof inputValues.label !== 'string'
2131
- ) {
2132
- throw Error(
2133
- '"label" property (if added) must be a text. Example: "Phone contact"',
2134
- )
2135
- }
2136
- if (
2137
- inputValues.placeholder !== undefined &&
2138
- typeof inputValues.placeholder !== 'string'
2139
- ) {
2140
- throw Error(
2141
- '"placeholder" property (if added) must be a text. Example: "+333 33 33 33 33"',
2142
- )
2143
- }
2144
- if (
2145
- inputValues.format !== undefined &&
2146
- typeof inputValues.format !== 'string'
2147
- ) {
2148
- throw Error(
2149
- '"format" property (if added) must be a text. Example: "Required format: +33 7 17 17 17 17"',
2150
- )
2151
- }
2152
- if (inputValues.pattern !== undefined) {
2153
- if (typeof inputValues.pattern !== 'string') {
2154
- throw Error(
2155
- '"pattern" property (if added) must be a text and a valid regex. Example: "[+][0-9]{3}[0-9]{2}[0-9]{2}[0-9]{2}[0-9]{2}"',
2156
- )
2157
- }
2158
- try {
2159
- return new RegExp(value)
2160
- } catch {
2161
- throw Error('Invalid Regular expression.')
2162
- }
2163
- }
2164
- },
2165
- },
2166
- }
2167
-
2168
- const getInScope = ({ attrs }) => {
2169
- const now = Date.now()
2170
- return now > attrs.scopeStart && now < attrs.scopeEnd
2171
- }
2172
- const getProjectInScope = ({ attrs }) => attrs.hasStarted
2173
- const getExamExerciseInScope = ({ parent }) => parent.attrs.inScope
2174
- const getExerciseInScope = ({ parent, attrs }) => {
2175
- if (parent.attrs.inScope) {
2176
- if (isHackathon(parent)) {
2177
- const now = Date.now()
2178
- return now > attrs.start && now < attrs.duration * DAY + attrs.start
2179
- }
2180
- return true
2181
- }
2182
- // when is it that bonus is always inScope? should be open if prev ex are done no?
2183
- return parent.attrs.hasStarted && attrs.category === 'bonus'
2184
- }
2185
- // this should never be editable!
2186
- const sharedInScope = {
2187
- label: 'Reward scope',
2188
- required: true,
2189
- type: 'boolean',
2190
- }
2191
- const eventInScope = {
2192
- ...sharedInScope,
2193
- private: true,
2194
- label: 'Active scope (distinct from temporal-window)',
2195
- ...Functions({ 'from start date to end date': getInScope }), // TODO: check it is not an issue for raids - reward after end of event
2196
- }
2197
- // NB: it's a boolean but it is more understandable if it's described as a duration
2198
- // if we decide it is editable one day, we should just add 2 fn: always and never
2199
- // instead of add editable: true
2200
- // 'always open': () => true,
2201
- // 'never open': () => false,
2202
- relationAttrs.inScope = {
2203
- // TODO: should it be defined for modules items also?
2204
- piscine: {
2205
- quest: {
2206
- ...sharedInScope,
2207
- label: 'Reward scope (temporal-window with extra duration)',
2208
- instruction:
2209
- 'Defines whether the content keeps giving the rewards or not.',
2210
- ...Functions({ 'from start day to end of extra duration': getInScope }),
2211
- },
2212
- raid: eventInScope,
2213
- exam: eventInScope,
2214
- project: {
2215
- ...sharedInScope,
2216
- ...Functions({ 'parent temporal-window': getProjectInScope }),
2217
- },
2218
- },
2219
- // TODO: find a way to make it clear for hackathon exercise scope
2220
- quest: {
2221
- exercise: {
2222
- ...sharedInScope,
2223
- ...Functions({
2224
- 'parent temporal-window (or one by one in hackathon)':
2225
- getExerciseInScope,
2226
- }),
2227
- instruction:
2228
- 'Defines whether the content keeps giving the rewards or not.',
2229
- },
2230
- },
2231
- // needed?
2232
- exam: {
2233
- exercise: {
2234
- ...sharedInScope,
2235
- private: true,
2236
- ...Functions({ 'parent scope': getExamExerciseInScope }),
2237
- },
2238
- },
2239
- module: {
2240
- project: {
2241
- ...sharedInScope,
2242
- ...Functions({ 'parent scope': getProjectInScope }),
2243
- },
2244
- exam: eventInScope,
2245
- },
2246
- }
2247
-
2248
- const sharedLanguage = Literal('', {
2249
- label: 'Programming language',
2250
- instruction: 'Example: "js", "go", "rust"',
2251
- editable: true,
2252
- restrictive: true,
2253
- })
2254
- attrs.language = {
2255
- exam: sharedLanguage,
2256
- exercise: sharedLanguage,
2257
- raid: sharedLanguage,
2258
- project: sharedLanguage,
2259
- }
2260
-
2261
- const getLevel = ({ parent, attrs }) => {
2262
- if (parent && parent.type === 'exam') return parent.attrs.level
2263
-
2264
- const totalScore = attrs.difficulty + attrs.xpIndex
2265
- const match = LEVELS.find(lvl => lvl.xpIndex > totalScore)
2266
- return match ? match.level : 0
2267
- }
2268
- const sharedLevel = Literal(1, {
2269
- label: 'Level',
2270
- required: true,
2271
- editable: true,
2272
- editableDefaultValue: 1,
2273
- ...Functions({ 'minimum required': getLevel }),
2274
- options: arrayOf(MAX_LEVEL, 1),
2275
- check: value => checkIntegerInBetween(value, 1, MAX_LEVEL),
2276
- })
2277
- relationAttrs.level = {
2278
- module: { piscine: sharedLevel, exam: sharedLevel, project: sharedLevel },
2279
- piscine: {
2280
- quest: sharedLevel,
2281
- raid: sharedLevel,
2282
- exam: sharedLevel,
2283
- project: sharedLevel,
2284
- },
2285
- quest: { exercise: sharedLevel },
2286
- exam: { exercise: sharedLevel },
2287
- // TODO: TO RM - HERE ONLY TO COMPARE CAMPUS BEFORE AND AFTER REL ATTRS IMPLEM
2288
- campus: { piscine: sharedLevel },
2289
- }
2290
-
2291
- types.objectChildRelativePath = Literal('./', {
2292
- label: 'Content relative path',
2293
- instruction: 'As a child of this content',
2294
- editable: true,
2295
- check: getObjectFromRelativePath,
2296
- options: object =>
2297
- Object.entries(object.children || {})
2298
- .sort(byValueIdx)
2299
- .map(([key, _]) => `./${key}`),
2300
- })
2301
-
2302
- const checkRelativePaths = (objects, object) => {
2303
- if (!Array.isArray(objects) || !objects.length) {
2304
- const error = new Error('Must be a non empty array')
2305
- error.userFeedback =
2306
- 'This list cannot be empty! Please add an item or remove the setting.'
2307
- throw error
2308
- }
2309
- const uniques = [...new Set(objects)]
2310
- if (objects.length !== uniques.length) {
2311
- throw Error('Duplicates are not allowed.')
2312
- }
2313
-
2314
- const invalidObjectsRequirements = objects.filter(path => {
2315
- const objectFromPath = getObjectFromRelativePath(path, object, {
2316
- throwError: false,
2317
- })
2318
- return !objectFromPath
2319
- })
2320
-
2321
- if (invalidObjectsRequirements.length) {
2322
- const paths = invalidObjectsRequirements.map(p => `'${p}'`).join(', ')
2323
- const error = new Error(
2324
- `Invalid objects requirements - no object found for the following relative paths: ${paths}`,
2325
- )
2326
- error.userFeedback = 'Some Contents are misconfigured, please update them!'
2327
- console.error(error.message)
2328
- throw error
2329
- }
2330
- }
2331
-
2332
- types.objectRootRelativePath = Literal('../', {
2333
- label: 'Content relative path (Mandatory)',
2334
- instruction: 'In same parent',
2335
- editable: true,
2336
- check: (path, object) => {
2337
- try {
2338
- getObjectFromRelativePath(path, object)
2339
- } catch (err) {
2340
- err.userFeedback = `This content relative path (${path}) is invalid, please select a valid content in the list below or remove it.`
2341
- throw err
2342
- }
2343
- },
2344
- options: object => {
2345
- if (!object?.parent?.children) return []
2346
- const entries = Object.entries(object.parent.children)
2347
- const options = entries.filter(([k]) => k !== object.key)
2348
- return options.map(([key]) => `../${key}`)
2349
- },
2350
- })
2351
-
2352
- // NOTE: objects requirements is declared here
2353
- types.sharedObjectList = {
2354
- label: 'Contents required', // synonyms: item, material
2355
- editable: true,
2356
- check: (objects, object) => {
2357
- // objects are split into alternative paths or mandatory paths
2358
- // for the check we will just flat the array so that we check every path the same way
2359
- checkRelativePaths(objects.flat(), object)
2360
- },
2361
- }
2362
-
2363
- const sharedRequirements = {
2364
- editable: true,
2365
- label: 'Access conditions',
2366
- }
2367
-
2368
- const contentRequirements = {
2369
- ...sharedRequirements,
2370
- instruction: 'Conditions to access this content',
2371
- description:
2372
- 'Sets the requirements that have to be met for a content to be accessible to a student.',
2373
- check: (requirements, object) => {
2374
- const { skills, objects, ...rest } = requirements
2375
- if (Object.keys(rest).length) {
2376
- throw Error(`Unsupported attribute "${Object.keys(rest)[0]}"`)
2377
- }
2378
- if (!skills && !objects) {
2379
- throw Error('Empty requirements, should be removed')
2380
- }
2381
-
2382
- const invalidObjectsRequirements = (objects || []).flat().filter(path => {
2383
- const objectFromPath = getObjectFromRelativePath(path, object, {
2384
- throwError: false,
2385
- })
2386
- return !objectFromPath
2387
- })
2388
-
2389
- if (invalidObjectsRequirements.length) {
2390
- const paths = invalidObjectsRequirements.map(p => `'${p}'`).join(', ')
2391
- const error = new Error(
2392
- `Invalid objects requirements - no object found for the following relative paths: ${paths}`,
2393
- )
2394
- error.userFeedback =
2395
- 'You have some misconfigured Contents required, please update them!'
2396
- console.error(error.message)
2397
- throw error
2398
- }
2399
- },
2400
- }
2401
-
2402
- const levelRequirements = {
2403
- ...sharedRequirements,
2404
- instruction: 'Conditions to unlock and earn this level',
2405
- description:
2406
- 'Sets the requirements that have to be met for a level to be unlocked and earned by a student.',
2407
- check: requirements => {
2408
- const { skills, objects, ...rest } = requirements
2409
- if (Object.keys(rest).length) {
2410
- throw Error(`Unsupported attribute "${Object.keys(rest)[0]}"`)
2411
- }
2412
- if (!skills && !objects) {
2413
- throw Error('Empty requirements, should be removed')
2414
- }
2415
- },
2416
- }
2417
-
2418
- // NOTE: does level definition support the multiple pathways??
2419
- // i think it should not for now, the multiple pathways was defined for the students to get to a content throughout different ways
2420
- // not for the level be defined different ways
2421
- types.levelDefinition = TypeObject({
2422
- label: 'Level definition',
2423
- type: {
2424
- level: Literal(MAX_LEVEL, {
2425
- label: 'Level',
2426
- editable: true,
2427
- required: true,
2428
- primary: true,
2429
- // to put max level first in options - could also be [MAX_LEVEL, ...arrayOf(MAX_LEVEL -1, 1)]
2430
- options: arrayOf(MAX_LEVEL, 1).reverse(),
2431
- check: (level, object) => {
2432
- checkIntegerInBetween(level, 1, MAX_LEVEL)
2433
-
2434
- const definitionsWithSameLevel = object.attrs.levelsDefinitions?.filter(
2435
- levelDefinition => levelDefinition.level === level,
2436
- )
2437
- if (definitionsWithSameLevel?.length > 1) {
2438
- throw Error(
2439
- `Level ${level} is set for different levels definitions! A given level can only be defined once.`,
2440
- )
2441
- }
2442
- },
2443
- }),
2444
- requirements: TypeObject({
2445
- ...levelRequirements,
2446
- type: {
2447
- skills: {
2448
- ...skillsList,
2449
- label: 'Skills required',
2450
- instruction: 'Expertises required',
2451
- },
2452
- objects: {
2453
- ...types.sharedObjectList,
2454
- value: (...args) => {
2455
- const option = types.objectChildRelativePath.options(...args)?.[0]
2456
- return option ? [option] : []
2457
- },
2458
- type: [types.objectChildRelativePath],
2459
- instruction: 'Items to be succeeded',
2460
- },
2461
- },
2462
- instruction: 'Conditions to access this level',
2463
- }),
2464
- },
2465
- })
2466
- const sharedLevelsDefinitions = {
2467
- label: 'Level requirements',
2468
- instruction:
2469
- 'Set the requirements and conditions for the user to access specific levels',
2470
- value: (...args) => [
2471
- mapValues(types.levelDefinition.type, subDef =>
2472
- getDefaultValue(subDef, ...args),
2473
- ),
2474
- ],
2475
- editable: true,
2476
- restrictive: true,
2477
- type: [types.levelDefinition],
2478
- check: levelsDefinitions => {
2479
- if (!levelsDefinitions?.length || !Array.isArray(levelsDefinitions)) {
2480
- throw Error('Must be a non empty array')
2481
- }
2482
- },
2483
- }
2484
- attrs.levelsDefinitions = {
2485
- module: sharedLevelsDefinitions,
2486
- // TODO: solve bug on piscine: required objects that already started
2487
- // (quests, exams, etc.) always have the status as `succeeded`
2488
- // in the reward service when getting the object with `getObject`
2489
- // piscine: sharedLevelsDefinitions,
2490
- }
2491
-
2492
- // TODO: define the type when html attributes handled/our css handled
2493
- // as all other attributes wanted for the link can be added (e.g. target="_blank")
2494
- attrs.link = {
2495
- 'sign-step': TypeObject({
2496
- label: 'Associated link to the document',
2497
- editable: true,
2498
- type: {
2499
- href: Literal('', {
2500
- required: true,
2501
- editable: true,
2502
- label: 'URL',
2503
- instruction: 'Href HTML attribute',
2504
- check: value => {
2505
- if (value.length) {
2506
- checkValidURL(value)
2507
- }
2508
- },
2509
- }),
2510
- label: Literal('> Link to the document', {
2511
- editable: true,
2512
- label: 'Label / text displayed',
2513
- }),
2514
- target: Literal('_blank', {
2515
- label: 'Target HTML attribute',
2516
- instruction: 'Open the link in a new browser tab',
2517
- }),
2518
- },
2519
- ...translatable, // in case there are different versions of the doc to dl
2520
- }),
2521
- 'avatar-step': TypeObject({
2522
- label: 'Link to the legal page',
2523
- editable: false,
2524
- required: true,
2525
- value: {
2526
- href: '/legal',
2527
- label: '> Privacy policy',
2528
- target: '_blank',
2529
- },
2530
- type: {
2531
- href: Literal('/legal', {
2532
- editable: false,
2533
- }),
2534
- label: Literal('> Privacy policy', {
2535
- editable: false,
2536
- }),
2537
- target: Literal('_blank', {
2538
- editable: false,
2539
- }),
2540
- },
2541
- }),
2542
- }
2543
-
2544
- relationAttrs.mandatory = {
2545
- module: {
2546
- project: {
2547
- label: 'Mandatory content to validate the curriculum',
2548
- type: 'boolean',
2549
- required: false,
2550
- editable: true,
2551
- },
2552
- },
2553
- }
2554
-
2555
- // TODO: should be required for exam exercises.
2556
- // But it is about to be refactored to define group base on exercise level?
2557
- const maxGroupReducer = (t, c) => Math.max(c.attrs.group || 1, t) // added '|| 1' as group is not required and could be undefined
2558
- const getMaxExamGroup = object => _children(object).reduce(maxGroupReducer, 1)
2559
- attrs.maxGroup = {
2560
- exam: {
2561
- type: 'number',
2562
- label: 'Max exercise group',
2563
- private: true, // interesting only in a context of an event
2564
- required: true,
2565
- ...Functions({ 'biggest exercise group of the exam': getMaxExamGroup }),
2566
- },
2567
- }
2568
-
2569
- // TODO: mv handling of name in displayedName attribute
2570
- const sharedName = Literal('', {
2571
- label: 'Name',
2572
- editable: true,
2573
- ...translatable,
2574
- })
2575
- const attrsNameObj = {}
2576
- for (const type of onboardingTypes) {
2577
- if (type !== 'avatar-step') {
2578
- attrsNameObj[type] = sharedName
2579
- }
2580
- }
2581
- attrs.name = {
2582
- signup: sharedName,
2583
- ...attrsNameObj,
2584
- }
2585
-
2586
- const getParentType = ({ parent }) => parent?.type
2587
- const sharedParentType = {
2588
- type: 'string',
2589
- private: true,
2590
- required: true,
2591
- label: 'Upper content type',
2592
- ...Functions({ 'from parent': getParentType }),
2593
- }
2594
- // TODO: define the one we need to keep?
2595
- relationAttrs.parentType = {
2596
- campus: {
2597
- // module: sharedParentType,
2598
- piscine: sharedParentType,
2599
- },
2600
- module: {
2601
- piscine: sharedParentType,
2602
- exam: sharedParentType,
2603
- project: sharedParentType,
2604
- },
2605
- piscine: {
2606
- quest: sharedParentType,
2607
- exam: sharedParentType,
2608
- raid: sharedParentType,
2609
- project: sharedParentType,
2610
- },
2611
- quest: { exercise: sharedParentType },
2612
- exam: { exercise: sharedParentType },
2613
- }
2614
-
2615
- types.rankDefinitionName = Literal('', {
2616
- label: 'Name',
2617
- editable: true,
2618
- required: true,
2619
- primary: true,
2620
- check: (name, object) => {
2621
- // checkNotEmpty(name): have to be commented or it's impossible
2622
- // to add a new rank to the ranks definition, as default value is ''
2623
- // and check is done before add
2624
- const definitionsWithSameName = object.attrs.ranksDefinitions?.filter(
2625
- rankDefinition => rankDefinition.name === name,
2626
- )
2627
- if (definitionsWithSameName?.length > 1) {
2628
- throw Error(
2629
- `Name "${name}" is already set for a rank definition! A given name can only be attributed once to a rank.`,
2630
- )
2631
- }
2632
- },
2633
- })
2634
-
2635
- types.rankDefinition = TypeObject({
2636
- label: 'Rank requirements',
2637
- type: {
2638
- name: types.rankDefinitionName,
2639
- level: Literal(MAX_LEVEL, {
2640
- label: 'Level required',
2641
- editable: true,
2642
- required: true,
2643
- // to put max level first in options - could also be [MAX_LEVEL, ...arrayOf(MAX_LEVEL -1, 0)]
2644
- check: (level, object) => {
2645
- checkIntegerInBetween(level, 0, MAX_LEVEL)
2646
-
2647
- const definitionsWithSameLevel = object.attrs.ranksDefinitions?.filter(
2648
- rankDefinition => rankDefinition.level === level,
2649
- )
2650
- if (definitionsWithSameLevel?.length > 1) {
2651
- throw Error(
2652
- `Level ${level} is already set for a rank definition! A given level can only be attributed once to a rank.`,
2653
- )
2654
- }
2655
- },
2656
- }),
2657
- milestone: Literal('', {
2658
- label: 'Milestone description',
2659
- editable: true,
2660
- }),
2661
- },
2662
- })
2663
- const sharedRanksDefinitions = {
2664
- label: 'Rank requirements',
2665
- instruction: 'List of ranks access conditions',
2666
- editable: true,
2667
- restrictive: true,
2668
- value: (...args) => [
2669
- mapValues(types.rankDefinition.type, subDef =>
2670
- getDefaultValue(subDef, ...args),
2671
- ),
2672
- ],
2673
- type: [types.rankDefinition],
2674
- check: ranksDefinitions => {
2675
- if (!ranksDefinitions?.length || !Array.isArray(ranksDefinitions)) {
2676
- throw Error('Must be a non empty array')
2677
- }
2678
- },
2679
- }
2680
-
2681
- attrs.ranksDefinitions = {
2682
- module: sharedRanksDefinitions,
2683
- piscine: sharedRanksDefinitions,
2684
- }
2685
-
2686
- const sharedRegistrationDuration = {
2687
- label: 'Registration duration (to an event)',
2688
- required: true,
2689
- editable: true,
2690
- check: duration => checkDurationInBetween(duration * MIN, 1 * MIN, 365 * DAY), // 1 min to 1 years (+1 extra day)
2691
- }
2692
- attrs.registrationDuration = {
2693
- exam: Literal((1.5 * DAY) / MIN, sharedRegistrationDuration),
2694
- module: Literal((30 * DAY) / MIN, sharedRegistrationDuration),
2695
- interview: Literal((2 * WEEK) / MIN, sharedRegistrationDuration),
2696
- piscine: Literal((30 * DAY) / MIN, sharedRegistrationDuration),
2697
- raid: Literal((1.5 * DAY) / MIN, sharedRegistrationDuration),
2698
- }
2699
-
2700
- const GIT_URL = `https://${process.env.DOMAIN}/git/`
2701
- const getRepositoryURL = ({ attrs }) => `${GIT_URL}${attrs.repositoryPath}`
2702
- const sharedRepository = {
2703
- label: 'Repository URL',
2704
- type: 'string',
2705
- required: true,
2706
- private: true,
2707
- ...Functions({ 'from gitea user account': getRepositoryURL }),
2708
- }
2709
- attrs.repository = {
2710
- exercise: sharedRepository,
2711
- project: sharedRepository,
2712
- raid: sharedRepository,
2713
- }
2714
-
2715
- const getCaptainLogin = ({ group, parent }) => {
2716
- group || (group = parent?.group)
2717
- return group && (group.captainLogin || group.captain?.login)
2718
- }
2719
- const getRepositoryPath = ({ name, group, parent }, user) =>
2720
- `${getCaptainLogin({ group, parent }) || user.login}/${name}`
2721
- const getExerciseRepositoryPath = ({ attrs, parent, group }, user) => {
2722
- const captainLogin = getCaptainLogin({ parent, group })
2723
- return captainLogin
2724
- ? `${captainLogin}/${parent.path.replace(/\/+/g, '-')}`
2725
- : `${user.login}/${attrs.rootName}`
2726
- }
2727
- const sharedRepositoryPath = {
2728
- label: 'Repository path',
2729
- type: 'string',
2730
- required: true,
2731
- private: true, // ??
2732
- ...Functions({ 'from captain': getRepositoryPath }),
2733
- }
2734
- attrs.repositoryPath = {
2735
- exercise: {
2736
- ...sharedRepositoryPath,
2737
- ...Functions({ 'from captain': getExerciseRepositoryPath }),
2738
- },
2739
- project: sharedRepositoryPath,
2740
- raid: sharedRepositoryPath,
2741
- }
2742
-
2743
- attrs.requiredAuditRatio = {
2744
- project: Literal(0.5, {
2745
- label: 'Audit ratio required to begin the project',
2746
- instruction: 'From 0 to 2 (1 being the perfect balance of audits)',
2747
- required: true,
2748
- editable: true,
2749
- options: [...Array(21)].map((_, i) => Math.round(i * 0.1 * 10) / 10),
2750
- check: value => checkNumberInBetween(value, 0, 2),
2751
- }),
2752
- }
2753
-
2754
- types.pathwaysRequirementObjects = {
2755
- label: 'Multiple content choices',
2756
- instruction:
2757
- 'Adding this will create a new path way for the project being edit.',
2758
- check: (objects, object) => {
2759
- // objects are split into alternative paths or mandatory paths
2760
- // for the check we will just flat the array so that we check every path the same way
2761
- checkRelativePaths(objects.flat(), object)
2762
- },
2763
- required: false,
2764
- editable: true,
2765
- type: [
2766
- {
2767
- ...types.objectRootRelativePath,
2768
- label: 'Content relative path (optional)',
2769
- },
2770
- ],
2771
- value: (...args) => {
2772
- const option = types.objectRootRelativePath.options(...args)?.[0]
2773
- return option ? [option] : []
2774
- },
2775
- }
2776
-
2777
- // NOTE: relation attribute requirements objects
2778
- const sharedContentRequirementsForMainAttr = TypeObject({
2779
- ...contentRequirements,
2780
- type: {
2781
- skills: {
2782
- ...skillsList,
2783
- label: 'Skills required',
2784
- instruction: 'Necessary skill level to unlock the current content',
2785
- description:
2786
- 'Define the skills and tier required to unlock the current content. The skill tracker button opens a modal where you can visualize the contents that reward the selected skill',
2787
- },
2788
- objects: {
2789
- ...types.sharedObjectList,
2790
- value: (...args) => {
2791
- const option = types.objectRootRelativePath.options(...args)?.[0]
2792
- const pathways = types.pathwaysRequirementObjects.value(...args)
2793
- return option ? [option, pathways] : []
2794
- },
2795
- type: [types.objectRootRelativePath, types.pathwaysRequirementObjects],
2796
- instruction: 'Content required to unlock the current one',
2797
- },
2798
- },
2799
- })
2800
- relationAttrs.requirements = {
2801
- module: {
2802
- project: sharedContentRequirementsForMainAttr,
2803
- piscine: sharedContentRequirementsForMainAttr,
2804
- },
2805
- campus: {
2806
- piscine: {
2807
- // TODO: once we start the build the new feature it will no longer be hidden
2808
- hidden: true,
2809
- ...sharedContentRequirementsForMainAttr,
2810
- },
2811
- module: {
2812
- hidden: true,
2813
- ...sharedContentRequirementsForMainAttr,
2814
- },
2815
- },
2816
- // should be open to any content: implementation to be checked when needed
2817
- // TODO: add exercise, raid, exam, quest
2818
- }
2819
-
2820
- const sharedResume = Literal('', {
2821
- label: 'Resume',
2822
- editable: true,
2823
- ...translatable,
2824
- })
2825
- attrs.resume = {
2826
- 'module-registration': sharedResume,
2827
- 'piscine-registration': sharedResume,
2828
- 'form-step': sharedResume,
2829
- interview: sharedResume,
2830
- }
2831
-
2832
- const getBaseSkills = ({ attrs }) => attrs.baseSkills
2833
- const getBaseXp = ({ attrs }) => attrs.baseXp
2834
- const getScopedBaseXp = ({ attrs }) => (attrs.inScope ? attrs.baseXp : 0)
2835
- types.skills = {
2836
- ...Functions({ 'collect skills': getBaseSkills }),
2837
- label: 'Skills',
2838
- required: true,
2839
- }
2840
- const sharedRewards = {
2841
- private: true,
2842
- required: true,
2843
- label: 'Reward conditions',
2844
- instruction: 'Conditions to collect xp and skills when succeeded',
2845
- }
2846
- const rewardsOfSkillsAndXpInScope = TypeObject({
2847
- type: {
2848
- skills: types.skills,
2849
- xp: {
2850
- ...Functions({ 'collect xp in scope': getScopedBaseXp }),
2851
- label: 'XP',
2852
- required: true,
2853
- },
2854
- },
2855
- ...sharedRewards,
2856
- })
2857
- relationAttrs.rewards = {
2858
- piscine: {
2859
- raid: TypeObject({
2860
- type: {
2861
- skills: types.skills,
2862
- xp: {
2863
- ...Functions({ 'collect xp': getBaseXp }),
2864
- label: 'XP',
2865
- required: true,
2866
- },
2867
- },
2868
- ...sharedRewards,
2869
- }),
2870
- project: rewardsOfSkillsAndXpInScope,
2871
- },
2872
- module: {
2873
- piscine: TypeObject({
2874
- type: {
2875
- skills: types.skills,
2876
- xp: {
2877
- ...Functions({ 'collect xp': getBaseXp }),
2878
- label: 'XP',
2879
- required: true,
2880
- },
2881
- },
2882
- ...sharedRewards,
2883
- }),
2884
- project: rewardsOfSkillsAndXpInScope,
2885
- },
2886
- // TODO: TO RM - HERE ONLY TO COMPARE CAMPUS BEFORE AND AFTER REL ATTRS IMPLEM
2887
- campus: {
2888
- piscine: TypeObject({
2889
- type: {
2890
- skills: types.skills,
2891
- xp: {
2892
- ...Functions({ 'collect xp': getBaseXp }),
2893
- label: 'XP',
2894
- required: true,
2895
- },
2896
- },
2897
- ...sharedRewards,
2898
- }),
2899
- },
2900
- quest: { exercise: rewardsOfSkillsAndXpInScope },
2901
- exam: { exercise: rewardsOfSkillsAndXpInScope },
2902
- }
2903
- const rewardParent = ({ parent }) => {
2904
- // cannot keep all parent or get an infinite loop
2905
- const { attrs, name, type, id, path } = parent
2906
- // keep a classical object structure (as we would retrieve it with path feature)
2907
- return type !== 'campus'
2908
- ? { attrs: { eventId: attrs.eventId }, name, type, id, path }
2909
- : undefined
2910
- }
2911
- relationAttrs.rewardsTarget = {
2912
- module: {
2913
- piscine: {
2914
- type: 'object',
2915
- label: 'Target of the rewards',
2916
- instruction: 'Content to which rewards are attributed',
2917
- required: true,
2918
- ...Functions({ 'parent event if exists': rewardParent }),
2919
- },
2920
- },
2921
- }
2922
-
2923
- const getRootName = ({ parent }) => {
2924
- while (parent) {
2925
- if (parent.type === 'module' || parent.type === 'piscine') return parent.key
2926
- parent = parent.parent
2927
- }
2928
- return ''
2929
- }
2930
- const sharedRootName = {
2931
- type: 'string',
2932
- // TODO: verify if ok: useless to be seen by admin in object edit,
2933
- // but is needed in front end; can be private?
2934
- private: true,
2935
- ...Functions({ 'root content key': getRootName }),
2936
- required: true,
2937
- label: 'Root key',
2938
- }
2939
- relationAttrs.rootName = {
2940
- quest: { exercise: sharedRootName },
2941
- exam: { exercise: sharedRootName },
2942
- module: { project: sharedRootName },
2943
- piscine: { project: sharedRootName, raid: sharedRootName },
2944
- }
2945
-
2946
- const getRootPath = ({ parent }) => {
2947
- while (parent) {
2948
- if (parent.type === 'module' || parent.type === 'piscine') {
2949
- return parent.path
2950
- }
2951
- parent = parent.parent
2952
- }
2953
- }
2954
- const sharedRootPath = {
2955
- type: 'string',
2956
- required: true,
2957
- private: true,
2958
- ...Functions({ 'root content path': getRootPath }),
2959
- label: 'Root path',
2960
- }
2961
- relationAttrs.rootPath = {
2962
- module: {
2963
- project: sharedRootPath,
2964
- piscine: sharedRootPath, // module rootPath needed
2965
- exam: sharedRootPath,
2966
- },
2967
- piscine: {
2968
- project: sharedRootPath,
2969
- raid: sharedRootPath,
2970
- exam: sharedRootPath,
2971
- quest: sharedRootPath,
2972
- },
2973
- quest: { exercise: sharedRootPath },
2974
- exam: { exercise: sharedRootPath },
2975
- }
2976
-
2977
- const questScopeEnd = object =>
2978
- getQuestExtraEndAt(object.attrs.scopeStart, object)
2979
- const getEventScopeEnd = ({ event }) => event && numTime(event.endAt)
2980
- // NB: Used for the timer in the front
2981
- const sharedScopeEnd = {
2982
- type: 'number',
2983
- required: true,
2984
- private: true, // not necessary: admins already see temporal-window and reward scope
2985
- }
2986
- const eventScopeEnd = {
2987
- ...sharedScopeEnd,
2988
- label: 'Active scope end',
2989
- ...Functions({ 'when event ends': getEventScopeEnd }),
2990
- }
2991
- const extraDurationScopeEnd = {
2992
- ...sharedScopeEnd,
2993
- label: 'Reward scope end',
2994
- ...Functions({ 'when extra duration ends': questScopeEnd }),
2995
- }
2996
- relationAttrs.scopeEnd = {
2997
- piscine: {
2998
- raid: eventScopeEnd,
2999
- // rm? (for exam) questScopeEnd was applied (which makes no sense)
3000
- // so I don't think we use it. Maybe we don't need inScope/startScope either then
3001
- // also it seems that timer is used only in ProjectView and QuestView
3002
- exam: eventScopeEnd,
3003
- quest: extraDurationScopeEnd,
3004
- project: extraDurationScopeEnd,
3005
- },
3006
- // TODO: TO RM - HERE ONLY TO COMPARE CAMPUS BEFORE AND AFTER REL ATTRS IMPLEM
3007
- module: { project: extraDurationScopeEnd, exam: eventScopeEnd },
3008
- }
3009
-
3010
- const sharedScopeExtraDuration = Literal(0, {
3011
- label: 'Extra duration (for reward scope)',
3012
- editable: true,
3013
- check: duration => checkDurationInBetween(duration * DAY, 0, 50 * DAY), // TODO: maybe max should be another int
3014
- })
3015
-
3016
- relationAttrs.scopeExtraDuration = {
3017
- piscine: {
3018
- quest: sharedScopeExtraDuration,
3019
- },
3020
- }
3021
-
3022
- // TODO: this attribute only get the start attribute. Maybe we could rm it
3023
- const getScopeStart = ({ attrs }) => attrs.start
3024
- const sharedScopeStart = {
3025
- type: 'number',
3026
- required: true,
3027
- private: true, // not necessary - admin already see temporal-window and reward scope
3028
- ...Functions({ 'from start date': getScopeStart }),
3029
- }
3030
- const sharedEventScopeStart = {
3031
- ...sharedScopeStart,
3032
- label: 'Active scope start',
3033
- }
3034
- const temporalWindowScopeStart = {
3035
- ...sharedScopeStart,
3036
- label: 'Temporal-window & reward scope start',
3037
- }
3038
- relationAttrs.scopeStart = {
3039
- piscine: {
3040
- quest: temporalWindowScopeStart,
3041
- project: temporalWindowScopeStart,
3042
- raid: sharedEventScopeStart,
3043
- exam: sharedEventScopeStart,
3044
- },
3045
- module: { exam: sharedEventScopeStart, project: temporalWindowScopeStart },
3046
- }
3047
-
3048
- const getSpecial = ({ parent }) => parent?.attrs.special
3049
- attrs.special = {
3050
- quest: Literal(false, {
3051
- label: 'Activate hackathon mode',
3052
- editable: true,
3053
- restrictive: true,
3054
- }),
3055
- exercise: {
3056
- // not private because it helps the admin to identify the exercise as special/in hackathon mode
3057
- label: 'In hackathon mode',
3058
- type: 'boolean',
3059
- required: true, // To be sure it will always be there if the quest is special
3060
- ...Functions({ 'activated with the quest': getSpecial }),
3061
- },
3062
- }
3063
-
3064
- // getStart returns date in milliseconds
3065
- const getStart = object => {
3066
- const match = findParent(object, hasEvent)
3067
- const event = match?.event
3068
-
3069
- if (event) {
3070
- const time = numTime(event.startAt)
3071
- return match === object ? time : getQuestStartAt(time, object)
3072
- }
3073
- if (!object.parent) return
3074
- // TODO: rm this part? should always find a match no? what is the no case?
3075
- return getQuestStartAt(object.parent.attrs.start, object)
3076
- }
3077
- const sharedStart = {
3078
- label: 'Start date',
3079
- type: 'number',
3080
- required: true,
3081
- private: true, // all infos are defined in duration and inScope attributes
3082
- ...Functions({ 'from start date of event': getStart }),
3083
- }
3084
- const projectStart = {
3085
- // stay private - not really useful now, as access is defined by requirements & type (could be confusing otherwise)
3086
- ...sharedStart,
3087
- ...Functions({ 'starts with module': getStart }),
3088
- }
3089
- const getExerciseStart = ({ parent, attrs }) =>
3090
- parent?.attrs.start + attrs.delay
3091
- const exerciseStart = {
3092
- ...sharedStart,
3093
- ...Functions({
3094
- 'from temporal-window (if in hackathon mode)': getExerciseStart,
3095
- }),
3096
- }
3097
- relationAttrs.start = {
3098
- campus: {
3099
- module: sharedStart,
3100
- piscine: sharedStart,
3101
- },
3102
- module: {
3103
- exam: sharedStart,
3104
- piscine: sharedStart,
3105
- // TODO: rm? or could be used with temporal-window, if we define a delay (could be an option)
3106
- // NB: this attribute is used to calculate hasStarted and inScope attributes
3107
- project: projectStart,
3108
- },
3109
- piscine: {
3110
- raid: sharedStart,
3111
- exam: sharedStart,
3112
- // non event ones
3113
- quest: {
3114
- ...sharedStart,
3115
- ...Functions({ 'from temporal-window': getStart }),
3116
- },
3117
- project: projectStart,
3118
- },
3119
- quest: { exercise: exerciseStart },
3120
- // exam: { exercise: exerciseStart }, // needed?
3121
- }
3122
-
3123
- // TODO: should also be handled differently in the event service
3124
- // if no startAfter is defined, children events should not be pre-created.
3125
- const sharedStartAfter = Literal(0, {
3126
- label: 'Starts after a delay of (within the event)',
3127
- editable: true,
3128
- required: true,
3129
- // check: () => {
3130
- // check that if there is a prev event with startAfter, it is bigger number here
3131
- // },
3132
- })
3133
-
3134
- relationAttrs.startAfter = {
3135
- module: { piscine: sharedStartAfter, exam: sharedStartAfter },
3136
- piscine: { exam: sharedStartAfter, raid: sharedStartAfter },
3137
- // TODO: TO RM - HERE ONLY TO COMPARE CAMPUS BEFORE AND AFTER REL ATTRS IMPLEM
3138
- campus: { piscine: sharedStartAfter },
3139
- }
3140
-
3141
- // TODO: this should be defined in the relation
3142
- // this attribute is not editable!
3143
- // to change the startDay, admins should update the duration of the elements
3144
- const getStartDay = ({ prev }) =>
3145
- prev ? prev.attrs.duration + prev.attrs.startDay : 1
3146
- const getExamStartDay = ({ parent, prev }) =>
3147
- parent.type === 'piscine' ? getStartDay({ prev }) : undefined
3148
- const sharedStartDay = Literal(1, {
3149
- label: 'Start day n°',
3150
- // TODO: find a smaller name for the fn
3151
- ...Functions({ 'from end of previous content temporal-window': getStartDay }),
3152
- required: true,
3153
- private: true, // would be interesting to show only in an event context
3154
- // some more infos internally:
3155
- // This attribute is used for :
3156
- // → selection piscine's graph UI: startDay is not used directly in the graph but to calculate the week attribute, to display each object - quest, raid or exam - on the corresponding branch/week it's happening
3157
- // → calendar view UI: display the duration of each object over the week (but currently the component is not in use)
3158
- // → stats: check if the object has begun inside the event to extract its stats\n→ object's attributes: calculation of the attributes scopeEnd, delay & week",
3159
- })
3160
- relationAttrs.startDay = {
3161
- piscine: {
3162
- exam: {
3163
- ...sharedStartDay,
3164
- ...Functions({
3165
- 'from end of previous content temporal-window': getExamStartDay,
3166
- }),
3167
- },
3168
- quest: sharedStartDay,
3169
- raid: sharedStartDay,
3170
- project: sharedStartDay,
3171
- },
3172
- }
3173
-
3174
- const getProgressStatus = progress => {
3175
- if (progress?.isDone) {
3176
- return progress.grade >= 1 ? 'succeeded' : 'failed'
3177
- }
3178
- }
3179
-
3180
- const examExerciseStatus = object => {
3181
- const { attrs, progress, parent } = object
3182
- const progressStatus = getProgressStatus(progress)
3183
- if (progressStatus) return progressStatus
3184
- if (!parent) return 'available'
3185
- if (parent.attrs.status === 'blocked') return 'blocked'
3186
-
3187
- const { group } = attrs
3188
- if (group <= 1) return parent.attrs.status
3189
- // check for previous exercises in the exam
3190
- // groups could be skipped due to potential misconfiguration,
3191
- // so we search for the closest previous group relative to the current group
3192
- const previousGroups = _children(parent).filter(c => c.attrs.group < group)
3193
- const maxGroup = Math.max(...previousGroups.map(c => c.attrs.group))
3194
- const prevValidated = previousGroups
3195
- .filter(c => c.attrs.group === maxGroup)
3196
- .some(c => c.attrs.status === 'succeeded')
3197
-
3198
- return prevValidated ? 'available' : 'blocked'
3199
- }
3200
- const questExerciseStatus = object => {
3201
- const { prev, attrs, progress, parent } = object
3202
- const progressStatus = getProgressStatus(progress)
3203
- if (progressStatus) return progressStatus
3204
- if (!parent) return 'available'
3205
- if (parent.attrs.status === 'blocked') return 'blocked'
3206
-
3207
- const now = Date.now()
3208
- if (attrs.special) {
3209
- if (now < attrs.start) return 'blocked'
3210
- if (now > attrs.duration + attrs.start) return 'failed'
3211
- return 'available'
3212
- }
3213
-
3214
- if (prev && prev.attrs.status !== 'succeeded') return 'blocked'
3215
- const tomorrow = Date.now() + DAY
3216
- if (
3217
- parent.prev?.type === 'project' &&
3218
- parent.prev?.attrs.status !== 'succeeded' &&
3219
- parent.prev?.attrs.scopeEnd > tomorrow
3220
- ) {
3221
- return 'blocked'
3222
- }
3223
-
3224
- const prevQuest = findPrev(parent.prev, isQuest)
3225
- if (
3226
- prevQuest &&
3227
- prevQuest.attrs.status !== 'succeeded' &&
3228
- prevQuest.attrs.scopeEnd > tomorrow
3229
- ) {
3230
- return 'blocked'
3231
- }
3232
-
3233
- return 'available'
3234
- }
3235
-
3236
- const isPathStatusSucceeded = (path, object) => {
3237
- try {
3238
- const obj = getObjectFromRelativePath(path, object)
3239
- return obj?.attrs.status === 'succeeded'
3240
- } catch {
3241
- // NOTE: should we make the requirement unblocked if the admin did not set the right requirement ????
3242
-
3243
- // consider the requirement unlocked if an error is thrown by getObjectFromRelativePath,
3244
- // because it would mean an admin wrongly set the requirement, probably manually in the db
3245
- // (it is not possible to set an invalid requirement from the admin configuration interface)
3246
- return true
3247
- }
3248
- }
3249
-
3250
- const addVersionChain = (object, path) => {
3251
- const objReq = object.parent.children[path.slice('../'.length)]
3252
- const versions = objReq?.versions?.map(({ key }) => `../${key}`)
3253
- return versions?.length ? [path, ...versions] : [path]
3254
- }
3255
-
3256
- /**
3257
- * @throws This function throws an error if any of the required objects is invalid
3258
- * @returns {bool} true if all the required objects are succeeded, false otherwise
3259
- * @params {any} A 01-object
3260
- */
3261
- const hasSucceededRequiredObjects = (requirements, object) => {
3262
- if (!requirements) return true
3263
- const { objects } = requirements
3264
- if (!objects || !objects.length) return true
3265
-
3266
- // include the old versions
3267
- const processedObjects = objects.map(p =>
3268
- Array.isArray(p)
3269
- ? p.flatMap(path => addVersionChain(object, path))
3270
- : addVersionChain(object, p),
3271
- )
3272
-
3273
- // required objects are the ones that need to be done to unblock the current object
3274
- const requiredObjects = processedObjects.filter(
3275
- p => !Array.isArray(p) && Boolean(p),
3276
- )
3277
- // pathway objects represent the choices a student can take to unblock the current object
3278
- // note: at least one object from each pathway must be successfully completed
3279
- const pathWayObjects = processedObjects?.filter(
3280
- p => Array.isArray(p) && Boolean(p),
3281
- )
3282
-
3283
- const hasSeccededRequiredObjects = requiredObjects.every(path =>
3284
- isPathStatusSucceeded(path, object),
3285
- )
3286
-
3287
- // it's possible to have a pathway with just one relative path
3288
- // in this case it will be considered as a required object
3289
- const hasSucceededPathWays = pathWayObjects?.every(paths =>
3290
- paths.some(path => isPathStatusSucceeded(path, object)),
3291
- )
3292
-
3293
- return hasSucceededPathWays !== undefined
3294
- ? hasSeccededRequiredObjects && hasSucceededPathWays
3295
- : hasSeccededRequiredObjects
3296
- }
3297
-
3298
- // check if the requirements are met for a given object
3299
- export const meetsRequirements = ({ requirements, object, user, progress }) => {
3300
- // check if there's already a progress, to not block students who began the project before implementing the requirements feature
3301
- if ((progress && Object.keys(progress).length) || !requirements) return true
3302
- // check if the required skills have been earned & the required objects have been succeeded
3303
- const hasSkills = hasRequiredSkills(requirements.skills, user.skills)
3304
- const hasSucceededObjects = hasSucceededRequiredObjects(requirements, object)
3305
-
3306
- return hasSucceededObjects && hasSkills
3307
- }
3308
-
3309
- const projectInPiscineStatus = object => {
3310
- const { prev, parent, attrs } = object
3311
- if (!parent.progress) return 'blocked'
3312
- if (!attrs.hasStarted) return 'blocked'
3313
- if (Date.now() < attrs.scopeEnd && notSucceeded(prev)) return 'blocked'
3314
-
3315
- return 'available'
3316
- }
3317
-
3318
- const projectStatus = (object, user) => {
3319
- const { attrs, progress } = object
3320
- const { requirements, parentType } = attrs
3321
-
3322
- // check first for existing progress
3323
- const progressStatus = getProgressStatus(progress)
3324
- if (progressStatus) return progressStatus
3325
-
3326
- // check if the object requirements are met
3327
- if (!meetsRequirements({ object, requirements, user, progress })) {
3328
- return 'blocked'
3329
- }
3330
-
3331
- // handle special case for project in piscine
3332
- if (parentType === 'piscine') return projectInPiscineStatus(object)
3333
- return 'available'
3334
- }
3335
-
3336
- const notSucceeded = quest =>
3337
- quest?.attrs.inScope && quest?.attrs.status !== 'succeeded'
3338
- const successStatus = children => {
3339
- for (const child of children) {
3340
- if (!isRequired(child)) continue
3341
- if (!child.progress) return 'failed'
3342
- const status = getProgressStatus(child.progress)
3343
- if (status && status !== 'succeeded') return status
3344
- }
3345
- return 'succeeded'
3346
- }
3347
-
3348
- const questStatus = object => {
3349
- const { prev, parent, attrs, progress } = object
3350
- if (!parent) return 'blocked'
3351
- if (!parent.progress) return 'blocked'
3352
- if (!attrs.hasStarted) return 'blocked'
3353
- if (
3354
- !attrs.special &&
3355
- Date.now() > attrs.scopeEnd &&
3356
- notSucceeded(findPrev(prev, isQuest))
3357
- ) {
3358
- return 'blocked'
3359
- }
3360
-
3361
- const children = Object.values(object.children || {})
3362
- return children.length
3363
- ? successStatus(children)
3364
- : getProgressStatus(progress) || 'available'
3365
- }
3366
-
3367
- const getMeetsRequirements = (object, user) => {
3368
- const { progress, attrs } = object
3369
- const { requirements } = attrs
3370
-
3371
- return meetsRequirements({ object, requirements, user, progress })
3372
- }
3373
- const getPiscineStatus = (object, user) => {
3374
- const { progress, event, attrs } = object
3375
- const { requirements } = attrs
3376
- const progressStatus = getProgressStatus(progress)
3377
- if (progressStatus) return progressStatus
3378
- if (
3379
- !meetsRequirements({ object, requirements, user, progress }) &&
3380
- !event?.registeredPosition // in case a learner was force added to a registration through hasura by an admin
3381
- ) {
3382
- return 'blocked'
3383
- }
3384
-
3385
- return 'available'
3386
- }
3387
-
3388
- const raidStatus = object => {
3389
- const { progress, event, attrs } = object
3390
- const progressStatus = getProgressStatus(progress)
3391
- if (progressStatus) return progressStatus
3392
-
3393
- if (!event) return 'blocked'
3394
- const now = Date.now()
3395
- if (now < attrs.start) return 'blocked'
3396
- // ! for some reason we are having problems setting the raid status blocked or failed
3397
- // ! when a user does not register to the raid it should render a view saying that he did not register in time
3398
- // ! but this is not the case here
3399
- // TODO : ^ fix the issue with the non registered users and the raid view
3400
- return 'available'
3401
- }
3402
-
3403
- const alwaysOpen = () => 'available'
3404
- const isWaitingEndOfEvent = (type, progress) =>
3405
- progress?.event &&
3406
- (type === 'interview' || type === 'piscine-registration') &&
3407
- new Date(progress.event.endAt) > Date.now()
3408
-
3409
- const onboardingStatus = object => {
3410
- const hasRelatedObject = object.type.endsWith('-registration')
3411
- const relatedObjectName = object.type.split('-')[0]
3412
- const relatedObject = hasRelatedObject
3413
- ? getChildByType(object.parent.parent.children, relatedObjectName)
3414
- : object
3415
-
3416
- if (!relatedObject) return 'blocked'
3417
- const progress = relatedObject.lastProgress
3418
- const progressStatus = getProgressStatus(progress)
3419
- if (
3420
- progressStatus === 'succeeded' &&
3421
- isWaitingEndOfEvent(object.type, relatedObject.lastProgress)
3422
- ) {
3423
- return 'available'
3424
- }
3425
- if (progressStatus) return progressStatus
3426
- if (
3427
- !object.prev ||
3428
- (object.prev && object.prev.attrs.status === 'succeeded')
3429
- ) {
3430
- return 'available'
3431
- }
3432
- return 'blocked'
3433
- }
3434
- const sharedOnboardingStepStatus = {
3435
- type: 'string',
3436
- label: 'Unlock system',
3437
- ...Functions({ 'previous step validated': onboardingStatus }), // old version on onboarding: step by step selection
3438
- options: ['blocked', 'available', 'succeeded', 'failed'],
3439
- required: true,
3440
- }
3441
- const sharedStatus = {
3442
- type: 'string',
3443
- label: 'Unlock system and result status',
3444
- required: true,
3445
- options: ['blocked', 'available', 'succeeded', 'failed'],
3446
- }
3447
- relationAttrs.status = {
3448
- campus: {
3449
- // TODO: could be rm for onboarding?
3450
- onboarding: {
3451
- type: 'string',
3452
- private: true,
3453
- required: true,
3454
- ...Functions({ 'always open': alwaysOpen }),
3455
- },
3456
- piscine: {
3457
- ...sharedStatus,
3458
- ...Functions({ 'by requirements and event': getPiscineStatus }),
3459
- },
3460
- },
3461
- onboarding: {
3462
- administration: sharedOnboardingStepStatus,
3463
- games: sharedOnboardingStepStatus,
3464
- 'piscine-registration': sharedOnboardingStepStatus,
3465
- 'module-registration': sharedOnboardingStepStatus,
3466
- interview: sharedOnboardingStepStatus,
3467
- },
3468
- piscine: {
3469
- exam: {
3470
- ...sharedStatus,
3471
- ...Functions({ 'by event': questStatus }),
3472
- },
3473
- quest: {
3474
- ...sharedStatus,
3475
- ...Functions({ 'by scope': questStatus }),
3476
- },
3477
- raid: {
3478
- ...sharedStatus,
3479
- ...Functions({ 'by event': raidStatus }),
3480
- },
3481
- project: {
3482
- ...sharedStatus,
3483
- editable: true,
3484
- editableDefaultValue: 'available',
3485
- ...Functions({ 'by requirements': projectStatus }),
3486
- },
3487
- },
3488
- quest: {
3489
- exercise: {
3490
- ...sharedStatus,
3491
- editable: true,
3492
- editableDefaultValue: 'available',
3493
- ...Functions({ 'one by one': questExerciseStatus }),
3494
- },
3495
- },
3496
- exam: {
3497
- exercise: {
3498
- ...sharedStatus,
3499
- editable: true,
3500
- editableDefaultValue: 'available',
3501
- ...Functions({
3502
- 'one by one (grouped by difficulty)': examExerciseStatus,
3503
- }),
3504
- },
3505
- },
3506
- module: {
3507
- project: {
3508
- ...sharedStatus,
3509
- editable: true,
3510
- editableDefaultValue: 'available',
3511
- ...Functions({ 'by requirements': projectStatus }),
3512
- },
3513
- piscine: {
3514
- ...sharedStatus,
3515
- ...Functions({ 'by requirements and event': getPiscineStatus }),
3516
- },
3517
- exam: {
3518
- ...sharedStatus,
3519
- ...Functions({ 'by event': questStatus }),
3520
- },
3521
- },
3522
- }
3523
-
3524
- types.meetRequirements = Literal(false, {
3525
- label: 'User meet requirement',
3526
- restrictive: true,
3527
- required: true,
3528
- hidden: true,
3529
- ...Functions({ 'by requirements': getMeetsRequirements }),
3530
- })
3531
- relationAttrs.meetsRequirements = {
3532
- module: {
3533
- piscine: types.meetRequirements,
3534
- project: types.meetRequirements,
3535
- },
3536
- }
3537
-
3538
- const getSubject = object => {
3539
- const path = `subjects/${getObjectPath(object)}/README.md`
3540
- return `/markdown/root/public/${path}`
3541
- }
3542
- const sharedSubject = Literal('', {
3543
- label: 'Subject URL',
3544
- editable: true,
3545
- required: true,
3546
- ...Functions({ 'README.md in subject folder': getSubject }),
3547
- // TODO: add a check? can return a relative path so it's complicated
3548
- })
3549
- attrs.subject = {
3550
- exercise: sharedSubject,
3551
- project: sharedSubject,
3552
- raid: sharedSubject,
3553
- }
3554
-
3555
- const sharedText = Literal('', { editable: true, ...translatable })
3556
-
3557
- types.teamworkRankName = Literal('', {
3558
- label: 'Rank name',
3559
- type: 'string',
3560
- editable: true,
3561
- required: true,
3562
- primary: true,
3563
- check: (name, object) => {
3564
- const { teamworkRanks } = object.attrs
3565
- const definitionsWithSameName = teamworkRanks?.filter(r => r.name === name)
3566
- if (definitionsWithSameName?.length > 1) {
3567
- throw Error(
3568
- `Name "${name}" is already set for a teamwork rank definition! A given name can only be attributed once to a rank.`,
3569
- )
3570
- }
3571
- },
3572
- })
3573
-
3574
- types.teamworkRankParticipations = Literal(0, {
3575
- label: 'Group Participations',
3576
- instruction:
3577
- 'The number of users the student has to work with, to unlock this rank.',
3578
- editable: true,
3579
- required: true,
3580
- options: arrayOf(150, 0),
3581
- type: 'number',
3582
- check: (groups, object) => {
3583
- if (!Number.isInteger(groups)) throw Error('Must be a whole number')
3584
- const definitionsWithSameLevel = object.attrs.teamworkRanks?.filter(
3585
- rankDefinition => rankDefinition.groups === groups,
3586
- )
3587
- if (definitionsWithSameLevel?.length > 1) {
3588
- throw Error(
3589
- `There is already a rank with ${groups} users required for the rank`,
3590
- )
3591
- }
3592
- },
3593
- })
3594
-
3595
- types.teamworkRanks = TypeObject({
3596
- type: {
3597
- name: types.teamworkRankName,
3598
- groups: types.teamworkRankParticipations,
3599
- },
3600
- })
3601
-
3602
- attrs.teamworkRanks = {
3603
- campus: {
3604
- label: 'Teamwork ranks',
3605
- instruction: 'List of teamwork ranks',
3606
- type: [types.teamworkRanks],
3607
- // this setting is required for practical UX reasons. It is not
3608
- // required for platform to work properly, but as there are no other
3609
- // settings for the campus, it avoid hiding it in "more settings to add"
3610
- // section and make it more visible/easy to configure for admins (as displayed by default)
3611
- required: true,
3612
- editable: true,
3613
- value: (...args) => [
3614
- mapValues(types.teamworkRanks.type, subDef =>
3615
- getDefaultValue(subDef, ...args),
3616
- ),
3617
- ],
3618
- check: teamworkRanks => {
3619
- if (!teamworkRanks?.length || !Array.isArray(teamworkRanks)) {
3620
- throw Error('Must be a non empty array')
3621
- }
3622
- },
3623
- },
3624
- }
3625
-
3626
- // TODO: rename it documentToSign, or something more specific than 'text'
3627
- attrs.text = {
3628
- 'upload-step': {
3629
- ...sharedText,
3630
- label: 'Resume', // TODO: mv it to attrs.resume
3631
- },
3632
- 'sign-step': {
3633
- ...sharedText,
3634
- label: 'Text to agree to',
3635
- required: true,
3636
- },
3637
- 'contact-validation-step': {
3638
- ...sharedText,
3639
- label: 'Resume', // TODO: mv it to attrs.resume
3640
- },
3641
- 'avatar-step': {
3642
- ...sharedText,
3643
- label: 'Resume', // TODO: mv it to attrs.resume
3644
- },
3645
- }
3646
-
3647
- types.timelineChunk = TypeObject({
3648
- label: 'Month guideline',
3649
- type: {
3650
- month: Literal(1, {
3651
- label: 'Month',
3652
- editable: true,
3653
- required: true,
3654
- primary: true,
3655
- check: (month, object) => {
3656
- checkIntegerInBetween(month, 1, 120)
3657
-
3658
- const definitionsWithSameMonth = object.attrs.timeline?.filter(
3659
- guideline => guideline.month === month,
3660
- )
3661
- if (definitionsWithSameMonth?.length > 1) {
3662
- throw Error(
3663
- `Month ${month} is set for different timeline guidelines! A given month can only be defined once.`,
3664
- )
3665
- }
3666
- },
3667
- }),
3668
- minLevel: Literal(0, {
3669
- label: 'Minimum level',
3670
- instruction: 'Minimum level to be considered on time',
3671
- editable: true,
3672
- required: true,
3673
- check: level => checkIntegerInBetween(level, 0, MAX_LEVEL),
3674
- }),
3675
- expectedLevel: Literal(0, {
3676
- label: 'Expected level',
3677
- editable: true,
3678
- required: true,
3679
- instruction: 'Recommended level the user should achieve',
3680
- check: level => checkIntegerInBetween(level, 0, MAX_LEVEL),
3681
- }),
3682
- checkpointLevel: Literal(0, {
3683
- label: 'Checkpoint level',
3684
- editable: true,
3685
- required: true,
3686
- instruction: 'Recommended checkpoint level the user should achieve',
3687
- check: level => checkIntegerInBetween(level, 0, 100),
3688
- }),
3689
- rank: Literal('', {
3690
- label: 'Rank',
3691
- editable: true,
3692
- instruction: 'Recommended rank the user should achieve',
3693
- options: object => object.attrs.ranksDefinitions?.map(item => item.name),
3694
- check: (rank, object) => {
3695
- if (!object.attrs.ranksDefinitions?.length) {
3696
- throw Error(
3697
- `Must match an existing rank name, but ranks definitions are not set.`,
3698
- )
3699
- }
3700
-
3701
- const ranksNames = object.attrs.ranksDefinitions?.map(
3702
- ({ name }) => name,
3703
- )
3704
- if (!ranksNames.includes(rank)) {
3705
- throw Error(
3706
- `Must match one of the following existing rank names: ${ranksNames.join(
3707
- ', ',
3708
- )}.`,
3709
- )
3710
- }
3711
- },
3712
- }),
3713
- skills: TypeObject({
3714
- label: 'Skills',
3715
- instruction: 'Recommended skills the user should get',
3716
- editable: true,
3717
- value: {},
3718
- type: mapValues(
3719
- skillsSet,
3720
- ({ name, type: instruction, description }) => ({
3721
- label: name,
3722
- instruction,
3723
- description,
3724
- editable: true,
3725
- type: 'number',
3726
- value: 1,
3727
- options: arrayOf(100, 1),
3728
- check: amount => checkIntegerInBetween(amount, 1, 100),
3729
- }),
3730
- ),
3731
- }),
3732
- notes: Literal('', {
3733
- label: 'Other notes',
3734
- instruction:
3735
- 'Write any other notes or expectations the user should aim for',
3736
- editable: true,
3737
- }),
3738
- },
3739
- })
3740
- const sharedTimeline = {
3741
- label: 'Timeline of the curriculum',
3742
- instruction:
3743
- 'Create a timeline with monthly expectations for users to better track their progression over time',
3744
- value: (...args) => [
3745
- mapValues(types.timelineChunk.type, subDef =>
3746
- getDefaultValue(subDef, ...args),
3747
- ),
3748
- ],
3749
- editable: true,
3750
- restrictive: true,
3751
- type: [types.timelineChunk],
3752
- check: timeline => {
3753
- if (!timeline?.length || !Array.isArray(timeline)) {
3754
- throw Error('Must be a non empty array')
3755
- }
3756
- },
3757
- }
3758
- attrs.timeline = {
3759
- module: sharedTimeline,
3760
- piscine: sharedTimeline,
3761
- }
3762
-
3763
- // TODO: resolve how to show this in front: as the only key is private, object is empty
3764
- types.adminSelectionValidation = TypeObject({
3765
- type: {
3766
- type: Literal('admin_selection', {
3767
- required: true,
3768
- private: true,
3769
- primary: true,
3770
- label: 'Type',
3771
- options: ['admin_selection'],
3772
- }),
3773
- },
3774
- label: 'Admin selection',
3775
- description: 'Admins manually select which submissions move forward.',
3776
- })
3777
-
3778
- const getTesterImage = ({ attrs }) =>
3779
- attrs.language && `ghcr.io/01-edu/test-${attrs.language}`
3780
- types.testerValidation = TypeObject({
3781
- label: 'Automatic testing',
3782
- description:
3783
- 'The solution submitted by the user will be automatically tested and a result will immediately be available.',
3784
- type: {
3785
- type: Literal('tester', {
3786
- required: true,
3787
- private: true,
3788
- label: 'Type',
3789
- options: ['tester'],
3790
- }),
3791
- testImage: {
3792
- type: 'string',
3793
- label: 'Docker image',
3794
- required: true,
3795
- editable: true,
3796
- primary: true,
3797
- instruction: 'used to run the tests',
3798
- // description currently in progress of writing
3799
- ...Functions({ 'from language': getTesterImage }),
3800
- },
3801
- // TODO: mv to relation attribute? Used in the attributes of an exercise or in the children attrs of a quest.
3802
- cooldown: Literal(3 * MIN, {
3803
- required: true,
3804
- editable: true,
3805
- label: 'Delay between two submit',
3806
- }),
3807
- },
3808
- })
3809
-
3810
- // should not be editable because it could break the audits query system
3811
- const sharedMatchWhere = {
3812
- type: 'object',
3813
- required: true,
3814
- label: 'Audited by',
3815
- instruction: 'Match groups with auditors',
3816
- }
3817
- // matchWhere for user_audit validations
3818
- // NB: user 1 should always be excluded because he has admin role that can't be removed
3819
- const usersOfCampusWithAdmins = ({ attrs }) => ({
3820
- campus: { _eq: attrs.campus },
3821
- })
3822
- const userInCampus = ({ attrs }) => ({
3823
- campus: { _eq: attrs.campus },
3824
- _not: { roles: { slug: { _in: ['admin', `campus_admin_${attrs.campus}`] } } },
3825
- })
3826
- const usersInEventWithAdmins = ({ attrs }) => ({
3827
- events: { eventId: { _eq: attrs.eventId } },
3828
- })
3829
- const userInEvent = ({ attrs }) => ({
3830
- _not: { roles: { slug: { _in: ['admin', `campus_admin_${attrs.campus}`] } } },
3831
- events: { eventId: { _eq: attrs.eventId } },
3832
- })
3833
-
3834
- // fallback on user 01 when no campus admin/dedicated auditor defined
3835
- const isUser01 = { id: { _eq: 1 } }
3836
- const userAuditorForEvent = ({ event }) => {
3837
- const labelName = `auditorFor${event?.id}`
3838
- const hasEventLabel = { labels: { label: { name: { _eq: labelName } } } }
3839
-
3840
- return { _or: [hasEventLabel, isUser01] }
3841
- }
3842
-
3843
- // matchWhere for admin_audit validations
3844
- const isCampusAdmin = ({ attrs }) => ({
3845
- _or: [
3846
- { private: { roles: { slug: { _eq: `campus_admin_${attrs.campus}` } } } },
3847
- isUser01,
3848
- ],
3849
- })
3850
- // if school don't want any specific attribution,
3851
- // we attribute audits by default to user 01-edu, that cannot be removed,
3852
- // cannot loose his admin role, or have any record associated (sql fn triggers)
3853
- const noAttribution = () => isUser01
3854
-
3855
- export const getAuditPath = object => {
3856
- const path = `subjects/${getObjectPath(object)}/audit/README.md`
3857
- // TODO: the new content system will no longer use public, in the future we should change this
3858
- // now we have dedicated repos on gitea for: modules, piscine, ....
3859
- // so the repo should be something more dynamic depending on the object.type?
3860
- // for now lets use the raw markdown
3861
- return `/markdown/raw/root/public/${path}`
3862
- }
3863
- const sharedTypesForm = {
3864
- required: true,
3865
- editable: true,
3866
- type: 'string',
3867
- label: 'Audit form URL',
3868
- instruction:
3869
- 'List of questions asked by the auditor during the audit. The URL should return raw markdown',
3870
- ...Functions({ 'README in audit folder': getAuditPath }),
3871
- }
3872
- types.adminAuditValidationDelay = Literal(0, {
3873
- label: 'Audit duration',
3874
- editable: true,
3875
- required: true,
3876
- instruction:
3877
- 'Time that the admins have to complete an audit before it expires', // TODO: convert to MS
3878
- })
3879
- types.adminAuditValidationRequired = Literal(1, {
3880
- label: 'Number required',
3881
- required: true,
3882
- instruction: 'Minimum of audits required',
3883
- check: value => {
3884
- if (value < 1) throw Error('must be at least one')
3885
- },
3886
- })
3887
- types.adminAuditValidationRatio = Literal(1, {
3888
- label: 'Ratio',
3889
- required: true,
3890
- instruction: 'Proportion of extra audits generated',
3891
- check: value => {
3892
- if (value < 1) throw Error('must be at least one')
3893
- },
3894
- })
3895
-
3896
- types.adminAuditValidation = TypeObject({
3897
- label: 'Admin audit',
3898
- description: 'Only admins can evaluate this content.',
3899
- type: {
3900
- type: Literal('admin_audit', {
3901
- required: true,
3902
- private: true,
3903
- primary: true,
3904
- label: 'Type',
3905
- options: ['admin_audit'],
3906
- }),
3907
- delay: types.adminAuditValidationDelay,
3908
- required: types.adminAuditValidationRequired,
3909
- ratio: types.adminAuditValidationRatio,
3910
- matchWhere: {
3911
- ...sharedMatchWhere,
3912
- ...Functions({
3913
- 'an admin of same campus': isCampusAdmin,
3914
- 'manual attribution': noAttribution,
3915
- }),
3916
- },
3917
- form: sharedTypesForm,
3918
- },
3919
- })
3920
-
3921
- types.userAuditValidationDelay = Literal((2 * WEEK) / MIN, {
3922
- label: 'Audit duration',
3923
- required: true,
3924
- editable: true,
3925
- instruction:
3926
- 'Time that the users have to complete an audit before it expires', // TODO: convert to MS
3927
- })
3928
- types.userAuditValidationRequired = Literal(5, {
3929
- label: 'Number required',
3930
- required: true,
3931
- editable: true,
3932
- instruction: 'Minimum of audits required',
3933
- check: value => {
3934
- if (value < 1) throw Error('must be at least one')
3935
- },
3936
- })
3937
- types.userAuditValidationRatio = Literal(2, {
3938
- label: 'Ratio',
3939
- required: true,
3940
- editable: true,
3941
- instruction: 'Proportion of extra audits generated',
3942
- check: value => {
3943
- if (value < 1) throw Error('must be at least one')
3944
- },
3945
- })
3946
-
3947
- types.matchInfluence = TypeObject({
3948
- label: 'Audit Attribution Influence',
3949
- editable: true,
3950
- required: true,
3951
- instruction:
3952
- 'Adjust the weights in the match algorithm to influence how audits are attributed',
3953
- type: {
3954
- auditsRatio: Literal(1, {
3955
- label: 'Current Audit Ratio',
3956
- editable: true,
3957
- required: true,
3958
- }),
3959
- auditsAssigned: Literal(1, {
3960
- editable: true,
3961
- required: true,
3962
- label: 'Fewest Pending Audits',
3963
- }),
3964
- levelProximity: Literal(1, {
3965
- editable: true,
3966
- required: true,
3967
- label: 'Level Proximity',
3968
- }),
3969
- lastAuditAttributed: Literal(1, {
3970
- editable: true,
3971
- required: true,
3972
- label: 'Last Audit Attributed',
3973
- }),
3974
- },
3975
- })
3976
-
3977
- types.userAuditValidation = TypeObject({
3978
- label: 'User audit',
3979
- description:
3980
- 'Users will be assigned as auditors to peer-review the project submission. You can customise the rules of audit attribution and requirements for the group to succeed.',
3981
- type: {
3982
- type: Literal('user_audit', {
3983
- required: true,
3984
- private: true,
3985
- primary: true,
3986
- label: 'Type',
3987
- options: ['user_audit'],
3988
- }),
3989
- delay: types.userAuditValidationDelay,
3990
- required: types.userAuditValidationRequired,
3991
- ratio: types.userAuditValidationRatio,
3992
- matchInfluence: types.matchInfluence,
3993
- matchWhere: {
3994
- ...sharedMatchWhere,
3995
- ...Functions({
3996
- 'any user in same campus': userInCampus,
3997
- 'any user in same event': userInEvent,
3998
- 'any user in same campus (with admins)': usersOfCampusWithAdmins,
3999
- 'any user in same event (with admins)': usersInEventWithAdmins,
4000
- }),
4001
- },
4002
- form: sharedTypesForm,
4003
- preQuestions: {
4004
- editable: true,
4005
- type: 'array',
4006
- label: 'Pre questions',
4007
- instruction:
4008
- 'Define a set of questions that appear before the main audit. Go to “edit & preview” mode to visualize the question content',
4009
- },
4010
- postQuestions: {
4011
- editable: true,
4012
- type: 'array',
4013
- label: 'Post questions',
4014
- instruction:
4015
- 'Define a set of questions that appear after the main audit. Go to “edit & preview” mode to visualize the question content',
4016
- },
4017
- },
4018
- })
4019
-
4020
- types.raidAuditorValidation = TypeObject({
4021
- label: 'Dedicated auditors for event',
4022
- description:
4023
- 'Define a list of users that will audit the content. This list is created on each event related to this content, and needs to be available before the event ends.',
4024
- type: {
4025
- type: Literal('dedicated_auditors_for_event', {
4026
- required: true,
4027
- private: true,
4028
- primary: true,
4029
- label: 'Type',
4030
- options: ['dedicated_auditors_for_event'],
4031
- }),
4032
- delay: types.adminAuditValidationDelay, // no limit to audit?
4033
- required: types.adminAuditValidationRequired,
4034
- ratio: types.adminAuditValidationRatio,
4035
- matchWhere: {
4036
- ...sharedMatchWhere,
4037
- ...Functions({
4038
- "any user labelled as event's auditor ": userAuditorForEvent,
4039
- }),
4040
- },
4041
- form: sharedTypesForm,
4042
- },
4043
- })
4044
-
4045
- const sharedValidations = {
4046
- label: 'Evaluations required',
4047
- instruction: 'Define the evaluation methods for this content',
4048
- check: value => {
4049
- if (!value.length) throw Error('must have at least one element')
4050
- },
4051
- required: true,
4052
- editable: true,
4053
- }
4054
- attrs.validations = {
4055
- project: {
4056
- ...sharedValidations,
4057
- type: [types.userAuditValidation], // TODO: add types.adminAuditValidation? test if would work
4058
- value: (...args) => [
4059
- mapValues(types.userAuditValidation.type, subDef =>
4060
- getDefaultValue(subDef, ...args),
4061
- ),
4062
- ],
4063
- },
4064
- raid: {
4065
- ...sharedValidations,
4066
- type: [types.adminAuditValidation, types.raidAuditorValidation],
4067
- maxElements: 1,
4068
- value: (...args) => [
4069
- mapValues(types.adminAuditValidation.type, subDef =>
4070
- getDefaultValue(subDef, ...args),
4071
- ),
4072
- ],
4073
- },
4074
- exercise: {
4075
- ...sharedValidations,
4076
- type: [types.testerValidation],
4077
- value: (...args) => [
4078
- mapValues(types.testerValidation.type, subDef =>
4079
- getDefaultValue(subDef, ...args),
4080
- ),
4081
- ],
4082
- },
4083
- piscine: {
4084
- ...sharedValidations,
4085
- type: [types.adminSelectionValidation],
4086
- value: (...args) => [
4087
- mapValues(types.adminSelectionValidation.type, subDef =>
4088
- getDefaultValue(subDef, ...args),
4089
- ),
4090
- ],
4091
- },
4092
- interview: {
4093
- ...sharedValidations,
4094
- type: [types.adminSelectionValidation],
4095
- value: (...args) => [
4096
- mapValues(types.adminSelectionValidation.type, subDef =>
4097
- getDefaultValue(subDef, ...args),
4098
- ),
4099
- ],
4100
- },
4101
- }
4102
-
4103
- attrs.videos = {
4104
- quest: Literal('https://www.youtube.com/', {
4105
- type: 'string',
4106
- label: 'Videos URL',
4107
- editable: true,
4108
- check: checkValidURL,
4109
- }),
4110
- }
4111
-
4112
- attrs.legalText = {
4113
- 'avatar-step': Literal(
4114
- "Please make sure to upload a photograph that complies with the training center's internal regulations and standards of decency. This photo will be visible to the teaching staff to support essential individual academic monitoring, as well as to other learners to facilitate peer-to-peer collaboration. Any request for deletion or modification must be submitted to the management.",
4115
- {
4116
- type: 'string',
4117
- label: 'Legal text',
4118
- editable: false,
4119
- required: true,
4120
- },
4121
- ),
4122
- }
4123
-
4124
- const getWeek = ({ attrs }) => {
4125
- if (!attrs.startDay) return undefined // for exams in module for example
4126
- const diff = attrs.startDay / 7
4127
- return Math.ceil(diff)
4128
- }
4129
- // TODO: this should be defined in the relation
4130
- const sharedWeek = Literal(1, {
4131
- label: 'Week n°',
4132
- required: true,
4133
- private: true, // would be interesting to show only in an event context
4134
- // UI only, represents the number of the week inside an event.
4135
- // This attribute is used for :
4136
- // → selection piscine's graph UI : position each object on the corresponding branch (one branch represents one week)
4137
- // → calendar view UI: position each object on the calendar's weeks (but currently the component is not in use)
4138
- ...Functions({ 'from day n°': getWeek }),
4139
- })
4140
- relationAttrs.week = {
4141
- piscine: {
4142
- exam: sharedWeek,
4143
- quest: sharedWeek,
4144
- raid: sharedWeek,
4145
- project: {
4146
- ...sharedWeek,
4147
- value: 1,
4148
- functions: undefined,
4149
- required: false,
4150
- private: false,
4151
- },
4152
- },
4153
- }
4154
-
4155
- const checkRaidLimitation = ({ object, value }) => {
4156
- const raids = Object.values(object.parent.children).filter(
4157
- ({ type, key }) => key !== object.key && type === 'raid',
4158
- )
4159
- for (const { attrs } of raids) {
4160
- if (attrs.branch === value) {
4161
- throw Error('Raids should have their unique branch')
4162
- }
4163
- }
4164
- }
4165
- const checkBranchLimitation = ({ object, value, limit = 9 }) => {
4166
- const contentInBranch = Object.values(object.parent.children).filter(
4167
- ({ key, attrs }) => key !== object.key && attrs.branch === value,
4168
- )
4169
- // + 1 for the current branch being added
4170
- if (contentInBranch.length + 1 > limit) {
4171
- throw Error(`The limit of content in a branch is ${limit}`)
4172
- }
4173
- }
4174
-
4175
- const sharedBranchOptions = {
4176
- label: 'Base Branch',
4177
- instruction: 'From 1 to 4',
4178
- description: 'Changing branch can disrupt the piscine timeline.',
4179
- required: true,
4180
- editable: true,
4181
- hidden: true,
4182
- }
4183
-
4184
- const getBranchOptions = object => {
4185
- const limit = 9
4186
- const branchCounts = {}
4187
- for (const child of Object.values(object.parent.children || {})) {
4188
- const branch = child.attrs.branch
4189
- branchCounts[branch] = (branchCounts[branch] || 0) + 1
4190
- }
4191
- const allBranches = [1, 2, 3, 4]
4192
- return allBranches.filter(branch => (branchCounts[branch] || 0) < limit)
4193
- }
4194
-
4195
- const sharedBranch = Literal(1, {
4196
- ...sharedBranchOptions,
4197
- options: getBranchOptions,
4198
- check: (value, object) => {
4199
- checkNumberInBetween(value, 1, 4)
4200
- checkBranchLimitation({ object, value })
4201
- },
4202
- })
4203
-
4204
- const raidSharedBranch = Literal(1, {
4205
- ...sharedBranchOptions,
4206
- options: [1, 2, 3, 4],
4207
- check: (value, object) => {
4208
- checkNumberInBetween(value, 1, 4)
4209
- checkRaidLimitation({ object, value })
4210
- checkBranchLimitation({ object, value })
4211
- },
4212
- })
4213
- relationAttrs.branch = {
4214
- piscine: {
4215
- quest: sharedBranch,
4216
- project: sharedBranch,
4217
- raid: raidSharedBranch,
4218
- sharedBranch,
4219
- exam: sharedBranch,
4220
- },
4221
- }
4222
-
4223
- const sharedZeroXpIndex = Literal(0, {
4224
- required: true,
4225
- private: true,
4226
- })
4227
- const parentXpIndex = ({ parent }) => parent.attrs.xpIndex
4228
- const getXpIndex = ({ prev, parent }) => {
4229
- if (prev) return (prev.attrs.xpIndex || 0) + (_difficultyXpCoef(prev) || 1)
4230
- return parent?.attrs.xpIndex || 0
4231
- }
4232
- const sharedXpIndex = {
4233
- required: true,
4234
- private: true,
4235
- type: 'number',
4236
- ...Functions({
4237
- 'from previous xpIndex and XP coefficient (or parent if exam or no prev)':
4238
- getXpIndex,
4239
- }),
4240
- }
4241
- relationAttrs.xpIndex = {
4242
- campus: {
4243
- module: sharedZeroXpIndex,
4244
- piscine: sharedZeroXpIndex,
4245
- },
4246
- module: {
4247
- piscine: sharedZeroXpIndex,
4248
- project: sharedXpIndex,
4249
- exam: sharedXpIndex,
4250
- },
4251
- piscine: {
4252
- exam: sharedXpIndex,
4253
- project: sharedXpIndex,
4254
- quest: sharedXpIndex,
4255
- raid: sharedXpIndex,
4256
- },
4257
- quest: { exercise: sharedXpIndex },
4258
- exam: {
4259
- exercise: {
4260
- required: true,
4261
- private: true,
4262
- type: 'number',
4263
- ...Functions({ 'from parent xpIndex': parentXpIndex }),
4264
- },
4265
- },
4266
- }
4267
-
4268
- export const internal = {
4269
- hasSucceededRequiredObjects,
4270
- projectStatus,
4271
- getPiscineStatus,
4272
- meetsRequirements,
4273
- }