diff --git a/src/.meteor/packages b/src/.meteor/packages index afa4a35..34009bb 100644 --- a/src/.meteor/packages +++ b/src/.meteor/packages @@ -6,15 +6,15 @@ meteor-base@1.5.1 # Packages every Meteor app needs to have # mobile-experience@1.1.0 # Packages for a great mobile UX -mongo@1.15.0 # The database Meteor supports right now +mongo@1.16.4 # The database Meteor supports right now blaze-html-templates # Compile .html files into Meteor Blaze views jquery@3.0.0! # Wrapper package for npm-installed jquery -reactive-var@1.0.11 # Reactive variable for tracker -tracker@1.2.0 # Meteor's client-side reactive programming library +reactive-var@1.0.12 # Reactive variable for tracker +tracker@1.3.0 # Meteor's client-side reactive programming library es5-shim@4.8.0 # ECMAScript 5 compatibility for older browsers -ecmascript@0.16.2 # Enable ECMAScript2015+ syntax in app code -typescript@4.5.4 # Enable TypeScript syntax in .ts and .tsx modules +ecmascript@0.16.5 # Enable ECMAScript2015+ syntax in app code +typescript@4.7.4 # Enable TypeScript syntax in .ts and .tsx modules shell-server@0.5.0 # Server-side component of the `meteor shell` command # hot-module-replacement@0.3.0 # Update code in development without reloading the page @@ -31,26 +31,26 @@ shell-server@0.5.0 # Server-side component of the `meteor shell` comm # jkuester:template-monitor facts-base@1.0.1 -facts-ui@1.0.0 +facts-ui@1.0.1 #======================================== # Accounts #======================================== -accounts-base@2.2.3 -accounts-password@2.3.1 +accounts-base@2.2.6 +accounts-password@2.3.3 danimal:userpresence alanning:roles@3.3.0 #======================================== # MAIL SERVICE #======================================== -email@2.2.1 +email@2.2.3 #======================================== # build system #======================================== # standard-minifier-css@1.5.3 # CSS minifier run for production mode -standard-minifier-js@2.8.0 # JS minifier run for production mode +standard-minifier-js@2.8.1 # JS minifier run for production mode fourseven:scss seba:minifiers-autoprefixer # replace css minifier to run with sass diff --git a/src/.meteor/release b/src/.meteor/release index 66dd7b6..caaff0c 100644 --- a/src/.meteor/release +++ b/src/.meteor/release @@ -1 +1 @@ -METEOR@2.7.3 +METEOR@2.10.0 diff --git a/src/.meteor/versions b/src/.meteor/versions index dbd0537..cca1545 100644 --- a/src/.meteor/versions +++ b/src/.meteor/versions @@ -1,5 +1,5 @@ -accounts-base@2.2.4 -accounts-password@2.3.1 +accounts-base@2.2.6 +accounts-password@2.3.3 alanning:roles@3.4.0 aldeed:autoform@7.0.0 aldeed:collection2@3.5.0 @@ -7,7 +7,7 @@ allow-deny@1.1.1 arggh:teleport@1.1.2 audit-argument-checks@1.0.7 autoupdate@1.8.0 -babel-compiler@7.9.2 +babel-compiler@7.10.2 babel-runtime@1.5.1 base64@1.0.12 binary-heap@1.0.11 @@ -17,37 +17,37 @@ blaze-tools@1.1.3 boilerplate-generator@1.7.1 caching-compiler@1.2.2 caching-html-compiler@1.2.1 -callback-hook@1.4.0 +callback-hook@1.5.0 ccorcos:subs-cache@0.9.12 -check@1.3.1 +check@1.3.2 claire:item-range@1.0.0 claire:item-textarea@1.0.0 claire:plugin-registry@1.0.0 communitypackages:autoform-bootstrap4@1.0.6 danimal:userpresence@1.0.1 dburles:mongo-collection-instances@0.3.6 -ddp@1.4.0 -ddp-client@2.5.0 +ddp@1.4.1 +ddp-client@2.6.1 ddp-common@1.4.0 -ddp-rate-limiter@1.1.0 -ddp-server@2.5.0 +ddp-rate-limiter@1.1.1 +ddp-server@2.6.0 deps@1.0.12 -diff-sequence@1.1.1 +diff-sequence@1.1.2 dynamic-import@0.7.2 -ecmascript@0.16.2 +ecmascript@0.16.5 ecmascript-runtime@0.8.0 ecmascript-runtime-client@0.12.1 ecmascript-runtime-server@0.11.0 -ejson@1.1.2 -email@2.2.1 +ejson@1.1.3 +email@2.2.3 es5-shim@4.8.0 facts-base@1.0.1 -facts-ui@1.0.0 -fetch@0.1.1 +facts-ui@1.0.1 +fetch@0.1.3 force-ssl@1.1.0 force-ssl-common@1.1.0 fourseven:scss@4.15.0 -geojson-utils@1.0.10 +geojson-utils@1.0.11 hot-code-push@1.0.4 html-tools@1.1.3 htmljs@1.1.1 @@ -74,43 +74,42 @@ lmieulet:meteor-coverage@3.2.0 localstorage@1.2.0 logging@1.3.1 mdg:validated-method@1.2.0 -meteor@1.10.0 +meteor@1.11.0 meteor-base@1.5.1 meteortesting:browser-tests@1.5.1 -meteortesting:mocha@2.0.3 +meteortesting:mocha@2.0.4 meteortesting:mocha-core@8.1.2 -minifier-css@1.6.1 +minifier-css@1.6.2 minifier-js@2.7.5 -minimongo@1.8.0 -modern-browsers@0.1.8 -modules@0.18.0 -modules-runtime@0.13.0 +minimongo@1.9.1 +modern-browsers@0.1.9 +modules@0.19.0 +modules-runtime@0.13.1 momentjs:moment@2.29.3 -mongo@1.15.0 +mongo@1.16.4 mongo-decimal@0.1.3 mongo-dev-server@1.1.0 mongo-id@1.0.8 -muqube:autoform-nouislider@0.5.2 -npm-mongo@4.3.1 +muqube:autoform-nouislider@0.6.0 +npm-mongo@4.12.1 observe-sequence@1.0.20 ordered-dict@1.1.0 ostrio:cookies@2.7.2 ostrio:cstorage@4.0.1 -ostrio:files@2.3.0 +ostrio:files@2.3.2 ostrio:flow-router-extra@3.9.0 ostrio:i18n@3.2.0 -promise@0.12.0 +promise@0.12.2 raix:eventemitter@1.0.0 -random@1.2.0 +random@1.2.1 rate-limit@1.0.9 -react-fast-refresh@0.2.3 -reactive-dict@1.3.0 -reactive-var@1.0.11 +react-fast-refresh@0.2.5 +reactive-dict@1.3.1 +reactive-var@1.0.12 reload@1.3.1 retry@1.1.0 routepolicy@1.1.1 seba:minifiers-autoprefixer@2.0.1 -service-configuration@1.3.0 sha@1.0.9 shell-server@0.5.0 socket-stream-client@0.5.0 @@ -122,11 +121,11 @@ templating-compiler@1.4.1 templating-runtime@1.6.1 templating-tools@1.2.2 tmeasday:check-npm-versions@1.0.2 -tracker@1.2.0 -typescript@4.5.4 +tracker@1.3.0 +typescript@4.7.4 ui@1.0.13 -underscore@1.0.10 +underscore@1.0.11 url@1.3.2 -webapp@1.13.1 -webapp-hashing@1.1.0 +webapp@1.13.3 +webapp-hashing@1.1.1 zodern:types@1.0.9 diff --git a/src/.settings-schema.js b/src/.settings-schema.js index ac84d47..64cbfcd 100644 --- a/src/.settings-schema.js +++ b/src/.settings-schema.js @@ -55,7 +55,22 @@ const accountsFixtureSchema = schema({ }) const patchSchema = schema({ - removeDeadReferences: Boolean + removeDeadReferences: { + type: Boolean, + optional: true + }, + imageFiles: { + type: Boolean, + optional: true + }, + admin: { + type: Boolean, + optional: true + }, + roles: { + type: Boolean, + optional: true + } }) module.exports = schema({ diff --git a/src/imports/api/accounts/emailTemplates/enrollAccount.js b/src/imports/api/accounts/emailTemplates/enrollAccount.js index ff6b44b..5f87472 100644 --- a/src/imports/api/accounts/emailTemplates/enrollAccount.js +++ b/src/imports/api/accounts/emailTemplates/enrollAccount.js @@ -3,7 +3,7 @@ import { Meteor } from 'meteor/meteor' import { getCredentialsAsBuffer, getFullName } from './common' import { createLog } from '../../log/createLog' -const debug = createLog({ name: 'enrollAccount', type: 'debug' }) +const log = createLog({ name: 'enrollAccount', type: 'log' }) export const getEnrollAccountSubject = ({ siteName, defaultLocale }) => user => { const locale = user?.locale || defaultLocale @@ -24,8 +24,8 @@ export const getEnrollAccountText = ({ expiration, defaultLocale, supportEmail } const text = i18n.get(locale, 'accounts.enroll.text', textOptions) if (Meteor.isDevelopment && !Meteor.isTest) { - debug(textOptions) - debug(text) + log(textOptions) + log(text) } return text diff --git a/src/imports/api/accounts/registration/tests/getEnrollmentExpiration.tests.js b/src/imports/api/accounts/registration/tests/getEnrollmentExpiration.tests.js new file mode 100644 index 0000000..64099d4 --- /dev/null +++ b/src/imports/api/accounts/registration/tests/getEnrollmentExpiration.tests.js @@ -0,0 +1,15 @@ +/* eslint-env mocha */ +import { expect } from 'chai' +import { Meteor } from 'meteor/meteor' +import { getEnrollmentExpiration } from '../getEnrollmentExpiration' + +const { passwordEnrollTokenExpirationInDays } = Meteor.settings.accounts.config + +describe(getEnrollmentExpiration.name, function () { + it('returns the given expiration of days in ms from given date', () => { + const now = new Date() + const expires = getEnrollmentExpiration(now) + const diff = expires - now.getTime() + expect(diff / 86400000).to.equal(passwordEnrollTokenExpirationInDays) + }) +}) diff --git a/src/imports/api/accounts/registration/tests/index.js b/src/imports/api/accounts/registration/tests/index.js index 7aa2e2a..221d2e2 100644 --- a/src/imports/api/accounts/registration/tests/index.js +++ b/src/imports/api/accounts/registration/tests/index.js @@ -4,4 +4,5 @@ describe('registration', function () { import './UserFactory.tests' import './registerUserSchema' import './rollbackAccount.tests' + import './getEnrollmentExpiration.tests' }) diff --git a/src/imports/api/accounts/user/getUserByEmail.js b/src/imports/api/accounts/user/getUserByEmail.js index 4f2cf42..6458986 100644 --- a/src/imports/api/accounts/user/getUserByEmail.js +++ b/src/imports/api/accounts/user/getUserByEmail.js @@ -1,5 +1,5 @@ import { getUsersCollection } from '../../utils/getUsersCollection' export const getUserByEmail = email => { - return getUsersCollection().findOne({ emails: { address: email } }) + return getUsersCollection().findOne({ emails: { $elemMatch: { address: email } } }) } diff --git a/src/imports/api/config/Features.js b/src/imports/api/config/Features.js index b4278ab..872c304 100644 --- a/src/imports/api/config/Features.js +++ b/src/imports/api/config/Features.js @@ -11,7 +11,7 @@ export const Features = {} Features.get = (name) => { if (!name || !Object.hasOwnProperty.call(features, name)) { - throw new Error(`Features have no feature by name ${name}`) + throw new Error(`Features have no feature by name "${name}"`) } return features[name] } @@ -19,7 +19,7 @@ Features.get = (name) => { Features.ensure = (name, value = true) => { const current = Features.get(name) if (current !== value) { - throw new Error(`Feature is expected to be ${value} but is ${current}`) + throw new Error(`Feature "${name}" is expected to be ${value} but is ${current}`) } } diff --git a/src/imports/api/response/ResponseProcessorAPI.js b/src/imports/api/response/ResponseProcessorAPI.js index 4e787a7..82b2015 100644 --- a/src/imports/api/response/ResponseProcessorAPI.js +++ b/src/imports/api/response/ResponseProcessorAPI.js @@ -106,8 +106,12 @@ ResponseProcessorAPI.create = (itemData, templateInstance) => { actionHandlers.set(actionId, actionHandler) } }, - document: context.schema && (() => { - return getCollection(context.name).findOne({ lessonId, taskId, itemId }) + document: context.schema && (({ groupId }) => { + const query = { lessonId, taskId, itemId } + if (groupId) { + query.groupId = groupId + } + return getCollection(context.name).findOne(query) }), onResize: function (callback) { resizeListeners.set(actionId, callback) diff --git a/src/imports/api/schema/Schema.js b/src/imports/api/schema/Schema.js index bf0b888..5f09392 100644 --- a/src/imports/api/schema/Schema.js +++ b/src/imports/api/schema/Schema.js @@ -154,3 +154,4 @@ SimpleSchema.ErrorTypes.GENERIC = 'genericError' export const RegEx = SimpleSchema.RegEx export const ErrorTypes = SimpleSchema.ErrorTypes +export const Integer = SimpleSchema.Integer diff --git a/src/imports/api/utils/getUsersCollection.js b/src/imports/api/utils/getUsersCollection.js index 67a0834..88affc9 100644 --- a/src/imports/api/utils/getUsersCollection.js +++ b/src/imports/api/utils/getUsersCollection.js @@ -1,4 +1,5 @@ import { getCollection } from './getCollection' +import { getLocalCollection } from '../../infrastructure/collection/getLocalCollection' /** * This is a special case, since in Meteor the users collection is @@ -10,4 +11,6 @@ import { getCollection } from './getCollection' * it's value as convention. * @return {Mongo.Collection} the Meteor.users collection */ -export const getUsersCollection = () => getCollection('users') +export const getUsersCollection = (local = false) => local + ? getLocalCollection('users') + : getCollection('users') diff --git a/src/imports/api/utils/object/withProperty.js b/src/imports/api/utils/object/withProperty.js new file mode 100644 index 0000000..44a16ed --- /dev/null +++ b/src/imports/api/utils/object/withProperty.js @@ -0,0 +1,32 @@ +/** + * Ensures that a property exists on a given object + * @param target {object} + * @param name {string} + * @param factory {function|*} + * @return {object} + * @throws {Error} if target is not an object + */ +export const withProperty = (target, name, factory) => { + const type = typeof target + if (type !== 'object') { + throw new Error(`Expected object, get ${type}`) + } + + if (name in target) { + return target + } + + if (typeof factory === 'function') { + return factory(target, name) + } + else { + const descriptor = Object.create(null) + descriptor.value = factory + descriptor.enumerable = true + descriptor.writable = true + descriptor.configurable = true + Object.defineProperty(target, name, descriptor) + } + + return target +} diff --git a/src/imports/contexts/classroom/group/Group.js b/src/imports/contexts/classroom/group/Group.js index 3f4656c..a84c616 100644 --- a/src/imports/contexts/classroom/group/Group.js +++ b/src/imports/contexts/classroom/group/Group.js @@ -1,4 +1,3 @@ -import { Meteor } from 'meteor/meteor' import { onServer, onServerExec } from '../../../api/utils/archUtils' import { getCollection } from '../../../api/utils/getCollection' import { UserUtils } from '../../system/accounts/users/UserUtils' @@ -13,12 +12,18 @@ Group.publicFields = { title: 1, users: 1, maxUsers: 1, - lessonId: 1, + isAdhoc: 1, + classId: 1, + unitId: 1, phases: 1, material: 1, visible: 1 } +/** + * The group doc schema + * @type {object} + */ Group.schema = { title: { @@ -33,6 +38,7 @@ Group.schema = { users: { type: Array, + optional: true, label: 'group.users', min: 1 }, @@ -60,19 +66,21 @@ Group.schema = { }, /** - * Associate a class + * determines, whether a group has been created + * during a running lesson (ad-hoc). + * In such case it's a temporary group that + * is deleted, if the lesson is reset */ - - classId: { - type: String, + isAdhoc: { + type: Boolean, optional: true }, /** - * Limit scope to a certain lesson, if desired. + * Associate a class */ - lessonId: { + classId: { type: String, optional: true }, @@ -132,37 +140,39 @@ Group.publications = {} Group.publications.my = { name: 'group.publications.my', schema: { - lessonId: { + classId: { type: String, optional: true }, - classId: { + unitId: { type: String, optional: true } }, - run: onServer(function ({ lessonId, classId }) { + run: onServer(function ({ classId, unitId } = {}) { const { userId } = this const query = { $or: [] } // option 1: I am creator of these const myGroups = { createdBy: userId } - if (lessonId) myGroups.lessonId = lessonId if (classId) myGroups.classId = classId + if (unitId) myGroups.unitId = unitId // option 2: I am member of these groups const iamMember = { users: { $elemMatch: { userId } } } - if (lessonId) iamMember.lessonId = lessonId if (classId) iamMember.classId = classId + if (unitId) iamMember.unitId = unitId query.$or.push(myGroups, iamMember) - return getCollection(Group.name).find(query, { fields: Group.publicFields }) }) } +/** + * @role {student} + */ Group.publications.single = { name: 'group.publications.single', schema: { @@ -170,6 +180,7 @@ Group.publications.single = { type: String } }, + role: [UserUtils.roles.student], run: onServer(function ({ groupId }) { const { userId } = this const query = { _id: groupId, users: { $elemMatch: { userId } } } @@ -179,6 +190,42 @@ Group.publications.single = { Group.methods = {} +/** + * Returns all groups that a teacher owns by given ids + * @param ids {[string]} a list of group ids + * @returns {[object]} a list of documents for the given ids + * @throws {PermissionDenied} if user has no permission for one of the groups + */ +Group.methods.get = { + name: 'group.methods.get', + schema: { + ids: Array, + 'ids.$': String + }, + roles: UserUtils.roles.teacher, + run: onServerExec(function () { + import { $in } from '../../../api/utils/query/inSelector' + + return function ({ ids }) { + const { userId } = this + const query = { _id: $in(ids), createdBy: userId } + return getCollection(Group.name).find(query).fetch() + } + }) +} + +/** + * Saves a group document. Creates a new doc of it does not exist yet. + * + * @param _id {string=} only for updating a group doc required + * @param title {string} the title of the group + * @param users + * @param maxUsers + * @param classId + * @param phases + * @param material + * @param visible + */ Group.methods.save = { name: 'group.methods.save', schema: Object.assign({ @@ -187,15 +234,20 @@ Group.methods.save = { optional: true } }, Group.schema), + roles: UserUtils.roles.teacher, run: onServerExec(function () { import { checkEditPermission } from '../../../api/document/checkEditPermissions' + import { createDocGetter } from '../../../api/utils/document/createDocGetter' + + const getGroupDoc = createDocGetter({ name: Group.name }) return function (groupDoc) { const { userId } = this const { _id, ...doc } = groupDoc if (_id) { - checkEditPermission({ doc: groupDoc, userId }) + const originalDoc = getGroupDoc({ _id }) + checkEditPermission({ doc: originalDoc, userId }) return getCollection(Group.name).update(_id, { $set: doc }) } @@ -204,28 +256,58 @@ Group.methods.save = { }) } +Group.methods.update = { + name: 'group.methods.update', + schema: { _id: String, ...Group.schema }, + roles: UserUtils.roles.teacher, + run: onServerExec(function () { + import { checkEditPermission } from '../../../api/document/checkEditPermissions' + import { createDocGetter } from '../../../api/utils/document/createDocGetter' + + const getGroupDoc = createDocGetter({ name: Group.name }) + + return function ({ _id, ...updateDoc }) { + const doc = getGroupDoc({ _id }) + const { userId } = this + checkEditPermission({ doc, userId }) + return getCollection(Group.name).update({ _id }, { $set: updateDoc }) + } + }) +} + Group.methods.delete = { name: 'group.methods.delete', schema: { _id: String }, - run: onServer(function ({ _id }) { - return getCollection(Group.name).remove(_id) + roles: UserUtils.roles.teacher, + run: onServerExec(function () { + import { checkEditPermission } from '../../../api/document/checkEditPermissions' + import { createDocGetter } from '../../../api/utils/document/createDocGetter' + + const getGroupDoc = createDocGetter({ name: Group.name }) + + return function ({ _id }) { + const doc = getGroupDoc({ _id }) + const { userId } = this + checkEditPermission({ doc, userId }) + return getCollection(Group.name).remove(_id) + } }) } Group.methods.toggleMaterial = { name: 'group.methods.toggleMaterial', schema: { _id: String, materialId: String, contextName: String }, + roles: UserUtils.roles.teacher, run: onServerExec(function () { - return function ({ _id, materialId, contextName }) { - const GroupCollection = getCollection(Group.name) - const groupDoc = GroupCollection.findOne(_id) + import { checkEditPermission } from '../../../api/document/checkEditPermissions' + import { createDocGetter } from '../../../api/utils/document/createDocGetter' - // TODO use ensureDocument - if (!groupDoc) throw new Error('docNotFound') + const getGroupDoc = createDocGetter({ name: Group.name }) - // TODO ensureDocument - const materialExists = (groupDoc.material || []).includes(materialId) - if (!materialExists) throw new Error('noMaterial') + return function ({ _id, materialId, contextName }) { + const groupDoc = getGroupDoc({ _id }) + const { userId } = this + checkEditPermission({ doc: groupDoc, userId }) const mutation = {} const visibleList = groupDoc.visible || [] @@ -239,8 +321,7 @@ Group.methods.toggleMaterial = { const visible = { _id: materialId, context: contextName } mutation.$addToSet = { visible } } - this.log({ _id, mutation }) - return GroupCollection.update(_id, mutation) + return getCollection(Group.name).update(_id, mutation) } }) } @@ -254,27 +335,31 @@ Group.methods.users = { import { PermissionDeniedError } from '../../../api/errors/types/PermissionDeniedError' import { createDocGetter } from '../../../api/utils/document/createDocGetter' import { $in } from '../../../api/utils/query/inSelector' + import { getUsersCollection } from '../../../api/utils/getUsersCollection' const getGroupDoc = createDocGetter(Group) return function ({ groupId }) { const { userId } = this const groupDoc = getGroupDoc({ _id: groupId }) + const { users, createdBy } = groupDoc - if (!groupDoc.users || !groupDoc.users.some(entry => entry.userId === userId)) { + if (createdBy !== userId && !users.some(entry => entry.userId === userId)) { throw new PermissionDeniedError('group.notAMember', { groupId, userId }) } const allUserIds = [] - groupDoc.users.forEach(entry => { + users.forEach(entry => { if (entry.userId !== userId) { allUserIds.push(entry.userId) } }) - return Meteor.users.find({ _id: $in(allUserIds) }, { fields: Users.publicFields }).fetch() + return getUsersCollection() + .find({ _id: $in(allUserIds) }, { fields: Users.publicFields }) + .fetch() } }) } diff --git a/src/imports/contexts/classroom/group/GroupBuilder.js b/src/imports/contexts/classroom/group/GroupBuilder.js index 06224b5..2737135 100644 --- a/src/imports/contexts/classroom/group/GroupBuilder.js +++ b/src/imports/contexts/classroom/group/GroupBuilder.js @@ -2,21 +2,54 @@ import { Meteor } from 'meteor/meteor' import { check, Match } from 'meteor/check' import { ReactiveVar } from 'meteor/reactive-var' +const internal = { + defaultGroupTitle: 'group.defaultTitle' +} + +/** + * @class + * @member {ReactiveVar} groups + * @member {[string]} users + * @member {[string]} phases + * @member {[string]} material + * @member {number} maxGroups + * @member {number} maxUsers + * @member {boolean} materialForAllGroups + * @member {boolean} materialAutoShuffle + * @member {[string]} roles + * @member {string} groupTitleDefault + */ class GroupBuilder { - constructor ({ groupTitleDefault = 'change me!' } = {}) { + static defaultGroupTitle (value) { + internal.defaultGroupTitle = value + } + + constructor ({ groupTitleDefault = internal.defaultGroupTitle } = {}) { this.groups = new ReactiveVar([]) this.users = [] this.phases = [] this.material = [] this.maxGroups = 0 - this.maxUsers = 2 // per group + this.maxUsers = 0 // per group this.materialForAllGroups = false this.materialAutoShuffle = false + this.atLeastOneUserRequired = false this.roles = [] - this.oddDistribution = false this.groupTitleDefault = groupTitleDefault } + /** + * @param options {object} + * @param options.users {[string]=} + * @param options.phases {[string]=} + * @param options.material {[string]=} + * @param options.roles {[string]=} + * @param options.maxGroups {number=} + * @param options.maxUsers {number=} + * @param options.materialForAllGroups {boolean=} + * @param options.materialAutoShuffle {boolean=} + * @return {GroupBuilder} + */ setOptions (options) { check(options, { users: Match.Maybe([String]), @@ -26,22 +59,20 @@ class GroupBuilder { maxGroups: Match.Maybe(Number), maxUsers: Match.Maybe(Number), materialForAllGroups: Match.Maybe(Boolean), - materialAutoShuffle: Match.Maybe(Boolean) + materialAutoShuffle: Match.Maybe(Boolean), + atLeastOneUserRequired: Match.Maybe(Boolean) }) - this.maxGroups = options.maxGroups || this.maxGroups - this.maxUsers = options.maxUsers || this.maxUsers - this.materialForAllGroups = options.materialForAllGroups || this.materialForAllGroups - this.materialAutoShuffle = options.materialAutoShuffle || this.materialAutoShuffle + this.maxGroups = options.maxGroups ?? this.maxGroups + this.maxUsers = options.maxUsers ?? this.maxUsers + this.materialForAllGroups = options.materialForAllGroups ?? this.materialForAllGroups + this.materialAutoShuffle = options.materialAutoShuffle ?? this.materialAutoShuffle + this.atLeastOneUserRequired = options.atLeastOneUserRequired ?? this.atLeastOneUserRequired if (options.users) { // sanity check const maxSize = this.maxGroups * this.maxUsers - if (maxSize > 0 && options.users.length > maxSize) { - throw new Meteor.Error('groupBuilder.error', 'groupBuilder.maxUsersExceeded') - } - - this.oddDistribution = options.users.length % this.maxUsers !== 0 + checkUsers(options.users, maxSize) this.users = options.users } @@ -61,23 +92,27 @@ class GroupBuilder { } createGroups ({ shuffle = false }) { - checkUsers(this.users, this.maxUsers * this.maxGroups) + const usersCount = this.users.length - if (this.users.length === 0) { - throw new Error('groupBuilder.error', 'groupBuilder.atLeastOneUserRequired') + if (this.atLeastOneUserRequired && usersCount === 0) { + throw new Meteor.Error('groupBuilder.error', 'groupBuilder.atLeastOneUserRequired') } - const material = this.material || [] + const material = this.material ?? [] const materialCount = material.length + // If maxUsers is set, we use this value, otherwise, // no matter if we shuffle or not, we create a default set of groups - // that hypothetically allows to distribute all users - const groupLength = Math.ceil(this.users.length / (this.maxUsers || 1)) + // that hypothetic ally allows to distribute all users + const groupLength = this.maxGroups > 0 + ? this.maxGroups + : Math.ceil((usersCount || 1) / (this.maxUsers || 1)) for (let i = 0; i < groupLength; i++) { const group = {} group.title = `${this.groupTitleDefault} ${i + 1}` + group.material = [] - if (this.materialAutoShuffle) { + if (this.materialAutoShuffle && material.length > 0) { // if we have more groups than material or equal // we simply rotate each material around the groups if (groupLength >= materialCount) { @@ -89,12 +124,26 @@ class GroupBuilder { // and distribute multiple material per group else { const materialRatio = Math.round(materialCount / groupLength) - const offset = i * materialRatio - const groupMaterial = material.slice(offset, offset + materialRatio) + const from = i * materialRatio + const to = from + materialRatio + const groupMaterial = [] + + for (let j = from; j < to; j++) { + const index = j > material.length - 1 + ? j - material.length + : j + const value = material[index] + groupMaterial.push(value) + } + group.material = groupMaterial.filter(entry => !!entry) } } + if (this.materialForAllGroups && material.length > 0) { + group.material = material + } + this.addGroup(group) } @@ -127,15 +176,23 @@ class GroupBuilder { // GROUPS // --------------------------------------------------------------------------- + /** + * Adds a new group to the internal group stack + * @param options {object} + * @param options.users {[object]} + * @param options.title {string} + * @param options.material {[string]} + * @return {GroupBuilder} + */ addGroup (options) { checkGroupOptions(options) checkUsers(options.users, this.maxUsers * this.maxGroups) const { title, users = [] } = options const groups = this.groups.get() - const phases = [].concat(this.phases) + const phases = [].concat(this.phases ?? []) const material = this.materialForAllGroups - ? [].concat(this.material) - : options.material + ? [].concat(this.material ?? []) + : options.material ?? [] groups.push({ title, users, phases, material }) this.groups.set(groups) @@ -144,9 +201,7 @@ class GroupBuilder { removeGroup (index) { const groups = this.groups.get() - if (index < 0 || index > groups.length - 1) { - throw new Error('groupBuilder.error', 'groupBuilder.invalidIndex', { index }) - } + checkGroupIndex(index, groups) groups.splice(index, 1) this.groups.set(groups) @@ -155,9 +210,7 @@ class GroupBuilder { updateGroup ({ index, title }) { const groups = this.groups.get() - if (index < 0 || index > groups.length - 1) { - throw new Error('groupBuilder.error', 'groupBuilder.invalidIndex', { index }) - } + checkGroupIndex(index, groups) groups[index].title = title this.groups.set(groups) @@ -177,6 +230,10 @@ class GroupBuilder { return this } + hasMaxGroups () { + return (this.groups.get() ?? []).length >= this.maxGroups + } + // --------------------------------------------------------------------------- // MATERIAL // --------------------------------------------------------------------------- @@ -189,7 +246,7 @@ class GroupBuilder { group.material = group.material || [] if (group.material.includes(materialId)) { - throw new Error('groupBuilder.error', 'groupBuilder.expectedNoMaterial') + throw new Meteor.Error('groupBuilder.error', 'groupBuilder.expectedNoMaterial') } group.material.push(materialId) @@ -209,7 +266,7 @@ class GroupBuilder { const materialIndex = group.material.indexOf(materialId) if (materialIndex === -1) { - throw new Error('groupBuilder.error', 'groupBuilder.expectedMaterial') + throw new Meteor.Error('groupBuilder.error', 'groupBuilder.expectedMaterial') } group.material.splice(materialIndex, 1) @@ -223,7 +280,8 @@ class GroupBuilder { // Users // --------------------------------------------------------------------------- addUser ({ index, userId, role }) { - const groups = this.groups.get() || [] + checkUserId(this.users, userId) + const groups = this.groups.get() ?? [] checkGroupIndex(index, groups) check(userId, String) check(role, Match.Maybe(String)) @@ -242,6 +300,7 @@ class GroupBuilder { } updateUser ({ index, userId, role }) { + checkUserId(this.users, userId) const groups = this.groups.get() || [] checkGroupIndex(index, groups) check(userId, String) @@ -261,7 +320,8 @@ class GroupBuilder { } removeUser ({ index, userId, role }) { - const groups = this.groups.get() || [] + checkUserId(this.users, userId) + const groups = this.groups.get() checkGroupIndex(index, groups) check(userId, String) check(role, Match.Maybe(String)) @@ -280,9 +340,7 @@ class GroupBuilder { } userHasBeenAssigned (userId) { - if (!this.users.includes(userId)) { - throw new Meteor.Error('groupBuilder.error', 'groupBuilder.invalidUserId') - } + checkUserId(this.users, userId) const groups = this.groups.get() return userHasBeenAssigned(groups, userId) } @@ -316,6 +374,19 @@ const checkUsers = (users = [], maxUsers) => { } } +const checkUserId = (users, userId) => { + if (!users.includes(userId)) { + throw new Meteor.Error('groupBuilder.error', 'groupBuilder.invalidUserId') + } +} + +/** + * Creates a random shuffled version of a given array, + * independent of it's content + * @private + * @param input {*[]} + * @returns {*[]} + */ const shuffleArray = input => { const array = [].concat(input) for (let i = array.length - 1; i > 0; i--) { diff --git a/src/imports/contexts/classroom/group/GroupMode.js b/src/imports/contexts/classroom/group/GroupMode.js index fc1ca7d..91d2180 100644 --- a/src/imports/contexts/classroom/group/GroupMode.js +++ b/src/imports/contexts/classroom/group/GroupMode.js @@ -15,20 +15,13 @@ export const GroupMode = { value: 'override', label: 'groupMode.override' }, - /** - * Each person answers for themselves - */ - split: { - index: 2, - value: 'split', - label: 'groupMode.split' - }, + /** * All work on the same answer, individual values will be merged * into one final answer. */ merge: { - index: 3, + index: 2, value: 'merge', label: 'groupMode.merge' } diff --git a/src/imports/contexts/classroom/group/tests/Group.tests.js b/src/imports/contexts/classroom/group/tests/Group.tests.js new file mode 100644 index 0000000..432fbfb --- /dev/null +++ b/src/imports/contexts/classroom/group/tests/Group.tests.js @@ -0,0 +1,324 @@ +/* eslint-env mocha */ +import { Random } from 'meteor/random' +import { Group } from '../Group' +import { expect } from 'chai' +import { mockCollections, restoreAllCollections, clearCollections } from '../../../../../tests/testutils/mockCollection' +import { Users } from '../../../system/accounts/users/User' +import { createGroupDoc } from '../../../../../tests/testutils/doc/createGroupDoc' +import { DocNotFoundError } from '../../../../api/errors/types/DocNotFoundError' +import { PermissionDeniedError } from '../../../../api/errors/types/PermissionDeniedError' +import { Admin } from '../../../system/accounts/admin/Admin' +import { collectPublication } from '../../../../../tests/testutils/collectPublication' + +describe(Group.name, function () { + let GroupCollection + let UsersCollection + + before(function () { + [GroupCollection, UsersCollection] = mockCollections(Group, Users, Admin) + }) + + afterEach(function () { + clearCollections(Group, Users) + }) + + after(function () { + restoreAllCollections() + }) + + const checkExists = (fn) => { + it('throws if the group does not exist', function () { + const _id = Random.id() + const thrown = expect(() => fn({ _id })) + .to.throw(DocNotFoundError.name) + thrown.with.property('reason', 'getDocument.docUndefined') + thrown.with.deep.property('details', { query: { _id }, name: Group.name }) + }) + } + + const checkPermission = (fn) => { + it('throws if the user has no permission to edit the group', function () { + const groupDoc = createGroupDoc() + const userId = Random.id() + const env = { userId } + const _id = GroupCollection.insert(groupDoc) + const thrown = expect(() => fn.call(env, { _id })) + .to.throw(PermissionDeniedError.name) + thrown.with.property('reason', 'errors.noPermission') + thrown.with.deep.property('details', { _id, userId }) + }) + } + + describe('methods', function () { + describe(Group.methods.save.name, function () { + const saveGroup = Group.methods.save.run + + checkExists(saveGroup) + checkPermission(saveGroup) + + it('creates a new group doc', function () { + const groupDoc = createGroupDoc({ title: 'foobar' }) + expect(GroupCollection.find().count()).to.equal(0) + const groupId = saveGroup(groupDoc) + expect(GroupCollection.find().count()).to.equal(1) + const { _id, ...savedDoc } = GroupCollection.findOne(groupId) + expect(savedDoc).to.deep.equal(groupDoc) + }) + + it('updates a group doc', function () { + const userId = Random.id() + const groupDoc = createGroupDoc({ createdBy: userId }) + const env = { userId } + const groupId = GroupCollection.insert(groupDoc) + delete groupDoc._id + delete groupDoc.title + + const updated = saveGroup.call(env, { _id: groupId, title: 'foobar' }) + expect(updated).to.equal(1) + + const { _id, title, ...savedDoc } = GroupCollection.findOne(groupId) + expect(title).to.equal('foobar') + expect(savedDoc).to.deep.equal(groupDoc) + }) + }) + describe(Group.methods.users.name, function () { + const getUsers = Group.methods.users.run + + it('throws if the group does not exist', function () { + const _id = Random.id() + const thrown = expect(() => getUsers({ groupId: _id })) + .to.throw(DocNotFoundError.name) + thrown.with.property('reason', 'getDocument.docUndefined') + thrown.with.deep.property('details', { query: { _id }, name: Group.name }) + }) + it('throws if not a member and not owner', function () { + const userId = Random.id() + const groupDoc = createGroupDoc() + const groupId = GroupCollection.insert(groupDoc) + const thrown = expect(() => getUsers.call({ userId }, { groupId })) + .to.throw(PermissionDeniedError.name) + thrown.with.property('reason', 'group.notAMember') + thrown.with.deep.property('details', { groupId, userId }) + }) + it('returns all members of the group with restricted fields if user is member', function () { + const u1 = UsersCollection.insert({ + username: 'jane', + firstName: 'jane', + lastName: 'doe', + emails: [{ address: 'jane@example.com' }], + presence: { status: 'offline' }, + services: {} + }) + const u2 = UsersCollection.insert({ + username: 'john', + firstName: 'john', + lastName: 'doe', + emails: [{ address: 'john@example.com' }], + presence: { status: 'online' }, + services: {} + }) + const allUsers = [u1, u2] + const createdBy = Random.id() + const groupDoc = createGroupDoc({ createdBy, users: allUsers.map(userId => ({ userId })) }) + const groupId = GroupCollection.insert(groupDoc) + + allUsers.forEach(userId => { + const users = getUsers.call({ userId }, { groupId }) + expect(users.length).to.equal(allUsers.length - 1) // except callee user + expect(users[0]._id).to.not.equal(userId) + const userDoc = UsersCollection.findOne(users[0]._id) + delete userDoc.username + delete userDoc.emails + delete userDoc.services + expect(users[0]).to.deep.equal(userDoc) + }) + + // teacher gets all users + const allMembers = getUsers.call({ userId: createdBy }, { groupId }) + expect(allMembers.length).to.equal(allUsers.length) + }) + }) + describe(Group.methods.update.name, function () { + const updateGroup = Group.methods.update.run + checkExists(updateGroup) + checkPermission(updateGroup) + it('updates a group doc', function () { + const userId = Random.id() + const groupDoc = createGroupDoc({ createdBy: userId }) + const env = { userId } + const groupId = GroupCollection.insert(groupDoc) + delete groupDoc._id + delete groupDoc.title + + const updated = updateGroup.call(env, { _id: groupId, title: 'foobar' }) + expect(updated).to.equal(1) + + const { _id, title, ...savedDoc } = GroupCollection.findOne(groupId) + expect(title).to.equal('foobar') + expect(savedDoc).to.deep.equal(groupDoc) + }) + }) + describe(Group.methods.delete.name, function () { + const deleteGroup = Group.methods.delete.run + checkExists(deleteGroup) + checkPermission(deleteGroup) + it('deletes a group doc', function () { + const userId = Random.id() + const groupDoc = createGroupDoc({ createdBy: userId }) + const env = { userId } + const groupId = GroupCollection.insert(groupDoc) + delete groupDoc._id + delete groupDoc.title + + const removed = deleteGroup.call(env, { _id: groupId }) + expect(removed).to.equal(1) + expect(GroupCollection.findOne(groupId)).to.deep.equal(undefined) + }) + }) + describe(Group.methods.toggleMaterial.name, function () { + const toggleGroup = Group.methods.toggleMaterial.run + checkExists(toggleGroup) + checkPermission(toggleGroup) + it('makes invisible material visible', function () { + const userId = Random.id() + const materialId = Random.id() + const contextName = 'foobar' + const groupProps = { createdBy: userId, material: [materialId] } + const groupInput = createGroupDoc(groupProps) + const env = { userId } + const groupId = GroupCollection.insert(groupInput) + toggleGroup.call(env, { _id: groupId, materialId, contextName }) + + const groupDoc = GroupCollection.findOne(groupId) + expect(groupDoc).to.deep.equal({ + _id: groupId, + createdBy: userId, + material: [materialId], + title: groupInput.title, + unitId: groupInput.unitId, + isAdhoc: false, + maxUsers: groupInput.maxUsers, + users: groupInput.users, + visible: [ + { + _id: materialId, + context: contextName + } + ] + }) + }) + it('makes visible material invisible', function () { + const userId = Random.id() + const materialId = Random.id() + const contextName = 'foobar' + const groupProps = { + createdBy: userId, + material: [materialId], + visible: [{ _id: materialId, context: contextName }] + } + const groupInput = createGroupDoc(groupProps) + const env = { userId } + const groupId = GroupCollection.insert(groupInput) + toggleGroup.call(env, { _id: groupId, materialId, contextName }) + + const groupDoc = GroupCollection.findOne(groupId) + expect(groupDoc).to.deep.equal({ + _id: groupId, + createdBy: userId, + material: [materialId], + title: groupInput.title, + unitId: groupInput.unitId, + isAdhoc: false, + maxUsers: groupInput.maxUsers, + users: groupInput.users, + visible: [] + }) + }) + }) + describe(Group.methods.get.name, function () { + const getGroups = Group.methods.get.run + + it('returns an empty Array for empty or unknown ids', function () { + expect(getGroups({ ids: [] })).to.deep.equal([]) + expect(getGroups({ ids: [Random.id()] })).to.deep.equal([]) + const groupId = GroupCollection.insert(createGroupDoc()) + // case if user does not own the docs + expect(getGroups({ ids: [groupId] })).to.deep.equal([]) + }) + it('returns given group docs', function () { + const userId = Random.id() + const env = { userId } + const groupId = GroupCollection.insert(createGroupDoc({ createdBy: userId })) + expect(getGroups.call(env, { ids: [groupId] })).to.deep.equal([GroupCollection.findOne(groupId)]) + }) + }) + }) + + describe('publications', function () { + const myGroupsPub = Group.publications.my.run + const singleGroupPub = Group.publications.single.run + + describe(Group.publications.my.name, function () { + it('returns no docs if user is neither owner, nor member', function () { + const userId = Random.id() + GroupCollection.insert(createGroupDoc()) + const pub = collectPublication(myGroupsPub.call({ userId })) + expect(pub.length).to.equal(0) + }) + it('returns all group docs that user has created', function () { + const userId = Random.id() + const groupId = GroupCollection.insert(createGroupDoc({ createdBy: userId })) + const pub = collectPublication(myGroupsPub.call({ userId })) + expect(pub.length).to.equal(1) + expect(pub[0]._id).to.equal(groupId) + }) + it('returns all group docs that user is member', function () { + const userId = Random.id() + const groupId = GroupCollection.insert(createGroupDoc({ users: [{ userId }] })) + const pub = collectPublication(myGroupsPub.call({ userId })) + expect(pub.length).to.equal(1) + expect(pub[0]._id).to.equal(groupId) + }) + it('filters group docs by classId', function () { + const userId = Random.id() + const classId = Random.id() + GroupCollection.insert(createGroupDoc({ users: [{ userId }] })) + const groupId = GroupCollection.insert(createGroupDoc({ users: [{ userId }], classId })) + const pub = collectPublication(myGroupsPub.call({ userId }, { classId })) + expect(pub.length).to.equal(1) + expect(pub[0]._id).to.equal(groupId) + }) + it('filters group docs by unitId', function () { + const userId = Random.id() + const unitId = Random.id() + GroupCollection.insert(createGroupDoc({ createdBy: userId })) + const groupId = GroupCollection.insert(createGroupDoc({ users: [{ userId }], unitId })) + const pub = collectPublication(myGroupsPub.call({ userId }, { unitId })) + expect(pub.length).to.equal(1) + expect(pub[0]._id).to.equal(groupId) + }) + }) + describe(Group.publications.single.name, function () { + it('returns no docs if no _id matches', function () { + const userId = Random.id() + const groupId = Random.id() + GroupCollection.insert(createGroupDoc({ users: [{ userId }] })) + const pub = collectPublication(singleGroupPub.call({ userId }, { groupId })) + expect(pub.length).to.equal(0) + }) + it('returns no docs if user is not member', function () { + const userId = Random.id() + const groupId = GroupCollection.insert(createGroupDoc()) + const pub = collectPublication(singleGroupPub.call({ userId }, { groupId })) + expect(pub.length).to.equal(0) + }) + it('returns a group doc by _id if the student is member', function () { + const userId = Random.id() + const groupId = GroupCollection.insert(createGroupDoc({ users: [{ userId }] })) + const pub = collectPublication(singleGroupPub.call({ userId }, { groupId })) + expect(pub.length).to.equal(1) + expect(pub[0]._id).to.equal(groupId) + }) + }) + }) +}) diff --git a/src/imports/contexts/classroom/group/tests/GroupBuilder.tests.js b/src/imports/contexts/classroom/group/tests/GroupBuilder.tests.js new file mode 100644 index 0000000..204a89c --- /dev/null +++ b/src/imports/contexts/classroom/group/tests/GroupBuilder.tests.js @@ -0,0 +1,521 @@ +/* eslint-env mocha */ +import { expect } from 'chai' +import { GroupBuilder } from '../GroupBuilder' + +describe('GroupBuilder', function () { + describe('constructor', function () { + it('can be instatiated with optional defaults', function () { + expect(new GroupBuilder().groupTitleDefault).to.equal('group.defaultTitle') + expect(new GroupBuilder({ groupTitleDefault: 'foo' }).groupTitleDefault).to.equal('foo') + GroupBuilder.defaultGroupTitle('bar') + expect(new GroupBuilder().groupTitleDefault).to.equal('bar') + GroupBuilder.defaultGroupTitle('group.defaultTitle') + }) + }) + describe(GroupBuilder.prototype.setOptions.name, function () { + it('accepts all optional parameters', function () { + const builder = new GroupBuilder() + const initial = { ...builder } + builder.setOptions({}) + expect(builder).to.deep.equal(initial) + }) + it('throws if users size is greater than maxSize', function () { + const builder = new GroupBuilder() + const options = { + users: ['foo', 'bar'], + maxUsers: 1, + maxGroups: 1 + } + expect(() => builder.setOptions(options)) + .to.throw('groupBuilder.error') + .with.property('reason', 'groupBuilder.maxUsersExceeded') + }) + it('sets all options for groups, material and phases', function () { + const builder = new GroupBuilder() + const options = { + users: ['foo', 'bar'], + maxUsers: 2, + maxGroups: 1, + materialForAllGroups: false, + materialAutoShuffle: true, + phases: ['foo'], + material: ['bar'], + roles: ['he', 'her', 'them', '*'] + } + const initial = { ...builder } + builder.setOptions(options) + expect(builder).to.not.deep.equal(initial) + const { groups, ...next } = builder + expect(next).to.deep.equal({ + users: ['foo', 'bar'], + maxUsers: 2, + maxGroups: 1, + atLeastOneUserRequired: false, + materialForAllGroups: false, + materialAutoShuffle: true, + phases: ['foo'], + material: ['bar'], + roles: ['he', 'her', 'them', '*'], + groupTitleDefault: 'group.defaultTitle' + }) + // groups are initialized but empty + expect(groups.get()).to.deep.equal([]) + }) + }) + describe(GroupBuilder.prototype.createGroups.name, function () { + it('creates unshuffled groups', function () { + const builder = new GroupBuilder() + const options = { + users: ['foo', 'bar'], + maxUsers: 2, + maxGroups: 1, + materialForAllGroups: false, + materialAutoShuffle: false, + phases: ['foo'], + material: ['bar', 'baz'], + roles: ['he', 'her', 'them', '*'] + } + builder.setOptions(options) + builder.createGroups({ shuffle: false }) + const groups = builder.getAllGroups() + expect(groups).to.deep.equal([ + { + title: 'group.defaultTitle 1', + phases: options.phases, + users: [], + material: [] + } + ]) + }) + it('creates groups with material auto shuffle and equal material as group size', function () { + const builder = new GroupBuilder() + const options = { + users: ['foo', 'bar', 'baz', 'moo'], + maxUsers: 2, + maxGroups: 2, + materialForAllGroups: false, + materialAutoShuffle: true, + phases: ['foo'], + material: ['bar', 'baz'], + roles: ['he', 'her', 'them', '*'] + } + builder.setOptions(options) + builder.createGroups({}) + const groups = builder.getAllGroups() + expect(groups).to.deep.equal([ + { + title: 'group.defaultTitle 1', + phases: options.phases, + users: [], + material: ['bar'] + }, + { + title: 'group.defaultTitle 2', + phases: options.phases, + users: [], + material: ['baz'] + } + ]) + }) + it('creates groups with material auto shuffle and MORE material as group size', function () { + const builder = new GroupBuilder() + const options = { + users: ['foo', 'bar', 'baz', 'moo'], + maxUsers: 2, + maxGroups: 2, + materialForAllGroups: false, + materialAutoShuffle: true, + phases: ['foo'], + material: ['bar', 'baz', 'moo'], + roles: ['he', 'her', 'them', '*'] + } + builder.setOptions(options) + builder.createGroups({ shuffle: false }) + const groups = builder.getAllGroups() + expect(groups).to.deep.equal([ + { + title: 'group.defaultTitle 1', + phases: options.phases, + users: [], + material: ['bar', 'baz'] + }, + { + title: 'group.defaultTitle 2', + phases: options.phases, + users: [], + material: ['moo', 'bar'] + } + ]) + }) + it('creates shuffled groups', function () { + const builder = new GroupBuilder() + const options = { + users: ['foo', 'bar', 'baz', 'moo'], + maxUsers: 2, + maxGroups: 2, + materialForAllGroups: true, + materialAutoShuffle: false, + phases: ['foo'], + material: ['bar'], + roles: ['he', 'her', 'them', '*'] + } + builder.setOptions(options) + builder.createGroups({ shuffle: true }) + const groups = builder.getAllGroups() + expect(groups.length).to.equal(options.maxGroups) + + const existingUsers = new Set() + groups.forEach((group, index) => { + const { users, ...rest } = group + expect(rest).to.deep.equal({ + title: `group.defaultTitle ${index + 1}`, + phases: options.phases, + material: ['bar'] + }) + + expect(users.length).to.equal(2) + users.forEach(userId => { + expect(existingUsers.has(userId)).to.equal(false) + existingUsers.add(userId) + }) + }) + }) + it('throws if users length is zero and at least one user is required', function () { + const builder = new GroupBuilder() + builder.setOptions({ atLeastOneUserRequired: true }) + expect(() => builder.createGroups({ shuffle: false })) + .to.throw('groupBuilder.error') + .with.property('reason', 'groupBuilder.atLeastOneUserRequired') + }) + }) + describe(GroupBuilder.prototype.addGroup.name, function () { + it('adds a new group to the groups list', function () { + const builder = new GroupBuilder() + const options = { + users: ['foo', 'bar', 'baz', 'moo'], + maxUsers: 2, + maxGroups: 2, + materialForAllGroups: false, + materialAutoShuffle: false, + phases: ['foo'], + material: ['bar', 'baz'], + roles: ['he', 'her', 'them', '*'] + } + builder.setOptions(options) + builder.addGroup({ + users: [{ userId: 'foo', role: 'a' }, { userId: 'moo', role: 'b' }], + material: ['baz'], + title: 'group x', + phases: ['foo'] + }) + expect(builder.groups.get()).to.deep.equal([ + { + phases: ['foo'], + users: [{ userId: 'foo', role: 'a' }, { userId: 'moo', role: 'b' }], + material: ['baz'], + title: 'group x' + } + ]) + }) + }) + describe(GroupBuilder.prototype.removeGroup.name, function () { + it('throws if there is no group by given index', function () { + const builder = new GroupBuilder() + const options = { + users: ['foo', 'bar', 'baz', 'moo'], + maxUsers: 2, + maxGroups: 2, + materialForAllGroups: false, + materialAutoShuffle: true, + phases: ['foo'], + material: ['bar', 'baz'], + roles: ['he', 'her', 'them', '*'] + } + builder.setOptions(options) + builder.createGroups({ shuffle: false }) + + ;[-1, 3, 'foo'].forEach(index => { + expect(() => builder.removeGroup(index)) + .to.throw('groupBuilder.error') + .with.property('reason', 'groupBuilder.invalidIndex') + }) + }) + it('removes a group from the list at a given index', function () { + const builder = new GroupBuilder() + const options = { + users: ['foo', 'bar', 'baz', 'moo'], + maxUsers: 2, + maxGroups: 2, + materialForAllGroups: false, + materialAutoShuffle: true, + phases: ['foo'], + material: ['bar', 'baz'], + roles: ['he', 'her', 'them', '*'] + } + builder.setOptions(options) + builder.createGroups({ shuffle: false }) + expect(builder.getAllGroups().length).to.equal(2) + + builder.removeGroup(0) + const remain = builder.getAllGroups() + expect(remain).to.deep.equal([ + { + title: 'group.defaultTitle 2', + phases: options.phases, + users: [], + material: ['baz'] + } + ]) + }) + }) + describe(GroupBuilder.prototype.updateGroup.name, function () { + it('throws if there is no group by given index', function () { + const builder = new GroupBuilder() + const options = { + users: ['foo', 'bar', 'baz', 'moo'], + maxUsers: 2, + maxGroups: 2, + materialForAllGroups: false, + materialAutoShuffle: true, + phases: ['foo'], + material: ['bar', 'baz'], + roles: ['he', 'her', 'them', '*'] + } + builder.setOptions(options) + builder.createGroups({ shuffle: false }) + + ;[-1, 3, 'foo'].forEach(index => { + expect(() => builder.updateGroup(index)) + .to.throw('groupBuilder.error') + .with.property('reason', 'groupBuilder.invalidIndex') + }) + }) + it('updates a group title', function () { + const builder = new GroupBuilder() + const options = { + users: ['foo', 'bar', 'baz', 'moo'], + maxUsers: 2, + maxGroups: 2, + materialForAllGroups: false, + materialAutoShuffle: true, + phases: ['foo'], + material: ['bar', 'baz'], + roles: ['he', 'her', 'them', '*'] + } + builder.setOptions(options) + builder.createGroups({ shuffle: false }) + builder.updateGroup({ index: 0, title: 'foo' }) + expect(builder.getGroup(0).title).to.equal('foo') + expect(builder.getGroup(1).title).to.equal('group.defaultTitle 2') + }) + }) + describe(GroupBuilder.prototype.resetGroups.name, function () { + it('resets all groups to an empty array', function () { + const builder = new GroupBuilder() + const options = { + users: ['foo', 'bar', 'baz', 'moo'], + maxUsers: 2, + maxGroups: 2, + materialForAllGroups: false, + materialAutoShuffle: true, + phases: ['foo'], + material: ['bar', 'baz'], + roles: ['he', 'her', 'them', '*'] + } + builder.setOptions(options) + builder.createGroups({ shuffle: false }) + builder.resetGroups() + expect(builder.getAllGroups()).to.deep.equal([]) + }) + }) + describe(GroupBuilder.prototype.hasMaxGroups.name, function () { + it('returns false if max group is not reached or exceeded', function () { + const builder = new GroupBuilder() + const options = { + users: ['foo', 'bar', 'baz', 'moo'], + maxUsers: 2, + maxGroups: 2, + materialForAllGroups: false, + materialAutoShuffle: true, + phases: ['foo'], + material: ['bar', 'baz'], + roles: ['he', 'her', 'them', '*'] + } + builder.setOptions(options) + builder.createGroups({ shuffle: false }) + expect(builder.hasMaxGroups()).to.equal(true) + }) + }) + + const onInvalidGroupIndex = (fn) => { + it('throws on invalid group index', function () { + const builder = new GroupBuilder() + builder.setOptions({ + users: ['foo', 'bar'], + maxUsers: 3, + maxGroups: 1 + }) + builder.createGroups({ shuffle: false }) + expect(() => fn(builder)) + .to.throw('groupBuilder.error') + .with.property('reason', 'groupBuilder.invalidIndex') + }) + } + + describe(GroupBuilder.prototype.addMaterial.name, function () { + onInvalidGroupIndex((builder) => builder.addMaterial({ index: 5 })) + it('throws if material already exists', function () { + const builder = new GroupBuilder() + const options = { + users: ['foo', 'bar', 'baz', 'moo'], + maxUsers: 2, + maxGroups: 2, + materialForAllGroups: false, + materialAutoShuffle: false, + phases: ['foo'], + material: ['bar', 'baz'], + roles: ['he', 'her', 'them', '*'] + } + builder.setOptions(options) + builder.createGroups({ shuffle: false }) + builder.addMaterial({ index: 0, materialId: 'bar' }) + expect(() => builder.addMaterial({ index: 0, materialId: 'bar' })) + .to.throw('groupBuilder.error') + .with.property('reason', 'groupBuilder.expectedNoMaterial') + }) + }) + describe(GroupBuilder.prototype.removeMaterial.name, function () { + onInvalidGroupIndex((builder) => builder.removeMaterial({ index: 5 })) + const builder = new GroupBuilder() + const options = { + users: ['foo', 'bar', 'baz', 'moo'], + maxUsers: 2, + maxGroups: 2, + materialForAllGroups: false, + materialAutoShuffle: false, + phases: ['foo'], + material: ['bar', 'baz'], + roles: ['he', 'her', 'them', '*'] + } + builder.setOptions(options) + builder.createGroups({ shuffle: false }) + builder.addMaterial({ index: 0, materialId: 'bar' }) + builder.removeMaterial({ index: 0, materialId: 'bar' }) + expect(() => builder.removeMaterial({ index: 0, materialId: 'bar' })) + .to.throw('groupBuilder.error') + .with.property('reason', 'groupBuilder.expectedMaterial') + }) + + const onInvalidUserId = (fn) => { + it('throws on invalid userId', function () { + const builder = new GroupBuilder() + builder.setOptions({ + users: ['foo', 'bar'], + maxUsers: 3, + maxGroups: 1 + }) + builder.createGroups({ shuffle: false }) + expect(() => fn(builder)) + .to.throw('groupBuilder.error') + .with.property('reason', 'groupBuilder.invalidUserId') + }) + } + + const onInvalidUser = ({ fn, shuffle, reason }) => { + const builder = new GroupBuilder() + builder.setOptions({ + users: ['foo', 'bar'], + maxUsers: 2, + maxGroups: 1 + }) + builder.createGroups({ shuffle }) + expect(() => fn(builder)) + .to.throw('groupBuilder.error') + .with.property('reason', reason) + } + + describe(GroupBuilder.prototype.addUser.name, function () { + onInvalidUserId((builder) => builder.addUser({ index: 2, userId: 'moo' })) + onInvalidGroupIndex((builder) => builder.addUser({ index: 2, userId: 'foo' })) + it('throws if the user is already within that group', function () { + onInvalidUser({ + fn: (builder) => builder.addUser({ index: 0, userId: 'foo' }), + reason: 'groupBuilder.expectedNoUser', + shuffle: true + }) + }) + it('adds a user to given group', function () { + const builder = new GroupBuilder() + builder.setOptions({ + users: ['foo', 'bar'], + maxUsers: 2, + maxGroups: 1 + }) + builder.createGroups({ shuffle: false }) + const userId = 'foo' + const role = 'moo' + builder.addUser({ index: 0, userId, role }) + expect(builder.getGroup(0).users).to.deep.equal([{ userId, role }]) + }) + }) + describe(GroupBuilder.prototype.updateUser.name, function () { + onInvalidUserId((builder) => builder.addUser({ index: 2, userId: 'moo' })) + onInvalidGroupIndex((builder) => builder.updateUser({ index: 2, userId: 'foo' })) + it('throws if the user is NOT within that group', function () { + onInvalidUser({ + fn: (builder) => { + builder.addUser({ index: 0, userId: 'bar' }) + builder.updateUser({ index: 0, userId: 'foo' }) + }, + shuffle: false, + reason: 'groupBuilder.expectedUser' + }) + }) + it('updates the given user', function () { + const builder = new GroupBuilder() + builder.setOptions({ + users: ['foo', 'bar'], + maxUsers: 2, + maxGroups: 1 + }) + builder.createGroups({ shuffle: false }) + const userId = 'foo' + const role = 'moo' + builder.addUser({ index: 0, userId, role }) + builder.updateUser({ index: 0, userId, role: 'oink' }) + expect(builder.getGroup(0).users).to.deep.equal([{ userId, role: 'oink' }]) + }) + }) + describe(GroupBuilder.prototype.removeUser.name, function () { + onInvalidUserId((builder) => builder.addUser({ index: 2, userId: 'moo' })) + onInvalidGroupIndex((builder) => builder.removeUser({ index: 2, userId: 'foo' })) + it('throws if the user is NOT within that group', function () { + onInvalidUser({ + fn: (builder) => { + builder.addUser({ index: 0, userId: 'bar' }) + builder.removeUser({ index: 0, userId: 'foo' }) + }, + shuffle: false, + reason: 'groupBuilder.expectedUser' + }) + }) + }) + describe(GroupBuilder.prototype.userHasBeenAssigned.name, function () { + it('throws if user is not defined in the users list', function () { + const builder = new GroupBuilder() + expect(() => builder.userHasBeenAssigned('foo')) + .to.throw('groupBuilder.error') + .with.property('reason', 'groupBuilder.invalidUserId') + }) + it('returns whether a user has been assigned to one of the groups', function () { + const builder = new GroupBuilder() + const users = ['foo', 'bar'] + builder.setOptions({ users, maxUsers: 2, maxGroups: 2 }) + builder.createGroups({ shuffle: true }) + builder.addGroup({ title: 'moo' }) + builder.removeUser({ index: 0, userId: 'bar' }) + expect(builder.userHasBeenAssigned('foo')).to.equal(true) + expect(builder.userHasBeenAssigned('bar')).to.equal(false) + }) + }) +}) diff --git a/src/imports/contexts/classroom/group/tests/index.js b/src/imports/contexts/classroom/group/tests/index.js new file mode 100644 index 0000000..4bb98f2 --- /dev/null +++ b/src/imports/contexts/classroom/group/tests/index.js @@ -0,0 +1,2 @@ +import './Group.tests' +import './GroupBuilder.tests' diff --git a/src/imports/contexts/classroom/lessons/Lesson.js b/src/imports/contexts/classroom/lessons/Lesson.js index 0bcdee3..b4c4d44 100644 --- a/src/imports/contexts/classroom/lessons/Lesson.js +++ b/src/imports/contexts/classroom/lessons/Lesson.js @@ -629,7 +629,7 @@ Lesson.methods.restart = { ) } - const options = { lessonId: _id, userId } + const options = { lessonId: _id, userId, unitId: lessonDoc.unit } const runtimeDocs = LessonRuntime.removeDocuments(options) const groupDocs = LessonRuntime.resetGroups(options) const beamerReset = LessonRuntime.resetBeamer(options) diff --git a/src/imports/contexts/classroom/lessons/methods/removeLesson.js b/src/imports/contexts/classroom/lessons/methods/removeLesson.js index c40f4ef..6311dd1 100644 --- a/src/imports/contexts/classroom/lessons/methods/removeLesson.js +++ b/src/imports/contexts/classroom/lessons/methods/removeLesson.js @@ -49,7 +49,8 @@ export const removeLesson = (options) => { phasesRemoved: 0, materialRemoved: 0, runtimeDocsRemoved: 0, - beamerRemoved: 0 + beamerRemoved: 0, + groupsRemoved: 0 } log('remove runtime docs') @@ -61,7 +62,7 @@ export const removeLesson = (options) => { // removed and we need to remove the lesson but omit the unit doc // which is why it's optional const unitDoc = getCollection(Unit.name).findOne({ _id: lessonDoc.unit }) - log('has unitdoc?', unitDoc) + log('has unitDoc?', unitDoc ? unitDoc._id : false) if (unitDoc) { // removes all linked phases but not global phases @@ -73,8 +74,11 @@ export const removeLesson = (options) => { log('remove phase query', phaseQuery) result.phasesRemoved = getCollection(Phase.name).remove(phaseQuery) - result.materialRemoved = LessonRuntime.removeAllMaterial({ unitDoc, userId }) result.unitRemoved = getCollection(Unit.name).remove({ _id: unitDoc._id, _master: { $exists: false } }) + result.materialRemoved = LessonRuntime.removeAllMaterial({ unitDoc, userId }) + + const unitId = unitDoc._id + result.groupsRemoved = LessonRuntime.removeGroups({ unitId }) } // If the unit doc is not found we still try to remove phases and material. diff --git a/src/imports/contexts/classroom/lessons/runtime/LessonRuntime.js b/src/imports/contexts/classroom/lessons/runtime/LessonRuntime.js index 59eea46..009b621 100644 --- a/src/imports/contexts/classroom/lessons/runtime/LessonRuntime.js +++ b/src/imports/contexts/classroom/lessons/runtime/LessonRuntime.js @@ -1,6 +1,6 @@ import { resetBeamer } from './resetBeamer' import { removeDocuments } from './removeDocuments' -import { resetGroups } from './resetGroups' +import { removeGroups, resetGroups } from './resetGroups' import { createRemoveAllMaterial } from '../../../material/createRemoveAllMaterial' /** @@ -19,6 +19,7 @@ export const LessonRuntime = { * @type {function} */ removeDocuments: removeDocuments, + removeGroups: removeGroups, /** * @type {function} */ diff --git a/src/imports/contexts/classroom/lessons/runtime/resetGroups.js b/src/imports/contexts/classroom/lessons/runtime/resetGroups.js index 87082b3..d1334cc 100644 --- a/src/imports/contexts/classroom/lessons/runtime/resetGroups.js +++ b/src/imports/contexts/classroom/lessons/runtime/resetGroups.js @@ -3,19 +3,42 @@ import { getCollection } from '../../../../api/utils/getCollection' import { Group } from '../../group/Group' /** - * Resets all groups of a given lesson. + * Resets all groups of a given unit. + * Removes all ad-hoc groups. * @param options {object} * @param options.lessonId {string} + * @param options.unitId {string} * @return {number} number of updated documents */ export const resetGroups = (options) => { check(options, Match.ObjectIncluding({ - lessonId: String + unitId: String })) - const { lessonId } = options - const query = { lessonId } + + return { + updated: updateGroups(options), + removed: removeAdhocGroups(options) + } +} + +const updateGroups = ({ unitId }) => { + const query = { unitId, isAdhoc: { $ne: true } } const transform = { $set: { visible: [] } } const updateOptions = { multi: true } - return getCollection(Group.name).update(query, transform, updateOptions) } + +const removeAdhocGroups = ({ unitId }) => { + const query = { unitId, isAdhoc: true } + return getCollection(Group.name).remove(query) +} + +export const removeGroups = (options) => { + check(options, Match.ObjectIncluding({ + unitId: String + })) + + const { unitId } = options + const query = { unitId } + return getCollection(Group.name).remove(query) +} diff --git a/src/imports/contexts/classroom/lessons/tests/Lesson.tests.js b/src/imports/contexts/classroom/lessons/tests/Lesson.tests.js index ddfac4c..3deb5dd 100644 --- a/src/imports/contexts/classroom/lessons/tests/Lesson.tests.js +++ b/src/imports/contexts/classroom/lessons/tests/Lesson.tests.js @@ -36,6 +36,8 @@ import { mockPhaseDoc } from '../../../../../tests/testutils/doc/mockPhaseDoc' import { mockClassDoc } from '../../../../../tests/testutils/doc/mockClassDoc' import { Group } from '../../group/Group' import { PermissionDeniedError } from '../../../../api/errors/types/PermissionDeniedError' +import { createGroupDoc } from '../../../../../tests/testutils/doc/createGroupDoc' +import { getCollection } from '../../../../api/utils/getCollection' const log = () => { } @@ -331,7 +333,7 @@ describe(Lesson.name, function () { expect(runtimeDocs).to.equal(123) expect(beamerReset).to.equal(456) expect(lessonReset).to.equal(true) - expect(groupDocs).to.equal(0) + expect(groupDocs).to.deep.equal({ removed: 0, updated: 0 }) restore(LessonCollection, 'findOne') const updatedDoc = LessonCollection.findOne(lessonDoc._id) @@ -339,7 +341,26 @@ describe(Lesson.name, function () { expect(updatedDoc.visibleStudent).to.equal(undefined) expect(updatedDoc.visibleBeamer).to.equal(undefined) }) - it('resets all groups, associated with this lesson') + it('resets all groups, associated with this lesson', function () { + const { userId, lessonDoc } = stubTeacherDocs() + lessonDoc.startedAt = new Date() + lessonDoc.visibleStudent = [{ _id: Random.id(), context: Random.id(5) }] + lessonDoc.visibleBeamer = [Random.id()] + lessonDoc.phase = Random.id() + LessonCollection.insert(lessonDoc) + stub(LessonRuntime, LessonRuntime.removeDocuments.name, () => 0) + stub(LessonRuntime, LessonRuntime.resetBeamer.name, () => 0) + + const unitId = lessonDoc.unit + + getCollection(Group.name).insert(createGroupDoc({ title: 'not remove', unitId })) + getCollection(Group.name).insert(createGroupDoc({ title: 'to remove', unitId, isAdhoc: true })) + + const { groupDocs } = restartLesson.call({ userId, log }, lessonDoc) + + expect(groupDocs).to.deep.equal({ removed: 1, updated: 1 }) + restore(LessonCollection, 'findOne') + }) }) // ====================================================================== @@ -374,14 +395,19 @@ describe(Lesson.name, function () { stub(LessonRuntime, LessonRuntime.resetBeamer.name, () => 456) stub(LessonRuntime, LessonRuntime.removeAllMaterial.name, () => 0) - const { lessonRemoved, unitRemoved, runtimeDocsRemoved, beamerRemoved } = removeLesson.call({ + const result = removeLesson.call({ userId, log }, { _id: lessonDoc._id }) - expect(lessonRemoved).to.equal(1) - expect(unitRemoved).to.equal(1) - expect(runtimeDocsRemoved).to.equal(123) - expect(beamerRemoved).to.equal(456) + expect(result).to.deep.equal({ + lessonRemoved: 1, + unitRemoved: 1, + phasesRemoved: 0, + materialRemoved: 0, + runtimeDocsRemoved: 123, + beamerRemoved: 456, + groupsRemoved: 0 + }) expect(LessonCollection.find(lessonDoc._id).count()).to.equal(0) expect(UnitCollection.find(unitDoc._id).count()).to.equal(0) }) @@ -398,14 +424,19 @@ describe(Lesson.name, function () { stub(LessonRuntime, LessonRuntime.resetBeamer.name, () => 456) stub(LessonRuntime, LessonRuntime.removeAllMaterial.name, () => 0) - const { lessonRemoved, unitRemoved, runtimeDocsRemoved, beamerRemoved } = removeLesson.call({ + const result = removeLesson.call({ userId, log }, { _id: lessonDoc._id }) - expect(lessonRemoved).to.equal(1) - expect(unitRemoved).to.equal(0) - expect(runtimeDocsRemoved).to.equal(123) - expect(beamerRemoved).to.equal(456) + expect(result).to.deep.equal({ + lessonRemoved: 1, + unitRemoved: 0, + phasesRemoved: 0, + materialRemoved: 0, + runtimeDocsRemoved: 123, + beamerRemoved: 456, + groupsRemoved: 0 + }) expect(LessonCollection.find(lessonDoc._id).count()).to.equal(0) expect(UnitCollection.find(unitId).count()).to.equal(0) }) @@ -541,7 +572,22 @@ describe(Lesson.name, function () { }) it('removes custom material only if it\'s not used by other lessons') - it('removes groups, associated with this lesson') + it('removes groups, associated with this lesson', function () { + const userId = Random.id() + const unitDoc = mockUnitDoc({ createdBy: userId }, UnitCollection) + const unitId = unitDoc._id + const { lessonDoc } = stubTeacherDocs({}, { userId, unit: unitId }) + LessonCollection.insert(lessonDoc) + + stub(LessonRuntime, LessonRuntime.removeDocuments.name, () => 0) + stub(LessonRuntime, LessonRuntime.resetBeamer.name, () => 0) + + getCollection(Group.name).insert(createGroupDoc({ title: 'to remove', unitId })) + getCollection(Group.name).insert(createGroupDoc({ title: 'to remove', unitId, isAdhoc: true })) + + const { groupsRemoved } = removeLesson.call({ userId, log }, { _id: lessonDoc._id }) + expect(groupsRemoved).to.equal(2) + }) }) // ====================================================================== diff --git a/src/imports/contexts/classroom/lessons/tests/LessonRuntime.tests.js b/src/imports/contexts/classroom/lessons/tests/LessonRuntime.tests.js index d7b592c..03b1c74 100644 --- a/src/imports/contexts/classroom/lessons/tests/LessonRuntime.tests.js +++ b/src/imports/contexts/classroom/lessons/tests/LessonRuntime.tests.js @@ -18,6 +18,8 @@ import { DocumentFiles } from '../../../files/document/DocumentFiles' import { Users } from '../../../system/accounts/users/User' import { VideoFiles } from '../../../files/video/VideoFiles' import { stub, restoreAll } from '../../../../../tests/testutils/stub' +import { Group } from '../../group/Group' +import { createGroupDoc } from '../../../../../tests/testutils/doc/createGroupDoc' const randomReferences = (beamerDoc, lessonId) => { const rand = Math.floor(Math.random() * 53) @@ -67,9 +69,10 @@ describe(LessonRuntime.name, function () { let TaskWorkingStateCollection let ClusterCollection let BeamerCollection + let GroupCollection before(function () { - [TaskResultCollection, ImageFilesCollection, AudioFilesColection, DocumentFilesCollection, VideoFilesCollection, TaskWorkingStateCollection, ClusterCollection, BeamerCollection] = mockCollections( + [TaskResultCollection, ImageFilesCollection, AudioFilesColection, DocumentFilesCollection, VideoFilesCollection, TaskWorkingStateCollection, ClusterCollection, BeamerCollection, , GroupCollection] = mockCollections( [TaskResults, noSchema], [ImageFiles, forFiles], [AudioFiles, forFiles], @@ -78,7 +81,8 @@ describe(LessonRuntime.name, function () { [TaskWorkingState, noSchema], [Cluster, noSchema], Beamer, - Users + Users, + Group ) }) @@ -182,4 +186,54 @@ describe(LessonRuntime.name, function () { }) }) }) + + describe(LessonRuntime.resetGroups.name, function () { + it('throws on incomplete args', function () { + expect(() => LessonRuntime.resetGroups({})).to.throw('Match error: Missing key \'unitId\'') + }) + it('removes all ad-hoc groups', function () { + const unitId = Random.id() + const removeGroupId = GroupCollection.insert(createGroupDoc({ unitId, title: 'to remove', isAdhoc: true })) + const otherGroupId = GroupCollection.insert(createGroupDoc({ unitId, title: 'other', isAdhoc: false })) + expect(GroupCollection.find().count()).to.equal(2) + const result = LessonRuntime.resetGroups({ unitId }) + expect(result).to.deep.equal({ removed: 1, updated: 1 }) + expect(GroupCollection.find(removeGroupId).count()).to.equal(0) + expect(GroupCollection.find(otherGroupId).count()).to.equal(1) + expect(GroupCollection.find().count()).to.equal(1) + }) + it('resets groups that are defined on a unit-level', function () { + const lessonId = Random.id() + const unitId = Random.id() + const updateGroupId = GroupCollection.insert(createGroupDoc({ + title: 'to update', + unitId, + visible: [{ _id: Random.id(), context: 'foo' }], + isAdhoc: false + })) + const groupDoc = GroupCollection.findOne(updateGroupId) + const otherGroupId = GroupCollection.insert(createGroupDoc({ + title: 'other', + unitId: Random.id(), + isAdhoc: false + })) + const otherDoc = GroupCollection.findOne(otherGroupId) + + expect(GroupCollection.find().count()).to.equal(2) + const result = LessonRuntime.resetGroups({ lessonId, unitId }) + expect(result).to.deep.equal({ removed: 0, updated: 1 }) + expect(GroupCollection.findOne(updateGroupId)).to.deep.equal({ + _id: updateGroupId, + visible: [], + title: groupDoc.title, + isAdhoc: false, + unitId, + createdBy: groupDoc.createdBy, + users: groupDoc.users, + maxUsers: groupDoc.maxUsers + }) + expect(GroupCollection.find().count()).to.equal(2) + expect(GroupCollection.findOne(otherGroupId)).to.deep.equal(otherDoc) + }) + }) }) diff --git a/src/imports/contexts/system/accounts/users/getUser.js b/src/imports/contexts/system/accounts/users/getUser.js new file mode 100644 index 0000000..2c23b9f --- /dev/null +++ b/src/imports/contexts/system/accounts/users/getUser.js @@ -0,0 +1,5 @@ +import { getUsersCollection } from '../../../../api/utils/getUsersCollection' + +export const getUser = query => + getUsersCollection(false).findOne(query) || + getUsersCollection(true).findOne(query) diff --git a/src/imports/contexts/tasks/definitions/forms/groupText/groupText.html b/src/imports/contexts/tasks/definitions/forms/groupText/groupText.html index 1d2fd67..da7ff67 100644 --- a/src/imports/contexts/tasks/definitions/forms/groupText/groupText.html +++ b/src/imports/contexts/tasks/definitions/forms/groupText/groupText.html @@ -1,15 +1,57 @@ \ No newline at end of file diff --git a/src/imports/contexts/tasks/definitions/forms/groupText/groupText.js b/src/imports/contexts/tasks/definitions/forms/groupText/groupText.js index 312261a..556d615 100644 --- a/src/imports/contexts/tasks/definitions/forms/groupText/groupText.js +++ b/src/imports/contexts/tasks/definitions/forms/groupText/groupText.js @@ -1,14 +1,20 @@ /* global AutoForm $ */ import { Meteor } from 'meteor/meteor' +import { Tracker } from 'meteor/tracker' import { Template } from 'meteor/templating' import { TaskResults } from '../../../results/TaskResults' +import { Group } from '../../../../classroom/group/Group' import { getCollection } from '../../../../../api/utils/getCollection' import { debounce } from '../../../../../api/utils/debounce' +import { callMethod } from '../../../../../ui/controllers/document/callMethod' +import { withProperty } from '../../../../../api/utils/object/withProperty' import groupTextLanguage from './i18n/groupTextLanguage' import './groupText.scss' import './groupText.html' +import { dataTarget } from '../../../../../ui/utils/dataTarget' const subKey = 'groupTextSub' +const ensureObject = (target, name) => withProperty(target, name, {}) AutoForm.addInputType('groupText', { template: 'afGroupText', @@ -24,6 +30,13 @@ const API = Template.afGroupText.setDependencies({ Template.afGroupText.onCreated(function () { const instance = this + const userId = Meteor.userId() + + instance.getMemberUpdates = ({ reactive = false } = {}) => reactive + ? instance.state.get('memberUpdates') || {} + : Tracker.nonreactive(() => instance.state.get('memberUpdates')) || {} + + // fetching data on new groupId and itemId instance.autorun(() => { const data = Template.currentData() const groupId = data.atts['data-group'] @@ -35,6 +48,13 @@ Template.afGroupText.onCreated(function () { return instance.state.set({ subReady: true, subscribed: false }) } + callMethod({ + name: Group.methods.users, + args: { groupId }, + failure: API.notify, + success: (groupMembers) => instance.state.set({ groupMembers }) + }) + API.subscribe({ name: TaskResults.publications.byGroup, args: { itemId, groupId }, @@ -53,11 +73,62 @@ Template.afGroupText.onCreated(function () { } }) }) + + // once data has been loaded we want to observe changes + // for the "other" group members and add a state if new content is there + instance.autorun(() => { + const loadComplete = API.initComplete() && instance.state.get('subReady') + const groupMembers = instance.state.get('groupMembers') + const itemId = instance.state.get('itemId') + + if (!loadComplete || !itemId || !groupMembers) { + return // skip until loaded + } + + instance.observer = getCollection(TaskResults.name).find({ itemId, createdBy: { $ne: userId } }).observeChanges({ + added (id, doc) { + const user = groupMembers.find(({ _id }) => _id === doc.createdBy) + + if (!user) { + return console.warn(`Observer: added taskresult doc but found no user for doc ${id}`) + } + + const foundUserId = user._id + const memberUpdates = instance.getMemberUpdates({ reactive: false }) + ensureObject(memberUpdates, foundUserId) + // we set false here, since this is only for resolving the ids + // once the doc is added to the collection + // otherwise we would get an indicator of changes + // on every page reload + memberUpdates[foundUserId].status = true + memberUpdates[foundUserId].docId = id + instance.state.set({ memberUpdates }) + }, + changed (id) { + const memberUpdates = instance.getMemberUpdates({ reactive: false }) + const [foundUserId] = Object.entries(memberUpdates).find(([_id, entry]) => entry.docId === id) + + if (!foundUserId) { + return console.warn(`Observer: updated taskresult doc but found no user for doc ${id}`) + } + + memberUpdates[foundUserId].status = true + instance.state.set({ memberUpdates }) + } + }) + }) }) Template.afGroupText.onDestroyed(function () { - const subscribed = this.state.get('subscribed') - if (subscribed) API.dispose(subKey) + const instance = this + + if (instance.state.get('subscribed')) { + API.dispose(subKey) + } + + if (instance.observer) { + instance.observer.stop() + } }) Template.afGroupText.onRendered(function () { @@ -82,21 +153,40 @@ Template.afGroupText.onRendered(function () { Template.afGroupText.helpers({ loadComplete () { - return API.initComplete() && Template.getState('subReady') + return API.initComplete() && Template.getState('subReady') && Template.getState('groupMembers') }, dataSchemaKey () { return Template.currentData().atts['data-schema-key'] }, - groupOutputs () { - const userId = Meteor.userId() + groupMembers () { + return Template.getState('groupMembers') + }, + hasUpdate (userId) { + const updates = Template.instance().getMemberUpdates({ reactive: true }) + const updatedDoc = updates?.[userId] + return updatedDoc?.status + }, + memberResponse (userId) { const itemId = Template.getState('itemId') - if (!userId || !itemId) { return } - return getCollection(TaskResults.name).find({ itemId, createdBy: { $ne: userId } }) + const responseDoc = getCollection(TaskResults.name).findOne({ itemId, createdBy: userId }) + return responseDoc?.response } }) Template.afGroupText.events({ 'input .my-text': debounce(function (event, templateInstance) { $(event.currentTarget).closest('form').submit() - }, 300) + }, 300), + 'click .group-member-tab' (event, templateInstance) { + const userId = dataTarget(event, templateInstance, 'user') + const otherTab = dataTarget(event, templateInstance) + const memberUpdates = templateInstance.getMemberUpdates({ reactive: false }) + + if (memberUpdates[userId]) { + memberUpdates[userId].status = false + templateInstance.state.set({ memberUpdates }) + } + + templateInstance.$(otherTab).tab('show') + } }) diff --git a/src/imports/contexts/tasks/definitions/forms/groupText/groupText.scss b/src/imports/contexts/tasks/definitions/forms/groupText/groupText.scss index 9f2aca5..69f8e1a 100644 --- a/src/imports/contexts/tasks/definitions/forms/groupText/groupText.scss +++ b/src/imports/contexts/tasks/definitions/forms/groupText/groupText.scss @@ -1,3 +1,6 @@ -.white-space-pre { +.member-response { + height: auto; + max-height: 400px !important; + overflow-y: scroll; white-space: pre-wrap; } diff --git a/src/imports/contexts/tasks/definitions/items/base.js b/src/imports/contexts/tasks/definitions/items/base.js index f8959b5..1faa824 100644 --- a/src/imports/contexts/tasks/definitions/items/base.js +++ b/src/imports/contexts/tasks/definitions/items/base.js @@ -1,5 +1,6 @@ import { Random } from 'meteor/random' import { Features } from '../../../../api/config/Features' +import { firstOption } from '../common/helpers' export const ItemBase = { name: 'itemBase', @@ -45,14 +46,13 @@ export const ItemBase = { optional: true, label: translate('item.groupMode.title'), defaultValue: 'off', - allowedValues: ['off', 'split', 'override'], + allowedValues: ['off', 'override'], autoform: { defaultValue: 'off', + firstOption: firstOption, options: () => [ { value: 'off', label: translate('item.groupMode.off') }, - { value: 'split', label: translate('item.groupMode.split') }, - { value: 'override', label: translate('item.groupMode.override') }, - { value: 'merge', label: translate('item.groupMode.merge'), disabled: true } + { value: 'override', label: translate('item.groupMode.override') } ] } } diff --git a/src/imports/contexts/tasks/definitions/items/file.js b/src/imports/contexts/tasks/definitions/items/file.js index 21a6ce5..855a840 100644 --- a/src/imports/contexts/tasks/definitions/items/file.js +++ b/src/imports/contexts/tasks/definitions/items/file.js @@ -61,6 +61,7 @@ export const ItemFileSchema = { return `${base} - ${details}` }, autoform: { + label: false, afFieldInput: { type: FilesTemplates.upload.type, uploadTemplate: FilesTemplates.upload.template, diff --git a/src/imports/contexts/tasks/responseProcessors/aggregate/groupText/GroupText.js b/src/imports/contexts/tasks/responseProcessors/aggregate/groupText/GroupText.js new file mode 100644 index 0000000..d0afc7d --- /dev/null +++ b/src/imports/contexts/tasks/responseProcessors/aggregate/groupText/GroupText.js @@ -0,0 +1,30 @@ +import { ResponseProcessorTypes } from '../../ResposeProcessorTypes' +import { ResponseDataTypes } from '../../../../../api/plugins/ResponseDataTypes' + +export const GroupText = { + name: 'responseGroupText', + label: 'responseProcessors.groupText', + isResponseProcessor: true, + isGroupMode: true, + icon: 'layer-group', + type: ResponseProcessorTypes.aggregate.name, + dataTypes: [ResponseDataTypes.text.name], + renderer: { + template: 'itemResultGroupText', + async load () { + return import('./itemResultGroupText') + } + }, + schema: { + visibleUsers: { + type: Array, + optional: true + }, + 'visibleUsers.$': String, + hiddenAnswers: { + type: Array, + optional: true + }, + 'hiddenAnswers.$': String + } +} diff --git a/src/imports/contexts/tasks/responseProcessors/aggregate/groupText/itemResultGroupText.html b/src/imports/contexts/tasks/responseProcessors/aggregate/groupText/itemResultGroupText.html new file mode 100644 index 0000000..c8dcef1 --- /dev/null +++ b/src/imports/contexts/tasks/responseProcessors/aggregate/groupText/itemResultGroupText.html @@ -0,0 +1,29 @@ + \ No newline at end of file diff --git a/src/imports/contexts/tasks/responseProcessors/aggregate/groupText/itemResultGroupText.js b/src/imports/contexts/tasks/responseProcessors/aggregate/groupText/itemResultGroupText.js new file mode 100644 index 0000000..4a377f6 --- /dev/null +++ b/src/imports/contexts/tasks/responseProcessors/aggregate/groupText/itemResultGroupText.js @@ -0,0 +1,71 @@ +import { Template } from 'meteor/templating' +import '../../../../../ui/generic/nodocs/nodocs' +import { withProperty } from '../../../../../api/utils/object/withProperty' +import responseLanguage from '../../i18n/responseLanguage' +import './itemResultGroupText.html' +import { Group } from '../../../../classroom/group/Group' +import { loadIntoCollection } from '../../../../../infrastructure/loading/loadIntoCollection' +import { getLocalCollection } from '../../../../../infrastructure/collection/getLocalCollection' + +const ensureArray = (target, name) => { + withProperty(target, name, []) + return target[name] +} + +const API = Template.itemResultGroupText.setDependencies({ + contexts: [Group], + language: responseLanguage +}) + +Template.itemResultGroupText.onCreated(function () { + const instance = this + + instance.autorun(() => { + const data = Template.currentData() + + const groups = {} + let hasGroups = false + + const groupIds = new Set() + + data.results.forEach(resultDoc => { + if (resultDoc.groupId) { + groupIds.add(resultDoc.groupId) + ensureArray(groups, resultDoc.groupId).push(resultDoc) + hasGroups = true + } + }) + + const groupResults = Object + .entries(groups) + .map(([groupId, docs]) => ({ groupId, docs })) + + instance.state.set({ + groupResults, + hasGroups, + loadComplete: true + }) + + loadIntoCollection({ + name: Group.methods.get, + args: { ids: [...groupIds] }, + failure: API.notify, + collection: getLocalCollection(Group.name) + }) + }) +}) + +Template.itemResultGroupText.helpers({ + loadComplete () { + return Template.getState('loadComplete') + }, + hasGroups () { + return Template.getState('hasGroups') + }, + groupResults () { + return Template.getState('groupResults') + }, + groupDoc (groupId) { + return getLocalCollection(Group.name).findOne(groupId) + } +}) diff --git a/src/imports/contexts/tasks/responseProcessors/aggregate/text/itemResultText.js b/src/imports/contexts/tasks/responseProcessors/aggregate/text/itemResultText.js index a1f80ec..e6e1fa6 100644 --- a/src/imports/contexts/tasks/responseProcessors/aggregate/text/itemResultText.js +++ b/src/imports/contexts/tasks/responseProcessors/aggregate/text/itemResultText.js @@ -1,7 +1,7 @@ import { Template } from 'meteor/templating' import '../../../../../ui/generic/nodocs/nodocs' -import './itemResultText.html' import responseLanguage from '../../i18n/responseLanguage' +import './itemResultText.html' const API = Template.itemResultText.setDependencies({ language: responseLanguage diff --git a/src/imports/contexts/tasks/results/TaskResults.js b/src/imports/contexts/tasks/results/TaskResults.js index 3603bd5..af196da 100644 --- a/src/imports/contexts/tasks/results/TaskResults.js +++ b/src/imports/contexts/tasks/results/TaskResults.js @@ -66,7 +66,11 @@ TaskResults.methods.saveTask = { }, run: onServerExec(() => { import { saveTaskResult } from './methods/saveTaskResult' - return saveTaskResult + + return function (taskResultDoc) { + const { userId } = this + return saveTaskResult({ userId, ...taskResultDoc }) + } }) } @@ -114,6 +118,10 @@ TaskResults.publications.byTask = { taskId: { type: String, optional: true + }, + groupId: { + type: String, + optional: true } }, run: onServerExec(function () { diff --git a/src/imports/contexts/tasks/results/methods/getAllTaskResultsByTask.js b/src/imports/contexts/tasks/results/methods/getAllTaskResultsByTask.js index fdea43a..11877f0 100644 --- a/src/imports/contexts/tasks/results/methods/getAllTaskResultsByTask.js +++ b/src/imports/contexts/tasks/results/methods/getAllTaskResultsByTask.js @@ -10,11 +10,12 @@ const getLessonDoc = createDocGetter(Lesson) /** * Returns all task results for a given task (presumed, that the user is teacher/member of the lesson). - * @param lessonId - * @param taskId + * @param lessonId {string} + * @param taskId {string} + * @param groupId {string=} * @returns {*} */ -export const getAllTaskResultsByTask = function ({ lessonId, taskId }) { +export const getAllTaskResultsByTask = function ({ lessonId, taskId, groupId }) { const { userId } = this const lessonDoc = getLessonDoc({ _id: lessonId }) const isTeacher = lessonDoc.createdBy === userId @@ -26,7 +27,12 @@ export const getAllTaskResultsByTask = function ({ lessonId, taskId }) { const query = { lessonId } if (!isTeacher) { - query.createdBy = userId + if (groupId) { + query.groupId = groupId + } + else { + query.createdBy = userId + } } if (taskId) { diff --git a/src/imports/contexts/tasks/results/methods/saveTaskResult.js b/src/imports/contexts/tasks/results/methods/saveTaskResult.js index 6061911..c47a55b 100644 --- a/src/imports/contexts/tasks/results/methods/saveTaskResult.js +++ b/src/imports/contexts/tasks/results/methods/saveTaskResult.js @@ -3,12 +3,13 @@ import { LessonErrors } from '../../../classroom/lessons/LessonErrors' import { SchoolClass } from '../../../classroom/schoolclass/SchoolClass' import { LessonStates } from '../../../classroom/lessons/LessonStates' import { Lesson } from '../../../classroom/lessons/Lesson' -import { createDocGetter } from '../../../../api/utils/document/createDocGetter' import { Task } from '../../../curriculum/curriculum/task/Task' import { Group } from '../../../classroom/group/Group' -import { getCollection } from '../../../../api/utils/getCollection' import { TaskResults } from '../TaskResults' import { LessonHelpers } from '../../../classroom/lessons/LessonHelpers' +import { createDocGetter } from '../../../../api/utils/document/createDocGetter' +import { getCollection } from '../../../../api/utils/getCollection' +import { GroupMode } from '../../../classroom/group/GroupMode' const getLessonDoc = createDocGetter(Lesson) const checkTask = createDocGetter(Task) @@ -16,6 +17,7 @@ const getGroupDoc = createDocGetter(Group) /** * Saves a response to an item of a given task + * @param userId {string} the user to save the task * @param lessonId the lesson of the task * @param taskId the task * @param itemId the item the response is related to @@ -24,8 +26,7 @@ const getGroupDoc = createDocGetter(Group) * updated (0 if failed) */ -export const saveTaskResult = function ({ lessonId, taskId, itemId, groupId, groupMode, response }) { - const { userId } = this +export const saveTaskResult = ({ userId, lessonId, taskId, itemId, groupId, groupMode, response }) => { if (!LessonHelpers.isMemberOfLesson({ userId, lessonId })) { throw new Meteor.Error('errors.permissionDenied', SchoolClass.errors.notMember) } @@ -55,21 +56,35 @@ export const saveTaskResult = function ({ lessonId, taskId, itemId, groupId, gro const createdBy = userId const TaskResultCollection = getCollection(TaskResults.name) const query = { lessonId, taskId, itemId, createdBy } + const isOverride = groupMode === GroupMode.override.value + if (groupId) { query.groupId = groupId + + // in override mode any member can submit + // a response for the group, overriding the previous one + if (isOverride) { + delete query.createdBy + } } const taskResultDoc = TaskResultCollection.findOne(query) - if (!taskResultDoc) { - const insertDoc = { lessonId, taskId, itemId, response } - if (groupId) { - insertDoc.groupId = groupId + if (taskResultDoc) { + const updateDoc = { response } + + if (isOverride) { + updateDoc.createdBy = createdBy } - return TaskResultCollection.insert(insertDoc) + + return TaskResultCollection.update(taskResultDoc._id, { $set: updateDoc }) } - else { - return TaskResultCollection.update(taskResultDoc._id, { $set: { response } }) + const insertDoc = { lessonId, taskId, itemId, response } + + if (groupId) { + insertDoc.groupId = groupId } + + return TaskResultCollection.insert(insertDoc) } diff --git a/src/imports/contexts/tasks/results/tests/TaskResults.tests.js b/src/imports/contexts/tasks/results/tests/TaskResults.tests.js index 5775c66..304ce46 100644 --- a/src/imports/contexts/tasks/results/tests/TaskResults.tests.js +++ b/src/imports/contexts/tasks/results/tests/TaskResults.tests.js @@ -16,14 +16,19 @@ import { expect } from 'chai' import { Task } from '../../../curriculum/curriculum/task/Task' import { LessonErrors } from '../../../classroom/lessons/LessonErrors' import { LessonHelpers } from '../../../classroom/lessons/LessonHelpers' +import { PermissionDeniedError } from '../../../../api/errors/types/PermissionDeniedError' +import { Group } from '../../../classroom/group/Group' +import { createGroupDoc } from '../../../../../tests/testutils/doc/createGroupDoc' +import { collectPublication } from '../../../../../tests/testutils/collectPublication' describe(TaskResults.name, function () { let LessonCollection let TaskCollection let TaskResultCollection + let GroupCollection before(function () { - [LessonCollection, TaskCollection, TaskResultCollection] = mockCollections(Lesson, [Task, { noSchema: true }], TaskResults) + [LessonCollection, TaskCollection, TaskResultCollection, GroupCollection] = mockCollections(Lesson, [Task, { noSchema: true }], TaskResults, Group) }) afterEach(function () { @@ -122,4 +127,73 @@ describe(TaskResults.name, function () { }) }) }) + + describe('publications', function () { + const byGroupPub = TaskResults.publications.byGroup.run + + describe(TaskResults.publications.allByItem.name, function () { + it('is not implemented') + }) + describe(TaskResults.publications.byGroup.name, function () { + it('throws if there is no group doc by group id', function () { + ;[ + { + env: {}, + args: {} + }, + { + env: {}, + args: { groupId: Random.id() } + } + ].forEach(({ env, args }) => { + const { groupId } = args + const err = expect(() => byGroupPub.call(env, args)) + .to.throw(DocNotFoundError.name) + err.with.deep.property('details', { name: Group.name, query: groupId }) + }) + }) + it('throws if user has no permission to access the group', function () { + const groupId = GroupCollection.insert(createGroupDoc()) + ;[ + { + env: {}, + args: { groupId } + }, + { + env: { userId: Random.id() }, + args: { groupId } + } + ].forEach(({ env, args }) => { + const { userId } = env + const { groupId } = args + const err = expect(() => byGroupPub.call(env, args)) + .to.throw(PermissionDeniedError.name) + err.with.property('reason', 'group.notAMember') + err.with.deep.property('details', { userId, groupId }) + }) + }) + it('returns all task result docs for that given group and item', function () { + const userId = Random.id() + const groupId = GroupCollection.insert(createGroupDoc({ users: [{ userId }] })) + const itemId = Random.id() + const createDoc = { lessonId: Random.id(), taskId: Random.id(), itemId, response: [Random.id()], groupId } + TaskResultCollection.insert({ + lessonId: Random.id(), + taskId: Random.id(), + itemId: Random.id(), + response: [Random.id()], + groupId: Random.id() + }) + const taskResultId = TaskResultCollection.insert(createDoc) + const env = { userId } + const args = { groupId, itemId } + const pub = collectPublication(byGroupPub.call(env, args)) + expect(pub.length).to.equal(1) + expect(pub[0]._id).to.equal(taskResultId) + }) + }) + describe(TaskResults.publications.byTask.name, function () { + it('is not implemented') + }) + }) }) diff --git a/src/imports/contexts/tasks/results/tests/TaskWorkingState.tests.js b/src/imports/contexts/tasks/results/tests/TaskWorkingState.tests.js index 6640fde..36ede2c 100644 --- a/src/imports/contexts/tasks/results/tests/TaskWorkingState.tests.js +++ b/src/imports/contexts/tasks/results/tests/TaskWorkingState.tests.js @@ -2,7 +2,7 @@ import { Random } from 'meteor/random' import { TaskWorkingState } from '../../state/TaskWorkingState' import { LessonStates } from '../../../classroom/lessons/LessonStates' -import { restoreAll } from '../../../../../tests/testutils/stub' +import { restoreAll, stub } from '../../../../../tests/testutils/stub' import { clearAllCollections, mockCollections, @@ -16,12 +16,14 @@ import { Lesson } from '../../../classroom/lessons/Lesson' import { Users } from '../../../system/accounts/users/User' import { SchoolClass } from '../../../classroom/schoolclass/SchoolClass' import { DocNotFoundError } from '../../../../api/errors/types/DocNotFoundError' +import { Group } from '../../../classroom/group/Group' +import { Features } from '../../../../api/config/Features' describe(TaskWorkingState.name, function () { let TaskWorkingStateCollection before(function () { - [TaskWorkingStateCollection] = mockCollections(TaskWorkingState, Lesson, Users, SchoolClass, Task) + [TaskWorkingStateCollection] = mockCollections(TaskWorkingState, Lesson, Users, SchoolClass, Task, Group) }) afterEach(function () { @@ -62,7 +64,43 @@ describe(TaskWorkingState.name, function () { .with.property('details') .with.property('query', taskId) }) - it('throws if a given groupdoc does not exist by griupd id') + it('throws if a given groupDoc does not exist by groupId id', function () { + const taskId = Random.id() + const taskDoc = { _id: taskId } + const visibleStudent = [{ _id: taskId, context: Task.name }] + const { lessonDoc, userId } = stubStudentDocs({ startedAt: new Date(), visibleStudent }) + stubTaskDoc(taskDoc) + const insertDoc = { + lessonId: lessonDoc._id, + taskId: taskId, + complete: false, + page: 1, + groupId: Random.id(), + progress: 50 + } + stub(Features, 'ensure', () => {}) + const thrown = expect(() => saveState.call({ userId }, insertDoc)) + .to.throw('getDocument.docUndefined') + thrown.with.deep.property('details', { name: Group.name, query: insertDoc.groupId }) + }) + it('throws if a given groupId is rejected due to group features being disabled', function () { + const taskId = Random.id() + const taskDoc = { _id: taskId } + const visibleStudent = [{ _id: taskId, context: Task.name }] + const { lessonDoc, userId } = stubStudentDocs({ startedAt: new Date(), visibleStudent }) + stubTaskDoc(taskDoc) + const insertDoc = { + lessonId: lessonDoc._id, + taskId: taskId, + complete: false, + page: 1, + groupId: Random.id(), + progress: 50 + } + stub(Features, 'get', () => false) + expect(() => saveState.call({ userId }, insertDoc)) + .to.throw('Feature "groups" is expected to be true but is false') + }) it('throws if the task is not editable', function () { const { lessonDoc, userId } = stubStudentDocs({ startedAt: new Date() }) const taskId = Random.id() @@ -116,7 +154,13 @@ describe(TaskWorkingState.name, function () { expect(TaskWorkingStateCollection.findOne(insertDoc)).to.equal(undefined) const taskWorkingStateId = TaskWorkingStateCollection.insert(insertDoc) - const updated = saveState.call({ userId }, { lessonId: lessonDoc._id, taskId, complete: true, page: 5, progress: 100 }) + const updated = saveState.call({ userId }, { + lessonId: lessonDoc._id, + taskId, + complete: true, + page: 5, + progress: 100 + }) expect(updated).to.equal(1) const updatedDoc = TaskWorkingStateCollection.findOne(taskWorkingStateId) diff --git a/src/imports/contexts/tasks/state/methods/saveTaskWorkingState.js b/src/imports/contexts/tasks/state/methods/saveTaskWorkingState.js index 308f435..65b1d2b 100644 --- a/src/imports/contexts/tasks/state/methods/saveTaskWorkingState.js +++ b/src/imports/contexts/tasks/state/methods/saveTaskWorkingState.js @@ -41,18 +41,20 @@ export const saveTaskWorkingState = function ({ lessonId, taskId, groupId, compl checkTaskDoc(taskId) // if we have a group we need to get the groupDoc, too - const groupDoc = groupId && getGroupDoc(groupId) + let groupDoc if (groupId) { Features.ensure('groups') + groupDoc = groupId && getGroupDoc(groupId) ensureDocumentExists({ document: groupDoc, + name: Group.name, docId: groupId, userId: userId }) } - const groupVisible = groupDoc && groupDoc.visible + const groupVisible = groupDoc?.visible const allMaterial = (groupVisible || []).concat(lessonDoc.visibleStudent || []) const taskIsVisible = allMaterial.find(entry => entry._id === taskId) diff --git a/src/imports/startup/both/plugins/loadDefaultResponseProcessors.js b/src/imports/startup/both/plugins/loadDefaultResponseProcessors.js index f1d74ef..99c6a5f 100644 --- a/src/imports/startup/both/plugins/loadDefaultResponseProcessors.js +++ b/src/imports/startup/both/plugins/loadDefaultResponseProcessors.js @@ -9,6 +9,7 @@ import { AudioList } from '../../../contexts/tasks/responseProcessors/aggregate/ import { DocumentList } from '../../../contexts/tasks/responseProcessors/aggregate/documentList/DocumentList' import { Cluster } from '../../../contexts/tasks/responseProcessors/aggregate/cluster/Cluster' import { Text } from '../../../contexts/tasks/responseProcessors/aggregate/text/Text' +import { GroupText } from '../../../contexts/tasks/responseProcessors/aggregate/groupText/GroupText' import { ContextRegistry } from '../../../infrastructure/context/ContextRegistry' import { responseProcessorPipeline } from '../../../contexts/tasks/responseProcessors/responseProcessorPipeline' @@ -20,6 +21,7 @@ import { onClientExec, onServerExec } from '../../../api/utils/archUtils' */ [ Text, + GroupText, PieChart, BarChart, ImageGallery, diff --git a/src/imports/startup/client/minimal/templates.js b/src/imports/startup/client/minimal/templates.js index 0c96646..fe0933b 100644 --- a/src/imports/startup/client/minimal/templates.js +++ b/src/imports/startup/client/minimal/templates.js @@ -29,6 +29,18 @@ TemplateLoader.enable() .register('short', async () => import('../../../ui/generic/short/short')) .register('fail', async () => import('../../../ui/generic/fail/fail')) +/** + * @param options + * @param options.contexts + * @param options.loaders + * @param options.language + * @param options.debug + * @param options.useForms + * @param options.onError + * @param options.onComplete + * @return {TemplateInstance.api} + * @return {TemplateInstance.api} + */ Template.prototype.setDependencies = function (options = {}) { const template = this const { viewName } = template diff --git a/src/imports/ui/blaze/getParentView.js b/src/imports/ui/blaze/getParentView.js new file mode 100644 index 0000000..580dda5 --- /dev/null +++ b/src/imports/ui/blaze/getParentView.js @@ -0,0 +1,20 @@ +/** + * Traverses a view's parent tree until a Template is found + * @param view {Blaze.View} + * @param skipSame {boolean=false} + * @return {Blaze.View|undefined} + */ +export const getParentView = ({ view, skipSame = false }) => { + let currentView = view.parentView + + while (currentView && !currentView.name.includes('Template.')) { + currentView = currentView.parentView + } + + if (!currentView || !skipSame || currentView.name !== `Template.${view.name}`) { + return currentView + } + + // continue search view same view + return getParentView({ view: currentView, skipSame }) +} diff --git a/src/imports/ui/blaze/helpers.js b/src/imports/ui/blaze/helpers.js index 070c987..7dffda3 100644 --- a/src/imports/ui/blaze/helpers.js +++ b/src/imports/ui/blaze/helpers.js @@ -8,10 +8,10 @@ import { Routes } from '../../api/routes/Routes' import { Router } from '../../api/routes/Router' import { resolveRoute } from '../../api/routes/resolveRoute' import { contrastColor } from '../utils/color/contrastColor' -import { getLocalCollection } from '../../infrastructure/collection/getLocalCollection' import { Features } from '../../api/config/Features' import { isTodayOrYesterday } from '../../utils/isTodayOrYesterday' import { createLog } from '../../api/log/createLog' +import { getUser } from '../../contexts/system/accounts/users/getUser' export const feature = function (name) { return Features.get(name) @@ -252,4 +252,4 @@ export const getIndex = function (index) { return typeof index === 'number' ? index + 1 : undefined } -const getUser = query => (Meteor.users.findOne(query) || getLocalCollection(Meteor.users).findOne(query)) +export { getUser } diff --git a/src/imports/ui/components/forms/formUtils.js b/src/imports/ui/components/forms/formUtils.js index cf278b3..d5af7f9 100644 --- a/src/imports/ui/components/forms/formUtils.js +++ b/src/imports/ui/components/forms/formUtils.js @@ -18,6 +18,7 @@ export const formIsValid = function formIsValid (schema, formId, isUpdate, debug if (errors && errors.length > 0) { if (debug) debug('form validation errors', errors) + errors.forEach(err => AutoForm.addStickyValidationError(formId, err.key, err.type, err.value)) return null } diff --git a/src/imports/ui/components/forms/modal/formModal.js b/src/imports/ui/components/forms/modal/formModal.js index 722001b..9bfddcf 100644 --- a/src/imports/ui/components/forms/modal/formModal.js +++ b/src/imports/ui/components/forms/modal/formModal.js @@ -191,25 +191,26 @@ Template.formModal.events({ }) export const FormModal = { - show: ({ - action, - schema, - timeout = 500, - doc, - load, - bind, - custom, - onSubmit, - validation, - onClosed, - onError, - title, - description, - debug, - codeRequired, - hideLegend, - collapse - }) => { + show: (options) => { + const { + action, + schema, + timeout = 500, + doc, + load, + bind, + custom, + onSubmit, + validation, + onClosed, + onError, + title, + description, + debug, + codeRequired, + hideLegend, + collapse + } = options const modalData = state.get() if (modalData) { diff --git a/src/imports/ui/components/groupbuilder/i18n/groupBuilderLanguage.js b/src/imports/ui/components/groupbuilder/i18n/groupBuilderLanguage.js deleted file mode 100644 index 480d4cf..0000000 --- a/src/imports/ui/components/groupbuilder/i18n/groupBuilderLanguage.js +++ /dev/null @@ -1,5 +0,0 @@ -module.exports = { - de: () => import('./de'), - en: () => import('./en'), - tr: () => import('./tr') -} diff --git a/src/imports/ui/editors/groups/groupsEditor.html b/src/imports/ui/editors/groups/groupsEditor.html new file mode 100644 index 0000000..33bfd66 --- /dev/null +++ b/src/imports/ui/editors/groups/groupsEditor.html @@ -0,0 +1,92 @@ + \ No newline at end of file diff --git a/src/imports/ui/editors/groups/groupsEditor.js b/src/imports/ui/editors/groups/groupsEditor.js new file mode 100644 index 0000000..10c7d3b --- /dev/null +++ b/src/imports/ui/editors/groups/groupsEditor.js @@ -0,0 +1,168 @@ +import { Template } from 'meteor/templating' +import { Group } from '../../../contexts/classroom/group/Group' +import { Phase } from '../../../contexts/curriculum/curriculum/phase/Phase' +import { FormModal } from '../../components/forms/modal/formModal' +import { cursor } from '../../../api/utils/cursor' +import { getCollection } from '../../../api/utils/getCollection' +import { getLocalCollection } from '../../../infrastructure/collection/getLocalCollection' +import { dataTarget } from '../../utils/dataTarget' +import { createGroupForms } from '../../pages/lesson/views/info/groupForms' +import { getParentView } from '../../blaze/getParentView' +import { callMethod } from '../../controllers/document/callMethod' +import groupsLang from './i18n/groupsLang' +import '../../../ui/forms/groupbuilder/groupBuilder' +import './groupsEditor.html' + +const API = Template.groupsEditor.setDependencies({ contexts: [Group], language: groupsLang }) +const groupForms = createGroupForms({ onError: API.notify, translate: API.translate }) + +Template.groupsEditor.onCreated(function () { + const instance = this + const { classDoc, unitDoc, phases } = instance.data + const parent = getParentView({ view: instance.view, skipSame: true }) + + /** + * Executes a group action + * @param action {string} name of the action, one of 'create', 'view', 'update', 'delete' + * @param groupId {string=} optional doc _id, undefined for 'create' action + * @return {*} + */ + instance.onAction = ({ action, groupId }) => { + const groupDoc = groupId && getCollection(Group.name).findOne(groupId) + + if (action === 'create') { + instance.state.set({ + groupBuilderActive: true, + groupEditMode: action + }) + return API.showModal('manageGroupModal') + } + + const material = instance.state.get('materialOptions') + const definitions = groupForms[action] + const doc = definitions.doc || groupDoc + const options = { + action, + doc: doc, + bind: { groupId, groupDoc, classDoc, material, phases }, + hideLegend: true, + ...definitions + } + return FormModal.show(options) + } + + // the action buttons are usually located in a parent template + // and we can't access the parent's scope for events + Template[parent.name.split('.')[1]].events({ + 'click .groups-action-btn' (event, parentInstance) { + const action = dataTarget(event, parentInstance, 'action') + const groupId = dataTarget(event, parentInstance, 'id') + instance.onAction({ action, groupId }) + } + }) + + /** + * Returns the respective group documents as cursor. + * @return {*} + */ + instance.getGroups = () => { + const query = { + unitId: unitDoc._id + } + return getCollection(Group.name).find(query) + } + + /** + * Once we recive the groups from groupBuilder we + * can save them, depending on the current state + * @async + * @param groupSettings {object} + * @param groupsDoc {Array} + * @return {Promise} + */ + instance.onGroupCreated = async ({ groupSettings, groupsDoc }) => { + const groups = groupsDoc.groups.map(group => { + group.classId = classDoc?._id + group.unitId = unitDoc?._id + group.phases = groupSettings.phases + return group + }) + + for (const doc of groups) { + await callMethod({ + name: Group.methods.save, + args: doc, + failure: API.notify + }) + } + + instance.state.set('addGroups', false) + API.hideModal('manageGroupModal') + API.notify(true) + } + + // we need to autorun here, because the material may not be loaded + // when the template is complete + instance.autorun(() => { + const data = Template.currentData() + const phaseMaterial = [] + ;(data.phases || []).forEach(phase => { + if (phase.references) { + phaseMaterial.push(...phase.references) + } + }) + + const materialOptions = (data.unassociatedMaterial || []) + .concat(phaseMaterial) + .map(({ collection, document }) => { + const value = document + const materialDoc = getLocalCollection(collection).findOne(document) + const label = materialDoc?.title || materialDoc?.name + return { value, label } + }) + + instance.state.set({ materialOptions }) + }) +}) + +Template.groupsEditor.helpers({ + groups () { + return cursor(Template.instance().getGroups) + }, + phaseDocs (phaseIds) { + return phaseIds && cursor(() => getLocalCollection(Phase.name).find({ _id: { $in: phaseIds } })) + }, + groupBuilderAtts () { + const instance = Template.instance() + const data = Template.currentData() + if (!instance.state.get('groupBuilderActive')) { + return // skip to prevent offscreen drawing + } + + return { + lessonDoc: data.lessonDoc, + unitDoc: data.unitDoc, + classDoc: data.classDoc, + phases: data.phases ?? [], + material: instance.state.get('materialOptions'), + onCreated: instance.onGroupCreated + } + }, + groupEditMode () { + return Template.getState('groupEditMode') + } +}) + +Template.groupsEditor.events({ + 'hidden.bs.modal #manageGroupModal' (event, templateInstance) { + templateInstance.state.set({ + groupBuilderActive: false, + groupEditMode: null + }) + }, + 'click .groups-action-btn' (event, templateInstance) { + const action = dataTarget(event, templateInstance, 'action') + const groupId = dataTarget(event, templateInstance, 'id') + templateInstance.onAction({ action, groupId }) + } +}) diff --git a/src/imports/ui/editors/groups/i18n/de.json b/src/imports/ui/editors/groups/i18n/de.json new file mode 100644 index 0000000..add61b3 --- /dev/null +++ b/src/imports/ui/editors/groups/i18n/de.json @@ -0,0 +1,14 @@ +{ + "group": { + "title": "Gruppe", + "groups": "Gruppen", + "users": "Mitglieder", + "availableUsers": "Gesamtanzahl Schüler*innen", + "noUsers": "Die Gruppe hat noch keine Mitglieder!", + "phases": "Auf Unterrichtsphasen beschränkt", + "material": "U. Material für Gruppen", + "createGroups": "Gruppen anlegen", + "numUsers": "Mitglieder pro Gruppe", + "maxGroups": "Anzahl Gruppen" + } +} \ No newline at end of file diff --git a/src/imports/ui/editors/groups/i18n/en.json b/src/imports/ui/editors/groups/i18n/en.json new file mode 100644 index 0000000..320c6c8 --- /dev/null +++ b/src/imports/ui/editors/groups/i18n/en.json @@ -0,0 +1,10 @@ +{ + "group": { + "title": "Group", + "groups": "Groups", + "users": "Members", + "phases": "Limited to phases", + "material": "L. Material for groups", + "createGroups": "Create groups" + } +} \ No newline at end of file diff --git a/src/imports/ui/editors/groups/i18n/groupsLang.js b/src/imports/ui/editors/groups/i18n/groupsLang.js new file mode 100644 index 0000000..e8e5a1f --- /dev/null +++ b/src/imports/ui/editors/groups/i18n/groupsLang.js @@ -0,0 +1,5 @@ +module.exports = { + de: () => import('./de.json'), + en: () => import('./en.json'), + tr: () => import('./tr.json') +} diff --git a/src/imports/ui/editors/groups/i18n/tr.json b/src/imports/ui/editors/groups/i18n/tr.json new file mode 100644 index 0000000..6c3c4c8 --- /dev/null +++ b/src/imports/ui/editors/groups/i18n/tr.json @@ -0,0 +1,9 @@ +{ + "group": { + "title": "Grup", + "groups": "Gruplar", + "users": "Üyeler", + "phases": "Aşamalarla sınırlı", + "material": "Gruplar için öğretim materyali" + } +} \ No newline at end of file diff --git a/src/imports/ui/editors/unit/UnitEditorViewStates.js b/src/imports/ui/editors/unit/UnitEditorViewStates.js index 47aed73..4cef9d3 100644 --- a/src/imports/ui/editors/unit/UnitEditorViewStates.js +++ b/src/imports/ui/editors/unit/UnitEditorViewStates.js @@ -1,4 +1,5 @@ import { UserUtils } from '../../../contexts/system/accounts/users/UserUtils' +import { Features } from '../../../api/config/Features' export const UnitEditorViewStates = { summary: { @@ -40,8 +41,20 @@ export const UnitEditorViewStates = { load: async function () { return import('./views/phases/phases') } - }, - codeView: { + } +} + +if (Features.get('groups')) { + UnitEditorViewStates.groups = { + name: 'groups', + label: 'editor.unit.groups.title', + template: 'unitEditorGroupsView', + load: () => import('./views/groups/unitEditorGroupsView') + } +} + +if (UserUtils.isAdmin()) { + UnitEditorViewStates.codeView = { name: 'codeView', label: 'editor.unit.codeView', template: 'uecodeView', diff --git a/src/imports/ui/editors/unit/i18n/de.json b/src/imports/ui/editors/unit/i18n/de.json index 35329e8..5cef00e 100644 --- a/src/imports/ui/editors/unit/i18n/de.json +++ b/src/imports/ui/editors/unit/i18n/de.json @@ -68,6 +68,9 @@ "deletePhase": "Unterrichtsphase löschen", "confirmRemove": "Möchten Sie wirklich diese Unterrichtsphase entfernen und löschen?" }, + "groups": { + "title": "Gruppen" + }, "allDocsAdded": "Alle bestehenden Dokumente wurden bereits hinzugefügt", "codeView": "Quellcode" } diff --git a/src/imports/ui/editors/unit/i18n/en.json b/src/imports/ui/editors/unit/i18n/en.json index f9a6aa3..0ebb7d5 100644 --- a/src/imports/ui/editors/unit/i18n/en.json +++ b/src/imports/ui/editors/unit/i18n/en.json @@ -66,6 +66,9 @@ "deletePhase": "Delete phase", "confirmRemove": "Do you really want to remove / delete this phase?" }, + "groups": { + "title": "Groups" + }, "allDocsAdded": "All docs have already been added", "codeView": "Raw data" } diff --git a/src/imports/ui/editors/unit/i18n/tr.json b/src/imports/ui/editors/unit/i18n/tr.json index 31ed8a7..fe96b2c 100644 --- a/src/imports/ui/editors/unit/i18n/tr.json +++ b/src/imports/ui/editors/unit/i18n/tr.json @@ -66,6 +66,9 @@ "deletePhase": "Öğretim aşamasını sil", "confirmRemove": "Bu öğretim aşamasını gerçekten kaldırmak ve silmek istiyor musunuz?" }, + "groups": { + "title": "Çalışma grupları" + }, "allDocsAdded": "Mevcut tüm belgeler zaten eklenmiştir", "codeView": "Kaynak kodu" } diff --git a/src/imports/ui/editors/unit/views/groups/unitEditorGroupsView.html b/src/imports/ui/editors/unit/views/groups/unitEditorGroupsView.html new file mode 100644 index 0000000..f2b8af0 --- /dev/null +++ b/src/imports/ui/editors/unit/views/groups/unitEditorGroupsView.html @@ -0,0 +1,11 @@ + diff --git a/src/imports/ui/editors/unit/views/groups/unitEditorGroupsView.js b/src/imports/ui/editors/unit/views/groups/unitEditorGroupsView.js new file mode 100644 index 0000000..e5e09ff --- /dev/null +++ b/src/imports/ui/editors/unit/views/groups/unitEditorGroupsView.js @@ -0,0 +1,97 @@ +import { Template } from 'meteor/templating' +import { Phase } from '../../../../../contexts/curriculum/curriculum/phase/Phase' +import { Unit } from '../../../../../contexts/curriculum/curriculum/unit/Unit' +import { LessonMaterial } from '../../../../controllers/LessonMaterial' +import { Users } from '../../../../../contexts/system/accounts/users/User' +import { ProfileImages } from '../../../../../contexts/files/image/ProfileImages' +import { Group } from '../../../../../contexts/classroom/group/Group' +import { unitEditorSubscriptionKey } from '../../unitEditorSubscriptionKey' +import { getMaterialContexts } from '../../../../../contexts/material/initMaterial' +import { loadIntoCollection } from '../../../../../infrastructure/loading/loadIntoCollection' +import { getLocalCollection } from '../../../../../infrastructure/collection/getLocalCollection' +import { $in } from '../../../../../api/utils/query/inSelector' +import { findUnassociatedMaterial } from '../../../../../api/utils/findUnassociatedMaterial' +import '../../../groups/groupsEditor' +import './unitEditorGroupsView.html' + +const API = Template.unitEditorGroupsView.setDependencies({ + contexts: [...(new Set([Phase, Unit, ProfileImages, Users, Group].concat(getMaterialContexts()))).values()], + debug: true +}) + +Template.unitEditorGroupsView.onCreated(function () { + const instance = this + + instance.autorun(() => { + if (!API.initComplete()) { + return + } + + instance.state.set('loadComplete', false) + const data = Template.currentData() + const { unitDoc, classDoc } = data + const phasesList = unitDoc.phases || [] + + loadIntoCollection({ + name: Phase.methods.all, + args: { ids: phasesList }, + collection: getLocalCollection(Phase.name), + success: () => { + const phases = getLocalCollection(Phase.name).find({ _id: $in(phasesList) }).fetch() + instance.state.set({ phases }) + }, + failure: API.notify + }) + + loadIntoCollection({ + name: ProfileImages.methods.byClass, + args: { classId: classDoc._id }, + collection: getLocalCollection(ProfileImages.name), + failure: API.notify, + success: () => instance.state.set('profileImagesReady', true) + }) + + loadIntoCollection({ + name: Users.methods.byClass, + args: { classId: classDoc._id }, + collection: getLocalCollection(Users.name), + failure: API.notify, + success: () => instance.state.set('usersReady', true) + }) + + LessonMaterial.load(unitDoc, (err, material) => { + API.debug('material loaded', err, material) + const unassociatedMaterial = findUnassociatedMaterial(unitDoc) + instance.state.set({ + materialLoaded: true, + unassociatedMaterial + }) + }) + + API.subscribe({ + name: Group.publications.my, + args: { unitId: unitDoc._id }, + key: unitEditorSubscriptionKey, + callbacks: { + onError: API.fatal, + onReady: () => instance.state.set({ groupSubscriptionComplete: true }) + } + }) + }) +}) + +Template.unitEditorGroupsView.helpers({ + groupsEditorAtts () { + const { classDoc, unitDoc } = Template.currentData() + const phases = Template.getState('phases') + const unassociatedMaterial = Template.getState('unassociatedMaterial') + + return { + lessonDoc: null, + classDoc, + unitDoc, + phases, + unassociatedMaterial + } + } +}) diff --git a/src/imports/ui/editors/unit/views/phases/phaseBaseSchema.js b/src/imports/ui/editors/unit/views/phases/phaseBaseSchema.js new file mode 100644 index 0000000..9bd9da5 --- /dev/null +++ b/src/imports/ui/editors/unit/views/phases/phaseBaseSchema.js @@ -0,0 +1,50 @@ +import { Phase } from '../../../../../contexts/curriculum/curriculum/phase/Phase' +import { Unit } from '../../../../../contexts/curriculum/curriculum/unit/Unit' +import { i18n } from '../../../../../api/language/language' +import { Material } from '../../../../../contexts/material/Material' +import { Curriculum } from '../../../../../contexts/curriculum/Curriculum' +import { firstOption } from '../../../../../contexts/tasks/definitions/common/helpers' +import { getCollection } from '../../../../../api/utils/getCollection' +import { unitEditorMaterialNames } from '../../utils/unitEditorMaterialNames' +import { queryFromCollectionAndLocal } from '../../../../../api/utils/query/queryFromCollectionAndLocal' + +const defaultSchema = Curriculum.getDefaultSchema() + +export const phaseBaseSchema = Object.assign({}, defaultSchema, Phase.schema) +phaseBaseSchema['references.$.collection'].autoform = { + firstOption: firstOption, + options () { + const unitId = AutoForm.getFieldValue('unit') + if (!unitId) return [] + + const UnitCollection = getCollection(Unit.name) + const unitDoc = UnitCollection.findOne(unitId) + + return unitEditorMaterialNames().filter(option => { + const field = option.fieldName + const target = unitDoc[field] + return target && target.length > 0 + }).map(({ name, label }) => { + return { value: name, label: () => i18n.get(label) } + }) + } +} + +phaseBaseSchema['references.$.document'].autoform = { + firstOption: firstOption, + options () { + const index = this.name.split('.')[1] + const contextName = AutoForm.getFieldValue(`references.${index}.collection`) + const unitId = AutoForm.getFieldValue('unit') + + if (!unitId || !contextName) return [] + + const UnitCollection = getCollection(Unit.name) + const unitDoc = UnitCollection.findOne(unitId) + const context = Material.get(contextName) + const targetIds = unitDoc[context.fieldName] + const query = { _id: { $in: targetIds } } + + return queryFromCollectionAndLocal(contextName, query) + } +} diff --git a/src/imports/ui/editors/unit/views/phases/phases.js b/src/imports/ui/editors/unit/views/phases/phases.js index 4aea92d..0eff2bf 100644 --- a/src/imports/ui/editors/unit/views/phases/phases.js +++ b/src/imports/ui/editors/unit/views/phases/phases.js @@ -1,4 +1,3 @@ -/* global AutoForm */ import { Tracker } from 'meteor/tracker' import { Template } from 'meteor/templating' @@ -10,7 +9,6 @@ import { SocialStateType } from '../../../../../contexts/curriculum/curriculum/t import { i18n } from '../../../../../api/language/language' import { LessonMaterial } from '../../../../controllers/LessonMaterial' import { unitEditorSubscriptionKey } from '../../unitEditorSubscriptionKey' -import { Material } from '../../../../../contexts/material/Material' import { formIsValid, formReset } from '../../../../components/forms/formUtils' import { dataTarget } from '../../../../utils/dataTarget' @@ -22,63 +20,20 @@ import { insertContextDoc } from '../../../../controllers/document/insertContext import { removeContextDoc } from '../../../../controllers/document/removeContextDoc' import { $in } from '../../../../../api/utils/query/inSelector' import { getCollection } from '../../../../../api/utils/getCollection' -import { firstOption } from '../../../../../contexts/tasks/definitions/common/helpers' -import { unitEditorMaterialNames } from '../../utils/unitEditorMaterialNames' -import { queryFromCollectionAndLocal } from '../../../../../api/utils/query/queryFromCollectionAndLocal' import { getMaterialContexts } from '../../../../../contexts/material/initMaterial' - +import { isCurriculumDoc } from '../../../../../api/decorators/methods/isCurriculumDoc' +import { phaseBaseSchema } from './phaseBaseSchema' import Sortable from 'sortablejs' - import '../../../../generic/info/info' import '../phaserenderer/phaseRenderer' import './phases.css' import './phases.html' -import { isCurriculumDoc } from '../../../../../api/decorators/methods/isCurriculumDoc' const defaultSchema = Curriculum.getDefaultSchema() const API = Template.uephases.setDependencies({ contexts: [...(new Set([Phase, Unit].concat(getMaterialContexts()))).values()] }) -const phaseBaseSchema = Object.assign({}, defaultSchema, Phase.schema) -phaseBaseSchema['references.$.collection'].autoform = { - firstOption: firstOption, - options () { - const unitId = AutoForm.getFieldValue('unit') - if (!unitId) return [] - - const UnitCollection = getCollection(Unit.name) - const unitDoc = UnitCollection.findOne(unitId) - - return unitEditorMaterialNames().filter(option => { - const field = option.fieldName - const target = unitDoc[field] - return target && target.length > 0 - }).map(({ name, label }) => { - return { value: name, label: () => i18n.get(label) } - }) - } -} - -phaseBaseSchema['references.$.document'].autoform = { - firstOption: firstOption, - options () { - const index = this.name.split('.')[1] - const contextName = AutoForm.getFieldValue(`references.${index}.collection`) - const unitId = AutoForm.getFieldValue('unit') - - if (!unitId || !contextName) return [] - - const UnitCollection = getCollection(Unit.name) - const unitDoc = UnitCollection.findOne(unitId) - const context = Material.get(contextName) - const targetIds = unitDoc[context.fieldName] - const query = { _id: { $in: targetIds } } - - return queryFromCollectionAndLocal(contextName, query) - } -} - const hiddenUnitSchema = (unitDoc) => ({ type: String, regEx: Schema.provider.RegEx.Id, diff --git a/src/imports/ui/components/groupbuilder/api/createGroupSchema.js b/src/imports/ui/forms/groupbuilder/api/createGroupSchema.js similarity index 69% rename from src/imports/ui/components/groupbuilder/api/createGroupSchema.js rename to src/imports/ui/forms/groupbuilder/api/createGroupSchema.js index 2d50145..6cff897 100644 --- a/src/imports/ui/components/groupbuilder/api/createGroupSchema.js +++ b/src/imports/ui/forms/groupbuilder/api/createGroupSchema.js @@ -1,11 +1,28 @@ /* global AutoForm */ +import { Integer } from '../../../../api/schema/Schema' +import { phaseGroupSchema } from './phaseGroupSchema' + export const createGroupsSchema = ({ phases, material, translate }) => ({ maxUsers: { - label: translate('group.maxUsers'), - type: Number, - min: 2, + label: translate('group.numUsers'), + type: Integer, + min: 1, autoform: { - defaultValue: 2 + defaultValue: 1, + min: 1, + group: 'nums', + 'formgroup-class': 'col-12 col-md-6 float-left' + } + }, + maxGroups: { + label: translate('group.maxGroups'), + type: Integer, + min: 1, + autoform: { + min: 1, + defaultValue: 1, + group: 'nums', + 'formgroup-class': 'col-12 col-md-6 float-right' } }, @@ -17,28 +34,8 @@ export const createGroupsSchema = ({ phases, material, translate }) => ({ 'roles.$': String, - phases: { - label: translate('lesson.phases.title'), - type: Array, - optional: true, - autoform: { - type: () => { - if (phases?.length === 0) { - return 'hidden' - } - } - } - }, - 'phases.$': { - type: String, - autoform: { - firstOption: translate('form.selectOne'), - options: () => (phases || []).map(doc => doc && ({ - value: doc._id, - label: doc.title - })) - } - }, + ...phaseGroupSchema({ phases, translate }), + material: { label: translate('group.material'), type: Array, @@ -69,6 +66,9 @@ export const createGroupsSchema = ({ phases, material, translate }) => ({ disabled: () => { const materialAutoShuffle = AutoForm.getFieldValue('materialAutoShuffle') if (materialAutoShuffle) return true + }, + afFieldInput: { + class: 'ml-3' } } }, @@ -85,6 +85,9 @@ export const createGroupsSchema = ({ phases, material, translate }) => ({ disabled: () => { const materialForAllGroups = AutoForm.getFieldValue('materialForAllGroups') if (materialForAllGroups) return true + }, + afFieldInput: { + class: 'ml-3' } } } diff --git a/src/imports/ui/components/groupbuilder/api/editGroupSchema.js b/src/imports/ui/forms/groupbuilder/api/editGroupSchema.js similarity index 62% rename from src/imports/ui/components/groupbuilder/api/editGroupSchema.js rename to src/imports/ui/forms/groupbuilder/api/editGroupSchema.js index 9df0231..5a150f4 100644 --- a/src/imports/ui/components/groupbuilder/api/editGroupSchema.js +++ b/src/imports/ui/forms/groupbuilder/api/editGroupSchema.js @@ -1,9 +1,19 @@ -import '../../../forms/users/userGroupSelect' +import '../../users/userGroupSelect' import { translateReactive } from '../../../../utils/translateReactive' -export const editGroupSchema = (groupBuilderInstance, options) => { - const { users = [], maxUsers, maxGroups /*, materialForAllGroups, roles = [], material = [] */ } = groupBuilderInstance - const minCount = Math.floor(users.length / (maxUsers || 1)) +/** + * Creates a schema definition for editing groups + * @param groupBuilderInstance {GroupBuilder} + * @param options {object=} optional + * @param options.material {[{ label:string, value: string }]} object of material docs with value/label combination of + * all available materials + * @return {object} schema definition object + */ +export const editGroupSchema = (groupBuilderInstance, options = {}) => { + const { users = [], maxUsers, maxGroups } = groupBuilderInstance + const minCount = maxUsers + ? Math.floor(users.length / maxUsers) + : 1 return { groups: { @@ -26,6 +36,7 @@ export const editGroupSchema = (groupBuilderInstance, options) => { 'groups.$.title': String, 'groups.$.users': { type: Array, + optional: !groupBuilderInstance.atLeastOneUserRequired, label: translateReactive('group.users'), minCount: 1 }, diff --git a/src/imports/ui/forms/groupbuilder/api/phaseGroupSchema.js b/src/imports/ui/forms/groupbuilder/api/phaseGroupSchema.js new file mode 100644 index 0000000..7c3e398 --- /dev/null +++ b/src/imports/ui/forms/groupbuilder/api/phaseGroupSchema.js @@ -0,0 +1,26 @@ +export const phaseGroupSchema = ({ phases, translate }) => { + return { + phases: { + label: translate('group.phases'), + type: Array, + optional: true, + autoform: { + type: () => { + if (phases?.length === 0) { + return 'hidden' + } + } + } + }, + 'phases.$': { + type: String, + autoform: { + firstOption: translate('form.selectOne'), + options: () => (phases || []).map(doc => doc && ({ + value: doc._id, + label: doc.title + })) + } + } + } +} diff --git a/src/imports/ui/components/groupbuilder/groupBuilder.html b/src/imports/ui/forms/groupbuilder/groupBuilder.html similarity index 87% rename from src/imports/ui/components/groupbuilder/groupBuilder.html rename to src/imports/ui/forms/groupbuilder/groupBuilder.html index 6d73a4a..d0e5f3e 100644 --- a/src/imports/ui/components/groupbuilder/groupBuilder.html +++ b/src/imports/ui/forms/groupbuilder/groupBuilder.html @@ -1,10 +1,15 @@ diff --git a/src/imports/ui/pages/lesson/views/material/lessonMaterial.js b/src/imports/ui/pages/lesson/views/material/lessonMaterial.js index 1c1616a..656629a 100644 --- a/src/imports/ui/pages/lesson/views/material/lessonMaterial.js +++ b/src/imports/ui/pages/lesson/views/material/lessonMaterial.js @@ -19,13 +19,14 @@ import { getCollection } from '../../../../../api/utils/getCollection' import { resolveMaterialReference } from '../../../../../contexts/material/resolveMaterialReference' import { callMethod } from '../../../../controllers/document/callMethod' import { getLocalCollection } from '../../../../../infrastructure/collection/getLocalCollection' +import { lessonSubKey } from '../../lessonSubKey' import '../../../../renderer/phase/full/phaseFullRenderer' import '../../../../renderer/phase/compact/compactPhases' import '../../../../renderer/phase/nonphaseMaterial/nonPhaseMaterial' import '../progress/taskProgress' +import './phaseMaterial/phaseMaterial' import './lessonMaterial.scss' import './lessonMaterial.html' -import { lessonSubKey } from '../../lessonSubKey' const API = Template.lessonMaterial.setDependencies({ contexts: getMaterialContexts().concat([TaskResults]), @@ -62,10 +63,46 @@ Template.lessonMaterial.onCreated(function () { return downloading[key] } - const lessonId = instance.data.lessonDoc._id + instance.isActiveStudent = (materialId, groupMaterial) => { + if (groupMaterial && groupMaterial.some(m => m._id === materialId)) { + return true + } + + const { lessonDoc } = instance.data + return lessonDoc && + lessonDoc.visibleStudent && + lessonDoc.visibleStudent.find(ref => ref._id === materialId) + } + + instance.showResults = (materialId, groupId) => { + const states = Template.getState('showResults') + let showId = materialId + if (typeof groupId === 'string') { + showId += groupId + } + return states && states[showId] + } + + instance.resultButtonDisabled = (materialName) => { + const { lessonDoc } = instance.data + return !lessonDoc || LessonStates.isIdle(lessonDoc) || materialName !== Task.name + } + + instance.isIdle = () => { + const { lessonDoc } = instance.data + return LessonStates.isIdle(lessonDoc) + } + + instance.isOnBeamer = (referenceId, itemId) => { + const { lessonDoc } = instance.data + const lessonId = lessonDoc && lessonDoc._id + return lessonId && Beamer.doc.has({ referenceId, lessonId, itemId }) + } + + const unitId = instance.data.unitDoc._id instance.autorun(() => { const hasGroups = {} - getCollection(Group.name).find({ lessonId }).forEach(groupDoc => { + getCollection(Group.name).find({ unitId }).forEach(groupDoc => { if (groupDoc.phases?.length) { groupDoc.phases.forEach(phaseId => { hasGroups[phaseId] = true @@ -124,23 +161,16 @@ Template.lessonMaterial.helpers({ return Template.instance().data.unassociatedMaterial }, hasGroups (phaseId) { - return Template.getState('hasGroups')[phaseId] + const dict = Template.getState('hasGroups') + return phaseId === 'global' + ? dict.global + : dict[phaseId] }, groups (phaseId) { - if (phaseId === 'global') { - return - } - return getCollection(Group.name).find({ phases: phaseId }) - }, - isActiveStudent (referenceId, groupMaterial) { - if (groupMaterial && groupMaterial.some(m => m._id === referenceId)) { - return true - } - - const { lessonDoc } = Template.instance().data - return lessonDoc && - lessonDoc.visibleStudent && - lessonDoc.visibleStudent.find(ref => ref._id === referenceId) + const query = phaseId === 'global' + ? { phases: { $exists: false } } + : { phases: phaseId } + return getCollection(Group.name).find(query) }, lessonId () { const { lessonDoc } = Template.instance().data @@ -150,12 +180,48 @@ Template.lessonMaterial.helpers({ const { unitDoc } = Template.instance().data return unitDoc.phases }, - resolvedReference (refId) { - return Template.instance().references.get(refId) + phaseMaterialAtts (materialId, phase, group) { + const groupId = group?._id + const instance = Template.instance() + const material = instance.references.get(materialId) + + if (!material) { + return null + } + + const ctx = Material.get(material.name) + const downloadable = ctx?.material?.downloadable + const downloading = instance.isDownloading(materialId, group) + const downloadButtonDisabled = downloadable !== true + const isActiveStudent = instance.isActiveStudent(materialId, group?.visible) + const isIdle = instance.isIdle() + const updating = instance.isUpdating(materialId, groupId) + const presentButtonDisabled = !Beamer.actions.get() + const showResults = instance.showResults(materialId, groupId) + const resultButtonDisabled = instance.resultButtonDisabled(material.name) + const isOnBeamer = instance.isOnBeamer(material) + + return { + materialId, + material, + phase, + group, + downloadButtonDisabled, + downloading, + isActiveStudent, + isIdle, + updating, + presentButtonDisabled, + showResults, + resultButtonDisabled, + isOnBeamer + } + }, + showResults (materialId, groupId) { + return Template.instance().showResults(materialId, groupId) }, isIdle () { - const { lessonDoc } = Template.instance().data - return LessonStates.isIdle(lessonDoc) + return Template.instance().isIdle() }, canStart () { const { lessonDoc } = Template.instance().data @@ -173,9 +239,6 @@ Template.lessonMaterial.helpers({ const { lessonDoc } = Template.instance().data return LessonStates.canComplete(lessonDoc) }, - updating (phaseId, referenceId) { - return Template.instance().isUpdating(referenceId) - }, downloading (phaseId, referenceId) { return Template.instance().isDownloading(referenceId) }, @@ -227,27 +290,6 @@ Template.lessonMaterial.helpers({ ? i18n.get('lesson.actions.toggleMobileActive') : i18n.get('lesson.actions.toggleMobileInactive') }, - resultButtonDisabled (refType) { - const { lessonDoc } = Template.instance().data - if (!lessonDoc || LessonStates.isIdle(lessonDoc)) return true - return refType !== Task.name - }, - presentButtonDisabled (refType) { - return !Beamer.actions.get() - }, - downloadButtonDisabled (refType) { - const ctx = Material.get(refType) - const downloadable = ctx.material?.downloadable - return downloadable !== true - }, - showResults (referenceId, groupId) { - const states = Template.getState('showResults') - let showId = referenceId - if (typeof groupId === 'string') { - showId += groupId - } - return states && states[showId] - }, currentItems () { return Template.instance().currentItems.get() }, @@ -275,9 +317,7 @@ Template.lessonMaterial.helpers({ return item.responseProcessors?.[0] }, isOnBeamer (referenceId, itemId) { - const { lessonDoc } = Template.instance().data - const lessonId = lessonDoc && lessonDoc._id - return lessonId && Beamer.doc.has({ referenceId, lessonId, itemId }) + return Template.instance().isOnBeamer(referenceId, itemId) }, sendingToBeamer (referenceId, itemId) { const sendingToBeamerDoc = Template.instance().state.get('sendingToBeamer') @@ -390,7 +430,7 @@ Template.lessonMaterial.events({ name: context, referenceId }, templateInstance) - .catch(e => API.API.notify(e)) + .catch(e => API.notify(e)) .then(() => { const previewDoc = { name: context, referenceId } const template = LessonMaterial.getPreviewTemplate(previewDoc) @@ -464,7 +504,7 @@ Template.lessonMaterial.events({ const printRoot = dataTarget(event, templateInstance) printHTMLElement(printRoot, () => { }, err => { - API.API.notify(err) + API.notify(err) }) }, @@ -590,7 +630,7 @@ Template.lessonMaterial.events({ const updateDoc = { _id: beamerDoc._id, references: beamerDoc.references } updateDoc.references[index].responseProcessor = name Beamer.doc.update(updateDoc, (err) => { - if (err) return API.API.notify(err) + if (err) return API.notify(err) }) } } diff --git a/src/imports/ui/pages/lesson/views/material/phaseMaterial/phaseMaterial.html b/src/imports/ui/pages/lesson/views/material/phaseMaterial/phaseMaterial.html new file mode 100644 index 0000000..6ae33ed --- /dev/null +++ b/src/imports/ui/pages/lesson/views/material/phaseMaterial/phaseMaterial.html @@ -0,0 +1,109 @@ + \ No newline at end of file diff --git a/src/imports/ui/pages/lesson/views/material/phaseMaterial/phaseMaterial.js b/src/imports/ui/pages/lesson/views/material/phaseMaterial/phaseMaterial.js new file mode 100644 index 0000000..4cf0430 --- /dev/null +++ b/src/imports/ui/pages/lesson/views/material/phaseMaterial/phaseMaterial.js @@ -0,0 +1 @@ +import './phaseMaterial.html' diff --git a/src/imports/ui/pages/student/lesson/lesson.html b/src/imports/ui/pages/student/lesson/lesson.html index c31f118..b794550 100644 --- a/src/imports/ui/pages/student/lesson/lesson.html +++ b/src/imports/ui/pages/student/lesson/lesson.html @@ -1,56 +1,57 @@