@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.
- package/dist/attrs-defs.js +1 -0
- package/dist/attrs.js +1 -0
- package/dist/bin/check-definitions.js +3 -0
- package/dist/chunk-727IHWVW.js +1 -0
- package/dist/chunk-7ZTI6THC.js +1 -0
- package/dist/chunk-CBWHFDVB.js +1 -0
- package/dist/chunk-EI7MMDWY.js +1 -0
- package/dist/chunk-EOY6G4KE.js +1 -0
- package/dist/chunk-K5Z4W5GV.js +1 -0
- package/dist/chunk-LDUPRVV4.js +1 -0
- package/dist/chunk-NQCTSDJE.js +1 -0
- package/dist/chunk-V47RDOUO.js +1 -0
- package/dist/chunk-XYW3ROIR.js +1 -0
- package/dist/definitions-checker.js +1 -0
- package/dist/event-utils.js +1 -0
- package/dist/games-utils.js +1 -0
- package/dist/graph.js +1 -0
- package/dist/hasura-core.js +1 -0
- package/dist/hasura-model.js +34 -0
- package/dist/hasura-prepare.js +1 -0
- package/dist/modular-steps-utils.js +1 -0
- package/dist/object-structure.js +1 -0
- package/dist/onboarding.js +1 -0
- package/dist/path.js +1 -0
- package/dist/qa-utils.js +1 -0
- package/dist/score.js +1 -0
- package/dist/skill-definitions.js +1 -0
- package/dist/toolbox.js +1 -0
- package/package.json +17 -6
- package/attrs-defs.js +0 -4273
- package/attrs.js +0 -423
- package/bin/check-definitions.js +0 -74
- package/definitions-checker.js +0 -233
- package/event-utils.js +0 -58
- package/graph.js +0 -96
- package/hasura-core.js +0 -217
- package/hasura-model.js +0 -138
- package/hasura-prepare.js +0 -44
- package/languages.js +0 -147
- package/onboarding.js +0 -25
- package/path.js +0 -73
- package/programming-languages.js +0 -21
- package/qa-utils.js +0 -13
- package/score.js +0 -80
- package/skill-definitions.js +0 -359
- 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
|
-
}
|