@01-edu/shared 1.0.9 → 1.1.0
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/attrs-defs.js +563 -150
- package/attrs.js +96 -7
- package/bin/check-definitions.js +1 -1
- package/onboarding.js +1 -0
- package/package.json +1 -1
- package/toolbox.js +8 -1
package/attrs-defs.js
CHANGED
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
getQuestStartAt,
|
|
8
8
|
numTime,
|
|
9
9
|
} from './event-utils.js'
|
|
10
|
+
import { flatGraphContents, getCoreOfSattelite, limitations } from './graph.js'
|
|
10
11
|
import { onboardingTypes } from './onboarding.js'
|
|
11
12
|
import { getObjectFromRelativePath } from './path.js'
|
|
12
13
|
import { gamesScoring } from './score.js'
|
|
@@ -61,22 +62,12 @@ const firstOfGroup = (a, _i, children) => {
|
|
|
61
62
|
return children.find(b => b.attrs.group === group) === a
|
|
62
63
|
}
|
|
63
64
|
|
|
64
|
-
const getCoreName = object => {
|
|
65
|
-
const corePath = object.attrs.requirements?.core
|
|
66
|
-
const core = _children(object.parent).find(
|
|
67
|
-
child =>
|
|
68
|
-
child.id ===
|
|
69
|
-
getObjectFromRelativePath(corePath, object, { throwError: false })?.id,
|
|
70
|
-
)
|
|
71
|
-
return core?.name || ''
|
|
72
|
-
}
|
|
73
|
-
|
|
74
65
|
const getObjectPath = object => {
|
|
75
|
-
const
|
|
76
|
-
if (!
|
|
66
|
+
const core = getCoreOfSattelite(object)
|
|
67
|
+
if (!core) return object.name
|
|
77
68
|
// special case only for projects with a core requirement set
|
|
78
|
-
const coreName = getCoreName(object)
|
|
79
|
-
const path = `${
|
|
69
|
+
// const coreName = getCoreName(object)
|
|
70
|
+
const path = `${core.name}/${getProjectName(object)}`
|
|
80
71
|
return path
|
|
81
72
|
}
|
|
82
73
|
|
|
@@ -125,10 +116,11 @@ const TypeObject = def => ({
|
|
|
125
116
|
// label: // text displayed in front end, instead of attribute name (client friendly),
|
|
126
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
|
|
127
118
|
// instruction: // mention displayed under the label in the front end - to give precision that need to be seen,
|
|
128
|
-
// description: complete explanation of what the attributes does - description is displayed only when the used hover the
|
|
119
|
+
// description: complete explanation of what the attributes does - description is displayed only when the used hover the i icon in the front end,
|
|
129
120
|
// required: // if the attribute is always there by default,
|
|
130
121
|
// editable: // if it can be override by schools in the db (=/= choice in between several functions),
|
|
131
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,
|
|
132
124
|
// primary: // only used objects in arrays, allow to indicate what key must have a unique value
|
|
133
125
|
// options: // array containing literal possible values
|
|
134
126
|
// acceptDuplicates: // when array type can have duplicates
|
|
@@ -927,15 +919,13 @@ relationAttrs.difficultyMod = {
|
|
|
927
919
|
|
|
928
920
|
const getName = ({ name }) => name
|
|
929
921
|
const getProjectName = object => {
|
|
930
|
-
const
|
|
931
|
-
if (!
|
|
932
|
-
// only for projects with a core
|
|
922
|
+
const core = getCoreOfSattelite(object)
|
|
923
|
+
if (!core) return object.name
|
|
924
|
+
// only for projects with a core, and which name starts by the core name:
|
|
933
925
|
// remove the core name from the name to avoid repetition
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
? name.slice(coreName.length + 1)
|
|
938
|
-
: name
|
|
926
|
+
return core.name && object.name.startsWith(core.name)
|
|
927
|
+
? object.name.slice(core.name.length + 1)
|
|
928
|
+
: object.name
|
|
939
929
|
}
|
|
940
930
|
const sharedDisplayedName = Literal('', {
|
|
941
931
|
editable: true,
|
|
@@ -978,7 +968,7 @@ const sharedDuration = Literal(1, {
|
|
|
978
968
|
relationAttrs.duration = {
|
|
979
969
|
// TODO: rm, here just for campus comparison
|
|
980
970
|
module: {
|
|
981
|
-
exam: sharedDuration,
|
|
971
|
+
exam: { ...sharedDuration, hidden: true },
|
|
982
972
|
},
|
|
983
973
|
piscine: {
|
|
984
974
|
quest: sharedDuration,
|
|
@@ -1628,24 +1618,306 @@ attrs.grade = {
|
|
|
1628
1618
|
}),
|
|
1629
1619
|
}
|
|
1630
1620
|
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1621
|
+
/* MODULE GRAPH ATTRIBUTE */
|
|
1622
|
+
types.graphArcName = TypeObject({
|
|
1623
|
+
label: 'Name of the arc',
|
|
1624
|
+
type: {
|
|
1625
|
+
text: Literal('', {
|
|
1626
|
+
label: 'The text name of the arc',
|
|
1627
|
+
type: 'string',
|
|
1628
|
+
}),
|
|
1629
|
+
hidden: Literal(true, {
|
|
1630
|
+
label: 'Display the text name on the graph',
|
|
1631
|
+
}),
|
|
1632
|
+
},
|
|
1633
|
+
})
|
|
1634
|
+
|
|
1635
|
+
const checkGraphArcContentIsValid = (contentName, object) => {
|
|
1636
|
+
const matchingObject = object.children[contentName]
|
|
1637
|
+
if (!matchingObject) {
|
|
1638
|
+
throw Error(
|
|
1639
|
+
`Invalid object - no object found in the module for the following key name: ${contentName}`,
|
|
1640
|
+
)
|
|
1641
|
+
}
|
|
1636
1642
|
}
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1643
|
+
|
|
1644
|
+
types.graphArcContentName = Literal('', {
|
|
1645
|
+
label: 'The text name of a content placed on an arc',
|
|
1646
|
+
check: (contentName, object) =>
|
|
1647
|
+
checkGraphArcContentIsValid(contentName, object),
|
|
1648
|
+
})
|
|
1649
|
+
|
|
1650
|
+
types.graphArcContentWithSubContents = {
|
|
1651
|
+
value: {},
|
|
1652
|
+
label: 'Content with sub-contents placed on an arc',
|
|
1653
|
+
type: 'object',
|
|
1654
|
+
check: (value, object) => {
|
|
1655
|
+
if (isntObjectOrIsEmpty(value)) {
|
|
1656
|
+
throw Error('Should be a non empty object.')
|
|
1657
|
+
}
|
|
1658
|
+
|
|
1659
|
+
const entries = Object.entries(value)
|
|
1660
|
+
if (entries.length > 1) {
|
|
1661
|
+
throw Error(
|
|
1662
|
+
"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.",
|
|
1663
|
+
)
|
|
1664
|
+
}
|
|
1665
|
+
|
|
1666
|
+
const [contentName, subContentsList] = entries[0]
|
|
1667
|
+
|
|
1668
|
+
// check content name key matches an existing object in the module
|
|
1669
|
+
checkGraphArcContentIsValid(contentName, object)
|
|
1670
|
+
|
|
1671
|
+
// check sub-contents' list is a non empty array
|
|
1672
|
+
if (!Array.isArray(subContentsList) || !subContentsList.length) {
|
|
1673
|
+
throw Error('Must be a non empty array')
|
|
1674
|
+
}
|
|
1675
|
+
|
|
1676
|
+
// check there's no duplicates in sub-contents' list
|
|
1677
|
+
const uniques = new Set(subContentsList)
|
|
1678
|
+
if (subContentsList.length !== uniques.size) {
|
|
1679
|
+
throw Error('Duplicates are not allowed.')
|
|
1680
|
+
}
|
|
1681
|
+
|
|
1682
|
+
const maxSattelitesAllowed =
|
|
1683
|
+
limitations.SLICE.innerCircle.maxSubContentsCount
|
|
1684
|
+
if (subContentsList.length > maxSattelitesAllowed) {
|
|
1685
|
+
throw Error('Max sattelites reached')
|
|
1686
|
+
}
|
|
1687
|
+
|
|
1688
|
+
// check sub-contents' name keys match existing objects in the module
|
|
1689
|
+
for (const keyName of subContentsList) {
|
|
1690
|
+
checkGraphArcContentIsValid(keyName, object)
|
|
1691
|
+
}
|
|
1692
|
+
},
|
|
1693
|
+
}
|
|
1694
|
+
|
|
1695
|
+
const graphArcBasicChecks = value => {
|
|
1696
|
+
const { name: _name, contents, type: _type, id: _id, ...rest } = value
|
|
1697
|
+
|
|
1698
|
+
if (Object.keys(rest).length) {
|
|
1699
|
+
throw Error(`Unsupported attribute "${Object.keys(rest)[0]}"`)
|
|
1700
|
+
}
|
|
1701
|
+
|
|
1702
|
+
if (!Array.isArray(contents) || !contents?.length) {
|
|
1703
|
+
throw Error('Must be a non empty array')
|
|
1704
|
+
}
|
|
1705
|
+
}
|
|
1706
|
+
|
|
1707
|
+
types.graphArc = TypeObject({
|
|
1708
|
+
label: 'Name & contents of an arc',
|
|
1709
|
+
type: {
|
|
1710
|
+
id: Literal('', {
|
|
1711
|
+
label: 'A randomly-generated id to identify the arc',
|
|
1712
|
+
}),
|
|
1713
|
+
name: types.graphArcName,
|
|
1714
|
+
contents: {
|
|
1715
|
+
label: 'The list of contents to be placed on an arc',
|
|
1716
|
+
type: [types.graphArcContentName],
|
|
1717
|
+
},
|
|
1718
|
+
},
|
|
1719
|
+
check: graphArcBasicChecks,
|
|
1720
|
+
})
|
|
1721
|
+
|
|
1722
|
+
types.innerCircleSlice = TypeObject({
|
|
1723
|
+
label: 'Slice on the inner circle',
|
|
1724
|
+
type: {
|
|
1725
|
+
id: Literal('', {
|
|
1726
|
+
label: 'A randomly-generated id to identify the slice',
|
|
1727
|
+
}),
|
|
1728
|
+
name: types.graphArcName,
|
|
1729
|
+
|
|
1730
|
+
type: Literal('slice', {
|
|
1731
|
+
label: 'The slice type',
|
|
1732
|
+
}),
|
|
1733
|
+
|
|
1734
|
+
entryPoint: types.graphArcContentName,
|
|
1735
|
+
|
|
1736
|
+
innerArc: TypeObject({
|
|
1737
|
+
label: 'Inner arc of an inner circle slice',
|
|
1738
|
+
type: {
|
|
1739
|
+
...types.graphArc.type,
|
|
1740
|
+
contents: {
|
|
1741
|
+
label:
|
|
1742
|
+
'The list of contents to be placed on an inner arc of an inner circle slice',
|
|
1743
|
+
type: [
|
|
1744
|
+
types.graphArcContentName,
|
|
1745
|
+
types.graphArcContentWithSubContents, // for now, sub-contents are only displayed on the inner arc of an inner circle slice
|
|
1746
|
+
],
|
|
1747
|
+
},
|
|
1748
|
+
},
|
|
1749
|
+
check: graphArcBasicChecks,
|
|
1750
|
+
}),
|
|
1751
|
+
|
|
1752
|
+
outerArcs: {
|
|
1753
|
+
label: 'Outer arcs of an inner circle slice',
|
|
1754
|
+
type: [types.graphArc],
|
|
1755
|
+
check: outerArcs => {
|
|
1756
|
+
const { maxArcsCount, maxContentsCount } = limitations.SLICE.outerArc
|
|
1757
|
+
if (outerArcs.length > maxArcsCount) {
|
|
1758
|
+
throw Error('Max outerArcs reached')
|
|
1759
|
+
}
|
|
1760
|
+
if (
|
|
1761
|
+
outerArcs.reduce(
|
|
1762
|
+
(total, arc) => total + (arc.contents?.length || 0),
|
|
1763
|
+
0,
|
|
1764
|
+
) > maxContentsCount
|
|
1765
|
+
) {
|
|
1766
|
+
throw Error('Max contents spread over outerArcs of slice reached')
|
|
1767
|
+
}
|
|
1768
|
+
},
|
|
1769
|
+
},
|
|
1770
|
+
},
|
|
1771
|
+
|
|
1772
|
+
check: innerCircle => {
|
|
1773
|
+
const { name, entryPoint, type, innerArc, outerArcs, id, ...rest } =
|
|
1774
|
+
innerCircle
|
|
1775
|
+
|
|
1776
|
+
if (Object.keys(rest).length) {
|
|
1777
|
+
throw Error(`Unsupported attribute "${Object.keys(rest)[0]}"`)
|
|
1778
|
+
}
|
|
1779
|
+
|
|
1780
|
+
if (!name && !entryPoint && !type && !innerArc && !outerArcs && !id) {
|
|
1781
|
+
throw Error('Empty inner circle, should be removed')
|
|
1782
|
+
}
|
|
1783
|
+
},
|
|
1784
|
+
})
|
|
1785
|
+
|
|
1786
|
+
types.innerCircleLine = {
|
|
1787
|
+
...types.graphArc,
|
|
1788
|
+
label: 'Line on the inner circle',
|
|
1789
|
+
type: {
|
|
1790
|
+
...types.graphArc.type,
|
|
1791
|
+
type: Literal('line', {
|
|
1792
|
+
label: 'The line type',
|
|
1793
|
+
}),
|
|
1794
|
+
},
|
|
1795
|
+
check: line => {
|
|
1796
|
+
if (line.contents.length > limitations.LINE.maxContentsCount) {
|
|
1797
|
+
throw Error('Max contents reached for a line')
|
|
1798
|
+
}
|
|
1799
|
+
},
|
|
1800
|
+
}
|
|
1801
|
+
|
|
1802
|
+
const graphStructure = TypeObject({
|
|
1803
|
+
private: true, // not displayed in the admin settings; could use hidden too but private more efficient
|
|
1804
|
+
required: true,
|
|
1805
|
+
editable: true, // TODO: check if should be here?
|
|
1806
|
+
value: { innerCircle: [], middleCircle: [], outerCircle: [] },
|
|
1807
|
+
label: 'Graph structure',
|
|
1808
|
+
instruction: 'Structure of the visual graph',
|
|
1809
|
+
description:
|
|
1810
|
+
'Sets the visual structure & hierarchy of the graph of a module.',
|
|
1811
|
+
type: {
|
|
1812
|
+
centralPoint: types.graphArcContentName,
|
|
1813
|
+
|
|
1814
|
+
innerCircle: {
|
|
1815
|
+
value: [],
|
|
1816
|
+
required: true,
|
|
1817
|
+
editable: true,
|
|
1818
|
+
label: 'List of slices and/or lines spread on the inner circle',
|
|
1819
|
+
type: [types.innerCircleSlice, types.innerCircleLine],
|
|
1820
|
+
check: innerCircle => {
|
|
1821
|
+
const slices = innerCircle.filter(section => section.type === 'slice')
|
|
1822
|
+
const lines = innerCircle.filter(section => section.type === 'line')
|
|
1823
|
+
const { SLICE, LINE } = limitations
|
|
1824
|
+
if (slices.length > SLICE.maxSlicesCount) {
|
|
1825
|
+
throw Error('Max slices sections reached')
|
|
1826
|
+
}
|
|
1827
|
+
if (lines.length > LINE.maxLinesCount) {
|
|
1828
|
+
throw Error('Max lines sections reached')
|
|
1829
|
+
}
|
|
1830
|
+
if (
|
|
1831
|
+
slices.flatMap(({ innerArc }) =>
|
|
1832
|
+
innerArc.contents.flatMap(c =>
|
|
1833
|
+
typeof c === 'string' ? c : Object.keys(c)[0],
|
|
1834
|
+
),
|
|
1835
|
+
).length > SLICE.innerCircle.maxContentsCount
|
|
1836
|
+
) {
|
|
1837
|
+
throw Error('Max contents spread over innerArcs of slices reached')
|
|
1838
|
+
}
|
|
1839
|
+
},
|
|
1840
|
+
},
|
|
1841
|
+
|
|
1842
|
+
middleCircle: {
|
|
1843
|
+
value: [],
|
|
1844
|
+
required: true,
|
|
1845
|
+
editable: true,
|
|
1846
|
+
label: 'List of arcs spread on the middle circle',
|
|
1847
|
+
type: [types.graphArc],
|
|
1848
|
+
check: middleCircle => {
|
|
1849
|
+
const { maxArcsCount, maxContentsCount } = limitations.MIDDLE_CIRCLE
|
|
1850
|
+
if (middleCircle.length > maxArcsCount) {
|
|
1851
|
+
throw Error('Max arches reach on middleCircle')
|
|
1852
|
+
}
|
|
1853
|
+
if (
|
|
1854
|
+
middleCircle.reduce(
|
|
1855
|
+
(total, arc) => total + (arc.contents?.length || 0),
|
|
1856
|
+
0,
|
|
1857
|
+
) > maxContentsCount
|
|
1858
|
+
) {
|
|
1859
|
+
throw Error('Max contents reach on middleCircle')
|
|
1860
|
+
}
|
|
1861
|
+
},
|
|
1862
|
+
},
|
|
1863
|
+
|
|
1864
|
+
outerCircle: {
|
|
1865
|
+
value: [],
|
|
1866
|
+
required: true,
|
|
1867
|
+
editable: true,
|
|
1868
|
+
label: 'List of arcs spread on the outer circle',
|
|
1869
|
+
type: [types.graphArc],
|
|
1870
|
+
check: outerCircle => {
|
|
1871
|
+
const { maxArcsCount, maxContentsCount } = limitations.OUTER_CIRCLE
|
|
1872
|
+
if (outerCircle.length > maxArcsCount) {
|
|
1873
|
+
throw Error('Max arches reach on outerCircle')
|
|
1874
|
+
}
|
|
1875
|
+
if (
|
|
1876
|
+
outerCircle.reduce(
|
|
1877
|
+
(total, arc) => total + (arc.contents?.length || 0),
|
|
1878
|
+
0,
|
|
1879
|
+
) > maxContentsCount
|
|
1880
|
+
) {
|
|
1881
|
+
throw Error('Max contents reach on outerCircle')
|
|
1882
|
+
}
|
|
1883
|
+
},
|
|
1647
1884
|
},
|
|
1648
1885
|
},
|
|
1886
|
+
check: (graph, object) => {
|
|
1887
|
+
const { centralPoint, innerCircle, middleCircle, outerCircle, ...rest } =
|
|
1888
|
+
graph
|
|
1889
|
+
|
|
1890
|
+
if (Object.keys(rest).length) {
|
|
1891
|
+
throw Error(`Unsupported attribute "${Object.keys(rest)[0]}"`)
|
|
1892
|
+
}
|
|
1893
|
+
|
|
1894
|
+
if (
|
|
1895
|
+
!centralPoint &&
|
|
1896
|
+
!innerCircle?.length &&
|
|
1897
|
+
!middleCircle?.length &&
|
|
1898
|
+
!outerCircle?.length
|
|
1899
|
+
) {
|
|
1900
|
+
throw Error('Empty graph, should be removed')
|
|
1901
|
+
}
|
|
1902
|
+
const flattenContents = flatGraphContents(graph)
|
|
1903
|
+
const flattenContentsSet = new Set(flattenContents)
|
|
1904
|
+
if (flattenContents.length !== flattenContentsSet.size) {
|
|
1905
|
+
throw Error('Graph should not contain duplicate contents keys')
|
|
1906
|
+
}
|
|
1907
|
+
const childrenKeys = Object.keys(object.children)
|
|
1908
|
+
if (flattenContentsSet.size !== childrenKeys.length) {
|
|
1909
|
+
// console.log(flattenContents.filter(c => !childrenKeys.some(k => k === c)))
|
|
1910
|
+
// console.log(childrenKeys.filter(c => !flattenContents.some(k => k === c)))
|
|
1911
|
+
// TODO: update for throw Error when fixed with content
|
|
1912
|
+
console.error(
|
|
1913
|
+
`Inconsistancy in between graph and children: different size (${flattenContentsSet.size} vs ${childrenKeys.length})`,
|
|
1914
|
+
)
|
|
1915
|
+
}
|
|
1916
|
+
},
|
|
1917
|
+
})
|
|
1918
|
+
|
|
1919
|
+
attrs.graph = {
|
|
1920
|
+
module: graphStructure,
|
|
1649
1921
|
}
|
|
1650
1922
|
|
|
1651
1923
|
// about to be refactored to define group base on exercise level?
|
|
@@ -1654,6 +1926,8 @@ relationAttrs.group = {
|
|
|
1654
1926
|
exercise: Literal(1, {
|
|
1655
1927
|
label: 'Exercise group',
|
|
1656
1928
|
editable: true,
|
|
1929
|
+
required: true,
|
|
1930
|
+
hidden: true,
|
|
1657
1931
|
options: arrayOf(100, 1),
|
|
1658
1932
|
check: value => checkIntegerInBetween(value, 1, 100),
|
|
1659
1933
|
}),
|
|
@@ -1728,6 +2002,7 @@ const sharedPrivateHasStarted = {
|
|
|
1728
2002
|
const sharedPublicHasStared = {
|
|
1729
2003
|
label: 'Starts when',
|
|
1730
2004
|
type: 'boolean',
|
|
2005
|
+
hidden: true,
|
|
1731
2006
|
required: true,
|
|
1732
2007
|
...Functions({
|
|
1733
2008
|
'temporal-window has started (in hackathon mode)': getHasStarted,
|
|
@@ -1782,33 +2057,63 @@ const sharedInput = {
|
|
|
1782
2057
|
...translatable,
|
|
1783
2058
|
instruction: 'Required option: "type"',
|
|
1784
2059
|
}
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
throw Error('"required" property (if added) must be a true or false.')
|
|
1809
|
-
}
|
|
1810
|
-
},
|
|
2060
|
+
const uploadInput = {
|
|
2061
|
+
...sharedInput,
|
|
2062
|
+
editable: false,
|
|
2063
|
+
check: value => {
|
|
2064
|
+
const [inputValues] = Object.values(value)
|
|
2065
|
+
isntObjectOrIsEmpty(inputValues)
|
|
2066
|
+
if (!inputValues.type || inputValues.type !== 'file') {
|
|
2067
|
+
throw Error('"type":"file" property must be defined in the upload input.')
|
|
2068
|
+
}
|
|
2069
|
+
if (
|
|
2070
|
+
inputValues.accept !== undefined &&
|
|
2071
|
+
typeof inputValues.accept !== 'string'
|
|
2072
|
+
) {
|
|
2073
|
+
throw Error(
|
|
2074
|
+
'"accept" property (if added) must be a text. Example: "image/png, image/jpeg"',
|
|
2075
|
+
)
|
|
2076
|
+
}
|
|
2077
|
+
if (
|
|
2078
|
+
inputValues.required !== undefined &&
|
|
2079
|
+
typeof inputValues.required !== 'boolean'
|
|
2080
|
+
) {
|
|
2081
|
+
throw Error('"required" property (if added) must be a true or false.')
|
|
2082
|
+
}
|
|
1811
2083
|
},
|
|
2084
|
+
}
|
|
2085
|
+
|
|
2086
|
+
const avatarInput = TypeObject({
|
|
2087
|
+
label: 'Avatar input',
|
|
2088
|
+
required: true,
|
|
2089
|
+
editable: false,
|
|
2090
|
+
value: {
|
|
2091
|
+
type: 'file',
|
|
2092
|
+
accept: 'image/png, image/jpeg',
|
|
2093
|
+
required: true,
|
|
2094
|
+
},
|
|
2095
|
+
type: {
|
|
2096
|
+
type: Literal('file', {
|
|
2097
|
+
label: 'Input type',
|
|
2098
|
+
required: true,
|
|
2099
|
+
editable: false,
|
|
2100
|
+
}),
|
|
2101
|
+
accept: Literal('image/png, image/jpeg', {
|
|
2102
|
+
label: 'Accepted file types',
|
|
2103
|
+
editable: false,
|
|
2104
|
+
required: true,
|
|
2105
|
+
}),
|
|
2106
|
+
required: Literal(true, {
|
|
2107
|
+
label: 'Required',
|
|
2108
|
+
editable: false,
|
|
2109
|
+
required: true,
|
|
2110
|
+
}),
|
|
2111
|
+
},
|
|
2112
|
+
})
|
|
2113
|
+
|
|
2114
|
+
attrs.input = {
|
|
2115
|
+
'upload-step': uploadInput,
|
|
2116
|
+
'avatar-step': avatarInput,
|
|
1812
2117
|
'contact-validation-step': {
|
|
1813
2118
|
...sharedInput,
|
|
1814
2119
|
check: value => {
|
|
@@ -1999,8 +2304,38 @@ types.objectChildRelativePath = Literal('./', {
|
|
|
1999
2304
|
.map(([key, _]) => `./${key}`),
|
|
2000
2305
|
})
|
|
2001
2306
|
|
|
2307
|
+
const checkRelativePaths = (objects, object) => {
|
|
2308
|
+
if (!Array.isArray(objects) || !objects.length) {
|
|
2309
|
+
const error = new Error('Must be a non empty array')
|
|
2310
|
+
error.userFeedback =
|
|
2311
|
+
'This list cannot be empty! Please add an item or remove the setting.'
|
|
2312
|
+
throw error
|
|
2313
|
+
}
|
|
2314
|
+
const uniques = [...new Set(objects)]
|
|
2315
|
+
if (objects.length !== uniques.length) {
|
|
2316
|
+
throw Error('Duplicates are not allowed.')
|
|
2317
|
+
}
|
|
2318
|
+
|
|
2319
|
+
const invalidObjectsRequirements = objects.filter(path => {
|
|
2320
|
+
const objectFromPath = getObjectFromRelativePath(path, object, {
|
|
2321
|
+
throwError: false,
|
|
2322
|
+
})
|
|
2323
|
+
return !objectFromPath
|
|
2324
|
+
})
|
|
2325
|
+
|
|
2326
|
+
if (invalidObjectsRequirements.length) {
|
|
2327
|
+
const paths = invalidObjectsRequirements.map(p => `'${p}'`).join(', ')
|
|
2328
|
+
const error = new Error(
|
|
2329
|
+
`Invalid objects requirements - no object found for the following relative paths: ${paths}`,
|
|
2330
|
+
)
|
|
2331
|
+
error.userFeedback = 'Some Contents are misconfigured, please update them!'
|
|
2332
|
+
console.error(error.message)
|
|
2333
|
+
throw error
|
|
2334
|
+
}
|
|
2335
|
+
}
|
|
2336
|
+
|
|
2002
2337
|
types.objectRootRelativePath = Literal('../', {
|
|
2003
|
-
label: 'Content relative path',
|
|
2338
|
+
label: 'Content relative path (Mandatory)',
|
|
2004
2339
|
instruction: 'In same parent',
|
|
2005
2340
|
editable: true,
|
|
2006
2341
|
check: (path, object) => {
|
|
@@ -2013,49 +2348,27 @@ types.objectRootRelativePath = Literal('../', {
|
|
|
2013
2348
|
},
|
|
2014
2349
|
options: object => {
|
|
2015
2350
|
if (!object?.parent?.children) return []
|
|
2016
|
-
const
|
|
2017
|
-
const
|
|
2018
|
-
|
|
2019
|
-
const before = sorted.slice(0, index)
|
|
2020
|
-
return before.map(([key, _]) => `../${key}`).reverse()
|
|
2351
|
+
const entries = Object.entries(object.parent.children)
|
|
2352
|
+
const options = entries.filter(([k]) => k !== object.key)
|
|
2353
|
+
return options.map(([key]) => `../${key}`)
|
|
2021
2354
|
},
|
|
2022
2355
|
})
|
|
2356
|
+
|
|
2357
|
+
// NOTE: objects requirements is declared here
|
|
2023
2358
|
types.sharedObjectList = {
|
|
2024
|
-
label: 'Contents', // synonyms: item, material
|
|
2359
|
+
label: 'Contents required', // synonyms: item, material
|
|
2025
2360
|
editable: true,
|
|
2026
2361
|
check: (objects, object) => {
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
|
|
2030
|
-
'This list cannot be empty! Please add an item or remove the setting.'
|
|
2031
|
-
throw error
|
|
2032
|
-
}
|
|
2033
|
-
const uniques = [...new Set(objects)]
|
|
2034
|
-
if (objects.length !== uniques.length) {
|
|
2035
|
-
throw Error('Duplicates are not allowed.')
|
|
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
|
-
}
|
|
2362
|
+
// objects are split into alternative paths or mandatory paths
|
|
2363
|
+
// for the check we will just flat the array so that we check every path the same way
|
|
2364
|
+
checkRelativePaths(objects.flat(), object)
|
|
2055
2365
|
},
|
|
2056
2366
|
}
|
|
2057
2367
|
|
|
2058
|
-
const sharedRequirements = {
|
|
2368
|
+
const sharedRequirements = {
|
|
2369
|
+
editable: true,
|
|
2370
|
+
label: 'Access conditions',
|
|
2371
|
+
}
|
|
2059
2372
|
|
|
2060
2373
|
const contentRequirements = {
|
|
2061
2374
|
...sharedRequirements,
|
|
@@ -2063,16 +2376,15 @@ const contentRequirements = {
|
|
|
2063
2376
|
description:
|
|
2064
2377
|
'Sets the requirements that have to be met for a content to be accessible to a student.',
|
|
2065
2378
|
check: (requirements, object) => {
|
|
2066
|
-
const { skills, objects,
|
|
2379
|
+
const { skills, objects, ...rest } = requirements
|
|
2067
2380
|
if (Object.keys(rest).length) {
|
|
2068
2381
|
throw Error(`Unsupported attribute "${Object.keys(rest)[0]}"`)
|
|
2069
2382
|
}
|
|
2070
|
-
if (!skills && !objects
|
|
2383
|
+
if (!skills && !objects) {
|
|
2071
2384
|
throw Error('Empty requirements, should be removed')
|
|
2072
2385
|
}
|
|
2073
2386
|
|
|
2074
|
-
const
|
|
2075
|
-
const invalidObjectsRequirements = allObjects.filter(path => {
|
|
2387
|
+
const invalidObjectsRequirements = (objects || []).flat().filter(path => {
|
|
2076
2388
|
const objectFromPath = getObjectFromRelativePath(path, object, {
|
|
2077
2389
|
throwError: false,
|
|
2078
2390
|
})
|
|
@@ -2108,6 +2420,9 @@ const levelRequirements = {
|
|
|
2108
2420
|
},
|
|
2109
2421
|
}
|
|
2110
2422
|
|
|
2423
|
+
// NOTE: does level definition support the multiple pathways??
|
|
2424
|
+
// i think it should not for now, the multiple pathways was defined for the students to get to a content throughout different ways
|
|
2425
|
+
// not for the level be defined different ways
|
|
2111
2426
|
types.levelDefinition = TypeObject({
|
|
2112
2427
|
label: 'Level definition',
|
|
2113
2428
|
type: {
|
|
@@ -2207,6 +2522,27 @@ attrs.link = {
|
|
|
2207
2522
|
},
|
|
2208
2523
|
...translatable, // in case there are different versions of the doc to dl
|
|
2209
2524
|
}),
|
|
2525
|
+
'avatar-step': TypeObject({
|
|
2526
|
+
label: 'Link to the legal page',
|
|
2527
|
+
editable: false,
|
|
2528
|
+
required: true,
|
|
2529
|
+
value: {
|
|
2530
|
+
href: '/legal',
|
|
2531
|
+
label: '> Privacy policy',
|
|
2532
|
+
target: '_blank',
|
|
2533
|
+
},
|
|
2534
|
+
type: {
|
|
2535
|
+
href: Literal('/legal', {
|
|
2536
|
+
editable: false,
|
|
2537
|
+
}),
|
|
2538
|
+
label: Literal('> Privacy policy', {
|
|
2539
|
+
editable: false,
|
|
2540
|
+
}),
|
|
2541
|
+
target: Literal('_blank', {
|
|
2542
|
+
editable: false,
|
|
2543
|
+
}),
|
|
2544
|
+
},
|
|
2545
|
+
}),
|
|
2210
2546
|
}
|
|
2211
2547
|
|
|
2212
2548
|
relationAttrs.mandatory = {
|
|
@@ -2242,7 +2578,9 @@ const sharedName = Literal('', {
|
|
|
2242
2578
|
})
|
|
2243
2579
|
const attrsNameObj = {}
|
|
2244
2580
|
for (const type of onboardingTypes) {
|
|
2245
|
-
|
|
2581
|
+
if (type !== 'avatar-step') {
|
|
2582
|
+
attrsNameObj[type] = sharedName
|
|
2583
|
+
}
|
|
2246
2584
|
}
|
|
2247
2585
|
attrs.name = {
|
|
2248
2586
|
signup: sharedName,
|
|
@@ -2417,23 +2755,43 @@ attrs.requiredAuditRatio = {
|
|
|
2417
2755
|
}),
|
|
2418
2756
|
}
|
|
2419
2757
|
|
|
2758
|
+
types.pathwaysRequirementObjects = {
|
|
2759
|
+
label: 'Multiple content choices',
|
|
2760
|
+
instruction:
|
|
2761
|
+
'Adding this will create a new path way for the project being edit.',
|
|
2762
|
+
check: (objects, object) => {
|
|
2763
|
+
// objects are split into alternative paths or mandatory paths
|
|
2764
|
+
// for the check we will just flat the array so that we check every path the same way
|
|
2765
|
+
checkRelativePaths(objects.flat(), object)
|
|
2766
|
+
},
|
|
2767
|
+
required: false,
|
|
2768
|
+
editable: true,
|
|
2769
|
+
type: [
|
|
2770
|
+
{
|
|
2771
|
+
...types.objectRootRelativePath,
|
|
2772
|
+
label: 'Content relative path (optional)',
|
|
2773
|
+
},
|
|
2774
|
+
],
|
|
2775
|
+
value: (...args) => {
|
|
2776
|
+
const option = types.objectRootRelativePath.options(...args)?.[0]
|
|
2777
|
+
return option ? [option] : []
|
|
2778
|
+
},
|
|
2779
|
+
}
|
|
2780
|
+
|
|
2781
|
+
// NOTE: relation attribute requirements objects
|
|
2420
2782
|
const sharedContentRequirementsForMainAttr = TypeObject({
|
|
2421
2783
|
...contentRequirements,
|
|
2422
2784
|
type: {
|
|
2423
2785
|
skills: { ...skillsList, instruction: 'Expertises required' },
|
|
2424
|
-
core: {
|
|
2425
|
-
...types.objectRootRelativePath,
|
|
2426
|
-
label: 'Core content',
|
|
2427
|
-
instruction: 'Relative path of the core content to be succeeded',
|
|
2428
|
-
},
|
|
2429
2786
|
objects: {
|
|
2430
2787
|
...types.sharedObjectList,
|
|
2431
2788
|
value: (...args) => {
|
|
2432
2789
|
const option = types.objectRootRelativePath.options(...args)?.[0]
|
|
2433
|
-
|
|
2790
|
+
const pathways = types.pathwaysRequirementObjects.value(...args)
|
|
2791
|
+
return option ? [option, pathways] : []
|
|
2434
2792
|
},
|
|
2435
|
-
type: [types.objectRootRelativePath],
|
|
2436
|
-
instruction: '
|
|
2793
|
+
type: [types.objectRootRelativePath, types.pathwaysRequirementObjects],
|
|
2794
|
+
instruction: 'Content required to unlock the current one',
|
|
2437
2795
|
},
|
|
2438
2796
|
},
|
|
2439
2797
|
})
|
|
@@ -2863,6 +3221,20 @@ const questExerciseStatus = object => {
|
|
|
2863
3221
|
return 'available'
|
|
2864
3222
|
}
|
|
2865
3223
|
|
|
3224
|
+
const isPathStatusSucceeded = (path, object) => {
|
|
3225
|
+
try {
|
|
3226
|
+
const obj = getObjectFromRelativePath(path, object)
|
|
3227
|
+
return obj?.attrs.status === 'succeeded'
|
|
3228
|
+
} catch {
|
|
3229
|
+
// NOTE: should we make the requirement unblocked if the admin did not set the right requirement ????
|
|
3230
|
+
|
|
3231
|
+
// consider the requirement unlocked if an error is thrown by getObjectFromRelativePath,
|
|
3232
|
+
// because it would mean an admin wrongly set the requirement, probably manually in the db
|
|
3233
|
+
// (it is not possible to set an invalid requirement from the admin configuration interface)
|
|
3234
|
+
return true
|
|
3235
|
+
}
|
|
3236
|
+
}
|
|
3237
|
+
|
|
2866
3238
|
/**
|
|
2867
3239
|
* @throws This function throws an error if any of the required objects is invalid
|
|
2868
3240
|
* @returns {bool} true if all the required objects are succeeded, false otherwise
|
|
@@ -2870,30 +3242,34 @@ const questExerciseStatus = object => {
|
|
|
2870
3242
|
*/
|
|
2871
3243
|
const hasSucceededRequiredObjects = (requirements, object) => {
|
|
2872
3244
|
if (!requirements) return true
|
|
2873
|
-
const {
|
|
2874
|
-
if (
|
|
3245
|
+
const { objects } = requirements
|
|
3246
|
+
if (!objects || !objects.length) return true
|
|
2875
3247
|
|
|
2876
|
-
//
|
|
2877
|
-
const requiredObjects =
|
|
2878
|
-
|
|
2879
|
-
|
|
2880
|
-
|
|
2881
|
-
|
|
2882
|
-
|
|
2883
|
-
|
|
2884
|
-
|
|
2885
|
-
|
|
2886
|
-
|
|
2887
|
-
|
|
2888
|
-
|
|
2889
|
-
|
|
3248
|
+
// required objects are the ones that need to be done to unblock the current object
|
|
3249
|
+
const requiredObjects = objects.filter(p => !Array.isArray(p) && Boolean(p))
|
|
3250
|
+
// pathway objects represent the choices a student can take to unblock the current object
|
|
3251
|
+
// note: at least one object from each pathway must be successfully completed
|
|
3252
|
+
const pathWayObjects = objects?.filter(p => Array.isArray(p) && Boolean(p))
|
|
3253
|
+
|
|
3254
|
+
const hasSeccededRequiredObjects = requiredObjects.every(path =>
|
|
3255
|
+
isPathStatusSucceeded(path, object),
|
|
3256
|
+
)
|
|
3257
|
+
|
|
3258
|
+
// it's possible to have a pathway with just one relative path
|
|
3259
|
+
// in this case it will be considered as a required object
|
|
3260
|
+
const hasSucceededPathWays = pathWayObjects?.every(paths =>
|
|
3261
|
+
paths.some(path => isPathStatusSucceeded(path, object)),
|
|
3262
|
+
)
|
|
3263
|
+
|
|
3264
|
+
return hasSucceededPathWays !== undefined
|
|
3265
|
+
? hasSeccededRequiredObjects && hasSucceededPathWays
|
|
3266
|
+
: hasSeccededRequiredObjects
|
|
2890
3267
|
}
|
|
2891
3268
|
|
|
2892
3269
|
// check if the requirements are met for a given object
|
|
2893
|
-
const meetsRequirements = ({ requirements, object, user, progress }) => {
|
|
3270
|
+
export const meetsRequirements = ({ requirements, object, user, progress }) => {
|
|
2894
3271
|
// check if there's already a progress, to not block students who began the project before implementing the requirements feature
|
|
2895
3272
|
if ((progress && Object.keys(progress).length) || !requirements) return true
|
|
2896
|
-
|
|
2897
3273
|
// check if the required skills have been earned & the required objects have been succeeded
|
|
2898
3274
|
const hasSkills = hasRequiredSkills(requirements.skills, user.skills)
|
|
2899
3275
|
const hasSucceededObjects = hasSucceededRequiredObjects(requirements, object)
|
|
@@ -2959,17 +3335,20 @@ const questStatus = object => {
|
|
|
2959
3335
|
: getProgressStatus(progress) || 'available'
|
|
2960
3336
|
}
|
|
2961
3337
|
|
|
3338
|
+
const getMeetsRequirements = (object, user) => {
|
|
3339
|
+
const { progress, attrs } = object
|
|
3340
|
+
const { requirements } = attrs
|
|
3341
|
+
|
|
3342
|
+
return meetsRequirements({ object, requirements, user, progress })
|
|
3343
|
+
}
|
|
2962
3344
|
const getPiscineStatus = (object, user) => {
|
|
2963
3345
|
const { progress, event, attrs } = object
|
|
2964
3346
|
const { requirements } = attrs
|
|
2965
3347
|
const progressStatus = getProgressStatus(progress)
|
|
2966
3348
|
if (progressStatus) return progressStatus
|
|
2967
|
-
|
|
2968
|
-
if (!event) return 'blocked'
|
|
2969
|
-
const registrationNotStarted = Date.now() < event.registrationStartAt
|
|
2970
3349
|
if (
|
|
2971
|
-
!meetsRequirements({ object, requirements, user, progress })
|
|
2972
|
-
|
|
3350
|
+
!meetsRequirements({ object, requirements, user, progress }) &&
|
|
3351
|
+
!event?.registeredPosition // in case a learner was force added to a registration through hasura by an admin
|
|
2973
3352
|
) {
|
|
2974
3353
|
return 'blocked'
|
|
2975
3354
|
}
|
|
@@ -3113,6 +3492,20 @@ relationAttrs.status = {
|
|
|
3113
3492
|
},
|
|
3114
3493
|
}
|
|
3115
3494
|
|
|
3495
|
+
types.meetRequirements = Literal(false, {
|
|
3496
|
+
label: 'User meet requirement',
|
|
3497
|
+
restrictive: true,
|
|
3498
|
+
required: true,
|
|
3499
|
+
hidden: true,
|
|
3500
|
+
...Functions({ 'by requirements': getMeetsRequirements }),
|
|
3501
|
+
})
|
|
3502
|
+
relationAttrs.meetsRequirements = {
|
|
3503
|
+
module: {
|
|
3504
|
+
piscine: types.meetRequirements,
|
|
3505
|
+
project: types.meetRequirements,
|
|
3506
|
+
},
|
|
3507
|
+
}
|
|
3508
|
+
|
|
3116
3509
|
const getSubject = object => {
|
|
3117
3510
|
const path = `subjects/${getObjectPath(object)}/README.md`
|
|
3118
3511
|
return `/markdown/root/public/${path}`
|
|
@@ -3130,10 +3523,7 @@ attrs.subject = {
|
|
|
3130
3523
|
raid: sharedSubject,
|
|
3131
3524
|
}
|
|
3132
3525
|
|
|
3133
|
-
const sharedText = Literal('', {
|
|
3134
|
-
editable: true,
|
|
3135
|
-
...translatable,
|
|
3136
|
-
})
|
|
3526
|
+
const sharedText = Literal('', { editable: true, ...translatable })
|
|
3137
3527
|
|
|
3138
3528
|
types.teamworkRankName = Literal('', {
|
|
3139
3529
|
label: 'Rank name',
|
|
@@ -3142,9 +3532,8 @@ types.teamworkRankName = Literal('', {
|
|
|
3142
3532
|
required: true,
|
|
3143
3533
|
primary: true,
|
|
3144
3534
|
check: (name, object) => {
|
|
3145
|
-
const
|
|
3146
|
-
|
|
3147
|
-
)
|
|
3535
|
+
const { teamworkRanks } = object.attrs
|
|
3536
|
+
const definitionsWithSameName = teamworkRanks?.filter(r => r.name === name)
|
|
3148
3537
|
if (definitionsWithSameName?.length > 1) {
|
|
3149
3538
|
throw Error(
|
|
3150
3539
|
`Name "${name}" is already set for a teamwork rank definition! A given name can only be attributed once to a rank.`,
|
|
@@ -3159,12 +3548,11 @@ types.teamworkRankParticipations = Literal(0, {
|
|
|
3159
3548
|
'The number of users the student has to work with, to unlock this rank.',
|
|
3160
3549
|
editable: true,
|
|
3161
3550
|
required: true,
|
|
3162
|
-
options: arrayOf(150,
|
|
3551
|
+
options: arrayOf(150, 0),
|
|
3163
3552
|
type: 'number',
|
|
3164
3553
|
check: (groups, object) => {
|
|
3165
3554
|
if (!Number.isInteger(groups)) throw Error('Must be a whole number')
|
|
3166
|
-
|
|
3167
|
-
const definitionsWithSameLevel = object.attrs.teamworkRanks.filter(
|
|
3555
|
+
const definitionsWithSameLevel = object.attrs.teamworkRanks?.filter(
|
|
3168
3556
|
rankDefinition => rankDefinition.groups === groups,
|
|
3169
3557
|
)
|
|
3170
3558
|
if (definitionsWithSameLevel?.length > 1) {
|
|
@@ -3187,6 +3575,10 @@ attrs.teamworkRanks = {
|
|
|
3187
3575
|
label: 'Teamwork ranks',
|
|
3188
3576
|
instruction: 'List of teamwork ranks',
|
|
3189
3577
|
type: [types.teamworkRanks],
|
|
3578
|
+
// this setting is required for practical UX reasons. It is not
|
|
3579
|
+
// required for platform to work properly, but as there are no other
|
|
3580
|
+
// settings for the campus, it avoid hiding it in "more settings to add"
|
|
3581
|
+
// section and make it more visible/easy to configure for admins (as displayed by default)
|
|
3190
3582
|
required: true,
|
|
3191
3583
|
editable: true,
|
|
3192
3584
|
value: (...args) => [
|
|
@@ -3217,6 +3609,10 @@ attrs.text = {
|
|
|
3217
3609
|
...sharedText,
|
|
3218
3610
|
label: 'Resume', // TODO: mv it to attrs.resume
|
|
3219
3611
|
},
|
|
3612
|
+
'avatar-step': {
|
|
3613
|
+
...sharedText,
|
|
3614
|
+
label: 'Resume', // TODO: mv it to attrs.resume
|
|
3615
|
+
},
|
|
3220
3616
|
}
|
|
3221
3617
|
|
|
3222
3618
|
types.timelineChunk = TypeObject({
|
|
@@ -3428,14 +3824,19 @@ const noAttribution = () => isUser01
|
|
|
3428
3824
|
|
|
3429
3825
|
export const getAuditPath = object => {
|
|
3430
3826
|
const path = `subjects/${getObjectPath(object)}/audit/README.md`
|
|
3431
|
-
|
|
3827
|
+
// TODO: the new content system will no longer use public, in the future we should change this
|
|
3828
|
+
// now we have dedicated repos on gitea for: modules, piscine, ....
|
|
3829
|
+
// so the repo should be something more dynamic depending on the object.type?
|
|
3830
|
+
// for now lets use the raw markdown
|
|
3831
|
+
return `/markdown/raw/root/public/${path}`
|
|
3432
3832
|
}
|
|
3433
3833
|
const sharedTypesForm = {
|
|
3434
3834
|
required: true,
|
|
3435
3835
|
editable: true,
|
|
3436
3836
|
type: 'string',
|
|
3437
|
-
label: 'Audit form
|
|
3438
|
-
instruction:
|
|
3837
|
+
label: 'Audit form URL',
|
|
3838
|
+
instruction:
|
|
3839
|
+
'List of questions asked by the auditor during the audit. The URL should return raw markdown',
|
|
3439
3840
|
...Functions({ 'README in audit folder': getAuditPath }),
|
|
3440
3841
|
}
|
|
3441
3842
|
types.adminAuditValidationDelay = Literal(0, {
|
|
@@ -3669,6 +4070,18 @@ attrs.videos = {
|
|
|
3669
4070
|
}),
|
|
3670
4071
|
}
|
|
3671
4072
|
|
|
4073
|
+
attrs.legalText = {
|
|
4074
|
+
'avatar-step': Literal(
|
|
4075
|
+
"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.",
|
|
4076
|
+
{
|
|
4077
|
+
type: 'string',
|
|
4078
|
+
label: 'Legal text',
|
|
4079
|
+
editable: false,
|
|
4080
|
+
required: true,
|
|
4081
|
+
},
|
|
4082
|
+
),
|
|
4083
|
+
}
|
|
4084
|
+
|
|
3672
4085
|
const getWeek = ({ attrs }) => {
|
|
3673
4086
|
if (!attrs.startDay) return undefined // for exams in module for example
|
|
3674
4087
|
const diff = attrs.startDay / 7
|
package/attrs.js
CHANGED
|
@@ -12,6 +12,11 @@ const typeCheckers = {
|
|
|
12
12
|
array: Array.isArray,
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
+
const determinType = value => {
|
|
16
|
+
if (Array.isArray(value)) return 'array'
|
|
17
|
+
if (typeof value === 'object' && value !== null) return value.type || 'object'
|
|
18
|
+
return typeof value
|
|
19
|
+
}
|
|
15
20
|
const typeChecker = (defs, value, object, key) => {
|
|
16
21
|
const { type, check, options } = defs
|
|
17
22
|
|
|
@@ -61,16 +66,23 @@ const typeChecker = (defs, value, object, key) => {
|
|
|
61
66
|
// every value have to match one of the type definition
|
|
62
67
|
|
|
63
68
|
const uniqueDef = type.length === 1 && type[0]
|
|
69
|
+
// convert array type into object for better accessibility
|
|
64
70
|
const types =
|
|
65
71
|
!uniqueDef &&
|
|
66
|
-
Object.fromEntries(
|
|
72
|
+
Object.fromEntries(
|
|
73
|
+
type.map(t => [
|
|
74
|
+
Array.isArray(t.type) ? 'array' : t.type?.type?.value || t.type,
|
|
75
|
+
t,
|
|
76
|
+
]),
|
|
77
|
+
)
|
|
78
|
+
|
|
67
79
|
for (const [index, v] of value.entries()) {
|
|
68
80
|
const err = Error('checks failed for all types')
|
|
69
81
|
err.index = index
|
|
70
82
|
err.key = key
|
|
71
83
|
err.label = defs.label
|
|
72
|
-
const subdefs =
|
|
73
|
-
|
|
84
|
+
const subdefs = uniqueDef || types[determinType(v)]
|
|
85
|
+
|
|
74
86
|
if (!subdefs) {
|
|
75
87
|
err.details = {
|
|
76
88
|
label: 'Unknown structure',
|
|
@@ -85,7 +97,6 @@ const typeChecker = (defs, value, object, key) => {
|
|
|
85
97
|
label: subdefs.label || error.label,
|
|
86
98
|
err: error,
|
|
87
99
|
}
|
|
88
|
-
|
|
89
100
|
throw err
|
|
90
101
|
}
|
|
91
102
|
}
|
|
@@ -136,6 +147,68 @@ export const relationAttributes = mapEntries(
|
|
|
136
147
|
],
|
|
137
148
|
)
|
|
138
149
|
|
|
150
|
+
// white list of attributes that can be applied in bulk
|
|
151
|
+
const allowedBulkAttrs = {
|
|
152
|
+
codeEditor: { enabled: {} },
|
|
153
|
+
validations: {
|
|
154
|
+
// this is for the different options of validation,
|
|
155
|
+
// we know that raids for now is the only one that contains multiple validations
|
|
156
|
+
// and we **don't** want admin_selection
|
|
157
|
+
type: [
|
|
158
|
+
'admin_audit',
|
|
159
|
+
'tester',
|
|
160
|
+
'dedicated_auditors_for_event',
|
|
161
|
+
'user_audit',
|
|
162
|
+
],
|
|
163
|
+
ratio: {},
|
|
164
|
+
required: {},
|
|
165
|
+
matchInfluence: {},
|
|
166
|
+
cooldown: {},
|
|
167
|
+
preQuestions: {},
|
|
168
|
+
postQuestions: {},
|
|
169
|
+
matchWhere: {},
|
|
170
|
+
},
|
|
171
|
+
}
|
|
172
|
+
export const getAllBulkAttrs = (parentType, childType) => {
|
|
173
|
+
const relationAttrs = getDefaultRelAttrs(parentType, childType)
|
|
174
|
+
const attrs = getDefaultAttrs({ type: childType })
|
|
175
|
+
const allAttrs = { ...relationAttrs, ...attrs }
|
|
176
|
+
return filterBulkAttrs(allAttrs, allowedBulkAttrs)
|
|
177
|
+
}
|
|
178
|
+
export const filterBulkAttrs = (attrs, allowedAttrs) => {
|
|
179
|
+
if (!attrs) return {}
|
|
180
|
+
if (Array.isArray(attrs.type)) {
|
|
181
|
+
const { type, ...rest } = attrs
|
|
182
|
+
// if it is type array and from the validation attribute we need to filter the validation type
|
|
183
|
+
const types = type
|
|
184
|
+
.map(a => filterBulkAttrs(a, allowedAttrs))
|
|
185
|
+
.filter(({ type }) => allowedAttrs.type.includes(type.type.value))
|
|
186
|
+
return { ...rest, type: types }
|
|
187
|
+
}
|
|
188
|
+
if (attrs.type && typeof attrs.type === 'object') {
|
|
189
|
+
const { type, ...rest } = attrs
|
|
190
|
+
const filtered = { type: {}, ...rest }
|
|
191
|
+
const typesKeys = Object.keys(type)
|
|
192
|
+
for (const t of typesKeys) {
|
|
193
|
+
if (allowedAttrs?.[t]) {
|
|
194
|
+
filtered.type[t] = type[t]
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return filtered
|
|
198
|
+
}
|
|
199
|
+
if (typeof attrs === 'object' && !attrs.type) {
|
|
200
|
+
const filtered = {}
|
|
201
|
+
const keys = Object.keys(attrs)
|
|
202
|
+
for (const k of keys) {
|
|
203
|
+
if (allowedAttrs[k]) {
|
|
204
|
+
filtered[k] = filterBulkAttrs(attrs[k], allowedAttrs[k])
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return filtered
|
|
208
|
+
}
|
|
209
|
+
return null
|
|
210
|
+
}
|
|
211
|
+
|
|
139
212
|
// map from attrs[name][type] to attrs[type][name]
|
|
140
213
|
export const attrsByType = {}
|
|
141
214
|
|
|
@@ -147,7 +220,7 @@ for (const [name, matches] of Object.entries(attributes)) {
|
|
|
147
220
|
// handle translations: generate translation attrs and required status
|
|
148
221
|
const { label, ...restDefs } = defs
|
|
149
222
|
if (defs.functionsByName?.translate) {
|
|
150
|
-
// perf measures done:
|
|
223
|
+
// perf measures done: increase from 0.671ms loadtime to 13.261ms
|
|
151
224
|
// should not impact the perfs
|
|
152
225
|
for (const [code, language] of languagesEntries) {
|
|
153
226
|
const newLabel = `${label} - ${language}`
|
|
@@ -236,16 +309,24 @@ const expandAttr = (key, value, defs, object, getUser) => {
|
|
|
236
309
|
let def
|
|
237
310
|
if (types) {
|
|
238
311
|
const isObject = typeof value[key][i] === 'object'
|
|
312
|
+
|
|
313
|
+
// when type is set to "object" for items that cannot be defined in attrs because
|
|
314
|
+
// the key cannot be known (like types.graphArcContentWithSubContents)
|
|
315
|
+
const typeCouldNotBeDefined = isObject && Boolean(types.object)
|
|
316
|
+
|
|
239
317
|
const invalidType =
|
|
240
318
|
!value[key][i].type || typeof value[key][i].type !== 'string'
|
|
241
319
|
const allowedTypes = Object.keys(types).join(',').slice(0, -1)
|
|
242
|
-
|
|
320
|
+
|
|
321
|
+
if (isObject && invalidType && !typeCouldNotBeDefined) {
|
|
243
322
|
console.warn(
|
|
244
323
|
`Type not allowed. Object item for ${key} array (#${i} item) must have one of these "type" property: ${allowedTypes}.`,
|
|
245
324
|
)
|
|
246
325
|
}
|
|
326
|
+
|
|
247
327
|
def = isObject ? types[value[key][i].type] : types[typeof value[key][i]]
|
|
248
|
-
|
|
328
|
+
|
|
329
|
+
if (!def && !typeCouldNotBeDefined) {
|
|
249
330
|
console.warn(
|
|
250
331
|
`Missing type definition. Object item for ${key} array (#${i} item) must have one of these "type" property: ${allowedTypes}.`,
|
|
251
332
|
)
|
|
@@ -265,6 +346,14 @@ const expandAttr = (key, value, defs, object, getUser) => {
|
|
|
265
346
|
|
|
266
347
|
export const expandAttrs = (object, getUser) => {
|
|
267
348
|
if (!object.children) return (object.children = {})
|
|
349
|
+
|
|
350
|
+
for (const [key, defs] of getDefaultAttrsEntries(object)) {
|
|
351
|
+
if (object.attrs[key] === null) {
|
|
352
|
+
console.warn(`value is null for ${object.name} - ${key} - ${object.id}`)
|
|
353
|
+
}
|
|
354
|
+
expandAttr(key, object.attrs, defs, object, getUser)
|
|
355
|
+
}
|
|
356
|
+
|
|
268
357
|
let prev
|
|
269
358
|
for (const child of Object.values(object.children)) {
|
|
270
359
|
child.parent = object
|
package/bin/check-definitions.js
CHANGED
|
@@ -4,7 +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', '
|
|
7
|
+
const rootTypes = ['module', 'piscine', 'signup', 'onboarding']
|
|
8
8
|
const isAudit = validation => validation.type.endsWith('_audit')
|
|
9
9
|
const readDef = async key => {
|
|
10
10
|
const path = key == null ? 'content/def.json' : `content/${key}/def.json`
|
package/onboarding.js
CHANGED
package/package.json
CHANGED
package/toolbox.js
CHANGED
|
@@ -27,7 +27,13 @@ export const objectTypes = new Set([...contentObjects, ...onboardingTypes])
|
|
|
27
27
|
|
|
28
28
|
export const childTypes = {
|
|
29
29
|
campus: ['signup', 'onboarding', 'piscine', 'module'],
|
|
30
|
-
signup: [
|
|
30
|
+
signup: [
|
|
31
|
+
'form-step',
|
|
32
|
+
'sign-step',
|
|
33
|
+
'upload-step',
|
|
34
|
+
'contact-validation-step',
|
|
35
|
+
'avatar-step',
|
|
36
|
+
],
|
|
31
37
|
onboarding: [
|
|
32
38
|
'games',
|
|
33
39
|
'administration',
|
|
@@ -40,6 +46,7 @@ export const childTypes = {
|
|
|
40
46
|
'sign-step',
|
|
41
47
|
'upload-step',
|
|
42
48
|
'contact-validation-step',
|
|
49
|
+
'avatar-step',
|
|
43
50
|
],
|
|
44
51
|
piscine: ['quest', 'exam', 'raid', 'project'],
|
|
45
52
|
exam: ['exercise'],
|