@01-edu/shared 1.0.6 → 1.0.12
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/README.md +27 -1
- package/attrs-defs.js +412 -115
- package/attrs.js +102 -15
- package/bin/check-definitions.js +8 -0
- package/definitions-checker.js +25 -23
- package/onboarding.js +1 -0
- package/package.json +2 -2
- package/path.js +18 -8
- package/toolbox.js +32 -9
package/attrs-defs.js
CHANGED
|
@@ -9,7 +9,6 @@ import {
|
|
|
9
9
|
} from './event-utils.js'
|
|
10
10
|
import { onboardingTypes } from './onboarding.js'
|
|
11
11
|
import { getObjectFromRelativePath } from './path.js'
|
|
12
|
-
import { supportedLang } from './programming-languages.js'
|
|
13
12
|
import { gamesScoring } from './score.js'
|
|
14
13
|
import { hasRequiredSkills, skillsSet } from './skill-definitions.js'
|
|
15
14
|
import {
|
|
@@ -49,14 +48,14 @@ const numOrOne = x => (typeof x === 'number' && !Number.isNaN(x) ? x : 1)
|
|
|
49
48
|
const _difficultyXpCoef = o =>
|
|
50
49
|
o.attrs.difficulty * numOrOne(o.attrs.difficultyMod)
|
|
51
50
|
const add = (a, b) => a + b
|
|
52
|
-
const byValueIdx = ([
|
|
51
|
+
const byValueIdx = ([_ak, av], [_bk, bv]) => av.index - bv.index
|
|
53
52
|
|
|
54
53
|
const translate = (object, user, key) =>
|
|
55
54
|
object.attrs[`${key}-${user?.attrs?.language}`] ||
|
|
56
55
|
object.attrs[`${key}-en`] ||
|
|
57
56
|
key
|
|
58
57
|
|
|
59
|
-
const firstOfGroup = (a,
|
|
58
|
+
const firstOfGroup = (a, _i, children) => {
|
|
60
59
|
const { group } = a.attrs
|
|
61
60
|
if (!group) return true
|
|
62
61
|
return children.find(b => b.attrs.group === group) === a
|
|
@@ -65,7 +64,9 @@ const firstOfGroup = (a, i, children) => {
|
|
|
65
64
|
const getCoreName = object => {
|
|
66
65
|
const corePath = object.attrs.requirements?.core
|
|
67
66
|
const core = _children(object.parent).find(
|
|
68
|
-
child =>
|
|
67
|
+
child =>
|
|
68
|
+
child.id ===
|
|
69
|
+
getObjectFromRelativePath(corePath, object, { throwError: false })?.id,
|
|
69
70
|
)
|
|
70
71
|
return core?.name || ''
|
|
71
72
|
}
|
|
@@ -122,8 +123,9 @@ const TypeObject = def => ({
|
|
|
122
123
|
// value*: // defaultValue ONLY IF the attribute is required, or when it is added,
|
|
123
124
|
// 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),
|
|
124
125
|
// label: // text displayed in front end, instead of attribute name (client friendly),
|
|
126
|
+
// restrictive: // if true, the attribute will be defined only on the object itself and cannot be overridden in the relation to the parent object
|
|
125
127
|
// instruction: // mention displayed under the label in the front end - to give precision that need to be seen,
|
|
126
|
-
// description: complete explanation of what the attributes does - description is displayed only when the used hover the
|
|
128
|
+
// description: complete explanation of what the attributes does - description is displayed only when the used hover the i icon in the front end,
|
|
127
129
|
// required: // if the attribute is always there by default,
|
|
128
130
|
// editable: // if it can be override by schools in the db (=/= choice in between several functions),
|
|
129
131
|
// private: // if the attribute should not be seen in front end,
|
|
@@ -212,15 +214,13 @@ attrs.allowedFunctions = {
|
|
|
212
214
|
const autoValidateLabel = 'Automatic success'
|
|
213
215
|
const autoValidateWhereLabel = `${autoValidateLabel} if`
|
|
214
216
|
const autoRejectWhereLabel = `Automatic reject if`
|
|
215
|
-
const sharedAutoValidationOperator =
|
|
217
|
+
const sharedAutoValidationOperator = action => ({
|
|
216
218
|
type: 'string',
|
|
217
219
|
restrictive: true,
|
|
218
220
|
label: `${action} - number of conditions to fulfil`,
|
|
219
221
|
...Functions({ all: () => 'and', one: () => 'or' }),
|
|
220
222
|
})
|
|
221
|
-
attrs.autoRejectOperator = {
|
|
222
|
-
games: sharedAutoValidationOperator('Reject', autoRejectWhereLabel),
|
|
223
|
-
}
|
|
223
|
+
attrs.autoRejectOperator = { games: sharedAutoValidationOperator('Reject') }
|
|
224
224
|
|
|
225
225
|
const games = Object.entries(gamesScoring)
|
|
226
226
|
const gamesParameters = games.flatMap(([game, params]) => [
|
|
@@ -244,10 +244,6 @@ const allParameters = [
|
|
|
244
244
|
['level', { shouldFailUnder: 10, shouldSucceedFrom: 25 }],
|
|
245
245
|
...gamesParameters,
|
|
246
246
|
]
|
|
247
|
-
const allParametersStr = allParameters
|
|
248
|
-
.map(([parameter, _]) => breath(parameter))
|
|
249
|
-
.join(', ')
|
|
250
|
-
.slice(0, -2)
|
|
251
247
|
|
|
252
248
|
// TODO: mv to a attrs-utils file
|
|
253
249
|
const isntObjectOrIsEmpty = elem =>
|
|
@@ -286,7 +282,7 @@ const checkTextLength = (str, length) => {
|
|
|
286
282
|
const checkValidURL = value => {
|
|
287
283
|
try {
|
|
288
284
|
return Boolean(new URL(value))
|
|
289
|
-
} catch
|
|
285
|
+
} catch {
|
|
290
286
|
throw Error('Invalid URL.')
|
|
291
287
|
}
|
|
292
288
|
}
|
|
@@ -350,9 +346,7 @@ attrs.autoValidate = {
|
|
|
350
346
|
}),
|
|
351
347
|
}
|
|
352
348
|
|
|
353
|
-
attrs.autoValidateOperator = {
|
|
354
|
-
games: sharedAutoValidationOperator('Success', autoValidateWhereLabel),
|
|
355
|
-
}
|
|
349
|
+
attrs.autoValidateOperator = { games: sharedAutoValidationOperator('Success') }
|
|
356
350
|
|
|
357
351
|
// none of these conditions are required
|
|
358
352
|
types.autoValidateWhereConditions = Object.fromEntries(
|
|
@@ -554,20 +548,6 @@ attrs.capacity = {
|
|
|
554
548
|
}
|
|
555
549
|
|
|
556
550
|
const exerciseCategories = new Set(['required', 'optional', 'bonus'])
|
|
557
|
-
const projectCategories = new Set(['synchronous', 'enrichment', 'asynchronous'])
|
|
558
|
-
// to check: should we set dynamically the enrichment project category relying
|
|
559
|
-
// on the fact that the object has a valid core project set in its requirements?
|
|
560
|
-
// if so, in the admin interface we have to enforce to select a core project for the enrichment category to be applied
|
|
561
|
-
const projectCategory = Literal('synchronous', {
|
|
562
|
-
label: 'Category of the project',
|
|
563
|
-
required: true,
|
|
564
|
-
options: ['synchronous', 'asynchronous', 'enrichment'],
|
|
565
|
-
check: value => {
|
|
566
|
-
if (!projectCategories.has(value)) {
|
|
567
|
-
throw Error('Must be "synchronous", "asynchronous" or "enrichment"')
|
|
568
|
-
}
|
|
569
|
-
},
|
|
570
|
-
})
|
|
571
551
|
relationAttrs.category = {
|
|
572
552
|
// exercises in exams need the category for expected xp calculation
|
|
573
553
|
exam: {
|
|
@@ -596,11 +576,7 @@ relationAttrs.category = {
|
|
|
596
576
|
},
|
|
597
577
|
}),
|
|
598
578
|
},
|
|
599
|
-
|
|
600
|
-
piscine: {
|
|
601
|
-
project: { ...projectCategory, private: true },
|
|
602
|
-
raid: Literal('required', { required: true, private: true }),
|
|
603
|
-
},
|
|
579
|
+
piscine: { raid: Literal('required', { required: true, private: true }) },
|
|
604
580
|
}
|
|
605
581
|
|
|
606
582
|
// checkbox is not required - sign step can be used without, just to display a text
|
|
@@ -956,7 +932,10 @@ const getProjectName = object => {
|
|
|
956
932
|
// only for projects with a core requirement set, and which name starts by the core name:
|
|
957
933
|
// remove the core name from the name to avoid repetition
|
|
958
934
|
const coreName = getCoreName(object)
|
|
959
|
-
|
|
935
|
+
// TODO: couldn't we just display the key?
|
|
936
|
+
return coreName && name.startsWith(coreName)
|
|
937
|
+
? name.slice(coreName.length + 1)
|
|
938
|
+
: name
|
|
960
939
|
}
|
|
961
940
|
const sharedDisplayedName = Literal('', {
|
|
962
941
|
editable: true,
|
|
@@ -981,7 +960,7 @@ attrs.displayedName = {
|
|
|
981
960
|
}
|
|
982
961
|
|
|
983
962
|
const isHackathon = object => object.attrs.special
|
|
984
|
-
const getExerciseDuration = ({ parent
|
|
963
|
+
const getExerciseDuration = ({ parent }) =>
|
|
985
964
|
isHackathon(parent)
|
|
986
965
|
? parent.attrs.duration / Object.keys(parent.children).length
|
|
987
966
|
: 0
|
|
@@ -1803,33 +1782,63 @@ const sharedInput = {
|
|
|
1803
1782
|
...translatable,
|
|
1804
1783
|
instruction: 'Required option: "type"',
|
|
1805
1784
|
}
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1785
|
+
const uploadInput = {
|
|
1786
|
+
...sharedInput,
|
|
1787
|
+
editable: false,
|
|
1788
|
+
check: value => {
|
|
1789
|
+
const [inputValues] = Object.values(value)
|
|
1790
|
+
isntObjectOrIsEmpty(inputValues)
|
|
1791
|
+
if (!inputValues.type || inputValues.type !== 'file') {
|
|
1792
|
+
throw Error('"type":"file" property must be defined in the upload input.')
|
|
1793
|
+
}
|
|
1794
|
+
if (
|
|
1795
|
+
inputValues.accept !== undefined &&
|
|
1796
|
+
typeof inputValues.accept !== 'string'
|
|
1797
|
+
) {
|
|
1798
|
+
throw Error(
|
|
1799
|
+
'"accept" property (if added) must be a text. Example: "image/png, image/jpeg"',
|
|
1800
|
+
)
|
|
1801
|
+
}
|
|
1802
|
+
if (
|
|
1803
|
+
inputValues.required !== undefined &&
|
|
1804
|
+
typeof inputValues.required !== 'boolean'
|
|
1805
|
+
) {
|
|
1806
|
+
throw Error('"required" property (if added) must be a true or false.')
|
|
1807
|
+
}
|
|
1808
|
+
},
|
|
1809
|
+
}
|
|
1810
|
+
|
|
1811
|
+
const avatarInput = TypeObject({
|
|
1812
|
+
label: 'Avatar input',
|
|
1813
|
+
required: true,
|
|
1814
|
+
editable: false,
|
|
1815
|
+
value: {
|
|
1816
|
+
type: 'file',
|
|
1817
|
+
accept: 'image/png, image/jpeg',
|
|
1818
|
+
required: true,
|
|
1832
1819
|
},
|
|
1820
|
+
type: {
|
|
1821
|
+
type: Literal('file', {
|
|
1822
|
+
label: 'Input type',
|
|
1823
|
+
required: true,
|
|
1824
|
+
editable: false,
|
|
1825
|
+
}),
|
|
1826
|
+
accept: Literal('image/png, image/jpeg', {
|
|
1827
|
+
label: 'Accepted file types',
|
|
1828
|
+
editable: false,
|
|
1829
|
+
required: true,
|
|
1830
|
+
}),
|
|
1831
|
+
required: Literal(true, {
|
|
1832
|
+
label: 'Required',
|
|
1833
|
+
editable: false,
|
|
1834
|
+
required: true,
|
|
1835
|
+
}),
|
|
1836
|
+
},
|
|
1837
|
+
})
|
|
1838
|
+
|
|
1839
|
+
attrs.input = {
|
|
1840
|
+
'upload-step': uploadInput,
|
|
1841
|
+
'avatar-step': avatarInput,
|
|
1833
1842
|
'contact-validation-step': {
|
|
1834
1843
|
...sharedInput,
|
|
1835
1844
|
check: value => {
|
|
@@ -1878,7 +1887,7 @@ attrs.input = {
|
|
|
1878
1887
|
}
|
|
1879
1888
|
try {
|
|
1880
1889
|
return new RegExp(value)
|
|
1881
|
-
} catch
|
|
1890
|
+
} catch {
|
|
1882
1891
|
throw Error('Invalid Regular expression.')
|
|
1883
1892
|
}
|
|
1884
1893
|
}
|
|
@@ -1892,7 +1901,7 @@ const getInScope = ({ attrs }) => {
|
|
|
1892
1901
|
}
|
|
1893
1902
|
const getProjectInScope = ({ attrs }) => attrs.hasStarted
|
|
1894
1903
|
const getExamExerciseInScope = ({ parent }) => parent.attrs.inScope
|
|
1895
|
-
const getExerciseInScope = ({ parent, attrs
|
|
1904
|
+
const getExerciseInScope = ({ parent, attrs }) => {
|
|
1896
1905
|
if (parent.attrs.inScope) {
|
|
1897
1906
|
if (isHackathon(parent)) {
|
|
1898
1907
|
const now = Date.now()
|
|
@@ -2020,35 +2029,66 @@ types.objectChildRelativePath = Literal('./', {
|
|
|
2020
2029
|
.map(([key, _]) => `./${key}`),
|
|
2021
2030
|
})
|
|
2022
2031
|
|
|
2032
|
+
const checkRelativePaths = (objects, object) => {
|
|
2033
|
+
if (!Array.isArray(objects) || !objects.length) {
|
|
2034
|
+
const error = new Error('Must be a non empty array')
|
|
2035
|
+
error.userFeedback =
|
|
2036
|
+
'This list cannot be empty! Please add an item or remove the setting.'
|
|
2037
|
+
throw error
|
|
2038
|
+
}
|
|
2039
|
+
const uniques = [...new Set(objects)]
|
|
2040
|
+
if (objects.length !== uniques.length) {
|
|
2041
|
+
throw Error('Duplicates are not allowed.')
|
|
2042
|
+
}
|
|
2043
|
+
|
|
2044
|
+
const invalidObjectsRequirements = objects.filter(path => {
|
|
2045
|
+
const objectFromPath = getObjectFromRelativePath(path, object, {
|
|
2046
|
+
throwError: false,
|
|
2047
|
+
})
|
|
2048
|
+
return !objectFromPath
|
|
2049
|
+
})
|
|
2050
|
+
|
|
2051
|
+
if (invalidObjectsRequirements.length) {
|
|
2052
|
+
const paths = invalidObjectsRequirements.map(p => `'${p}'`).join(', ')
|
|
2053
|
+
const error = new Error(
|
|
2054
|
+
`Invalid objects requirements - no object found for the following relative paths: ${paths}`,
|
|
2055
|
+
)
|
|
2056
|
+
error.userFeedback = 'Some Contents are misconfigured, please update them!'
|
|
2057
|
+
console.error(error.message)
|
|
2058
|
+
throw error
|
|
2059
|
+
}
|
|
2060
|
+
}
|
|
2061
|
+
|
|
2023
2062
|
types.objectRootRelativePath = Literal('../', {
|
|
2024
|
-
label: 'Content relative path',
|
|
2063
|
+
label: 'Content relative path (Mandatory)',
|
|
2025
2064
|
instruction: 'In same parent',
|
|
2026
2065
|
editable: true,
|
|
2027
|
-
check:
|
|
2066
|
+
check: (path, object) => {
|
|
2067
|
+
try {
|
|
2068
|
+
getObjectFromRelativePath(path, object)
|
|
2069
|
+
} catch (err) {
|
|
2070
|
+
err.userFeedback = `This content relative path (${path}) is invalid, please select a valid content in the list below or remove it.`
|
|
2071
|
+
throw err
|
|
2072
|
+
}
|
|
2073
|
+
},
|
|
2028
2074
|
options: object => {
|
|
2029
2075
|
if (!object?.parent?.children) return []
|
|
2030
2076
|
const sorted = Object.entries(object.parent.children).sort(byValueIdx)
|
|
2031
|
-
const index = sorted.findIndex(([k
|
|
2077
|
+
const index = sorted.findIndex(([k]) => k === object.key)
|
|
2032
2078
|
if (index < 1) return sorted.map(([key, _]) => `../${key}`)
|
|
2033
2079
|
const before = sorted.slice(0, index)
|
|
2034
|
-
// TODO: add a filter on category and let possibility to select the async after?
|
|
2035
|
-
// const after = sorted.slice(index + 1)
|
|
2036
|
-
// .filter(([k, v]) => v.attrs?.category === 'asynchronous')
|
|
2037
|
-
// return [...before, ...after].map(([key, _]) => `../${key}`)
|
|
2038
2080
|
return before.map(([key, _]) => `../${key}`).reverse()
|
|
2039
2081
|
},
|
|
2040
2082
|
})
|
|
2083
|
+
|
|
2084
|
+
// NOTE: objects requirements is declared here
|
|
2041
2085
|
types.sharedObjectList = {
|
|
2042
|
-
label: 'Contents', // synonyms: item, material
|
|
2086
|
+
label: 'Contents required', // synonyms: item, material
|
|
2043
2087
|
editable: true,
|
|
2044
|
-
check: objects => {
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
const uniques = [...new Set(objects)]
|
|
2049
|
-
if (objects.length !== uniques.length) {
|
|
2050
|
-
throw Error('Duplicates are not allowed.')
|
|
2051
|
-
}
|
|
2088
|
+
check: (objects, object) => {
|
|
2089
|
+
// objects are split into alternative paths or mandatory paths
|
|
2090
|
+
// for the check we will just flat the array so that we check every path the same way
|
|
2091
|
+
checkRelativePaths(objects.flat(), object)
|
|
2052
2092
|
},
|
|
2053
2093
|
}
|
|
2054
2094
|
|
|
@@ -2067,6 +2107,25 @@ const contentRequirements = {
|
|
|
2067
2107
|
if (!skills && !objects && !core) {
|
|
2068
2108
|
throw Error('Empty requirements, should be removed')
|
|
2069
2109
|
}
|
|
2110
|
+
|
|
2111
|
+
const allObjects = [...(objects || []), core || []].flat()
|
|
2112
|
+
const invalidObjectsRequirements = allObjects.filter(path => {
|
|
2113
|
+
const objectFromPath = getObjectFromRelativePath(path, object, {
|
|
2114
|
+
throwError: false,
|
|
2115
|
+
})
|
|
2116
|
+
return !objectFromPath
|
|
2117
|
+
})
|
|
2118
|
+
|
|
2119
|
+
if (invalidObjectsRequirements.length) {
|
|
2120
|
+
const paths = invalidObjectsRequirements.map(p => `'${p}'`).join(', ')
|
|
2121
|
+
const error = new Error(
|
|
2122
|
+
`Invalid objects requirements - no object found for the following relative paths: ${paths}`,
|
|
2123
|
+
)
|
|
2124
|
+
error.userFeedback =
|
|
2125
|
+
'You have some misconfigured Access conditions, please update them!'
|
|
2126
|
+
console.error(error.message)
|
|
2127
|
+
throw error
|
|
2128
|
+
}
|
|
2070
2129
|
},
|
|
2071
2130
|
}
|
|
2072
2131
|
|
|
@@ -2075,7 +2134,7 @@ const levelRequirements = {
|
|
|
2075
2134
|
instruction: 'Conditions to unlock and earn this level',
|
|
2076
2135
|
description:
|
|
2077
2136
|
'Sets the requirements that have to be met for a level to be unlocked and earned by a student.',
|
|
2078
|
-
check:
|
|
2137
|
+
check: requirements => {
|
|
2079
2138
|
const { skills, objects, ...rest } = requirements
|
|
2080
2139
|
if (Object.keys(rest).length) {
|
|
2081
2140
|
throw Error(`Unsupported attribute "${Object.keys(rest)[0]}"`)
|
|
@@ -2086,6 +2145,9 @@ const levelRequirements = {
|
|
|
2086
2145
|
},
|
|
2087
2146
|
}
|
|
2088
2147
|
|
|
2148
|
+
// NOTE: does level definition support the multiple pathways??
|
|
2149
|
+
// i think it should not for now, the multiple pathways was defined for the students to get to a content throughout different ways
|
|
2150
|
+
// not for the level be defined different ways
|
|
2089
2151
|
types.levelDefinition = TypeObject({
|
|
2090
2152
|
label: 'Level definition',
|
|
2091
2153
|
type: {
|
|
@@ -2185,6 +2247,38 @@ attrs.link = {
|
|
|
2185
2247
|
},
|
|
2186
2248
|
...translatable, // in case there are different versions of the doc to dl
|
|
2187
2249
|
}),
|
|
2250
|
+
'avatar-step': TypeObject({
|
|
2251
|
+
label: 'Link to the legal page',
|
|
2252
|
+
editable: false,
|
|
2253
|
+
required: true,
|
|
2254
|
+
value: {
|
|
2255
|
+
href: '/legal',
|
|
2256
|
+
label: '> Privacy policy',
|
|
2257
|
+
target: '_blank',
|
|
2258
|
+
},
|
|
2259
|
+
type: {
|
|
2260
|
+
href: Literal('/legal', {
|
|
2261
|
+
editable: false,
|
|
2262
|
+
}),
|
|
2263
|
+
label: Literal('> Privacy policy', {
|
|
2264
|
+
editable: false,
|
|
2265
|
+
}),
|
|
2266
|
+
target: Literal('_blank', {
|
|
2267
|
+
editable: false,
|
|
2268
|
+
}),
|
|
2269
|
+
},
|
|
2270
|
+
}),
|
|
2271
|
+
}
|
|
2272
|
+
|
|
2273
|
+
relationAttrs.mandatory = {
|
|
2274
|
+
module: {
|
|
2275
|
+
project: {
|
|
2276
|
+
label: 'Mandatory content to validate the curriculum',
|
|
2277
|
+
type: 'boolean',
|
|
2278
|
+
required: false,
|
|
2279
|
+
editable: true,
|
|
2280
|
+
},
|
|
2281
|
+
},
|
|
2188
2282
|
}
|
|
2189
2283
|
|
|
2190
2284
|
// TODO: should be required for exam exercises.
|
|
@@ -2209,7 +2303,9 @@ const sharedName = Literal('', {
|
|
|
2209
2303
|
})
|
|
2210
2304
|
const attrsNameObj = {}
|
|
2211
2305
|
for (const type of onboardingTypes) {
|
|
2212
|
-
|
|
2306
|
+
if (type !== 'avatar-step') {
|
|
2307
|
+
attrsNameObj[type] = sharedName
|
|
2308
|
+
}
|
|
2213
2309
|
}
|
|
2214
2310
|
attrs.name = {
|
|
2215
2311
|
signup: sharedName,
|
|
@@ -2351,7 +2447,7 @@ const getCaptainLogin = ({ group, parent }) => {
|
|
|
2351
2447
|
}
|
|
2352
2448
|
const getRepositoryPath = ({ name, group, parent }, user) =>
|
|
2353
2449
|
`${getCaptainLogin({ group, parent }) || user.login}/${name}`
|
|
2354
|
-
const getExerciseRepositoryPath = ({ attrs, parent,
|
|
2450
|
+
const getExerciseRepositoryPath = ({ attrs, parent, group }, user) => {
|
|
2355
2451
|
const captainLogin = getCaptainLogin({ parent, group })
|
|
2356
2452
|
return captainLogin
|
|
2357
2453
|
? `${captainLogin}/${parent.path.replace(/\/+/g, '-')}`
|
|
@@ -2384,6 +2480,30 @@ attrs.requiredAuditRatio = {
|
|
|
2384
2480
|
}),
|
|
2385
2481
|
}
|
|
2386
2482
|
|
|
2483
|
+
types.pathwaysRequirementObjects = {
|
|
2484
|
+
label: 'Multiple content choices',
|
|
2485
|
+
instruction:
|
|
2486
|
+
'Adding this will create a new path way for the project being edit.',
|
|
2487
|
+
check: (objects, object) => {
|
|
2488
|
+
// objects are split into alternative paths or mandatory paths
|
|
2489
|
+
// for the check we will just flat the array so that we check every path the same way
|
|
2490
|
+
checkRelativePaths(objects.flat(), object)
|
|
2491
|
+
},
|
|
2492
|
+
required: false,
|
|
2493
|
+
editable: true,
|
|
2494
|
+
type: [
|
|
2495
|
+
{
|
|
2496
|
+
...types.objectRootRelativePath,
|
|
2497
|
+
label: 'Content relative path (optional)',
|
|
2498
|
+
},
|
|
2499
|
+
],
|
|
2500
|
+
value: (...args) => {
|
|
2501
|
+
const option = types.objectRootRelativePath.options(...args)?.[0]
|
|
2502
|
+
return option ? [option] : []
|
|
2503
|
+
},
|
|
2504
|
+
}
|
|
2505
|
+
|
|
2506
|
+
// NOTE: relation attribute requirements objects
|
|
2387
2507
|
const sharedContentRequirementsForMainAttr = TypeObject({
|
|
2388
2508
|
...contentRequirements,
|
|
2389
2509
|
type: {
|
|
@@ -2397,10 +2517,11 @@ const sharedContentRequirementsForMainAttr = TypeObject({
|
|
|
2397
2517
|
...types.sharedObjectList,
|
|
2398
2518
|
value: (...args) => {
|
|
2399
2519
|
const option = types.objectRootRelativePath.options(...args)?.[0]
|
|
2400
|
-
|
|
2520
|
+
const pathways = types.pathwaysRequirementObjects.value(...args)
|
|
2521
|
+
return option ? [option, pathways] : []
|
|
2401
2522
|
},
|
|
2402
|
-
type: [types.objectRootRelativePath],
|
|
2403
|
-
instruction: '
|
|
2523
|
+
type: [types.objectRootRelativePath, types.pathwaysRequirementObjects],
|
|
2524
|
+
instruction: 'Content required to unlock the current one',
|
|
2404
2525
|
},
|
|
2405
2526
|
},
|
|
2406
2527
|
})
|
|
@@ -2775,7 +2896,7 @@ const getProgressStatus = progress => {
|
|
|
2775
2896
|
}
|
|
2776
2897
|
|
|
2777
2898
|
const examExerciseStatus = object => {
|
|
2778
|
-
const {
|
|
2899
|
+
const { attrs, progress, parent } = object
|
|
2779
2900
|
const progressStatus = getProgressStatus(progress)
|
|
2780
2901
|
if (progressStatus) return progressStatus
|
|
2781
2902
|
if (!parent) return 'available'
|
|
@@ -2795,7 +2916,7 @@ const examExerciseStatus = object => {
|
|
|
2795
2916
|
return prevValidated ? 'available' : 'blocked'
|
|
2796
2917
|
}
|
|
2797
2918
|
const questExerciseStatus = object => {
|
|
2798
|
-
const { prev, attrs, progress, parent
|
|
2919
|
+
const { prev, attrs, progress, parent } = object
|
|
2799
2920
|
const progressStatus = getProgressStatus(progress)
|
|
2800
2921
|
if (progressStatus) return progressStatus
|
|
2801
2922
|
if (!parent) return 'available'
|
|
@@ -2830,6 +2951,20 @@ const questExerciseStatus = object => {
|
|
|
2830
2951
|
return 'available'
|
|
2831
2952
|
}
|
|
2832
2953
|
|
|
2954
|
+
const isPathStatusSucceeded = (path, object) => {
|
|
2955
|
+
try {
|
|
2956
|
+
const obj = getObjectFromRelativePath(path, object)
|
|
2957
|
+
return obj?.attrs.status === 'succeeded'
|
|
2958
|
+
} catch {
|
|
2959
|
+
// NOTE: should we make the requirement unblocked if the admin did not set the right requirement ????
|
|
2960
|
+
|
|
2961
|
+
// consider the requirement unlocked if an error is thrown by getObjectFromRelativePath,
|
|
2962
|
+
// because it would mean an admin wrongly set the requirement, probably manually in the db
|
|
2963
|
+
// (it is not possible to set an invalid requirement from the admin configuration interface)
|
|
2964
|
+
return true
|
|
2965
|
+
}
|
|
2966
|
+
}
|
|
2967
|
+
|
|
2833
2968
|
/**
|
|
2834
2969
|
* @throws This function throws an error if any of the required objects is invalid
|
|
2835
2970
|
* @returns {bool} true if all the required objects are succeeded, false otherwise
|
|
@@ -2837,28 +2972,39 @@ const questExerciseStatus = object => {
|
|
|
2837
2972
|
*/
|
|
2838
2973
|
const hasSucceededRequiredObjects = (requirements, object) => {
|
|
2839
2974
|
if (!requirements) return true
|
|
2975
|
+
// TODO: in the near future remove the core attribute
|
|
2840
2976
|
const { core, objects } = requirements
|
|
2841
2977
|
if ((!objects || !objects.length) && !core) return true
|
|
2842
2978
|
|
|
2843
2979
|
// aggregate core object and regular required objects in the list of objects to check
|
|
2844
|
-
|
|
2845
|
-
|
|
2846
|
-
|
|
2847
|
-
|
|
2848
|
-
|
|
2849
|
-
|
|
2850
|
-
|
|
2851
|
-
|
|
2852
|
-
|
|
2853
|
-
|
|
2854
|
-
|
|
2980
|
+
// only keep values that are not undefined
|
|
2981
|
+
// required objects are the ones that need to be done to unblock the current object
|
|
2982
|
+
const requiredObjects = [...(objects || []), core].filter(
|
|
2983
|
+
p => !Array.isArray(p) && Boolean(p),
|
|
2984
|
+
)
|
|
2985
|
+
// pathway objects represent the choices a student can take to unblock the current object
|
|
2986
|
+
// note: at least one object from each pathway must be successfully completed
|
|
2987
|
+
const pathWayObjects = objects?.filter(p => Array.isArray(p) && Boolean(p))
|
|
2988
|
+
|
|
2989
|
+
const hasSeccededRequiredObjects = requiredObjects.every(path =>
|
|
2990
|
+
isPathStatusSucceeded(path, object),
|
|
2991
|
+
)
|
|
2992
|
+
|
|
2993
|
+
// it's possible to have a pathway with just one relative path
|
|
2994
|
+
// in this case it will be considered as a required object
|
|
2995
|
+
const hasSucceededPathWays = pathWayObjects?.every(paths =>
|
|
2996
|
+
paths.some(path => isPathStatusSucceeded(path, object)),
|
|
2997
|
+
)
|
|
2998
|
+
|
|
2999
|
+
return hasSucceededPathWays !== undefined
|
|
3000
|
+
? hasSeccededRequiredObjects && hasSucceededPathWays
|
|
3001
|
+
: hasSeccededRequiredObjects
|
|
2855
3002
|
}
|
|
2856
3003
|
|
|
2857
3004
|
// check if the requirements are met for a given object
|
|
2858
3005
|
const meetsRequirements = ({ requirements, object, user, progress }) => {
|
|
2859
3006
|
// check if there's already a progress, to not block students who began the project before implementing the requirements feature
|
|
2860
3007
|
if ((progress && Object.keys(progress).length) || !requirements) return true
|
|
2861
|
-
|
|
2862
3008
|
// check if the required skills have been earned & the required objects have been succeeded
|
|
2863
3009
|
const hasSkills = hasRequiredSkills(requirements.skills, user.skills)
|
|
2864
3010
|
const hasSucceededObjects = hasSucceededRequiredObjects(requirements, object)
|
|
@@ -2924,17 +3070,20 @@ const questStatus = object => {
|
|
|
2924
3070
|
: getProgressStatus(progress) || 'available'
|
|
2925
3071
|
}
|
|
2926
3072
|
|
|
3073
|
+
const getMeetsRequirements = (object, user) => {
|
|
3074
|
+
const { progress, attrs } = object
|
|
3075
|
+
const { requirements } = attrs
|
|
3076
|
+
|
|
3077
|
+
return meetsRequirements({ object, requirements, user, progress })
|
|
3078
|
+
}
|
|
2927
3079
|
const getPiscineStatus = (object, user) => {
|
|
2928
3080
|
const { progress, event, attrs } = object
|
|
2929
3081
|
const { requirements } = attrs
|
|
2930
3082
|
const progressStatus = getProgressStatus(progress)
|
|
2931
3083
|
if (progressStatus) return progressStatus
|
|
2932
|
-
|
|
2933
|
-
if (!event) return 'blocked'
|
|
2934
|
-
const registrationNotStarted = Date.now() < event.registrationStartAt
|
|
2935
3084
|
if (
|
|
2936
|
-
!meetsRequirements({ object, requirements, user, progress })
|
|
2937
|
-
|
|
3085
|
+
!meetsRequirements({ object, requirements, user, progress }) &&
|
|
3086
|
+
!event?.registeredPosition // in case a learner was force added to a registration through hasura by an admin
|
|
2938
3087
|
) {
|
|
2939
3088
|
return 'blocked'
|
|
2940
3089
|
}
|
|
@@ -3078,6 +3227,19 @@ relationAttrs.status = {
|
|
|
3078
3227
|
},
|
|
3079
3228
|
}
|
|
3080
3229
|
|
|
3230
|
+
types.meetRequirements = Literal(false, {
|
|
3231
|
+
label: 'User meet requirement',
|
|
3232
|
+
restrictive: true,
|
|
3233
|
+
required: true,
|
|
3234
|
+
...Functions({ 'by requirements': getMeetsRequirements }),
|
|
3235
|
+
})
|
|
3236
|
+
relationAttrs.meetsRequirements = {
|
|
3237
|
+
module: {
|
|
3238
|
+
piscine: types.meetRequirements,
|
|
3239
|
+
project: types.meetRequirements,
|
|
3240
|
+
},
|
|
3241
|
+
}
|
|
3242
|
+
|
|
3081
3243
|
const getSubject = object => {
|
|
3082
3244
|
const path = `subjects/${getObjectPath(object)}/README.md`
|
|
3083
3245
|
return `/markdown/root/public/${path}`
|
|
@@ -3095,10 +3257,77 @@ attrs.subject = {
|
|
|
3095
3257
|
raid: sharedSubject,
|
|
3096
3258
|
}
|
|
3097
3259
|
|
|
3098
|
-
const sharedText = Literal('', {
|
|
3260
|
+
const sharedText = Literal('', { editable: true, ...translatable })
|
|
3261
|
+
|
|
3262
|
+
types.teamworkRankName = Literal('', {
|
|
3263
|
+
label: 'Rank name',
|
|
3264
|
+
type: 'string',
|
|
3099
3265
|
editable: true,
|
|
3100
|
-
|
|
3266
|
+
required: true,
|
|
3267
|
+
primary: true,
|
|
3268
|
+
check: (name, object) => {
|
|
3269
|
+
const { teamworkRanks } = object.attrs
|
|
3270
|
+
const definitionsWithSameName = teamworkRanks?.filter(r => r.name === name)
|
|
3271
|
+
if (definitionsWithSameName?.length > 1) {
|
|
3272
|
+
throw Error(
|
|
3273
|
+
`Name "${name}" is already set for a teamwork rank definition! A given name can only be attributed once to a rank.`,
|
|
3274
|
+
)
|
|
3275
|
+
}
|
|
3276
|
+
},
|
|
3277
|
+
})
|
|
3278
|
+
|
|
3279
|
+
types.teamworkRankParticipations = Literal(0, {
|
|
3280
|
+
label: 'Group Participations',
|
|
3281
|
+
instruction:
|
|
3282
|
+
'The number of users the student has to work with, to unlock this rank.',
|
|
3283
|
+
editable: true,
|
|
3284
|
+
required: true,
|
|
3285
|
+
options: arrayOf(150, 0),
|
|
3286
|
+
type: 'number',
|
|
3287
|
+
check: (groups, object) => {
|
|
3288
|
+
if (!Number.isInteger(groups)) throw Error('Must be a whole number')
|
|
3289
|
+
const definitionsWithSameLevel = object.attrs.teamworkRanks?.filter(
|
|
3290
|
+
rankDefinition => rankDefinition.groups === groups,
|
|
3291
|
+
)
|
|
3292
|
+
if (definitionsWithSameLevel?.length > 1) {
|
|
3293
|
+
throw Error(
|
|
3294
|
+
`There is already a rank with ${groups} users required for the rank`,
|
|
3295
|
+
)
|
|
3296
|
+
}
|
|
3297
|
+
},
|
|
3298
|
+
})
|
|
3299
|
+
|
|
3300
|
+
types.teamworkRanks = TypeObject({
|
|
3301
|
+
type: {
|
|
3302
|
+
name: types.teamworkRankName,
|
|
3303
|
+
groups: types.teamworkRankParticipations,
|
|
3304
|
+
},
|
|
3101
3305
|
})
|
|
3306
|
+
|
|
3307
|
+
attrs.teamworkRanks = {
|
|
3308
|
+
campus: {
|
|
3309
|
+
label: 'Teamwork ranks',
|
|
3310
|
+
instruction: 'List of teamwork ranks',
|
|
3311
|
+
type: [types.teamworkRanks],
|
|
3312
|
+
// this setting is required for practical UX reasons. It is not
|
|
3313
|
+
// required for platform to work properly, but as there are no other
|
|
3314
|
+
// settings for the campus, it avoid hiding it in "more settings to add"
|
|
3315
|
+
// section and make it more visible/easy to configure for admins (as displayed by default)
|
|
3316
|
+
required: true,
|
|
3317
|
+
editable: true,
|
|
3318
|
+
value: (...args) => [
|
|
3319
|
+
mapValues(types.teamworkRanks.type, subDef =>
|
|
3320
|
+
getDefaultValue(subDef, ...args),
|
|
3321
|
+
),
|
|
3322
|
+
],
|
|
3323
|
+
check: teamworkRanks => {
|
|
3324
|
+
if (!teamworkRanks?.length || !Array.isArray(teamworkRanks)) {
|
|
3325
|
+
throw Error('Must be a non empty array')
|
|
3326
|
+
}
|
|
3327
|
+
},
|
|
3328
|
+
},
|
|
3329
|
+
}
|
|
3330
|
+
|
|
3102
3331
|
// TODO: rename it documentToSign, or something more specific than 'text'
|
|
3103
3332
|
attrs.text = {
|
|
3104
3333
|
'upload-step': {
|
|
@@ -3114,6 +3343,10 @@ attrs.text = {
|
|
|
3114
3343
|
...sharedText,
|
|
3115
3344
|
label: 'Resume', // TODO: mv it to attrs.resume
|
|
3116
3345
|
},
|
|
3346
|
+
'avatar-step': {
|
|
3347
|
+
...sharedText,
|
|
3348
|
+
label: 'Resume', // TODO: mv it to attrs.resume
|
|
3349
|
+
},
|
|
3117
3350
|
}
|
|
3118
3351
|
|
|
3119
3352
|
types.timelineChunk = TypeObject({
|
|
@@ -3242,6 +3475,7 @@ types.adminSelectionValidation = TypeObject({
|
|
|
3242
3475
|
private: true,
|
|
3243
3476
|
primary: true,
|
|
3244
3477
|
label: 'Type',
|
|
3478
|
+
options: ['admin_selection'],
|
|
3245
3479
|
}),
|
|
3246
3480
|
},
|
|
3247
3481
|
label: 'Admin selection evaluation',
|
|
@@ -3252,7 +3486,12 @@ const getTesterImage = ({ attrs }) =>
|
|
|
3252
3486
|
types.testerValidation = TypeObject({
|
|
3253
3487
|
label: 'Tester evaluation',
|
|
3254
3488
|
type: {
|
|
3255
|
-
type: Literal('tester', {
|
|
3489
|
+
type: Literal('tester', {
|
|
3490
|
+
required: true,
|
|
3491
|
+
private: true,
|
|
3492
|
+
label: 'Type',
|
|
3493
|
+
options: ['tester'],
|
|
3494
|
+
}),
|
|
3256
3495
|
testImage: {
|
|
3257
3496
|
type: 'string',
|
|
3258
3497
|
label: 'Docker image',
|
|
@@ -3281,12 +3520,18 @@ const sharedMatchWhere = {
|
|
|
3281
3520
|
}
|
|
3282
3521
|
// matchWhere for user_audit validations
|
|
3283
3522
|
// NB: user 1 should always be excluded because he has admin role that can't be removed
|
|
3523
|
+
const usersOfCampusWithAdmins = ({ attrs }) => ({
|
|
3524
|
+
campus: { _eq: attrs.campus },
|
|
3525
|
+
})
|
|
3284
3526
|
const userInCampus = ({ attrs }) => ({
|
|
3285
3527
|
campus: { _eq: attrs.campus },
|
|
3286
3528
|
_not: { roles: { slug: { _in: ['admin', `campus_admin_${attrs.campus}`] } } },
|
|
3287
3529
|
})
|
|
3530
|
+
const usersInEventWithAdmins = ({ attrs }) => ({
|
|
3531
|
+
events: { eventId: { _eq: attrs.eventId } },
|
|
3532
|
+
})
|
|
3288
3533
|
const userInEvent = ({ attrs }) => ({
|
|
3289
|
-
|
|
3534
|
+
_not: { roles: { slug: { _in: ['admin', `campus_admin_${attrs.campus}`] } } },
|
|
3290
3535
|
events: { eventId: { _eq: attrs.eventId } },
|
|
3291
3536
|
})
|
|
3292
3537
|
|
|
@@ -3313,14 +3558,19 @@ const noAttribution = () => isUser01
|
|
|
3313
3558
|
|
|
3314
3559
|
export const getAuditPath = object => {
|
|
3315
3560
|
const path = `subjects/${getObjectPath(object)}/audit/README.md`
|
|
3316
|
-
|
|
3561
|
+
// TODO: the new content system will no longer use public, in the future we should change this
|
|
3562
|
+
// now we have dedicated repos on gitea for: modules, piscine, ....
|
|
3563
|
+
// so the repo should be something more dynamic depending on the object.type?
|
|
3564
|
+
// for now lets use the raw markdown
|
|
3565
|
+
return `/markdown/raw/root/public/${path}`
|
|
3317
3566
|
}
|
|
3318
3567
|
const sharedTypesForm = {
|
|
3319
3568
|
required: true,
|
|
3320
3569
|
editable: true,
|
|
3321
3570
|
type: 'string',
|
|
3322
|
-
label: 'Audit form
|
|
3323
|
-
instruction:
|
|
3571
|
+
label: 'Audit form URL',
|
|
3572
|
+
instruction:
|
|
3573
|
+
'List of questions asked by the auditor during the audit. The URL should return raw markdown',
|
|
3324
3574
|
...Functions({ 'README in audit folder': getAuditPath }),
|
|
3325
3575
|
}
|
|
3326
3576
|
types.adminAuditValidationDelay = Literal(0, {
|
|
@@ -3355,6 +3605,7 @@ types.adminAuditValidation = TypeObject({
|
|
|
3355
3605
|
private: true,
|
|
3356
3606
|
primary: true,
|
|
3357
3607
|
label: 'Type',
|
|
3608
|
+
options: ['admin_audit'],
|
|
3358
3609
|
}),
|
|
3359
3610
|
delay: types.adminAuditValidationDelay,
|
|
3360
3611
|
required: types.adminAuditValidationRequired,
|
|
@@ -3395,6 +3646,35 @@ types.userAuditValidationRatio = Literal(2, {
|
|
|
3395
3646
|
},
|
|
3396
3647
|
})
|
|
3397
3648
|
|
|
3649
|
+
types.matchInfluence = TypeObject({
|
|
3650
|
+
label: 'Match Algorithm Influence',
|
|
3651
|
+
editable: true,
|
|
3652
|
+
required: true,
|
|
3653
|
+
instruction: 'Adjust weights in the Match algorithm',
|
|
3654
|
+
type: {
|
|
3655
|
+
auditsRatio: Literal(2.0, {
|
|
3656
|
+
label: 'Current Audit Ratio',
|
|
3657
|
+
editable: true,
|
|
3658
|
+
required: true,
|
|
3659
|
+
}),
|
|
3660
|
+
auditsAssigned: Literal(2.0, {
|
|
3661
|
+
editable: true,
|
|
3662
|
+
required: true,
|
|
3663
|
+
label: 'Fewest Pending Audits',
|
|
3664
|
+
}),
|
|
3665
|
+
levelProximity: Literal(2.0, {
|
|
3666
|
+
editable: true,
|
|
3667
|
+
required: true,
|
|
3668
|
+
label: 'Level Proximity',
|
|
3669
|
+
}),
|
|
3670
|
+
lastAuditAttributed: Literal(2.0, {
|
|
3671
|
+
editable: true,
|
|
3672
|
+
required: true,
|
|
3673
|
+
label: 'Last Audit Attributed',
|
|
3674
|
+
}),
|
|
3675
|
+
},
|
|
3676
|
+
})
|
|
3677
|
+
|
|
3398
3678
|
types.userAuditValidation = TypeObject({
|
|
3399
3679
|
label: 'User audit evaluation',
|
|
3400
3680
|
type: {
|
|
@@ -3403,15 +3683,19 @@ types.userAuditValidation = TypeObject({
|
|
|
3403
3683
|
private: true,
|
|
3404
3684
|
primary: true,
|
|
3405
3685
|
label: 'Type',
|
|
3686
|
+
options: ['user_audit'],
|
|
3406
3687
|
}),
|
|
3407
3688
|
delay: types.userAuditValidationDelay,
|
|
3408
3689
|
required: types.userAuditValidationRequired,
|
|
3409
3690
|
ratio: types.userAuditValidationRatio,
|
|
3691
|
+
matchInfluence: types.matchInfluence,
|
|
3410
3692
|
matchWhere: {
|
|
3411
3693
|
...sharedMatchWhere,
|
|
3412
3694
|
...Functions({
|
|
3413
3695
|
'any user in same campus': userInCampus,
|
|
3414
3696
|
'any user in same event': userInEvent,
|
|
3697
|
+
'any user in same campus (with admins)': usersOfCampusWithAdmins,
|
|
3698
|
+
'any user in same event (with admins)': usersInEventWithAdmins,
|
|
3415
3699
|
}),
|
|
3416
3700
|
},
|
|
3417
3701
|
form: sharedTypesForm,
|
|
@@ -3438,6 +3722,7 @@ types.raidAuditorValidation = TypeObject({
|
|
|
3438
3722
|
private: true,
|
|
3439
3723
|
primary: true,
|
|
3440
3724
|
label: 'Type',
|
|
3725
|
+
options: ['dedicated_auditors_for_event'],
|
|
3441
3726
|
}),
|
|
3442
3727
|
delay: types.adminAuditValidationDelay, // no limit to audit?
|
|
3443
3728
|
required: types.adminAuditValidationRequired,
|
|
@@ -3519,6 +3804,18 @@ attrs.videos = {
|
|
|
3519
3804
|
}),
|
|
3520
3805
|
}
|
|
3521
3806
|
|
|
3807
|
+
attrs.legalText = {
|
|
3808
|
+
'avatar-step': Literal(
|
|
3809
|
+
"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.",
|
|
3810
|
+
{
|
|
3811
|
+
type: 'string',
|
|
3812
|
+
label: 'Legal text',
|
|
3813
|
+
editable: false,
|
|
3814
|
+
required: true,
|
|
3815
|
+
},
|
|
3816
|
+
),
|
|
3817
|
+
}
|
|
3818
|
+
|
|
3522
3819
|
const getWeek = ({ attrs }) => {
|
|
3523
3820
|
if (!attrs.startDay) return undefined // for exams in module for example
|
|
3524
3821
|
const diff = attrs.startDay / 7
|
|
@@ -3556,7 +3853,7 @@ const sharedZeroXpIndex = Literal(0, {
|
|
|
3556
3853
|
private: true,
|
|
3557
3854
|
})
|
|
3558
3855
|
const parentXpIndex = ({ parent }) => parent.attrs.xpIndex
|
|
3559
|
-
const getXpIndex = ({
|
|
3856
|
+
const getXpIndex = ({ prev, parent }) => {
|
|
3560
3857
|
if (prev) return (prev.attrs.xpIndex || 0) + (_difficultyXpCoef(prev) || 1)
|
|
3561
3858
|
return parent?.attrs.xpIndex || 0
|
|
3562
3859
|
}
|