· 5 years ago · Mar 13, 2020, 05:24 PM
1import generated/rhapsode_state;
2import material/extra/csvimporter/csvimporter;
3import custom/rhapsode_database_customized;
4import educator/views/educator_views_utils;
5import educator/views/class_learner_progress;
6import educator/educator_utils;
7import import/ccl/educator_import;
8import authentication/model;
9import mail_editor/mail_editor;
10import rhapsode/custom_table_filter;
11import rhapsode/rhapsode_skin_utils;
12import educator/integration/integration_launch;
13import rhapsode/research_questionnaires/research_questionnaires_utils;
14
15export {
16 buildRhapsodeClassesLearnersView(state : RhapsodeState<EducatorExtraState>) -> Material;
17 buildRhapsodeAllLearnersView(state : RhapsodeState<EducatorExtraState>) -> Material;
18 buildRhapsodeResearchGroupLearnersView(state : RhapsodeState<EducatorExtraState>) -> Material;
19 buildRhapsodeIntegrationLearnersView(state : RhapsodeState<EducatorExtraState>, classId : int) -> Material;
20 buildRhapsodeResearchProjectLearnersView(state : RhapsodeState<EducatorExtraState>) -> Material;
21}
22
23addLearnersBatchSize = 20;
24fieldsWidth = 320.0;
25usersSocialLoaded : ref Maybe<bool> = ref None();
26
27ClassLearnersTableStyle ::= ClassLearnersTableClass, ClassLearnersTableClassLearnerMode, ClassLearnersTableGetLearnerIds, ClassLearnersTableAllAvailableUsers, ClassLearnersTableUnion,
28 ClassLearnersTableResearchProject, ClassLearnersTableResearchGroupData, ClassLearnersTableRights;
29 // Learners from this class will be shown,
30 // if class == None() all learners from all classes will be shown there (all learner mode)
31 ClassLearnersTableClass(classM : Maybe<RhapsodeClass>);
32 // This style for detect class learner mode
33 ClassLearnersTableClassLearnerMode(idM : Maybe<int>);
34 // Get learners from the class or all learners for all learners mode
35 ClassLearnersTableGetLearnerIds(fn : ([RhapsodeClassesLearner]) -> [int]);
36 // All available users to add to class
37 ClassLearnersTableAllAvailableUsers(emails : [string]);
38 // The type of content we work with: class, research project, research group
39 // This type will be displayed in email invitations and UI
40 ClassLearnersTableUnion(type : ClassLearnersUnionType);
41 //These types are for convinient work with ClassLearnersTable in Research projects and groups
42 ClassLearnersTableResearchProject(project : RhapsodeResearchProject);
43 ClassLearnersTableResearchGroupData(groupData : RhapsodeResearchGroupData);
44 ClassLearnersTableRights(add : bool, edit : bool, delete : bool);
45
46isActiveUserForClass(user : User) -> bool {
47 user.email != "" && user.active
48}
49
50emailCheckboxText() -> MText {
51 MText(_("Send Notification to Email Address"), [MBody()]);
52}
53
54buildRhapsodeResearchGroupLearnersView(state : RhapsodeState<EducatorExtraState>) -> Material {
55 buildPebbleParameterBasedView(
56 state.pebbleController,
57 "research_group",
58 "class",
59 \cl -> i2s(cl.id),
60 state.dbState.rhapsodeDbState.rhapsodeClassesB,
61 \class : RhapsodeClass -> {
62
63 onError = \err -> {
64 println(err);
65 showMSnackbar(state.manager, err, []);
66 };
67
68 tableB = make(MEmpty());
69 eitherFn(
70 find(
71 getValue(state.dbState.rhapsodeDbState.rhapsodeResearchGroupDataB),
72 \group -> group.classId == class.id
73 ),
74 \group -> eitherFn(
75 find(
76 getValue(state.dbState.rhapsodeDbState.rhapsodeResearchProjectsB),
77 \project -> project.id == group.researchProjectId
78 ),
79 \project -> {
80 //same learner cannot be added in several groups
81 projectGroupClassesIds = filtermap(
82 getValue(state.dbState.rhapsodeDbState.rhapsodeResearchGroupDataB),
83 \gd -> if (gd.researchProjectId == project.id) Some(gd.classId) else None()
84 );
85 distributedUsersIds =
86 uniq(filtermap(
87 getValue(state.dbState.rhapsodeDbState.rhapsodeClassesLearnersB),
88 \cl -> if (contains(projectGroupClassesIds, cl.classId)) Some(cl.userId) else None()
89 ));
90 devtrace("projectGroupClassesIds");
91 devtrace(projectGroupClassesIds);
92 devtrace("distributedUsersIds");
93 devtrace(distributedUsersIds);
94 onlyOnce(usersSocialLoaded, \-> {
95 loadUsersSocials(state.userInfo.jwt, nop1, onError);
96 true
97 });
98
99 loadRhapsodeResearchQuestionnaireResponsesByResearchProjectId(
100 state.dbConnector,
101 project.id,
102 \responses : [RhapsodeResearchQuestionnaireResponse] -> {
103 checkLearnerResponse = \learnerId -> {
104 learnerResponse = find(responses, \r -> r.userId == learnerId);
105 userClaims = concatA(filtermap(getValue(state.dbState.oauthDbState.usersSocialB), \userSocial : UsersSocial -> {
106 if (userSocial.userId == learnerId) Some(json2Claims(userSocial.claims).value) else None()
107 }));
108 checkResearchQuestionnaireResponseForGroupCriteria(
109 learnerResponse,
110 group.inclusionCriteria,
111 json2ResearchProjectsReportAttributes(project.reportAttributes).attributes,
112 userClaims
113 )
114 };
115 checkOtherGroupsNotContainingUser = \userId -> !contains(distributedUsersIds, userId);
116 learnerIds = filtermap(
117 getValue(state.dbState.rhapsodeDbState.rhapsodeClassesLearnersB),
118 \cl -> {
119 if (cl.classId == project.classId && checkOtherGroupsNotContainingUser(cl.userId) && checkLearnerResponse(cl.userId)) {
120 devtrace(cl);
121 checkLearnerResponse(cl.userId);
122 devtrace(checkOtherGroupsNotContainingUser(cl.userId));
123 Some(cl.userId)
124 } else None()
125 }
126 );
127 isLocked = researchProjectIsLocked(project);
128 nextDistinct(tableB,
129 buildCustomRhapsodeClassLearnersTable(state, [
130 ClassLearnersTableClass(Some(class)),
131 ClassLearnersTableGetLearnerIds(\classLearners -> filtermap(
132 classLearners, \cl -> if (cl.classId == class.id) Some(cl.userId) else None()
133 )),
134 ClassLearnersTableAllAvailableUsers(
135 filtermap(
136 getValue(state.dbState.oauthDbState.usersB),
137 \user -> if (contains(learnerIds, user.id)) Some(user.email) else None()
138 )
139 ),
140 ClassLearnersTableUnion(UnionTypeResearchGroup()),
141 ClassLearnersTableResearchProject(project),
142 ClassLearnersTableResearchGroupData(group),
143 ClassLearnersTableRights(true, !isLocked, !isLocked)
144 ])
145 );
146 },
147 [DbErrorRecovery(FailOnDbError(onError))]
148 )
149 },
150 \-> onError("Research project is not found")
151 ),
152 \-> onError("Group is not found")
153 )
154 MMutable(tableB)
155 }
156 );
157}
158
159buildRhapsodeResearchProjectLearnersView(state : RhapsodeState<EducatorExtraState>) -> Material {
160 buildPebbleParameterBasedView(
161 state.pebbleController,
162 "research_project",
163 "research_project",
164 \pr -> i2s(pr.id),
165 state.dbState.rhapsodeDbState.rhapsodeResearchProjectsB,
166 \project : RhapsodeResearchProject -> {
167 classM = find(
168 getValue(state.dbState.rhapsodeDbState.rhapsodeClassesB),
169 \class -> class.id == project.classId
170 );
171 isLocked = researchProjectIsLocked(project);
172 buildCustomRhapsodeClassLearnersTable(state, [
173 ClassLearnersTableClass(classM),
174 ClassLearnersTableGetLearnerIds(\classLearners ->
175 eitherMap(classM, \class -> filtermap(
176 classLearners, \cl -> if (cl.classId == class.id) Some(cl.userId) else None()
177 ), [])
178 ),
179 ClassLearnersTableUnion(UnionTypeResearchProject()),
180 ClassLearnersTableResearchProject(project),
181 ClassLearnersTableRights(true, !isLocked, !isLocked)
182 ]);
183 }
184 );
185}
186
187buildRhapsodeClassesLearnersView(state : RhapsodeState<EducatorExtraState>) -> Material {
188 buildPebbleParameterBasedView(
189 state.pebbleController,
190 "class",
191 "class",
192 \cl -> i2s(cl.id),
193 state.dbState.rhapsodeDbState.rhapsodeClassesB,
194 \class : RhapsodeClass -> {
195 buildCustomRhapsodeClassLearnersTable(state, [
196 ClassLearnersTableClass(Some(class)),
197 ClassLearnersTableClassLearnerMode(Some(class.id)),
198 ClassLearnersTableGetLearnerIds(\classLearners -> uniq(filtermap(
199 classLearners, \cl -> if (cl.classId == class.id) Some(cl.userId) else None()
200 )))
201 ]);
202 }
203 );
204}
205
206buildRhapsodeIntegrationLearnersView(state : RhapsodeState<EducatorExtraState>, classId : int) -> Material {
207 buildCustomRhapsodeClassLearnersTable(state, [
208 ClassLearnersTableClass(find(
209 getValue(state.dbState.rhapsodeDbState.rhapsodeClassesB),
210 \class -> class.id == classId
211 )),
212 ClassLearnersTableClassLearnerMode(Some(classId)),
213 ClassLearnersTableGetLearnerIds(\classLearners -> uniq(filtermap(
214 classLearners, \cl -> if (cl.classId == classId) Some(cl.userId) else None()
215 )))
216 ]);
217}
218
219buildRhapsodeAllLearnersView(state : RhapsodeState<EducatorExtraState>) -> Material {
220 buildCustomRhapsodeClassLearnersTable(state, []);
221}
222
223
224ClassLearnersTableItem(
225 userId : int,
226 firstName : string,
227 lastName : string,
228 email : string,
229 visibleEmail : string,
230 status : LearnerStatus
231);
232
233needCreateClassBeforeNotification(type : ClassLearnersUnionType) -> string {
234 switch (type : ClassLearnersUnionType) {
235 UnionTypeClass() : _("You must create a class before adding users");
236 UnionTypeResearchProject() : _("You must create a research project before adding users");
237 UnionTypeResearchGroup() : _("You must create a research group before adding users");
238 }
239}
240
241dontHaveEditRightsNotification(type : ClassLearnersUnionType) -> string {
242 switch (type : ClassLearnersUnionType) {
243 UnionTypeClass() : _("You don't have edit rights to this class");
244 UnionTypeResearchProject() : _("You don't have edit rights to this research project");
245 UnionTypeResearchGroup() : _("You don't have edit rights to this research group");
246 }
247}
248
249addingLearnerToClassNotification(type : ClassLearnersUnionType) -> string {
250 switch (type : ClassLearnersUnionType) {
251 UnionTypeClass() : _("Adding the Learner to the Class");
252 UnionTypeResearchProject() : _("Adding the Learner to the Research Project");
253 UnionTypeResearchGroup() : _("Adding the Learner to the Research Group");
254 }
255}
256
257invitationLanguageTitle(type : ClassLearnersUnionType) -> string {
258 switch (type : ClassLearnersUnionType) {
259 UnionTypeClass() : _("Select class invitation language:");
260 UnionTypeResearchProject() : _("Select research project invitation language:");
261 UnionTypeResearchGroup() : _("Select research group invitation language:");
262 }
263}
264
265getSkinSelectorTitle(type : ClassLearnersUnionType) -> string {
266 switch (type : ClassLearnersUnionType) {
267 UnionTypeClass() : _("Select class invitation skin:");
268 UnionTypeResearchProject() : _("Select research project invitation skin:");
269 UnionTypeResearchGroup() : _("Select research group invitation skin:");
270 }
271}
272
273addingYourselfNotification(type : ClassLearnersUnionType) -> string {
274 switch (type : ClassLearnersUnionType) {
275 UnionTypeClass() : _("Adding yourself to your class is forbidden");
276 UnionTypeResearchProject() : _("Adding yourself to your research project is forbidden");
277 UnionTypeResearchGroup() : _("Adding yourself to your research group is forbidden");
278 }
279}
280
281addingClassOwnerNotification(type : ClassLearnersUnionType) -> string {
282 switch (type : ClassLearnersUnionType) {
283 UnionTypeClass() : _("Adding a class owner to the class is forbidden");
284 UnionTypeResearchProject() : _("Adding a research project owner to the research project is forbidden");
285 UnionTypeResearchGroup() : _("Adding a research group owner to the research group is forbidden");
286 }
287}
288
289showAddLearnerDialog(
290 state : RhapsodeState<?>,
291 classM : Maybe<RhapsodeClass>,
292 classes : [RhapsodeClass],
293 isRunningB : Transform<bool>,
294 validationStringsB : DynamicBehaviour<[Pair<int, string>]>,
295 styles : [ClassLearnersTableStyle],
296 onClose : () -> void,
297 type : ClassLearnersUnionType,
298 onError : (string) -> void
299) -> void {
300 switch (type : ClassLearnersUnionType) {
301 UnionTypeClass() : showAddLearnerClassDialog(state, classM, classes, isRunningB, validationStringsB, styles, onClose, onError);
302 UnionTypeResearchProject() : showAddLearnerClassDialog(state, classM, classes, isRunningB, validationStringsB, styles, onClose, onError);
303 UnionTypeResearchGroup() : eitherFn(classM, \class -> showAddLearnerResearchGroupDialog(state, class, styles, onClose, onError), \-> onError(_("Research group class wasn't found")));
304 }
305
306}
307
308buildCustomRhapsodeClassLearnersTable(
309 state : RhapsodeState<EducatorExtraState>,
310 styles : [ClassLearnersTableStyle]
311) -> Material {
312 classM = extractStruct(styles, ClassLearnersTableClass(None())).classM;
313 classLearnerModeClassIdM = extractStruct(styles, ClassLearnersTableClassLearnerMode(None())).idM;
314 getLearnerIdsFn = extractStruct(styles, ClassLearnersTableGetLearnerIds(
315 \classLearners -> map(classLearners, \cl -> cl.userId)
316 )).fn;
317 unionType = extractStruct(styles, ClassLearnersTableUnion(UnionTypeClass())).type;
318 rights = extractStruct(styles, ClassLearnersTableRights(true, true, true));
319
320 availableClass = eitherMap(classLearnerModeClassIdM, \id -> !isHiddenClass(state, id), true);
321 classesLearnersB = state.dbState.rhapsodeDbState.rhapsodeClassesLearnersB;
322 println("SSSSS: ");
323 println(classM);
324 usersB = addHidedUsers(
325 state.dbState.oauthDbState.usersB,
326 state.dbState.rhapsodeDbState.rhapsodeClassesLearnersB
327 );
328
329 integrationUsersB = state.extraRhapsodeState.rhapsodeIntegrationUsersB;
330
331 editable = availableClass && (isNone(classLearnerModeClassIdM) || fgetValue(isUpdatableId(state.dbState.rhapsodeDbState.rhapsodeClassesOwnRights, either(classLearnerModeClassIdM, -1))));
332
333 getVisibleEmail = \user, integrationUsers ->
334 eitherMap(find(integrationUsers, \u -> u.userId == user.id), \u -> u.externalUserEmail, user.email);
335
336 onError = \error -> {
337 println(error);
338 showSnackbarError(state, error);
339 };
340 onDelete = \userId -> {
341 classesLearners = switch (unionType : ClassLearnersUnionType) {
342 UnionTypeClass() : {
343 filter(getValue(classesLearnersB), \lr ->
344 availableClass && lr.userId == userId &&
345 eitherMap(classLearnerModeClassIdM, \classId -> lr.classId == classId, true) &&
346 eitherMap(classLearnerModeClassIdM, \__ -> true, !isHiddenClass(state, lr.classId))
347 );
348 };
349 UnionTypeResearchProject() : {
350 project = extractStruct(styles, ClassLearnersTableResearchProject(makeRhapsodeResearchProject())).project;
351 projectGroups = filtermap(getValue(state.dbState.rhapsodeDbState.rhapsodeResearchGroupDataB), \grp ->
352 if (grp.researchProjectId == project.id) {
353 Some(grp.classId)
354 } else None());
355 projectAndGroupClasses = arrayPush(projectGroups, project.classId);
356 filter(getValue(classesLearnersB), \lr ->
357 availableClass && lr.userId == userId &&
358 contains(projectAndGroupClasses, lr.classId)
359 );
360 };
361 UnionTypeResearchGroup() : {
362 grp = extractStruct(styles, ClassLearnersTableResearchGroupData(makeRhapsodeResearchGroupData())).groupData;
363 filter(getValue(classesLearnersB), \lr ->
364 availableClass && lr.userId == userId &&
365 lr.classId == grp.classId
366 );
367 };
368 }
369
370 deleteClassesLearnersWithConfirmation(state, classesLearners,
371 \-> {
372 showSnackbarError(state, _("Learner have been deleted successfully"));
373 switch (unionType : ClassLearnersUnionType) {
374 UnionTypeResearchGroup() : {
375 project = extractStruct(styles, ClassLearnersTableResearchProject(makeRhapsodeResearchProject())).project;
376 if(json2ResearchProjectsGrouping(project.grouping).groupingType == RandomGrouping()){
377 distributeProjectLearnersAmongResearchGroups(state, project.id, onError);
378 }
379 };
380 UnionTypeResearchProject() : {
381 project = extractStruct(styles, ClassLearnersTableResearchProject(makeRhapsodeResearchProject())).project;
382 deleteResearchQuestionnaireResponsesForUser(state.dbConnector, userId, project, onError);
383 };
384 UnionTypeClass() : nop();
385 }
386 iter(classesLearners, \cl -> deleteAssignmentsForUser(state, cl, nop, onError));
387 },
388 onError
389 )};
390 validationStringsB = make([]);
391 getValidationStrings(
392 getAppUrl(),
393 state.userInfo.jwt,
394 Some(map(getValue(classesLearnersB), \l -> l.userId)),
395 \keyPairs -> next(validationStringsB, keyPairs),
396 onError
397 );
398
399 getSignUpLinkM = \userId, validationStrings, email -> maybeBind(
400 find(validationStrings, \vs -> vs.first == userId),
401 \vs -> if (vs.second != "") {
402 Some(
403 buildRhapsodeAppUrl("learner", [
404 KeyValue("lang", getUrlParameter("lang")),
405 KeyValue("s", getUrlParameter("s")),
406 ]) + urlFragment([
407 KeyValue("login", email),
408 KeyValue("vs", vs.second),
409 KeyValue("invited", "1"),
410 ])
411 );
412 } else {
413 None();
414 }
415 );
416
417 copySignUpLink = \userId, email, empty -> MSelect(
418 validationStringsB,
419 \vss -> eitherMap(
420 getSignUpLinkM(userId, vss, email),
421 \link -> MIconButton("link", \-> {
422 setClipboard(link);
423 showMSnackbar(state.manager, _("Sign up url successfully copied to clipboard."), []);
424 }, [], [MTooltipText(const(_("Copy sign-up link")))]),
425 empty
426 )
427 );
428
429 getStatus = \userId -> {
430 progress = getValue(state.extraRhapsodeState.progressB);
431 eitherFn(
432 classLearnerModeClassIdM,
433 \classId -> if (availableClass) getStatusOfClassLearner(progress, classId, userId) else InactiveLearner(),
434 \ -> {
435 filteredProgress = filter(progress, \p -> p.learner.userId == userId && !isHiddenClass(state, p.learner.classId));
436 getStatusOfLearnerForAllClasses(filteredProgress, userId)
437 }
438 )
439 };
440
441 selectedIndexB = make(-1);
442
443 searchFilterB = make("");
444 filterB = make([
445 CustomFilterItem(_("First name"), CFModeNoFilter(), CFStringValue("")),
446 CustomFilterItem(_("Last name"), CFModeNoFilter(), CFStringValue("")),
447 CustomFilterItem(_("E-mail"), CFModeNoFilter(), CFStringValue("")),
448 CustomFilterItem(_("Status"), CFModeNoFilter(), CFStringEnumValue(0,
449 [learnerStatus2String(ActiveLearner()), learnerStatus2String(InactiveLearner()), learnerStatus2String(InvitedLearner())])),
450 ]);
451 //filter should be case insensitive here, so we use toLowerCase
452 isApplicableFilter = \item : ClassLearnersTableItem, curFilter : [CustomFilterItem] -> {
453 filterVal0 : CFStringValue = cast(curFilter[0].value : CustomFilterValue -> CFStringValue);
454 filterVal1 : CFStringValue = cast(curFilter[1].value : CustomFilterValue -> CFStringValue);
455 filterVal2 : CFStringValue = cast(curFilter[2].value : CustomFilterValue -> CFStringValue);
456 filterVal3 : CFStringValue = cast(curFilter[3].value : CustomFilterValue -> CFStringValue);
457 ! isCustomTableFilterOn(curFilter) ||
458 isCustomTableFiltersApplicable(CustomFilterItem(curFilter[0] with value = CFStringValue(filterVal0 with value = toLowerCase(filterVal0.value))), toLowerCase(item.firstName)) &&
459 isCustomTableFiltersApplicable(CustomFilterItem(curFilter[1] with value = CFStringValue(filterVal1 with value = toLowerCase(filterVal1.value))), toLowerCase(item.lastName)) &&
460 isCustomTableFiltersApplicable(CustomFilterItem(curFilter[2] with value = CFStringValue(filterVal2 with value = toLowerCase(filterVal2.value))), toLowerCase(item.email)) &&
461 isCustomTableFiltersApplicable(curFilter[3], learnerStatus2String(item.status))
462 };
463 isApplicableSearch = \item : ClassLearnersTableItem, pattern : string -> {
464 strFilterFn = \value -> pattern == "" || strContains(toLowerCase(value), toLowerCase(pattern));
465 strFilterFn(item.firstName) || strFilterFn(item.lastName) || strFilterFn(item.email)
466 };
467 isFilterOn = fselect(filterB, FLift(\v -> isCustomTableFilterOn(v)));
468 isSearchOn = fselect(searchFilterB, FLift(\v -> v != ""));
469
470 learnerIdsB = fselect(classesLearnersB, FLift(getLearnerIdsFn));
471
472 showAddDialogB = make(false);
473 showEditDialogB = make("");
474 showMoveDialogB = make("");
475 // PebbleParameterLinks
476 pebbleLinks : [PebbleParameterLink] = [
477 makePebbleBoolTrigger(
478 "add_learner",
479 showAddDialogB,
480 \isRunningB, disposer -> {
481 editableClassIds = getValue(state.dbState.rhapsodeDbState.rhapsodeClassesOwnRights.updatableIdsB);
482 educatorClasses = filter(
483 getValue(state.dbState.rhapsodeDbState.rhapsodeClassesB),
484 \cl -> cl.owner == state.userInfo.id || contains(editableClassIds, cl.id)
485 );
486 if (isNone(classM) && educatorClasses == []) {
487 showMSnackbar(
488 state.manager,
489 needCreateClassBeforeNotification(unionType),
490 [MSnackbarAutoDisappear(false)]
491 );
492 disposer();
493 } else if (eitherMap(classM, \class -> !contains(editableClassIds, class.id), false)) {
494 showMSnackbar(
495 state.manager,
496 dontHaveEditRightsNotification(unionType),
497 [MSnackbarAutoDisappear(false)]
498 );
499 disposer();
500 } else {
501 showAddLearnerDialog(
502 state, classM, educatorClasses, isRunningB, validationStringsB,
503 styles, disposer, unionType, \err -> {disposer(); onError(err)})
504 }
505 },
506 RecordURLChange()
507 ),
508 makePebbleStringEditDialogTrigger(
509 state.manager,
510 "edit_learner",
511 showEditDialogB,
512 \user -> i2s(user.id),
513 usersB,
514 \index, user, isRunningB, disposer -> {
515 showEditLearnerClassDialog(state, user, getVisibleEmail(user, getValue(integrationUsersB)), isRunningB, disposer, \err -> {disposer(); onError(err)})
516 },
517 RecordURLChange()
518 ),
519 makePebbleStringEditDialogTrigger(
520 state.manager,
521 "move_learner",
522 showMoveDialogB,
523 \user -> i2s(user.id),
524 usersB,
525 \index, user, isRunningB, disposer -> {
526 showMoveLearnerClassDialog(state, user, getVisibleEmail(user, getValue(integrationUsersB)), isRunningB, disposer, \err -> {disposer(); onError(err)})
527 },
528 RecordURLChange()
529 ),
530 PebbleStringLink("search_learner", searchFilterB, \v -> next(searchFilterB, v), RecordURLChange()),
531 ];
532
533 isInactiveUser = \userId -> {
534 userM = find(fgetValue(usersB), \user -> user.id == userId);
535 !eitherMap(userM, \u -> u.valid, false)
536 }
537
538 updatableClass = const(eitherMap(
539 classLearnerModeClassIdM,
540 \classId -> availableClass && isUpdatableClass(state, classId),
541 true
542 ));
543
544 data2row = \item : ClassLearnersTableItem -> {
545 [
546 MText(item.firstName, [MDataRow()]),
547 MText(item.lastName, [MDataRow()]),
548 MText(item.visibleEmail, [MDataRow()]),
549 MText(learnerStatus2String(item.status), [MDataRow()]),
550 MCols([
551 MFillX(),
552 eitherMap(classM,
553 \__ -> MIconButton(
554 "arrow_forward",
555 \-> nextDistinct(showMoveDialogB, i2s(item.userId)), [],
556 [MTooltipText(const(_("Move to another class"))), MEnabled(fand(updatableClass, const(rights.edit)))]
557 ),
558 MEmpty(),
559 ),
560 MIconButton(
561 "edit",
562 \-> nextDistinct(showEditDialogB, i2s(item.userId)), [],
563 [MTooltipText(const(_("Edit"))), MEnabled(fand(updatableClass, const(rights.edit)))]
564 ),
565 MIconButton(
566 "delete",
567 \-> onDelete(item.userId), [],
568 [MTooltipText(const(_("Delete"))), MEnabled(fand(updatableClass, const(rights.delete)))]
569 ),
570 MIf(
571 const(isInactiveUser(item.userId)),
572 copySignUpLink(item.userId, item.email, TFixed(32.0, 32.0)),
573 TFixed(32.0, 32.0)
574 )
575 ])
576 ]
577 };
578
579 dummyItem = ClassLearnersTableItem(0, "", "", "", "", InvitedLearner());
580
581 makeTable =\ -> MSelect(state.extraRhapsodeState.isLoadedB, \isLoaded -> if (isLoaded) {
582 filteredData = fselect5(
583 learnerIdsB, filterB, searchFilterB, usersB, integrationUsersB,
584 \learnerIds, filedFilter, searchFilter, users, integrationUsers -> {
585 filtermap(uniq(learnerIds), \userId -> {
586 userM = find(users, \u -> u.id == userId);
587 eitherMap(userM, \user -> {
588 item = ClassLearnersTableItem(userId, user.firstName, user.lastName, user.email, getVisibleEmail(user, integrationUsers), getStatus(userId));
589 if (!isHiddenLearner(state, userId) && isApplicableFilter(item, filedFilter) && isApplicableSearch(item, searchFilter)) Some(item) else None();
590 }, None())
591 });
592 }
593 );
594
595 onClick = \index -> {
596 item = elementAt(fgetValue(filteredData), index, dummyItem);
597 setViewFromPebble(
598 state.pebbleController,
599 extendCurrentPebbleWithPathPart(state.pebbleController,
600 PathPart("learner", [KeyValue("t", "progress"), KeyValue("learner", i2s(item.userId))]))
601 )
602 };
603
604 MDynamicDataTable(
605 [
606 MColumn(_("First name"), "", 144, [MAutoSort()]),
607 MColumn(_("Last name"), "", 144, [MAutoSort()]),
608 MColumn(_("E-mail"), "", 200, [MAutoSort()]),
609 MColumn(_("Status"), "", 70, [MAutoSort()]),
610 MColumn("", "", 96, [MAutoSort()])
611 ],
612 fmap(filteredData, data2row),
613 [
614 MCondensed(true),
615 MPaginationAutoScaling(),
616 MFullWidthAdvanced(),
617 MSingleSelection(selectedIndexB),
618 MOnListClick(fneq(selectedIndexB, -1), onClick),
619 MHeaderActions(MBaselineColsA([
620 TFillX(),
621 if (getUrlParameter("importkey") != "") {
622 eitherMap(classM, \class ->
623 MIconButton("playlist_add", \ -> addUsersToClassFromCCL(state, class, onError),
624 [], [MTooltipText(const(_("Import CCL user")))]),
625 MEmpty()
626 )
627 } else MEmpty(),
628 MVisible(fand(updatableClass, const(rights.add)), MIconButton(
629 "add", \-> nextDistinct(showAddDialogB, true), [MIconButtonBorder(1.)], []
630 )),
631 MFixedX(8.0),
632 MSelect(isSearchOn, \search -> MIconButton(
633 "search",
634 \ -> simpleSearchDialog(state.manager, searchFilterB),
635 [MIconButtonBorder(1.), if (search) MRedA(900) else MGrey(900)],
636 []
637 )),
638 MFixedX(8.0),
639 MSelect(isFilterOn, \filterOn -> MIconButton(
640 "filter_list",
641 \ -> buildCustomFilterDialog(state.manager, filterB),
642 [MIconButtonBorder(1.), if (filterOn) MRedA(900) else MGrey(900)],
643 []
644 )),
645 MSelect2(
646 validationStringsB,
647 filteredData,
648 \vss, ld -> if (vss != [] && ld != []) {
649 MCols2(
650 MFixedX(8.0),
651 MIconButton("file_download", \-> {
652 csvContent = makeCsvFile(
653 map(ld, \l -> [l.firstName, l.lastName, l.email, either(getSignUpLinkM(l.userId, vss, l.email), "")])
654 );
655 downloadContentAsFile(state.userInfo.jwt, "Learners.csv", csvContent, false, nop1, nop1);
656 }, [MIconButtonBorder(1.)], [])
657 );
658 } else {
659 MEmpty();
660 }
661 )
662 ]), []),
663 ]
664 )
665 } else {
666 MCenter(MProgressCircle([]))
667 });
668
669 MLinkPebbleParameters(
670 state.pebbleController,
671 pebbleLinks,
672 MIfLazy(
673 fselect(learnerIdsB, FLift(\arr -> arr == [])),
674 \noLearners -> if (noLearners) {
675 MBorder(10.0, 32.0, 0.0, 0.0, MTextButton(_("ADD LEARNER"), \-> nextDistinct(showAddDialogB, true), [], []));
676 } else {
677 MLines2(makeCustomTableHeader(_("My Rhapsode Learners")), MBorderA(16.0, 16.0, 16.0, 0.0, MAlignStart(makeTable())));
678 }
679 )
680 );
681}
682
683LearnerMailInfo(
684 firstName : string,
685 lastName : string,
686 email : string,
687 jwtOrKey : string,
688 isNew : bool
689);
690
691scheduleInvitationMails(
692 userId : int,
693 jwt : string,
694 params : [SendMailParameters],
695 subj : string,
696 msgPattern : string,
697 requestParams : [KeyValue],
698 onOK : ([int]) -> void,
699 onError : (string) -> void
700) -> void {
701 trimmedParams = map(requestParams, \p -> Pair(p.key, JsonString(trim(p.value))));
702
703 insertRhapsodeScheduledEmailArray(
704 jwt,
705 map(params, \p -> {
706 RhapsodeScheduledEmail(
707 -1,
708 userId,
709 JsonObject(
710 concat(
711 trimmedParams,
712 [
713 Pair("operation", JsonString("sendScheduledMail")),
714 Pair("params", JsonArray([JsonObject([
715 Pair("email", JsonString(trim(p.email))),
716 Pair("args", JsonArray(map(p.msgArgs, \arg -> JsonString(arg))))
717 ])])),
718 Pair("subj", JsonString(subj)),
719 Pair("msg", JsonString(msgPattern))
720 ]
721 )
722 ),
723 RhapsodeScheduledEmailStatusPending(),
724 0,
725 ""
726 )
727 }),
728 onOK,
729 onError
730 )
731}
732
733notifyLearnerClassUsers(
734 state : RhapsodeState<?>,
735 class : RhapsodeClass,
736 learners : [LearnerMailInfo],
737 language : string,
738 skinId : string,
739 unionType : string,
740 onDone : (results : [string]) -> void
741) -> void {
742 appName0 = getIssuerFromJWT(state.userInfo.jwt);
743 appName = if (appName0 == "rhapsode") appTitle else appName0;
744
745 onError = \e -> {
746 println(e);
747 showMSnackbar(state.manager, e, [MSnackbarAutoDisappear(false)]);
748 onDone([e])
749 };
750
751 loadMailTemplatesByPurposes(
752 state.userInfo.jwt,
753 if (isUrlParameterResearch()) {
754 ["Rhapsode_SubscribedToUnionNewUser", "Rhapsode_SubscribedToUnion"]
755 } else {
756 ["Rhapsode_SubscribedToClassNewUser", "Rhapsode_SubscribedToClass"]
757 },
758 classOptions2EmailParams(class),
759 \mailTemplates : [MailTemplate] -> {
760 templateTree = pairs2tree(map(mailTemplates, \t -> Pair(t.purpose, t)));
761
762 mkSendMailParamsM = \l, link -> Some(SendMailParameters(l.email, [l.firstName, l.lastName, link]));
763 newLearnerParams = filtermap(learners, \l -> {
764 if (l.isNew) mkSendMailParamsM(l, "#login=" + l.email + "&vs=" + l.jwtOrKey + "&invited=1") else None()
765 });
766 existedLearnerParams = filtermap(learners, \l -> if (!l.isNew) mkSendMailParamsM(l, "#home&login=" + l.email) else None());
767
768 commonParams = [
769 "app_name", appName,
770 "union_type", unionType,
771 "class", "<strong>'" + class.name + "'</strong>",
772 "url", buildRhapsodeAppUrl("learner", [
773 KeyValue("lang", language),
774 KeyValue("s", skinId)
775 ]) + "%3",
776 "user_first_name", " %1",
777 "user_last_name", " %2",
778 "user_name", " %1 %2"
779 ];
780
781 scheduledEmailsExtraParams = ifArrayPush([KeyValue("customsender", appTitle)], skinId != "", KeyValue("skin", skinId));
782 newUsersP = Promise(\fulfill, reject -> {
783 if (length(newLearnerParams) > 0) {
784 templateName = if (isUrlParameterResearch()) "Rhapsode_SubscribedToUnionNewUser"
785 else "Rhapsode_SubscribedToClassNewUser";
786 eitherFn(
787 lookupTree(templateTree, templateName),
788 \mailTemplate -> {
789 scheduleInvitationMails(
790 state.userInfo.id,
791 state.userInfo.jwt,
792 newLearnerParams,
793 blueprint(mailTemplate.subject, commonParams),
794 blueprint(mailTemplate.html, commonParams),
795 scheduledEmailsExtraParams,
796 fulfill,
797 reject
798 );
799 },
800 \-> reject(_("Couldn't load mail template for new users"))
801 );
802 } else fulfill([])
803 });
804 existedUsersP = Promise(\fulfill, reject -> {
805 if (length(existedLearnerParams) > 0) {
806 eitherFn(
807 lookupTree(templateTree, "Rhapsode_SubscribedToClass"),
808 \mailTemplate -> scheduleInvitationMails(
809 state.userInfo.id,
810 state.userInfo.jwt,
811 existedLearnerParams,
812 blueprint(mailTemplate.subject, commonParams),
813 blueprint(mailTemplate.html, commonParams),
814 scheduledEmailsExtraParams,
815 fulfill,
816 reject
817 ),
818 \-> reject(_("Couldn't load mail template for existed users"))
819 );
820 } else fulfill([])
821 });
822
823 doneP(allP([newUsersP, existedUsersP]), \results -> onDone(map(concatA(results), \__ -> "OK")), onError);
824 },
825 onError
826 )
827}
828
829classOptions2EmailParams(class : RhapsodeClass) -> [KeyValue] {
830 classOptions = json2RhapsodeClassOptions(class.classOptions).options;
831 [
832 KeyValue("skin", extractStruct(classOptions, RCOEmailSkin(0, "")).hash),
833 KeyValue("lang", extractStruct(classOptions, RCOEmailLang("")).lang)
834 ];
835}
836
837getSkinIdFromClass(class : RhapsodeClass) -> string {
838 classOptions = json2RhapsodeClassOptions(class.classOptions).options;
839 classSkin = extractStruct(classOptions, RCOEmailSkin(0, "")).hash;
840 urlSkin = getSkinHash();
841 if (urlSkin == "") classSkin else urlSkin;
842}
843
844showAddLearnerResearchGroupDialog(
845 state : RhapsodeState<?>,
846 groupClass : RhapsodeClass,
847 styles : [ClassLearnersTableStyle],
848 onClose : () -> void,
849 onError : (string) -> void
850) -> void {
851 allAvailableUserEmails = extractStruct(styles, ClassLearnersTableAllAvailableUsers([])).emails;
852 learnerIds = filtermap(getValue(state.dbState.oauthDbState.usersB), \usr -> if (contains(allAvailableUserEmails, usr.email)) Some(usr.id) else None());
853 isOpenDialogB = make(true);
854 selectedLearnersB = make(buildSet(mapi(learnerIds, \i, __ -> i)));
855 projectId = s2i(findCurrentPebbleParameter(state.pebbleController, "research_project", "research_project", ""));
856 project : RhapsodeResearchProject = findDef(getValue(state.dbState.rhapsodeDbState.rhapsodeResearchProjectsB), \p -> p.id == projectId, makeRhapsodeResearchProject());
857
858 onClose2 = \ -> {
859 nextDistinct(isOpenDialogB, false);
860 onClose();
861 };
862
863 showDialog = \columns, rows -> {
864 ShowMDialog(
865 state.manager,
866 isOpenDialogB,
867 [MDialogUseFrame(), MDialogScroll(), MDialogActions([
868 MTextButton(_("CANCEL"), \-> onClose2(), [], [MShortcut("esc")]),
869 MTextButton(
870 _("SAVE"),
871 \-> {
872 selectedArr = set2array(getValue(selectedLearnersB));
873 classesLearners = map(selectedArr, \l -> RhapsodeClassesLearner(makeRhapsodeClassesLearner() with classId = groupClass.id, userId = learnerIds[l], creator = state.userInfo.id));
874 insertRhapsodeClassesLearnerArray(state.dbConnector, classesLearners, \__ -> onClose2(), [DbErrorRecovery(FailOnDbError(onError))]);
875 },
876 [MButtonRaised()],
877 [MShortcut("enter")])
878 ])],
879 MLines([
880 MText(_("Add learners into research group"), [MCustomFont(18.0, "Roboto", 1.0)]) |> MBorderBottom(8.0),
881 MDataTable(
882 columns,
883 rows,
884 [
885 MCondensed(true),
886 MNoFooter(),
887 MCheckBoxSelection([]),
888 MMultiSelection(selectedLearnersB),
889 ],
890 )
891 ])
892 );
893 };
894 makeRows = \questionnaireVariables, questionaireResponses, attributesFromUsersSocial, socials -> {
895 filtermap(allAvailableUserEmails, \email -> {
896 eitherMap(
897 find(getValue(state.dbState.oauthDbState.usersB), \user -> user.email == email),
898 \user -> {
899 userRows = [
900 MGroup2(MText(user.email, [MDataRow()]), MFixedX(80.0)),
901 MGroup2(MText(user.lastName, [MDataRow()]), MFixedX(80.0)),
902 MGroup2(MText(user.firstName, [MDataRow()]), MFixedX(80.0))
903 ];
904 userResponses = json2ResearchQuestionnaireResponses(
905 findDef(
906 questionaireResponses,
907 \r -> r.userId == user.id,
908 makeRhapsodeResearchQuestionnaireResponse()
909 ).response
910 ).responses;
911 questionnaireRows = map(
912 questionnaireVariables,
913 \var -> {
914 responseValue = findDef(
915 userResponses,
916 \r -> r.variable == var,
917 makeDefaultResearchQuestionnaireResponse()
918 ).value;
919 MGroup2(MText(responseValue, [MDataRow()]), MFixedX(80.0));
920 }
921 );
922 userSocialsClaims = if (length(socials) > 0) {
923 concatA(filtermap(socials, \social -> {
924 if (social.userId == user.id) {
925 Some(json2Claims(social.claims).value);
926 } else None()
927 }))
928 } else [];
929
930 userSocialRows = map(attributesFromUsersSocial, \aus -> {
931 claimValue = findDef(userSocialsClaims, \claim -> claim.id == aus, makeDefaultClaim()).value;
932 MGroup2(MText(claimValue, [MDataRow()]), MFixedX(80.0));
933 });
934 Some(concat3(userRows, questionnaireRows, userSocialRows))
935 },
936 None()
937 )
938 });
939 };
940 getDataForTable = \responses : [RhapsodeResearchQuestionnaireResponse], questionnaire : RhapsodeResearchQuestionnaire -> {
941 questionaireResponses = filter(responses, \r -> r.researchQuestionnaireId == either(project.preliminaryQuestionnaireId, 0));
942 reportAttributes : [ResearchProjectsReportAttribute] = json2ResearchProjectsReportAttributes(project.reportAttributes).attributes;
943 questionnaireContent = questionnaire.content;
944 doc = string2wigiQuick(json2ResearchQuestionnaireContent(questionnaireContent).questions);
945 engine = dummyWigiEngineInitialized();
946 callback = registerToWigiEngineRendering(state.manager, doc, engine, []);
947 questionnaireVariables = getFilteredEnvVariables(engine);
948 callback();
949 questionnaireColumns = map(questionnaireVariables, \v -> {
950 MColumn(_(v), "", 100, [MWidthByContent()])
951 });
952 attributesFromUsersSocial = filtermap(reportAttributes, \ra : ResearchProjectsReportAttribute -> {
953 if (contains(questionnaireVariables, ra.id)) None() else Some(ra.id)
954 });
955 userSocialColumns = map(attributesFromUsersSocial, \attr -> {
956 MColumn(_(attr), "", 100, [MWidthByContent()])
957 });
958 columns =
959 concat3(
960 [
961 MColumn(_("Email"), "", 100, [MAutoSort(), MWidthByContent()]),
962 MColumn(_("Last name"), "", 100, [MWidthByContent()]),
963 MColumn(_("First name"), "", 100, [MWidthByContent()]),
964 ],
965 questionnaireColumns,
966 userSocialColumns
967 );
968 if (length(attributesFromUsersSocial) > 0) {
969 loadUsersSocials(state.userInfo.jwt, \socials -> {
970 showDialog(columns, makeRows(questionnaireVariables, questionaireResponses, attributesFromUsersSocial, socials));
971 },
972 onError)
973 } else {
974 showDialog(columns, makeRows(questionnaireVariables, questionaireResponses, attributesFromUsersSocial, []));
975 }
976 };
977
978 onResponsesLoaded = \responses : [RhapsodeResearchQuestionnaireResponse] -> {
979 loadResearchQuestionnaireById(state, either(project.preliminaryQuestionnaireId, 0), \q -> getDataForTable(responses, q) , onError);
980 };
981
982 loadRhapsodeResearchQuestionnaireResponsesByResearchProjectId(state.dbConnector, projectId, onResponsesLoaded, [DbErrorRecovery(FailOnDbError(onError))]);
983}
984
985showAddLearnerClassDialog(
986 state : RhapsodeState<?>,
987 classM : Maybe<RhapsodeClass>, //None() - need class selector
988 classes : [RhapsodeClass], //educator's classes
989 isRunningB : Transform<bool>,
990 validationStringsB : DynamicBehaviour<[Pair<int, string>]>,
991 styles : [ClassLearnersTableStyle],
992 onClose : () -> void,
993 onError : (string) -> void
994) -> void {
995 allAvailableUserEmails = extractStruct(styles, ClassLearnersTableAllAvailableUsers(
996 filtermap(getValue(state.dbState.oauthDbState.usersB), \user ->
997 if (isActiveUserForClass(user)) Some(user.email) else None()
998 )
999 )).emails;
1000 unionType = extractStruct(styles, ClassLearnersTableUnion(UnionTypeClass())).type;
1001
1002 classesLearners = getValue(state.dbState.rhapsodeDbState.rhapsodeClassesLearnersB);
1003 existingUsers = filter(getValue(state.dbState.oauthDbState.usersB), \p -> p.active);
1004
1005 findEducatorClass = \name -> find(classes, \cl -> cl.owner == state.userInfo.id && cl.name == name);
1006
1007 classNameB = make(eitherMap(classM, \c -> c.name, ""));
1008 classUserEmailsB = fselect(classNameB, FLift(\className -> {
1009 classId = eitherFn(classM, \class -> class.id, \ -> eitherMap(findEducatorClass(className), \c -> c.id, -1));
1010 filtermap(classesLearners, \p -> {
1011 if (p.classId == classId) {
1012 eitherMap(
1013 find(existingUsers, \u -> u.id == p.userId),
1014 \u -> Some(u.email),
1015 None()
1016 )
1017 } else None()
1018 })
1019 }));
1020
1021 availableUsersEmailsB = fselect(classUserEmailsB, FLift(\cuArr -> {
1022 subtractA(allAvailableUserEmails, arrayPush(cuArr, state.userInfo.email));
1023 }));
1024
1025 emailB = make("");
1026 standardEmailB = fselect(emailB, FLift(\e -> toLowerCase(trim(e))));
1027 firstNameB = make("");
1028 lastNameB = make("");
1029 sendEmailB = make(true);
1030 enabledB = make(false);
1031 emailFieldFocusB = make(false);
1032
1033 langIndexB = make(-1);
1034 langIsEmptyB = feq(langIndexB, -1);
1035 invitationReviewedB = make(false);
1036 invitationSettingOkB = fand(fnot(langIsEmptyB), invitationReviewedB);
1037
1038 currentUserM = fselect(
1039 standardEmailB,
1040 FLift(\email -> find(
1041 existingUsers,
1042 \user -> user.email == email && (contains(allAvailableUserEmails, user.email) || !isSameStructType(unionType, UnionTypeResearchGroup()))
1043 )
1044 )
1045 );
1046
1047 isPresent = fselect(currentUserM, FLift(isSome));
1048
1049 isClassOwnerEmailB = fselect2(currentUserM, classNameB, FLift2(\userM, className -> {
1050 eitherMap(userM, \user -> {
1051 class = eitherFn(classM, idfn, \ -> eitherFn(findEducatorClass(className), idfn, makeRhapsodeClass));
1052 user.id == class.owner
1053 }, false)
1054 }));
1055
1056 selectedClassInxB = make(0);
1057
1058 classnames = map(classes, \cl -> cl.name);
1059 selectedClassB = fselect2(
1060 state.dbState.rhapsodeDbState.rhapsodeClassesB,
1061 selectedClassInxB,
1062 FLift2(\allClasses, cInx -> {
1063 maybeBind(
1064 eitherMap(classM, \cl -> Some(cl), elementAtM(classes, cInx)),
1065 \selectedClass -> find(allClasses, \cl -> cl.id == selectedClass.id)
1066 )
1067 })
1068 );
1069 skinFromUrl = getSkinHash();
1070 isFirstNameValid = fselect2(firstNameB, isPresent, FLift2(\fn, pr -> pr || (fn != "" || skinFromUrl != "")));
1071 isLastNameValid = fselect2(lastNameB, isPresent, FLift2(\ln, pr -> pr || (ln != "" || skinFromUrl != "")));
1072 isEmailValid = fmin(
1073 fselect2(standardEmailB, classUserEmailsB,
1074 FLift2(\email, cuEmails -> isEmail(email) && !contains(cuEmails, email) && email != state.userInfo.email)
1075 ),
1076 fnot(isClassOwnerEmailB)
1077 );
1078 isClassValid = fneq(selectedClassB, None());
1079 isValid = fminA([isClassValid, isEmailValid, isFirstNameValid, isLastNameValid], false);
1080
1081 makeInputError = \elemError -> {
1082 MInputError(fselect(elemError, FLift(\err -> if (err == "") None() else Some(Pair(err, true)))), []);
1083 };
1084
1085 skinHashFileB = make(makeDefaultSkinFileInfo());
1086 isOpenDialogB = make(true);
1087
1088 saveClassOptions = \callback -> maybeApply(fgetValue(selectedClassB), \class -> {
1089 visibleSnackbar = make(true);
1090 callback2 = \v -> {nextDistinct(visibleSnackbar, false); callback(v)}
1091 onError2 = \e -> {nextDistinct(visibleSnackbar, false); onError(e)}
1092 // close dialog
1093 nextDistinct(isOpenDialogB, false);
1094 showMSnackbar(
1095 state.manager,
1096 addingLearnerToClassNotification(unionType),
1097 [MSnackbarAutoDisappear(false), MSnackbarVisible(visibleSnackbar)]
1098 );
1099
1100 classOpts = json2RhapsodeClassOptions(class.classOptions).options;
1101 langNewOpt = RCOEmailLang(elementAt(elementAt(languages, getValue(langIndexB), []), 1, ""));
1102 skinCurrentOpt = extractStruct(classOpts, RCOEmailSkin(0, ""));
1103
1104 updateClass = \newOpts -> {
1105 if (class.classOptions != newOpts) {
1106 classWithNewOpts = RhapsodeClass(class with classOptions = newOpts);
1107 updateRhapsodeClass(state.dbConnector, state.dbState.rhapsodeDbState.rhapsodeClassesB,
1108 classWithNewOpts, \-> callback2(classWithNewOpts), [DbRequestNotOK(\__ -> callback2(classWithNewOpts))]
1109 );
1110 } else {
1111 callback2(class)
1112 }
1113 }
1114
1115 if (skinFromUrl != "") {
1116 updateClass(RhapsodeClassOptions2json(RhapsodeClassOptions(
1117 replaceStructMany(classOpts, [langNewOpt, skinCurrentOpt])
1118 )))
1119 } else {
1120 skinHashFile = getValue(skinHashFileB);
1121 eitherFn(skinHashFile.fileM,
1122 \file ->
1123 makeFileSharedHashPreload(state.userInfo.jwt, file, \hash ->
1124 updateClass(RhapsodeClassOptions2json(RhapsodeClassOptions(
1125 replaceStructMany(classOpts, [langNewOpt, RCOEmailSkin(file.id, hash)])))),
1126 onError2
1127 ),
1128 \ ->
1129 updateClass(RhapsodeClassOptions2json(RhapsodeClassOptions(
1130 replaceStructMany(classOpts, [langNewOpt, RCOEmailSkin(0, skinHashFile.hash)])))),
1131 );
1132 }
1133 });
1134
1135 makeClassDD = \ -> {
1136 emptyList = classes == [];
1137 MConstruct([
1138 makeSubscribe(selectedClassInxB, \inx -> nextDistinct(classNameB, if (emptyList) "" else classnames[inx]))],
1139 MBorder(0.0, 16., 0.0, 20.0, MDropDown(selectedClassInxB, _("EMPTY CLASS LIST"), classnames, []))
1140 )
1141 };
1142
1143 makeLangPicker = \-> {
1144 urlLangM = if (getUrlParameter("lang") != "") Some(getUrlParameter("lang")) else None();
1145
1146 langsToLines = \langs -> map(langs, \l -> MMenuSingleLine(
1147 elementAt(l, 0, ""),
1148 []
1149 ));
1150
1151 langPicker = if (!isNone(urlLangM)) {
1152 MText(_("Language from URL will be used"), [])
1153 } else {
1154 MLines([
1155 MText(invitationLanguageTitle(unionType), []),
1156 TFixed(0., 12.),
1157 MLanguageSelector(langIndexB, [MWidth(256.0)]),
1158 MPad(8., 8., MShow(langIsEmptyB, MText(_("Language cannot be empty"), [MCaptionSolid(), MRed(550)])))
1159 ])
1160 };
1161
1162 classSkinOptsB = make(RCOEmailSkin(0, ""));
1163 fsPartitionsB = make([]);
1164
1165 skinPicker = if (skinFromUrl != "") {
1166 MText(_("Skin from URL will be used"), [])
1167 } else {
1168 MSelect2(fsPartitionsB, classSkinOptsB, \partitionIds, skinOpt -> {
1169 buildSkinSelector(Some(partitionIds), skinOpt.hash, skinOpt.fileId, skinHashFileB, make(false),
1170 [
1171 SkinSelectorTitle(getSkinSelectorTitle(unionType)),
1172 SkinSelectorDefaultLabel(_("Skin by ID / Default")),
1173 SkinSelectorInputLabel(_("Copy ID Here (Leave Empty for Default)"))
1174 ],
1175 onError);
1176 });
1177 };
1178
1179 MConstruct(
1180 [
1181 makeSubscribe(selectedClassB, \selectedClassM -> maybeApply(selectedClassM, \class -> {
1182 classOptions = json2RhapsodeClassOptions(class.classOptions).options;
1183
1184 classLangTagValue = extractStruct(classOptions, RCOEmailLang("")).lang;
1185 classLangM = if (classLangTagValue != "") Some(classLangTagValue) else None();
1186
1187 classPackages = filter(getValue(state.dbState.rhapsodeDbState.rhapsodePackagesB), \p -> {
1188 isPackageAddedToClass(getValue(state.dbState.rhapsodeDbState.rhapsodeClassesPackagesB), class.id, p.id);
1189 });
1190
1191 if (skinFromUrl == "") {
1192 partitions = getOrgPartitionsForProjects(state, map(classPackages, \p -> getProjectIdFromPackage(state, p, true)));
1193 nextDistinct(fsPartitionsB, partitions);
1194 nextDistinct(classSkinOptsB, extractStruct(classOptions, RCOEmailSkin(0, "")));
1195 }
1196
1197 getLangFromPackageTags = \p -> findmap(
1198 json2RhapsodeTags(p.tags).tags,
1199 \tag -> switch (tag) {
1200 ContentLanguage(lang) : if (lang != "") Some(lang) else None();
1201 default : None();
1202 }
1203 );
1204 langFromPackageTagsM = findmap(classPackages, getLangFromPackageTags);
1205 iter(
1206 [classLangM, langFromPackageTagsM, urlLangM],
1207 \ml -> {
1208 maybeApply(ml, \lt -> maybeApply(
1209 findi(languages, \l -> elementAt(l, 1, "") == lt),
1210 \i -> nextDistinct(langIndexB, i)
1211 ));
1212 }
1213 );
1214 }))
1215 ],
1216 MPad(0., 16., MLines([
1217 MText(_("Invitation Settings"), [MSubheading()]),
1218 MPad(8., 16., MLines([
1219 langPicker,
1220 skinPicker |> MBorderTop(16.),
1221 ])),
1222 MCheckBox(
1223 MText(_("I Have Reviewed the Invitation Settings"), []),
1224 invitationReviewedB,
1225 [MButtonTitle(const("I Have Reviewed the Invitation Settings"))]
1226 ) |> MBorderTop(16.),
1227 MGroup2(
1228 TFixed(0., 16.),
1229 MShow(fnot(invitationReviewedB), MText(_("Please check this before continue"), [MBody(), MRed(550)]))
1230 |> MBorderLeft(16.0)
1231 )
1232 ]))
1233 )
1234 };
1235
1236 languageB : Transform<string> = {
1237 pickerLangB = fselect(langIndexB, FLift(\i -> elementAtMap(
1238 languages,
1239 i,
1240 \langarr -> elementAt(langarr, 1, ""),
1241 ""
1242 )));
1243 fselect(pickerLangB, FLift(\pickerLang -> getUrlParameterDef("lang", pickerLang)));
1244 };
1245
1246 ShowMDialog(
1247 state.manager,
1248 fand(isOpenDialogB, isRunningB),
1249 [MDialogUseFrame(), MDialogScroll(), MDialogActions([
1250 MTextButton(_("CANCEL"), \-> saveClassOptions(\__ -> onClose()), [], [MShortcut("esc")]),
1251 MTextButton(_("SAVE"), \-> saveClassOptions(\class -> {
1252 errorFn = \e -> {
1253 ShowMConfirmation(state.manager, _("Error"), _("OK"), "Enter", MText(e, []));
1254 println(e);
1255 };
1256
1257 baseUrl = getAppUrl();
1258 newUserEmail = fgetValue(standardEmailB);
1259 newUserFirstName = getValue(firstNameB);
1260 newUserLastName = getValue(lastNameB);
1261
1262 createUserInvitations(
1263 baseUrl,
1264 state.userInfo.jwt,
1265 [Triple(newUserEmail, newUserFirstName, newUserLastName)],
1266 [],
1267 \results -> {
1268 insertUserFn = \userId -> insertRhapsodeClassesLearners(
1269 state,
1270 [RhapsodeClassesLearner(-1, class.id, userId, getCurrentTime(), state.userInfo.id, "")],
1271 \newClassLearners -> {
1272 eitherFn(
1273 if (newClassLearners == []) None() else find(getValue(state.dbState.oauthDbState.usersB), \u -> u.id == newClassLearners[0].userId),
1274 \addedUser -> {
1275 if (addedUser.firstName == newUserFirstName && addedUser.lastName == newUserLastName) {
1276 showMSnackbar(state.manager, _("Successfully added user: ") + newUserEmail, []);
1277 }
1278 else {
1279 showMSnackbar(state.manager, _("User already exists. Existing name can't be changed."), [MSnackbarAutoDisappear(false)]);
1280 }
1281 },
1282 \ -> errorFn(_("Adding user failed"))
1283 );
1284 },
1285 errorFn
1286 );
1287 notifyLearnerFn = \user, isNew, key -> {
1288 sendEnrollmentEmailToLearner(
1289 state.userInfo.jwt,
1290 user,
1291 class,
1292 if (isNew) Some(key) else None(),
1293 getSkinIdFromClass(class),
1294 fgetValue(languageB),
1295 classLearnersUnionTypeToString(unionType),
1296 \ -> showMSnackbar(state.manager, formatString(_("Email sent: %1"), [user.email]), []),
1297 errorFn
1298 )
1299 };
1300
1301 res = elementAt(results, 0, UserInvitationResult(_("Request error"), 0, "", "", "", false, false));
1302 if (res.error == "") {
1303 insertUserFn(res.id);
1304 newUser = User(makeUser() with
1305 id = res.id,
1306 email = newUserEmail,
1307 firstName = res.firstName,
1308 lastName = res.lastName,
1309 valid = res.isValid
1310 );
1311
1312 if (!exists(getValue(state.dbState.oauthDbState.usersB), \u -> u.id == newUser.id && u.email == newUser.email)) {
1313 dynArrayPush(state.dbState.oauthDbState.usersB, newUser)
1314 };
1315
1316 if (getValue(sendEmailB)) {
1317 if (res.isNew) {
1318 notifyLearnerFn(newUser, true, res.jwtOrKey);
1319 getValidationStrings(
1320 baseUrl,
1321 state.userInfo.jwt,
1322 Some([newUser.id]),
1323 \keyPair -> {
1324 next(validationStringsB, concat(getValue(validationStringsB), keyPair))
1325 },
1326 onError
1327 )
1328 } else if (newUser.valid) {
1329 notifyLearnerFn(newUser, false, "")
1330 } else {
1331 getValidationStrings(
1332 baseUrl,
1333 state.userInfo.jwt,
1334 Some([newUser.id]),
1335 \keyPair -> {
1336 notifyLearnerFn(newUser, true, firstElement(keyPair, Pair(newUser.id, "")).second);
1337 next(validationStringsB, concat(getValue(validationStringsB), keyPair))
1338 },
1339 \__ -> onError(formatString(_("An error occurred while sending email for %1"), [newUser.email]))
1340 )
1341 }
1342 }
1343 } else {
1344 errorFn(formatString(_("Invitation error: %1"), [res.error]));
1345 }
1346 },
1347 true
1348 );
1349 onClose();
1350 }), [MButtonRaised()], [MShortcut("enter"), MEnabled(
1351 fand(isValid, fOr(fnot(sendEmailB), fand(sendEmailB, invitationSettingOkB)))
1352 )])
1353 ])],
1354 MConstruct(
1355 [
1356 makeSubscribe(currentUserM, \eu -> switch (eu) {
1357 Some(user): {
1358 nextDistinct(firstNameB, user.firstName);
1359 nextDistinct(lastNameB, user.lastName);
1360 };
1361 None(): {
1362 nextDistinct(firstNameB, "");
1363 nextDistinct(lastNameB, "");
1364 }
1365 })
1366 ],
1367 MLines([
1368 MText(_("Add Learner"), [MTitle()]),
1369 MFixedY(16.0),
1370 if (isNone(classM)) makeClassDD() else TEmpty(),
1371 MBorder(-8.0, 0.0, -8.0, 0.0,
1372 MSelect(availableUsersEmailsB, \availableUsersEmails ->
1373 MAutoComplete(
1374 emailB,
1375 availableUsersEmails,
1376 [
1377 MWidth(fieldsWidth + 16.0),
1378 MShowDropDownArrow(true),
1379 MShowUnderline(true),
1380 MShowAllOnEmpty(),
1381 MMaxResults(5),
1382 MLabel(_("Email"))
1383 ],
1384 [
1385 makeInputError(fselect3(isEmailValid, emailFieldFocusB, isClassOwnerEmailB, \ev, focus, isClassOwnerEmail -> {
1386 if (focus || ev || fgetValue(standardEmailB) == "") ""
1387 else if (fgetValue(standardEmailB) == state.userInfo.email) addingYourselfNotification(unionType)
1388 else if (isClassOwnerEmail) addingClassOwnerNotification(unionType)
1389 else _("Invalid email or user already added")
1390 })),
1391 FAccessAttribute("name", const("email")),
1392 TTextInputFocus(emailFieldFocusB)
1393 ]
1394 )
1395 )
1396 ),
1397 MTextInput(firstNameB, [MLabel(_("First Name")), MWidth(fieldsWidth)], [MEnabled(fnot(ftransistor(fnot(emailFieldFocusB), isPresent))), makeInputError(
1398 fselect(ftransistor(fnot(emailFieldFocusB), isFirstNameValid), FLift(\fnv -> if (fnv) "" else _("First name cannot be empty")))
1399 ), FAccessAttribute("name", const("firstname"))]),
1400 MFixedY(16.0),
1401 MTextInput(lastNameB, [MLabel(_("Last Name")), MWidth(fieldsWidth)], [MEnabled(fnot(ftransistor(fnot(emailFieldFocusB), isPresent))), makeInputError(
1402 fselect(ftransistor(fnot(emailFieldFocusB), isLastNameValid), FLift(\fnv -> if (fnv) "" else _("Last name cannot be empty")))
1403 ), FAccessAttribute("name", const("lastname"))]),
1404 MFixedY(20.0),
1405 MCheckBox(emailCheckboxText(), sendEmailB, []),
1406 makeLangPicker(),
1407 MFixedY(32.0),
1408 MBaselineCols([
1409 MText(_("Invite via CSV file:"), []),
1410 MIconButton(
1411 "add",
1412 \-> addUsersToClassFromCSV(
1413 state,
1414 saveClassOptions,
1415 getValue(sendEmailB),
1416 fgetValue(languageB),
1417 validationStringsB,
1418 classLearnersUnionTypeToString(unionType),
1419 onClose,
1420 onError
1421 ),
1422 [MIconSize(18.0)],
1423 [MEnabled(fand(invitationSettingOkB, isClassValid))]
1424 ),
1425 MFixedX(32.0),
1426 MTooltip(MIcon("help", []), MText(
1427 _("Please make sure your spreadsheet has at least 3 columns.\nYou will be able to edit column titles when uploaded"),
1428 [MTooltipDesktop()]), [])
1429 ]),
1430 MFixedY(16.0)
1431 ])
1432 )
1433 );
1434}
1435
1436showEditLearnerClassDialog(
1437 state : RhapsodeState<?>,
1438 user : User,
1439 visibleEmail : string,
1440 isRunningB : Transform<bool>,
1441 onClose : () -> void,
1442 onError : (string) -> void
1443) -> void {
1444 firstNameB = make(user.firstName);
1445 lastNameB = make(user.lastName);
1446 ShowMDialog(
1447 state.manager,
1448 isRunningB,
1449 [MDialogUseFrame(), MDialogActions([
1450 MTextButton(_("CANCEL"), onClose, [], [MShortcut("esc")]),
1451 MTextButton(_("SAVE"), \-> {
1452 updatedUser = User(
1453 user with
1454 firstName = getValue(firstNameB),
1455 lastName = getValue(lastNameB)
1456 );
1457 editProfile(
1458 getAppUrl(),
1459 state.userInfo.jwt,
1460 user2EditProfileParams(updatedUser),
1461 \__ -> nextDistinct(
1462 state.dbState.oauthDbState.usersB,
1463 map(getValue(state.dbState.oauthDbState.usersB), \u -> {
1464 if (u.id == updatedUser.id) updatedUser else u
1465 })
1466 ),
1467 onError
1468 );
1469 onClose();
1470 }, [MButtonRaised()], [MShortcut("enter"),])
1471 ])],
1472 MLines([
1473 MText(_("Edit Learner"), [MTitle()]),
1474 MFixedY(16.0),
1475 MTextInput(make(if (visibleEmail == "") user.email else visibleEmail), [MLabel(_("Email")), MWidth(fieldsWidth)], [MEnabled(make(false))]),
1476 MFixedY(16.0),
1477 MTextInput(firstNameB, [MLabel(_("First Name")), MWidth(fieldsWidth)], []),
1478 MFixedY(16.0),
1479 MTextInput(lastNameB, [MLabel(_("Last Name")), MWidth(fieldsWidth)], []),
1480 ])
1481 );
1482}
1483
1484showMoveLearnerClassDialog(
1485 state : RhapsodeState<EducatorExtraState>,
1486 user : User,
1487 visibleEmail : string,
1488 isRunningB : Transform<bool>,
1489 onClose : () -> void,
1490 onError : (string) -> void
1491) -> void {
1492 classes : [RhapsodeClass] = filtermap(getAllUpdatableClasses(state),
1493 \cl -> if (isUpdatableClass(state, cl.id)) Some(cl) else None());
1494 selectedClassIndB = make(-1);
1495 selectedClassIdB = make(-1);
1496
1497 ShowMDialog(
1498 state.manager,
1499 isRunningB,
1500 [MDialogUseFrame(), MDialogActions([
1501 MTextButton(_("CANCEL"), onClose, [], [MShortcut("esc")]),
1502 MTextButton(_("MOVE"), \-> {
1503 // updatedUser = User(
1504 // user with
1505 // firstName = getValue(firstNameB),
1506 // lastName = getValue(lastNameB)
1507 // );
1508 // editProfile(
1509 // getAppUrl(),
1510 // state.userInfo.jwt,
1511 // user2EditProfileParams(updatedUser),
1512 // \__ -> nextDistinct(
1513 // state.dbState.oauthDbState.usersB,
1514 // map(getValue(state.dbState.oauthDbState.usersB), \u -> {
1515 // if (u.id == updatedUser.id) updatedUser else u
1516 // })
1517 // ),
1518 // onError
1519 // );
1520 onClose();
1521 }, [MButtonRaised()], [MShortcut("enter"),])
1522 ])],
1523 MLines([
1524 MText(_("Move Learner To Another Class"), [MTitle()]),
1525 MFixedY(16.0),
1526 MCols([MText(user.firstName, [MBodyBold()]), MFixedX(4.0), MText(user.lastName, [MBodyBold()])]),
1527 MText(visibleEmail, [MBodyBold()]),
1528 MFixedY(16.0),
1529 MConstruct(
1530 [makeSubscribe(selectedClassIndB, \i -> if (i != -1) next(selectedClassIdB, classes[i].id))],
1531 MCols2A(
1532 MBorder(0.0, 18.0, 16.0, 0.0, MText(_("Classes"), [])),
1533 getDropDownOrAutoComplete(selectedClassIndB, map(classes, \i -> i.name), false, None(), -1, _(""), [])
1534 )
1535 ),
1536 ])
1537 );
1538 // getAllUpdatableClasses
1539}
1540
1541
1542deleteClassesLearnersWithConfirmation(state : RhapsodeState<?>, classesLearners : [RhapsodeClassesLearner], callback : () -> void, onError : (string) -> void) -> void {
1543 onDelete = \-> deleteRhapsodeClassesLearners(
1544 state.dbConnector,
1545 state.userInfo.id,
1546 state.dbState.rhapsodeDbState.rhapsodeClassesLearnersB,
1547 classesLearners,
1548 callback,
1549 [DbErrorRecovery(FailOnDbError(onError))]
1550 );
1551
1552 //real use case : one user in many classes, take it in account to optimizite
1553 userId = if (classesLearners != []) classesLearners[0].userId else -1;
1554 allClasses = getValue(state.dbState.rhapsodeDbState.rhapsodeClassesB);
1555 userName = eitherMap(find(getValue(state.dbState.oauthDbState.usersB), \u -> u.id == userId), \v -> v.email, "");
1556
1557 classes = map(classesLearners, \lr -> {
1558 either(findmap(allClasses, \cl -> if (cl.id == lr.classId) Some(cl.name) else None()), "");
1559 });
1560
1561 content = MLines([
1562 MText(userName, []),
1563 MFixedY(8.0),
1564 MText(_("will be removed from classes:"), []),
1565 MText(strGlue(classes, ", "), [])
1566 ]);
1567
1568 showAreYouSureDialog(state.manager, content, \closeDialaog -> {onDelete(); closeDialaog();});
1569}
1570
1571ShortUserInfo(
1572 firstName : string,
1573 lastName : string,
1574 email : string
1575);
1576
1577addRefinedUsersToClassRecursive(
1578 state : RhapsodeState<?>,
1579 class : RhapsodeClass,
1580 loadedUsers : [ShortUserInfo],
1581 sendEmails : bool,
1582 language : string,
1583 skinId : string,
1584 validationStringsB : DynamicBehaviour<[Pair<int, string>]>,
1585 onError : (string) -> void,
1586 rewriteEmptyNames : bool,
1587 unionType : string
1588) -> void {
1589 closeB = make(false);
1590 retryB = make(false);
1591 okB = make(false);
1592
1593 usersStatusInfo = map(loadedUsers, \lu -> Pair(lu, make("")));
1594 addUsersFn = \usersInfo -> {
1595 batches = splitByNumber(usersInfo, addLearnersBatchSize);
1596 onDone = \__ -> {
1597 if (!forall(usersStatusInfo, \u -> getValue(u.second) == "OK")) next(retryB, true);
1598 next(okB, true);
1599 invalidUserIds = fold(usersStatusInfo, [], \acc, us -> {
1600 eitherMap(
1601 find(getValue(state.dbState.oauthDbState.usersB), \u -> u.email == us.first.email),
1602 \u -> if (!u.valid) arrayPush(acc, u.id) else acc,
1603 acc
1604 )
1605 });
1606 getValidationStrings(
1607 getAppUrl(),
1608 state.userInfo.jwt,
1609 Some(invalidUserIds),
1610 \keyPairs -> next(validationStringsB, concat(getValue(validationStringsB), keyPairs)),
1611 onError
1612 );
1613 };
1614 if (length(usersInfo) > addLearnersBatchSize) {
1615 mapAsync(
1616 batches,
1617 \batch, fulfill, __ -> {
1618 addRefinedBatchToClass(
1619 state, class, batch, sendEmails, language, skinId, unionType, \users : [Pair<ShortUserInfo, bool>] -> fulfill(users), rewriteEmptyNames);
1620 },
1621 \__ -> onDone([]),
1622 nop1
1623 );
1624 } else {
1625 addRefinedBatchToClass(state, class, usersInfo, sendEmails, language, skinId, unionType, onDone, rewriteEmptyNames);
1626 }
1627 };
1628
1629 mkGroup = \el, size -> MGroup2(MFixedX(size), MAvailable(el, MFixedX(size)));
1630 usersList = MLines(map(usersStatusInfo, \usi -> MBaselineCols([
1631 mkGroup(MEllipsisText(usi.first.email, [MDataRow()]), 256.0),
1632 MFixedX(8.0),
1633 mkGroup(MSelect(usi.second, \status -> {
1634 if (status == "OK") MIcon("check", [MGreen(300), MIconSize(16.0)])
1635 else if (status == "") MText(_("Inviting..."), [MDataRow()])
1636 else if (status == "MAIL") MText(_("Sending e-mail..."), [MDataRow()])
1637 else if (status == "ADD") MText(_("Adding to database..."), [MDataRow()])
1638 else MEllipsisText(status, [MDataRow(), MRed(300)])
1639 }), 196.0)
1640 ])));
1641
1642 ShowMDialog(
1643 state.manager,
1644 closeB,
1645 [
1646 MDialogUseFrame(),
1647 MDialogActions([
1648 MTextButton(_("RETRY"), \-> {
1649 next(retryB, false);
1650 next(okB, false);
1651 retryGroup = filter(usersStatusInfo, \usi -> getValue(usi.second) != "OK");
1652 iter(retryGroup, \rg -> next(rg.second, ""));
1653 addUsersFn(retryGroup);
1654 }, [MBlue(500)], [MEnabled(retryB)]),
1655 MTextButton(_("OK"), \ -> next(closeB, true), [MBlue(500)], [MEnabled(okB)])
1656 ])
1657 ],
1658 if (length(usersStatusInfo) > 8) {
1659 MScroll(MBorder(0.0, 0.0, 8.0, 0.0, usersList), MFillXH(196.0), [MScrollCropByContent()]);
1660 } else {
1661 usersList;
1662 }
1663 );
1664
1665 addUsersFn(usersStatusInfo);
1666}
1667
1668addRefinedBatchToClass(
1669 state : RhapsodeState<?>,
1670 class : RhapsodeClass,
1671 loadedUsers : [Pair<ShortUserInfo, DynamicBehaviour<string>>],
1672 sendEmails : bool,
1673 language : string,
1674 skinId : string,
1675 unionType : string,
1676 onDone : (users : [Pair<ShortUserInfo, bool>]) -> void,
1677 rewriteEmptyNames : bool
1678) -> void {
1679 onInvited = \preparedUsers : [Triple<string, UserInvitationResult, DynamicBehaviour<string>>] -> {
1680 insertUsersFn = \classLearnersList : [Triple<string, UserInvitationResult, DynamicBehaviour<string>>] -> {
1681 if (classLearnersList != []) {
1682 insertionList = map(
1683 classLearnersList,
1684 \user -> RhapsodeClassesLearner(-1, class.id, user.second.id, getCurrentTime(), state.userInfo.id, "")
1685 );
1686
1687 insertRhapsodeClassesLearners(
1688 state,
1689 insertionList,
1690 \__ -> onDone(map(classLearnersList, \user -> {
1691 next(user.third, "OK");
1692 Pair(ShortUserInfo(user.second.firstName, user.second.lastName, user.first), user.second.isNew);
1693 })),
1694 \e -> {
1695 iter(classLearnersList, \user -> next(user.third, _("Database error: Failed to add user")));
1696 onDone([]);
1697 }
1698 );
1699 } else {
1700 onDone([]);
1701 }
1702 };
1703
1704 if (sendEmails) {
1705 mailList = map(preparedUsers, \user -> LearnerMailInfo(user.second.firstName, user.second.lastName, user.first, user.second.jwtOrKey, user.second.isNew));
1706 notifyLearnerClassUsers(state, class, mailList, language, skinId, unionType, \results -> {
1707 mailedUsers = filtermapi(preparedUsers, \idx, user -> {
1708 userResult = elementAt(results, idx, "");
1709 if (userResult == "OK") {
1710 next(user.third, "ADD");
1711 Some(user);
1712 } else {
1713 next(user.third, _("Email error") + ": " + (if (userResult == "") _("Unknown error") else userResult));
1714 None();
1715 }
1716 });
1717
1718 insertUsersFn(mailedUsers);
1719 });
1720 } else {
1721 insertUsersFn(preparedUsers);
1722 }
1723 };
1724
1725 createUserInvitations(
1726 getAppUrl(),
1727 state.userInfo.jwt,
1728 map(loadedUsers, \lu -> Triple(lu.first.email, lu.first.firstName, lu.first.lastName)),
1729 [],
1730 \results -> {
1731 allResults = filtermapi(loadedUsers, \i, lu -> {
1732 res = elementAt(results, i, UserInvitationResult(_("Request error"), 0, "", "", "", false, false));
1733 if (res.error == "") {
1734 //this is when users table changes
1735 if (rewriteEmptyNames || res.isNew) {
1736 cur = getValue(state.dbState.oauthDbState.usersB);
1737 combineUserFn = \proto, user, email -> User(
1738 user.id,
1739 email,
1740 user.firstName,
1741 user.lastName,
1742 proto.userName,
1743 proto.password,
1744 proto.createdAt,
1745 proto.valid,
1746 proto.avatar,
1747 proto.phone,
1748 proto.dateOfBirth,
1749 true,
1750 proto.passwordSetDate
1751 );
1752
1753 eitherFn(
1754 findi(cur, \u -> u.id == res.id && u.email == lu.first.email),
1755 \i2 -> next(state.dbState.oauthDbState.usersB, replace(cur, i2, combineUserFn(cur[i2], res, lu.first.email))),
1756 \ -> next(state.dbState.oauthDbState.usersB, arrayPush(cur, combineUserFn(makeUser(), res, lu.first.email)))
1757 );
1758 }
1759
1760 next(lu.second, if (sendEmails) "MAIL" else "ADD");
1761 Some(Triple(lu.first.email, res, lu.second));
1762 } else {
1763 next(lu.second, _("Invitation error") + ": " + res.error);
1764 None();
1765 }
1766 });
1767 onInvited(allResults);
1768 },
1769 true
1770 );
1771}
1772
1773addLoadedUsersToClass(
1774 state : RhapsodeState<?>,
1775 class : RhapsodeClass,
1776 tree : Tree<string, int>,
1777 data : [[string]],
1778 sendEmails : bool,
1779 language : string,
1780 skinId : string,
1781 validationStringsB : DynamicBehaviour<[Pair<int, string>]>,
1782 unionType : string,
1783 onError : (string) -> void
1784) -> void {
1785 users = getValue(state.dbState.oauthDbState.usersB);
1786 classUsers = filtermap(
1787 getValue(state.dbState.rhapsodeDbState.rhapsodeClassesLearnersB),
1788 \ul : RhapsodeClassesLearner -> {
1789 if (ul.classId == class.id)
1790 findmap(users, \u -> if (u.id == ul.userId) Some(u.email) else None())
1791 else
1792 None()
1793 }
1794 );
1795
1796 idxMail = lookupTreeDef(tree, "email", -1);
1797 idxFirstName = lookupTreeDef(tree, "first name", -1);
1798 idxLastName = lookupTreeDef(tree, "last name", -1);
1799
1800 emails = ref makeList();
1801 loadedUsers = filtermap(data, \row -> {
1802 email = trim(toLowerCase(elementAt(row, idxMail, "")));
1803 user2add = ShortUserInfo(trim(elementAt(row, idxFirstName, "")), trim(elementAt(row, idxLastName, "")), email);
1804
1805 if (isEmail(email) && !containsList(^emails, email) && !contains(classUsers, email)) {
1806 rlistPush(emails, email);
1807 Some(user2add);
1808 } else {
1809 None();
1810 }
1811 });
1812
1813 if (loadedUsers == []) {
1814 onError(_("No valid users in the list"));
1815 } else {
1816 addRefinedUsersToClassRecursive(state, class, loadedUsers, sendEmails, language, skinId, validationStringsB, onError, true, unionType);
1817 }
1818}
1819
1820// warning: do not perform any filesystem-related actions just before openFileDialog call
1821// https://trello.com/c/TJHypymz/6626-csv-learners-import-via-safari-firefox-chrome-not-working
1822addUsersToClassFromCSV(
1823 state : RhapsodeState<?>,
1824 classFn : ((RhapsodeClass) -> void) -> void,
1825 sendEmails : bool,
1826 language : string,
1827 validationStringsB : DynamicBehaviour<[Pair<int, string>]>,
1828 unionType : string,
1829 onOk : () -> void,
1830 onError : (string) -> void
1831) -> void {
1832 openFileDialog(1, ["*.csv", "*.txt"], \files : [native] -> {
1833 if (files != []) {
1834 file = files[0];
1835 readFileEncClient(
1836 file,
1837 "text",
1838 "auto",
1839 \fileContent : string -> classFn(\class -> {
1840 secondCheckedB = make(sendEmails);
1841 showCsvImporterEx(
1842 state.manager,
1843 fileContent,
1844 fileNameClient(file),
1845 ["first name", "last name", "email"],
1846 ["first name", "last name", "email"],
1847 idfn,
1848 \fileName : string, tree : Tree<string, int>, names : [string], data : [[string]] -> {
1849 if (data != []) addLoadedUsersToClass(
1850 state, class, tree, data, getValue(secondCheckedB), language,
1851 getSkinIdFromClass(class), validationStringsB, unionType, onError
1852 );
1853 onOk();
1854 },
1855 onError,
1856 [
1857 MCsvImporterShowColumnCaptions(false),
1858 MCsvImporterSeparators([Pair("Comma", ","), Pair("Semicolon", ";"), Pair("Tab", "\t"), Pair("Space", " ")]),
1859 MCsvImporterExtractHeaders(false),
1860 MCsvImporterMatchColumns(true),
1861 MCsvImporterSkipSteps(false),
1862 MCsvImporterReorderColumns(\data, order -> {
1863 firstRow = elementAt(data, 0, []);
1864 swapIndexes(
1865 concat(order, arrayRepeat("", min(length(firstRow) - length(order), 100))),
1866 findiDef(firstRow, \v -> isEmail(v), -1),
1867 2
1868 );
1869 }),
1870 MCsvImporterModifyStepper(\stepper, stepCount, stepIndex -> MLines2(stepper, MShow(
1871 fselect(stepIndex, FLift(\si -> si > stepCount - 1)),
1872 MCheckBox(emailCheckboxText(), secondCheckedB, [])
1873 ))),
1874 MCsvImporterLineFilter(\line -> eitherMap(
1875 findi(reverseA(line), \el -> trim2(el, " \t\n") != ""),
1876 \ri -> Some(take(line, length(line) - ri)),
1877 None()
1878 ))
1879 ]
1880 );
1881 }),
1882 onError
1883 );
1884 } else {
1885 // Should never happen
1886 onError(_("Warning: no files selected"));
1887 }
1888 });
1889}
1890
1891deleteAssignmentsForUser(state : RhapsodeState<?>, classesLearner : RhapsodeClassesLearner, onOk : () -> void, onError : (string) -> void) -> void {
1892 classId = classesLearner.classId;
1893 userId = classesLearner.userId;
1894
1895 classMasterAssignments = getClassMasterAssignments(getValue(state.dbState.rhapsodeDbState.rhapsodeMasterAssignmentsB), classId);
1896
1897 loadRhapsodeAssignmentCards(
1898 state.dbConnector,
1899 \cards -> deleteRhapsodeAssignmentCardArray(
1900 state.dbConnector,
1901 make(cards),
1902 filter(cards, \card -> {
1903 exists(classMasterAssignments, \ma -> ma.id == card.masterAssignmentId) && (userId == card.ownerId)
1904 }),
1905 onOk,
1906 [DbErrorRecovery(FailOnDbError(onError))]
1907 ),
1908 [DbErrorRecovery(FailOnDbError(onError))]
1909 );
1910}