@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 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 { attrs, name } = object
76
- if (!attrs.requirements?.core) return name
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 = `${coreName}/${getProjectName(object)}`
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 ℹ️ icon in the front end,
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 { name, attrs } = object
931
- if (!attrs.requirements?.core) return name
932
- // only for projects with a core requirement set, and which name starts by the core name:
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
- const coreName = getCoreName(object)
935
- // TODO: couldn't we just display the key?
936
- return coreName && name.startsWith(coreName)
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
- types.moduleGraphViews = {
1632
- type: 'string',
1633
- options: ['Circular', 'Linear'],
1634
- editable: true,
1635
- value: '',
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
- attrs.graphView = {
1638
- module: {
1639
- value: [],
1640
- editable: true,
1641
- label: 'Graph views',
1642
- type: [types.moduleGraphViews],
1643
- check: value => {
1644
- if (value.length === 0) throw Error('Must contain at least one view')
1645
- if (new Set(value).size !== value.length)
1646
- throw Error('Should not contain duplicates')
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
- attrs.input = {
1786
- 'upload-step': {
1787
- ...sharedInput,
1788
- check: value => {
1789
- const [inputValues] = Object.values(value)
1790
- isntObjectOrIsEmpty(inputValues)
1791
- if (!inputValues.type || inputValues.type !== 'file') {
1792
- throw Error(
1793
- '"type":"file" property must be defined in the upload input.',
1794
- )
1795
- }
1796
- if (
1797
- inputValues.accept !== undefined &&
1798
- typeof inputValues.accept !== 'string'
1799
- ) {
1800
- throw Error(
1801
- '"accept" property (if added) must be a text. Example: "image/png, image/jpeg"',
1802
- )
1803
- }
1804
- if (
1805
- inputValues.required !== undefined &&
1806
- typeof inputValues.required !== 'boolean'
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 sorted = Object.entries(object.parent.children).sort(byValueIdx)
2017
- const index = sorted.findIndex(([k]) => k === object.key)
2018
- if (index < 1) return sorted.map(([key, _]) => `../${key}`)
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
- if (!Array.isArray(objects) || !objects.length) {
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
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 = { editable: true, label: 'Access conditions' }
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, core, ...rest } = requirements
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 && !core) {
2383
+ if (!skills && !objects) {
2071
2384
  throw Error('Empty requirements, should be removed')
2072
2385
  }
2073
2386
 
2074
- const allObjects = core ? [...(objects || []), core] : objects || []
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
- attrsNameObj[type] = sharedName
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
- return option ? [option] : []
2790
+ const pathways = types.pathwaysRequirementObjects.value(...args)
2791
+ return option ? [option, pathways] : []
2434
2792
  },
2435
- type: [types.objectRootRelativePath],
2436
- instruction: 'Items to be succeeded',
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 { core, objects } = requirements
2874
- if ((!objects || !objects.length) && !core) return true
3245
+ const { objects } = requirements
3246
+ if (!objects || !objects.length) return true
2875
3247
 
2876
- // aggregate core object and regular required objects in the list of objects to check
2877
- const requiredObjects = [...(objects || []), core].filter(Boolean) // only keep values that are not undefined
2878
- let objectFromPath
2879
- return requiredObjects.every(relativePath => {
2880
- try {
2881
- objectFromPath = getObjectFromRelativePath(relativePath, object)
2882
- } catch {
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
2887
- }
2888
- return objectFromPath?.attrs.status === 'succeeded'
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
- registrationNotStarted
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 definitionsWithSameName = object.attrs.teamworkRanks?.filter(
3146
- rankDefinition => rankDefinition.name === name,
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, 1),
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
- return `/markdown/root/public/${path}`
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 url',
3438
- instruction: 'List of questions asked by the auditor during the audit.',
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(type.map(t => [t.type?.type?.value || t.type, t]))
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
- uniqueDef || (typeof v === 'object' ? types[v.type] : types[typeof v])
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: inscrease from 0.671ms loadtime to 13.261ms
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
- if (isObject && invalidType) {
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
- if (!def) {
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
@@ -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', 'sign-up', 'onboarding']
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
@@ -9,6 +9,7 @@ export const onboardingTypes = new Set([
9
9
  'sign-step',
10
10
  'upload-step',
11
11
  'contact-validation-step',
12
+ 'avatar-step',
12
13
  ])
13
14
 
14
15
  export const prevValidated = (key, object) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@01-edu/shared",
3
- "version": "1.0.9",
3
+ "version": "1.1.0",
4
4
  "type": "module",
5
5
  "description": "",
6
6
  "scripts": {
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: ['form-step', 'sign-step', 'upload-step', 'contact-validation-step'],
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'],