@01-edu/shared 1.0.6 → 1.0.9
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 +206 -56
- package/attrs.js +16 -10
- package/bin/check-definitions.js +8 -0
- package/definitions-checker.js +25 -23
- package/package.json +2 -2
- package/path.js +18 -8
- package/toolbox.js +24 -8
package/README.md
CHANGED
|
@@ -1,5 +1,31 @@
|
|
|
1
1
|
# 01-Edu shared library
|
|
2
2
|
|
|
3
|
-
## `check-
|
|
3
|
+
## `check-defs` command
|
|
4
4
|
|
|
5
|
+
Script used to validate content correctness.
|
|
5
6
|
|
|
7
|
+
It check the overall structure, attributes and other properties in definitions
|
|
8
|
+
|
|
9
|
+
It also check the existance of needed `README.md` files
|
|
10
|
+
|
|
11
|
+
### Usage:
|
|
12
|
+
|
|
13
|
+
```sh
|
|
14
|
+
# Ensure your current working directory is the repository you want to check
|
|
15
|
+
cd piscine-test
|
|
16
|
+
|
|
17
|
+
# Normal usage
|
|
18
|
+
npx -y -p '@01-edu/shared' check-defs
|
|
19
|
+
|
|
20
|
+
# Pinned version
|
|
21
|
+
npx -y -p '@01-edu/shared@v1.0.6' check-defs
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
#### `-w`, `--watch` param
|
|
25
|
+
|
|
26
|
+
Will re-check on system events (new, updated, removed files)
|
|
27
|
+
Never exit, just keep waiting for changes to update the report
|
|
28
|
+
|
|
29
|
+
```sh
|
|
30
|
+
npx -y -p '@01-edu/shared' check-defs --watch
|
|
31
|
+
```
|
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,6 +123,7 @@ 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
128
|
// description: complete explanation of what the attributes does - description is displayed only when the used hover the ℹ️ icon in the front end,
|
|
127
129
|
// required: // if the attribute is always there by default,
|
|
@@ -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
|
|
@@ -1878,7 +1857,7 @@ attrs.input = {
|
|
|
1878
1857
|
}
|
|
1879
1858
|
try {
|
|
1880
1859
|
return new RegExp(value)
|
|
1881
|
-
} catch
|
|
1860
|
+
} catch {
|
|
1882
1861
|
throw Error('Invalid Regular expression.')
|
|
1883
1862
|
}
|
|
1884
1863
|
}
|
|
@@ -1892,7 +1871,7 @@ const getInScope = ({ attrs }) => {
|
|
|
1892
1871
|
}
|
|
1893
1872
|
const getProjectInScope = ({ attrs }) => attrs.hasStarted
|
|
1894
1873
|
const getExamExerciseInScope = ({ parent }) => parent.attrs.inScope
|
|
1895
|
-
const getExerciseInScope = ({ parent, attrs
|
|
1874
|
+
const getExerciseInScope = ({ parent, attrs }) => {
|
|
1896
1875
|
if (parent.attrs.inScope) {
|
|
1897
1876
|
if (isHackathon(parent)) {
|
|
1898
1877
|
const now = Date.now()
|
|
@@ -2024,31 +2003,55 @@ types.objectRootRelativePath = Literal('../', {
|
|
|
2024
2003
|
label: 'Content relative path',
|
|
2025
2004
|
instruction: 'In same parent',
|
|
2026
2005
|
editable: true,
|
|
2027
|
-
check:
|
|
2006
|
+
check: (path, object) => {
|
|
2007
|
+
try {
|
|
2008
|
+
getObjectFromRelativePath(path, object)
|
|
2009
|
+
} catch (err) {
|
|
2010
|
+
err.userFeedback = `This content relative path (${path}) is invalid, please select a valid content in the list below or remove it.`
|
|
2011
|
+
throw err
|
|
2012
|
+
}
|
|
2013
|
+
},
|
|
2028
2014
|
options: object => {
|
|
2029
2015
|
if (!object?.parent?.children) return []
|
|
2030
2016
|
const sorted = Object.entries(object.parent.children).sort(byValueIdx)
|
|
2031
|
-
const index = sorted.findIndex(([k
|
|
2017
|
+
const index = sorted.findIndex(([k]) => k === object.key)
|
|
2032
2018
|
if (index < 1) return sorted.map(([key, _]) => `../${key}`)
|
|
2033
2019
|
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
2020
|
return before.map(([key, _]) => `../${key}`).reverse()
|
|
2039
2021
|
},
|
|
2040
2022
|
})
|
|
2041
2023
|
types.sharedObjectList = {
|
|
2042
2024
|
label: 'Contents', // synonyms: item, material
|
|
2043
2025
|
editable: true,
|
|
2044
|
-
check: objects => {
|
|
2026
|
+
check: (objects, object) => {
|
|
2045
2027
|
if (!Array.isArray(objects) || !objects.length) {
|
|
2046
|
-
|
|
2028
|
+
const error = new Error('Must be a non empty array')
|
|
2029
|
+
error.userFeedback =
|
|
2030
|
+
'This list cannot be empty! Please add an item or remove the setting.'
|
|
2031
|
+
throw error
|
|
2047
2032
|
}
|
|
2048
2033
|
const uniques = [...new Set(objects)]
|
|
2049
2034
|
if (objects.length !== uniques.length) {
|
|
2050
2035
|
throw Error('Duplicates are not allowed.')
|
|
2051
2036
|
}
|
|
2037
|
+
|
|
2038
|
+
const invalidObjectsRequirements = objects.filter(path => {
|
|
2039
|
+
const objectFromPath = getObjectFromRelativePath(path, object, {
|
|
2040
|
+
throwError: false,
|
|
2041
|
+
})
|
|
2042
|
+
return !objectFromPath
|
|
2043
|
+
})
|
|
2044
|
+
|
|
2045
|
+
if (invalidObjectsRequirements.length) {
|
|
2046
|
+
const paths = invalidObjectsRequirements.map(p => `'${p}'`).join(', ')
|
|
2047
|
+
const error = new Error(
|
|
2048
|
+
`Invalid objects requirements - no object found for the following relative paths: ${paths}`,
|
|
2049
|
+
)
|
|
2050
|
+
error.userFeedback =
|
|
2051
|
+
'Some Contents are misconfigured, please update them!'
|
|
2052
|
+
console.error(error.message)
|
|
2053
|
+
throw error
|
|
2054
|
+
}
|
|
2052
2055
|
},
|
|
2053
2056
|
}
|
|
2054
2057
|
|
|
@@ -2067,6 +2070,25 @@ const contentRequirements = {
|
|
|
2067
2070
|
if (!skills && !objects && !core) {
|
|
2068
2071
|
throw Error('Empty requirements, should be removed')
|
|
2069
2072
|
}
|
|
2073
|
+
|
|
2074
|
+
const allObjects = core ? [...(objects || []), core] : objects || []
|
|
2075
|
+
const invalidObjectsRequirements = allObjects.filter(path => {
|
|
2076
|
+
const objectFromPath = getObjectFromRelativePath(path, object, {
|
|
2077
|
+
throwError: false,
|
|
2078
|
+
})
|
|
2079
|
+
return !objectFromPath
|
|
2080
|
+
})
|
|
2081
|
+
|
|
2082
|
+
if (invalidObjectsRequirements.length) {
|
|
2083
|
+
const paths = invalidObjectsRequirements.map(p => `'${p}'`).join(', ')
|
|
2084
|
+
const error = new Error(
|
|
2085
|
+
`Invalid objects requirements - no object found for the following relative paths: ${paths}`,
|
|
2086
|
+
)
|
|
2087
|
+
error.userFeedback =
|
|
2088
|
+
'You have some misconfigured Access conditions, please update them!'
|
|
2089
|
+
console.error(error.message)
|
|
2090
|
+
throw error
|
|
2091
|
+
}
|
|
2070
2092
|
},
|
|
2071
2093
|
}
|
|
2072
2094
|
|
|
@@ -2075,7 +2097,7 @@ const levelRequirements = {
|
|
|
2075
2097
|
instruction: 'Conditions to unlock and earn this level',
|
|
2076
2098
|
description:
|
|
2077
2099
|
'Sets the requirements that have to be met for a level to be unlocked and earned by a student.',
|
|
2078
|
-
check:
|
|
2100
|
+
check: requirements => {
|
|
2079
2101
|
const { skills, objects, ...rest } = requirements
|
|
2080
2102
|
if (Object.keys(rest).length) {
|
|
2081
2103
|
throw Error(`Unsupported attribute "${Object.keys(rest)[0]}"`)
|
|
@@ -2187,6 +2209,17 @@ attrs.link = {
|
|
|
2187
2209
|
}),
|
|
2188
2210
|
}
|
|
2189
2211
|
|
|
2212
|
+
relationAttrs.mandatory = {
|
|
2213
|
+
module: {
|
|
2214
|
+
project: {
|
|
2215
|
+
label: 'Mandatory content to validate the curriculum',
|
|
2216
|
+
type: 'boolean',
|
|
2217
|
+
required: false,
|
|
2218
|
+
editable: true,
|
|
2219
|
+
},
|
|
2220
|
+
},
|
|
2221
|
+
}
|
|
2222
|
+
|
|
2190
2223
|
// TODO: should be required for exam exercises.
|
|
2191
2224
|
// But it is about to be refactored to define group base on exercise level?
|
|
2192
2225
|
const maxGroupReducer = (t, c) => Math.max(c.attrs.group || 1, t) // added '|| 1' as group is not required and could be undefined
|
|
@@ -2351,7 +2384,7 @@ const getCaptainLogin = ({ group, parent }) => {
|
|
|
2351
2384
|
}
|
|
2352
2385
|
const getRepositoryPath = ({ name, group, parent }, user) =>
|
|
2353
2386
|
`${getCaptainLogin({ group, parent }) || user.login}/${name}`
|
|
2354
|
-
const getExerciseRepositoryPath = ({ attrs, parent,
|
|
2387
|
+
const getExerciseRepositoryPath = ({ attrs, parent, group }, user) => {
|
|
2355
2388
|
const captainLogin = getCaptainLogin({ parent, group })
|
|
2356
2389
|
return captainLogin
|
|
2357
2390
|
? `${captainLogin}/${parent.path.replace(/\/+/g, '-')}`
|
|
@@ -2775,7 +2808,7 @@ const getProgressStatus = progress => {
|
|
|
2775
2808
|
}
|
|
2776
2809
|
|
|
2777
2810
|
const examExerciseStatus = object => {
|
|
2778
|
-
const {
|
|
2811
|
+
const { attrs, progress, parent } = object
|
|
2779
2812
|
const progressStatus = getProgressStatus(progress)
|
|
2780
2813
|
if (progressStatus) return progressStatus
|
|
2781
2814
|
if (!parent) return 'available'
|
|
@@ -2795,7 +2828,7 @@ const examExerciseStatus = object => {
|
|
|
2795
2828
|
return prevValidated ? 'available' : 'blocked'
|
|
2796
2829
|
}
|
|
2797
2830
|
const questExerciseStatus = object => {
|
|
2798
|
-
const { prev, attrs, progress, parent
|
|
2831
|
+
const { prev, attrs, progress, parent } = object
|
|
2799
2832
|
const progressStatus = getProgressStatus(progress)
|
|
2800
2833
|
if (progressStatus) return progressStatus
|
|
2801
2834
|
if (!parent) return 'available'
|
|
@@ -2847,8 +2880,10 @@ const hasSucceededRequiredObjects = (requirements, object) => {
|
|
|
2847
2880
|
try {
|
|
2848
2881
|
objectFromPath = getObjectFromRelativePath(relativePath, object)
|
|
2849
2882
|
} catch {
|
|
2850
|
-
// consider the requirement
|
|
2851
|
-
|
|
2883
|
+
// consider the requirement unlocked if an error is thrown by getObjectFromRelativePath,
|
|
2884
|
+
// because it would mean an admin wrongly set the requirement, probably manually in the db
|
|
2885
|
+
// (it is not possible to set an invalid requirement from the admin configuration interface)
|
|
2886
|
+
return true
|
|
2852
2887
|
}
|
|
2853
2888
|
return objectFromPath?.attrs.status === 'succeeded'
|
|
2854
2889
|
})
|
|
@@ -3099,6 +3134,74 @@ const sharedText = Literal('', {
|
|
|
3099
3134
|
editable: true,
|
|
3100
3135
|
...translatable,
|
|
3101
3136
|
})
|
|
3137
|
+
|
|
3138
|
+
types.teamworkRankName = Literal('', {
|
|
3139
|
+
label: 'Rank name',
|
|
3140
|
+
type: 'string',
|
|
3141
|
+
editable: true,
|
|
3142
|
+
required: true,
|
|
3143
|
+
primary: true,
|
|
3144
|
+
check: (name, object) => {
|
|
3145
|
+
const definitionsWithSameName = object.attrs.teamworkRanks?.filter(
|
|
3146
|
+
rankDefinition => rankDefinition.name === name,
|
|
3147
|
+
)
|
|
3148
|
+
if (definitionsWithSameName?.length > 1) {
|
|
3149
|
+
throw Error(
|
|
3150
|
+
`Name "${name}" is already set for a teamwork rank definition! A given name can only be attributed once to a rank.`,
|
|
3151
|
+
)
|
|
3152
|
+
}
|
|
3153
|
+
},
|
|
3154
|
+
})
|
|
3155
|
+
|
|
3156
|
+
types.teamworkRankParticipations = Literal(0, {
|
|
3157
|
+
label: 'Group Participations',
|
|
3158
|
+
instruction:
|
|
3159
|
+
'The number of users the student has to work with, to unlock this rank.',
|
|
3160
|
+
editable: true,
|
|
3161
|
+
required: true,
|
|
3162
|
+
options: arrayOf(150, 1),
|
|
3163
|
+
type: 'number',
|
|
3164
|
+
check: (groups, object) => {
|
|
3165
|
+
if (!Number.isInteger(groups)) throw Error('Must be a whole number')
|
|
3166
|
+
|
|
3167
|
+
const definitionsWithSameLevel = object.attrs.teamworkRanks.filter(
|
|
3168
|
+
rankDefinition => rankDefinition.groups === groups,
|
|
3169
|
+
)
|
|
3170
|
+
if (definitionsWithSameLevel?.length > 1) {
|
|
3171
|
+
throw Error(
|
|
3172
|
+
`There is already a rank with ${groups} users required for the rank`,
|
|
3173
|
+
)
|
|
3174
|
+
}
|
|
3175
|
+
},
|
|
3176
|
+
})
|
|
3177
|
+
|
|
3178
|
+
types.teamworkRanks = TypeObject({
|
|
3179
|
+
type: {
|
|
3180
|
+
name: types.teamworkRankName,
|
|
3181
|
+
groups: types.teamworkRankParticipations,
|
|
3182
|
+
},
|
|
3183
|
+
})
|
|
3184
|
+
|
|
3185
|
+
attrs.teamworkRanks = {
|
|
3186
|
+
campus: {
|
|
3187
|
+
label: 'Teamwork ranks',
|
|
3188
|
+
instruction: 'List of teamwork ranks',
|
|
3189
|
+
type: [types.teamworkRanks],
|
|
3190
|
+
required: true,
|
|
3191
|
+
editable: true,
|
|
3192
|
+
value: (...args) => [
|
|
3193
|
+
mapValues(types.teamworkRanks.type, subDef =>
|
|
3194
|
+
getDefaultValue(subDef, ...args),
|
|
3195
|
+
),
|
|
3196
|
+
],
|
|
3197
|
+
check: teamworkRanks => {
|
|
3198
|
+
if (!teamworkRanks?.length || !Array.isArray(teamworkRanks)) {
|
|
3199
|
+
throw Error('Must be a non empty array')
|
|
3200
|
+
}
|
|
3201
|
+
},
|
|
3202
|
+
},
|
|
3203
|
+
}
|
|
3204
|
+
|
|
3102
3205
|
// TODO: rename it documentToSign, or something more specific than 'text'
|
|
3103
3206
|
attrs.text = {
|
|
3104
3207
|
'upload-step': {
|
|
@@ -3242,6 +3345,7 @@ types.adminSelectionValidation = TypeObject({
|
|
|
3242
3345
|
private: true,
|
|
3243
3346
|
primary: true,
|
|
3244
3347
|
label: 'Type',
|
|
3348
|
+
options: ['admin_selection'],
|
|
3245
3349
|
}),
|
|
3246
3350
|
},
|
|
3247
3351
|
label: 'Admin selection evaluation',
|
|
@@ -3252,7 +3356,12 @@ const getTesterImage = ({ attrs }) =>
|
|
|
3252
3356
|
types.testerValidation = TypeObject({
|
|
3253
3357
|
label: 'Tester evaluation',
|
|
3254
3358
|
type: {
|
|
3255
|
-
type: Literal('tester', {
|
|
3359
|
+
type: Literal('tester', {
|
|
3360
|
+
required: true,
|
|
3361
|
+
private: true,
|
|
3362
|
+
label: 'Type',
|
|
3363
|
+
options: ['tester'],
|
|
3364
|
+
}),
|
|
3256
3365
|
testImage: {
|
|
3257
3366
|
type: 'string',
|
|
3258
3367
|
label: 'Docker image',
|
|
@@ -3281,12 +3390,18 @@ const sharedMatchWhere = {
|
|
|
3281
3390
|
}
|
|
3282
3391
|
// matchWhere for user_audit validations
|
|
3283
3392
|
// NB: user 1 should always be excluded because he has admin role that can't be removed
|
|
3393
|
+
const usersOfCampusWithAdmins = ({ attrs }) => ({
|
|
3394
|
+
campus: { _eq: attrs.campus },
|
|
3395
|
+
})
|
|
3284
3396
|
const userInCampus = ({ attrs }) => ({
|
|
3285
3397
|
campus: { _eq: attrs.campus },
|
|
3286
3398
|
_not: { roles: { slug: { _in: ['admin', `campus_admin_${attrs.campus}`] } } },
|
|
3287
3399
|
})
|
|
3400
|
+
const usersInEventWithAdmins = ({ attrs }) => ({
|
|
3401
|
+
events: { eventId: { _eq: attrs.eventId } },
|
|
3402
|
+
})
|
|
3288
3403
|
const userInEvent = ({ attrs }) => ({
|
|
3289
|
-
|
|
3404
|
+
_not: { roles: { slug: { _in: ['admin', `campus_admin_${attrs.campus}`] } } },
|
|
3290
3405
|
events: { eventId: { _eq: attrs.eventId } },
|
|
3291
3406
|
})
|
|
3292
3407
|
|
|
@@ -3355,6 +3470,7 @@ types.adminAuditValidation = TypeObject({
|
|
|
3355
3470
|
private: true,
|
|
3356
3471
|
primary: true,
|
|
3357
3472
|
label: 'Type',
|
|
3473
|
+
options: ['admin_audit'],
|
|
3358
3474
|
}),
|
|
3359
3475
|
delay: types.adminAuditValidationDelay,
|
|
3360
3476
|
required: types.adminAuditValidationRequired,
|
|
@@ -3395,6 +3511,35 @@ types.userAuditValidationRatio = Literal(2, {
|
|
|
3395
3511
|
},
|
|
3396
3512
|
})
|
|
3397
3513
|
|
|
3514
|
+
types.matchInfluence = TypeObject({
|
|
3515
|
+
label: 'Match Algorithm Influence',
|
|
3516
|
+
editable: true,
|
|
3517
|
+
required: true,
|
|
3518
|
+
instruction: 'Adjust weights in the Match algorithm',
|
|
3519
|
+
type: {
|
|
3520
|
+
auditsRatio: Literal(2.0, {
|
|
3521
|
+
label: 'Current Audit Ratio',
|
|
3522
|
+
editable: true,
|
|
3523
|
+
required: true,
|
|
3524
|
+
}),
|
|
3525
|
+
auditsAssigned: Literal(2.0, {
|
|
3526
|
+
editable: true,
|
|
3527
|
+
required: true,
|
|
3528
|
+
label: 'Fewest Pending Audits',
|
|
3529
|
+
}),
|
|
3530
|
+
levelProximity: Literal(2.0, {
|
|
3531
|
+
editable: true,
|
|
3532
|
+
required: true,
|
|
3533
|
+
label: 'Level Proximity',
|
|
3534
|
+
}),
|
|
3535
|
+
lastAuditAttributed: Literal(2.0, {
|
|
3536
|
+
editable: true,
|
|
3537
|
+
required: true,
|
|
3538
|
+
label: 'Last Audit Attributed',
|
|
3539
|
+
}),
|
|
3540
|
+
},
|
|
3541
|
+
})
|
|
3542
|
+
|
|
3398
3543
|
types.userAuditValidation = TypeObject({
|
|
3399
3544
|
label: 'User audit evaluation',
|
|
3400
3545
|
type: {
|
|
@@ -3403,15 +3548,19 @@ types.userAuditValidation = TypeObject({
|
|
|
3403
3548
|
private: true,
|
|
3404
3549
|
primary: true,
|
|
3405
3550
|
label: 'Type',
|
|
3551
|
+
options: ['user_audit'],
|
|
3406
3552
|
}),
|
|
3407
3553
|
delay: types.userAuditValidationDelay,
|
|
3408
3554
|
required: types.userAuditValidationRequired,
|
|
3409
3555
|
ratio: types.userAuditValidationRatio,
|
|
3556
|
+
matchInfluence: types.matchInfluence,
|
|
3410
3557
|
matchWhere: {
|
|
3411
3558
|
...sharedMatchWhere,
|
|
3412
3559
|
...Functions({
|
|
3413
3560
|
'any user in same campus': userInCampus,
|
|
3414
3561
|
'any user in same event': userInEvent,
|
|
3562
|
+
'any user in same campus (with admins)': usersOfCampusWithAdmins,
|
|
3563
|
+
'any user in same event (with admins)': usersInEventWithAdmins,
|
|
3415
3564
|
}),
|
|
3416
3565
|
},
|
|
3417
3566
|
form: sharedTypesForm,
|
|
@@ -3438,6 +3587,7 @@ types.raidAuditorValidation = TypeObject({
|
|
|
3438
3587
|
private: true,
|
|
3439
3588
|
primary: true,
|
|
3440
3589
|
label: 'Type',
|
|
3590
|
+
options: ['dedicated_auditors_for_event'],
|
|
3441
3591
|
}),
|
|
3442
3592
|
delay: types.adminAuditValidationDelay, // no limit to audit?
|
|
3443
3593
|
required: types.adminAuditValidationRequired,
|
|
@@ -3556,7 +3706,7 @@ const sharedZeroXpIndex = Literal(0, {
|
|
|
3556
3706
|
private: true,
|
|
3557
3707
|
})
|
|
3558
3708
|
const parentXpIndex = ({ parent }) => parent.attrs.xpIndex
|
|
3559
|
-
const getXpIndex = ({
|
|
3709
|
+
const getXpIndex = ({ prev, parent }) => {
|
|
3560
3710
|
if (prev) return (prev.attrs.xpIndex || 0) + (_difficultyXpCoef(prev) || 1)
|
|
3561
3711
|
return parent?.attrs.xpIndex || 0
|
|
3562
3712
|
}
|
package/attrs.js
CHANGED
|
@@ -16,8 +16,8 @@ const typeChecker = (defs, value, object, key) => {
|
|
|
16
16
|
const { type, check, options } = defs
|
|
17
17
|
|
|
18
18
|
if (value == null) {
|
|
19
|
-
if (!defs.required) return true
|
|
20
|
-
// if no value for required attribute, reject
|
|
19
|
+
if (!defs.required || defs.value !== undefined) return true
|
|
20
|
+
// if no value for required attribute without a default value, reject
|
|
21
21
|
throw Error(`missing value for required attribute ${key}`)
|
|
22
22
|
}
|
|
23
23
|
|
|
@@ -33,11 +33,17 @@ const typeChecker = (defs, value, object, key) => {
|
|
|
33
33
|
|
|
34
34
|
if (options) {
|
|
35
35
|
const opts = typeof options === 'function' ? options(object) : options
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
36
|
+
if (opts.length === 1) {
|
|
37
|
+
if (opts[0] !== value) {
|
|
38
|
+
throw Error(`${key} must be ${opts[0]} but was ${value}`)
|
|
39
|
+
}
|
|
40
|
+
} else {
|
|
41
|
+
const isAnOption = opts.includes(value)
|
|
42
|
+
if (!isAnOption) {
|
|
43
|
+
throw Error(
|
|
44
|
+
`invalid option for ${key}: should be included in ${opts.join(', ')}`,
|
|
45
|
+
)
|
|
46
|
+
}
|
|
41
47
|
}
|
|
42
48
|
}
|
|
43
49
|
|
|
@@ -111,7 +117,7 @@ const typeChecker = (defs, value, object, key) => {
|
|
|
111
117
|
// same as attrs with the check & function by name generated and descriptions form markdown files
|
|
112
118
|
export const attributes = mapEntries(attrs, ([attrKey, matches]) => [
|
|
113
119
|
attrKey,
|
|
114
|
-
mapValues(matches, (defs
|
|
120
|
+
mapValues(matches, (defs /* type */) => ({
|
|
115
121
|
...defs,
|
|
116
122
|
check: (value, object) => typeChecker(defs, value, object, attrKey),
|
|
117
123
|
})),
|
|
@@ -121,8 +127,8 @@ export const relationAttributes = mapEntries(
|
|
|
121
127
|
relationAttrs,
|
|
122
128
|
([attrKey, byParent]) => [
|
|
123
129
|
attrKey,
|
|
124
|
-
mapValues(byParent, (matches
|
|
125
|
-
mapValues(matches, (defs
|
|
130
|
+
mapValues(byParent, (matches /* parentType */) =>
|
|
131
|
+
mapValues(matches, (defs /* childType */) => ({
|
|
126
132
|
...defs,
|
|
127
133
|
check: (value, object) => typeChecker(defs, value, object, attrKey),
|
|
128
134
|
})),
|
package/bin/check-definitions.js
CHANGED
|
@@ -4,6 +4,7 @@ import { readFile, stat, watch } from 'node:fs/promises'
|
|
|
4
4
|
|
|
5
5
|
import { checkAndBuildDefinitions } from '../definitions-checker.js'
|
|
6
6
|
|
|
7
|
+
const rootTypes = ['module', 'piscine', 'sign-up', 'onboarding']
|
|
7
8
|
const isAudit = validation => validation.type.endsWith('_audit')
|
|
8
9
|
const readDef = async key => {
|
|
9
10
|
const path = key == null ? 'content/def.json' : `content/${key}/def.json`
|
|
@@ -11,6 +12,12 @@ const readDef = async key => {
|
|
|
11
12
|
def.attrs || (def.attrs = {})
|
|
12
13
|
def.referencePath = path
|
|
13
14
|
|
|
15
|
+
if (key == null && !rootTypes.includes(def.type)) {
|
|
16
|
+
throw Error(
|
|
17
|
+
`Root definition must be one of ${rootTypes.join(', ')}, found: ${def.type}`,
|
|
18
|
+
)
|
|
19
|
+
}
|
|
20
|
+
|
|
14
21
|
switch (def.type) {
|
|
15
22
|
case 'project':
|
|
16
23
|
// biome-ignore lint/suspicious/noFallthroughSwitchClause: We want to fallthrough
|
|
@@ -54,6 +61,7 @@ if (process.argv.includes('--watch') || process.argv.includes('-w')) {
|
|
|
54
61
|
await runChecks()
|
|
55
62
|
for await (const event of watch('.', { recursive: true })) {
|
|
56
63
|
console.clear()
|
|
64
|
+
if (!event.filename.endsWith('def.json')) continue
|
|
57
65
|
console.log(event.eventType, 'on', event.filename, '\n')
|
|
58
66
|
await runChecks()
|
|
59
67
|
}
|
package/definitions-checker.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { childTypes, objectTypes } from './toolbox.js'
|
|
2
1
|
import { attributes, relationAttributes } from './attrs.js'
|
|
2
|
+
import { childTypes, objectTypes } from './toolbox.js'
|
|
3
3
|
|
|
4
4
|
const normalize = str =>
|
|
5
5
|
str
|
|
@@ -9,16 +9,8 @@ const normalize = str =>
|
|
|
9
9
|
.replaceAll(' ', '-')
|
|
10
10
|
|
|
11
11
|
const assertDef = def => {
|
|
12
|
-
const {
|
|
13
|
-
|
|
14
|
-
name,
|
|
15
|
-
attrs,
|
|
16
|
-
children,
|
|
17
|
-
childrenAttrs,
|
|
18
|
-
refId,
|
|
19
|
-
referencePath,
|
|
20
|
-
...rest
|
|
21
|
-
} = def
|
|
12
|
+
const { type, name, attrs, children, childrenAttrs, referencePath, ...rest } =
|
|
13
|
+
def
|
|
22
14
|
const [extra] = Object.keys(rest)
|
|
23
15
|
if (extra) {
|
|
24
16
|
throw Error(
|
|
@@ -27,11 +19,12 @@ const assertDef = def => {
|
|
|
27
19
|
}
|
|
28
20
|
if (!objectTypes.has(type)) throw Error(`Invalid type property`)
|
|
29
21
|
if (!name || typeof name !== 'string') throw Error(`Invalid name property`)
|
|
30
|
-
if (attrs
|
|
31
|
-
|
|
22
|
+
if (!attrs || typeof attrs !== 'object' || Array.isArray(attrs)) {
|
|
23
|
+
throw Error(`Invalid attrs property`)
|
|
24
|
+
}
|
|
32
25
|
if (childrenAttrs) throw Error(`childrenAttrs is no longer supported`)
|
|
33
26
|
|
|
34
|
-
for (const key of Object.keys(attrs)) {
|
|
27
|
+
for (const key of Object.keys(attrs || {})) {
|
|
35
28
|
if (relationAttributes[key]) {
|
|
36
29
|
throw Error(
|
|
37
30
|
`Attr ${key} should be defined in the relation with its parent, not on the object itself.`,
|
|
@@ -52,16 +45,19 @@ const assertDef = def => {
|
|
|
52
45
|
}
|
|
53
46
|
}
|
|
54
47
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
48
|
+
const buildTree = (
|
|
49
|
+
{ name, type, attrs, children, referencePath },
|
|
50
|
+
parent,
|
|
51
|
+
depth,
|
|
52
|
+
) => {
|
|
53
|
+
// As we check for the parent / child type structure, this should never happen
|
|
54
|
+
// so we did not bother to make a more descriptive error for this case
|
|
55
|
+
if (depth > 100) throw Error('Unexpected very deep tree, maybe circular ?')
|
|
60
56
|
const object = { name, type, attrs, children: {}, referencePath, parent }
|
|
61
57
|
if (!children) return object
|
|
62
58
|
let prev
|
|
63
59
|
for (const [key, { ref, ...rest }] of Object.entries(children)) {
|
|
64
|
-
const child = buildTree(ref, object)
|
|
60
|
+
const child = buildTree(ref, object, (depth || 0) + 1)
|
|
65
61
|
child.attrs = { ...child.attrs, ...rest }
|
|
66
62
|
prev && (prev.next = child)
|
|
67
63
|
child.prev = prev
|
|
@@ -73,7 +69,9 @@ const buildTree = ({ name, type, attrs, children, referencePath }, parent) => {
|
|
|
73
69
|
const assertRelation = (parent, key, relation) => {
|
|
74
70
|
if (!/^[a-z0-9-]*$/.test(key)) {
|
|
75
71
|
throw Error(
|
|
76
|
-
`Invalid key for child ${key} Kebab-case key suggestion: ${normalize(
|
|
72
|
+
`Invalid key for child ${key} Kebab-case key suggestion: ${normalize(
|
|
73
|
+
key,
|
|
74
|
+
)}`,
|
|
77
75
|
)
|
|
78
76
|
}
|
|
79
77
|
|
|
@@ -176,7 +174,10 @@ export const checkAndBuildDefinitions = async readDef => {
|
|
|
176
174
|
const getDefs = async ([key, relation]) => {
|
|
177
175
|
let def
|
|
178
176
|
try {
|
|
179
|
-
|
|
177
|
+
const cached = cache[key]
|
|
178
|
+
def = await (cached || (cache[key] = readDef(key)))
|
|
179
|
+
relation && (relation.ref = def)
|
|
180
|
+
if (cached) return [] // skip assert and children checks if already cached
|
|
180
181
|
} catch (err) {
|
|
181
182
|
err.name = key
|
|
182
183
|
throw err
|
|
@@ -188,11 +189,12 @@ export const checkAndBuildDefinitions = async readDef => {
|
|
|
188
189
|
err.referencePath = def.referencePath
|
|
189
190
|
throw err
|
|
190
191
|
}
|
|
191
|
-
relation && (relation.ref = def)
|
|
192
192
|
const relations = Object.entries(def.children || {}).map(getDefs)
|
|
193
193
|
try {
|
|
194
194
|
return [def, ...(await Promise.all(relations)).flat()]
|
|
195
195
|
} catch (err) {
|
|
196
|
+
// Some content may be used at multiple place (ex: exam exercises)
|
|
197
|
+
// so we may have multiple parents
|
|
196
198
|
key && (err.parents || (err.parents = [])).push(key)
|
|
197
199
|
throw err
|
|
198
200
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@01-edu/shared",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.9",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "",
|
|
6
6
|
"scripts": {
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
"./skill-definitions.js",
|
|
22
22
|
"./toolbox.js"
|
|
23
23
|
],
|
|
24
|
-
"license": "
|
|
24
|
+
"license": "Fair",
|
|
25
25
|
"bin": {
|
|
26
26
|
"check-defs": "./bin/check-definitions.js"
|
|
27
27
|
},
|
package/path.js
CHANGED
|
@@ -39,15 +39,23 @@ const walk = (object, segment) => {
|
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
/**
|
|
42
|
-
* @
|
|
43
|
-
* @
|
|
44
|
-
* @
|
|
42
|
+
* @param {string} relativePath - a relative path
|
|
43
|
+
* @param {Object} currentObj - the object from where the relative path starts
|
|
44
|
+
* @param {Object} [opts = { throwError: true } ] - options object with boolean property `throwError` to throw an error in case the path is invalid or no object is found
|
|
45
|
+
* @returns {Object} - the object in the relative path
|
|
45
46
|
*/
|
|
46
|
-
export const getObjectFromRelativePath = (
|
|
47
|
+
export const getObjectFromRelativePath = (
|
|
48
|
+
relativePath,
|
|
49
|
+
currentObj,
|
|
50
|
+
opts = { throwError: true },
|
|
51
|
+
) => {
|
|
47
52
|
const currentPath = currentObj.path
|
|
48
53
|
if (isAbsolutePath(relativePath)) {
|
|
49
|
-
throw Error("'relativePath' must be a relative path")
|
|
54
|
+
if (opts.throwError) throw Error("'relativePath' must be a relative path")
|
|
55
|
+
console.error("'relativePath' must be a relative path")
|
|
56
|
+
return undefined
|
|
50
57
|
}
|
|
58
|
+
|
|
51
59
|
// Ignore trailing `/` in the relative path
|
|
52
60
|
if (relativePath[relativePath.length - 1] === '/') {
|
|
53
61
|
relativePath = relativePath.slice(0, -1)
|
|
@@ -55,9 +63,11 @@ export const getObjectFromRelativePath = (relativePath, currentObj) => {
|
|
|
55
63
|
|
|
56
64
|
const matchingObject = relativePath.split('/').reduce(walk, currentObj)
|
|
57
65
|
if (matchingObject) return matchingObject
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
)
|
|
66
|
+
|
|
67
|
+
const errorMessage = `Incorrect relative path '${relativePath}': no object found — current path = '${currentPath}'`
|
|
68
|
+
if (opts.throwError) throw Error(errorMessage)
|
|
69
|
+
console.error(errorMessage)
|
|
70
|
+
return undefined
|
|
61
71
|
}
|
|
62
72
|
|
|
63
73
|
const isAbsolutePath = path => path.startsWith('/')
|
package/toolbox.js
CHANGED
|
@@ -182,15 +182,17 @@ export const formatedDuration = (seconds, { noSeconds } = {}) => {
|
|
|
182
182
|
}
|
|
183
183
|
|
|
184
184
|
const toDate = date => (date instanceof Date ? date : new Date(date))
|
|
185
|
-
export const toDateFormat = d => {
|
|
185
|
+
export const toDateFormat = (d, isISO = true) => {
|
|
186
186
|
const date = toDate(d)
|
|
187
187
|
const month = (date.getMonth() + 1).toString().padStart(2, '0')
|
|
188
188
|
const day = date.getDate().toString().padStart(2, '0')
|
|
189
189
|
|
|
190
|
-
return
|
|
190
|
+
return isISO
|
|
191
|
+
? `${date.getFullYear()}-${month}-${day}`
|
|
192
|
+
: `${day}/${month}/${date.getFullYear()}`
|
|
191
193
|
}
|
|
192
194
|
|
|
193
|
-
export const toISOStringWithTimeZone = d => toDate(d).toISOString()
|
|
195
|
+
export const toISOStringWithTimeZone = d => toDate(d).toISOString()
|
|
194
196
|
|
|
195
197
|
export const toDateFormatWithTime = (d, separator = 'T') => {
|
|
196
198
|
const date = toDate(d)
|
|
@@ -220,7 +222,7 @@ export const monthNames = [
|
|
|
220
222
|
'Dec',
|
|
221
223
|
]
|
|
222
224
|
|
|
223
|
-
const weekDays = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
|
|
225
|
+
export const weekDays = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
|
|
224
226
|
|
|
225
227
|
const hour = hour => {
|
|
226
228
|
if (hour === 0 || hour === 12) return 12
|
|
@@ -256,12 +258,12 @@ const getDateElems = date => {
|
|
|
256
258
|
return { day, jj, mm, yyyy, hh, min }
|
|
257
259
|
}
|
|
258
260
|
|
|
259
|
-
export const formatedDateTime = date => {
|
|
261
|
+
export const formatedDateTime = (date, splitter = 'at') => {
|
|
260
262
|
if (!date) return '-'
|
|
261
263
|
if (dateIsPermanent(date)) return '∞'
|
|
262
264
|
const { jj, mm, yyyy, hh, min } = getDateElems(date)
|
|
263
265
|
|
|
264
|
-
return `${monthNames[mm]} ${jj}, ${yyyy}
|
|
266
|
+
return `${monthNames[mm]} ${jj}, ${yyyy} ${splitter} ${hour(hh)}:${minPad(
|
|
265
267
|
min,
|
|
266
268
|
)} ${suffix(hh)}`
|
|
267
269
|
}
|
|
@@ -337,13 +339,12 @@ const toSnakeCase = str =>
|
|
|
337
339
|
// id card > id-card
|
|
338
340
|
// should be enough for unique usage we have of it in check-refs
|
|
339
341
|
// checked the perfs and it is faster + more secure than using replace(s) + toLowerCase
|
|
340
|
-
const toLowerCase = x => x.toLowerCase()
|
|
341
342
|
export const toKebabCase = str =>
|
|
342
343
|
str
|
|
343
344
|
.match(
|
|
344
345
|
/[A-Z](?=[A-Z][a-z]+[0-9]*[a-z]|\b)|[A-Z]?[a-z]+[0-9]+[a-z]?[A-Z]*|[A-Z]?[a-z]+[0-9]*|[A-Z]|[0-9]+/g,
|
|
345
346
|
)
|
|
346
|
-
.map(toLowerCase)
|
|
347
|
+
.map(x => x.toLowerCase())
|
|
347
348
|
.join('-')
|
|
348
349
|
|
|
349
350
|
const spaceJoin = (_, a, b) => `${a} ${b}`
|
|
@@ -481,3 +482,18 @@ export const base64Encode = str => {
|
|
|
481
482
|
|
|
482
483
|
export const timePlusDelay = (time, delay) =>
|
|
483
484
|
new Date(new Date(time).getTime() + delay).toISOString()
|
|
485
|
+
|
|
486
|
+
export const getRecordStatus = record => {
|
|
487
|
+
if (!isFinished(record.startAt)) return 'starting soon'
|
|
488
|
+
if (record.endAt && isFinished(record.endAt)) {
|
|
489
|
+
return 'finished'
|
|
490
|
+
}
|
|
491
|
+
if (record.type.isPermanent) return 'permanent'
|
|
492
|
+
return record.endAt ? 'in progress' : 'unblock required'
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
export const createFrequencyMap = arr =>
|
|
496
|
+
arr.reduce((map, item) => {
|
|
497
|
+
map[item] = (map[item] || 0) + 1
|
|
498
|
+
return map
|
|
499
|
+
}, {})
|