From 8db22cc54a30e2932c1d6f1e967ad44d27dd6e2c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pau=20Ferrer=20Oca=C3=B1a?= Date: Fri, 23 Apr 2021 13:24:48 +0200 Subject: [PATCH] MOBILE-3657 workshop: Index page --- .../workshop/components/components.module.ts | 46 ++ .../index/addon-mod-workshop-index.html | 245 ++++++++ .../mod/workshop/components/index/index.ts | 555 ++++++++++++++++++ .../mod/workshop/components/phase/phase.html | 48 ++ .../mod/workshop/components/phase/phase.ts | 73 +++ .../addon-mod-workshop-submission.html | 108 ++++ .../components/submission/submission.scss | 10 + .../components/submission/submission.ts | 138 +++++ src/addons/mod/workshop/lang.json | 63 ++ .../mod/workshop/pages/index/index.html | 22 + src/addons/mod/workshop/pages/index/index.ts | 41 ++ .../mod/workshop/workshop-lazy.module.ts | 63 ++ .../course/classes/main-resource-component.ts | 4 +- .../features/grades/services/grades-helper.ts | 45 +- src/core/singletons/form.ts | 4 +- src/theme/theme.base.scss | 5 + 16 files changed, 1444 insertions(+), 26 deletions(-) create mode 100644 src/addons/mod/workshop/components/components.module.ts create mode 100644 src/addons/mod/workshop/components/index/addon-mod-workshop-index.html create mode 100644 src/addons/mod/workshop/components/index/index.ts create mode 100644 src/addons/mod/workshop/components/phase/phase.html create mode 100644 src/addons/mod/workshop/components/phase/phase.ts create mode 100644 src/addons/mod/workshop/components/submission/addon-mod-workshop-submission.html create mode 100644 src/addons/mod/workshop/components/submission/submission.scss create mode 100644 src/addons/mod/workshop/components/submission/submission.ts create mode 100644 src/addons/mod/workshop/lang.json create mode 100644 src/addons/mod/workshop/pages/index/index.html create mode 100644 src/addons/mod/workshop/pages/index/index.ts create mode 100644 src/addons/mod/workshop/workshop-lazy.module.ts diff --git a/src/addons/mod/workshop/components/components.module.ts b/src/addons/mod/workshop/components/components.module.ts new file mode 100644 index 000000000..cc3cb1961 --- /dev/null +++ b/src/addons/mod/workshop/components/components.module.ts @@ -0,0 +1,46 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { NgModule } from '@angular/core'; +import { AddonModWorkshopIndexComponent } from './index/index'; +import { AddonModWorkshopSubmissionComponent } from './submission/submission'; +import { CoreCourseComponentsModule } from '@features/course/components/components.module'; +import { CoreEditorComponentsModule } from '@features/editor/components/components.module'; +import { CoreSharedModule } from '@/core/shared.module'; +import { AddonModWorkshopPhaseInfoComponent } from './phase/phase'; +import { AddonModWorkshopAssessmentComponent } from './assessment/assessment'; +import { AddonModWorkshopAssessmentStrategyComponent } from './assessment-strategy/assessment-strategy'; + +@NgModule({ + declarations: [ + AddonModWorkshopIndexComponent, + AddonModWorkshopSubmissionComponent, + AddonModWorkshopPhaseInfoComponent, + AddonModWorkshopAssessmentComponent, + AddonModWorkshopAssessmentStrategyComponent, + ], + imports: [ + CoreSharedModule, + CoreCourseComponentsModule, + CoreEditorComponentsModule, + ], + exports: [ + AddonModWorkshopIndexComponent, + AddonModWorkshopSubmissionComponent, + AddonModWorkshopPhaseInfoComponent, + AddonModWorkshopAssessmentComponent, + AddonModWorkshopAssessmentStrategyComponent, + ], +}) +export class AddonModWorkshopComponentsModule {} diff --git a/src/addons/mod/workshop/components/index/addon-mod-workshop-index.html b/src/addons/mod/workshop/components/index/addon-mod-workshop-index.html new file mode 100644 index 000000000..4c5c167f2 --- /dev/null +++ b/src/addons/mod/workshop/components/index/addon-mod-workshop-index.html @@ -0,0 +1,245 @@ + + + + + + + + + + + + + + + + + + + + + + + + +

{{ phases[workshop!.phase].title }}

+
+
+ + + + + + + +

{{task.title}}

+

+
+ +
+
+
+ + + + + + {{ 'core.hasdatatosync' | translate: {$a: moduleName} }} + + + + + + + +

{{ 'core.description' | translate }}

+ + +
+
+
+ +
+ + + + + +

{{ 'addon.mod_workshop.conclusion' | translate }}

+ + +
+
+
+ + + +

{{ 'addon.mod_workshop.yourgrades' | translate }}

+
+ + +

{{ 'addon.mod_workshop.submissiongrade' | translate }}

+

{{ userGrades.submissionlongstrgrade }}

+
+
+ + +

{{ 'addon.mod_workshop.gradinggrade' | translate }}

+

{{ userGrades.assessmentlongstrgrade }}

+
+
+
+
+ + + + + +

{{ 'addon.mod_workshop.areainstructauthors' | translate }}

+ + +
+
+
+ + + + +

{{ 'addon.mod_workshop.yoursubmission' | translate }}

+

{{ 'addon.mod_workshop.noyoursubmission' | translate }}

+
+
+ + + +

{{ 'addon.mod_workshop.yoursubmission' | translate }}

+
+ + +
+
+ + + + + + + + {{ 'addon.mod_workshop.createsubmission' | translate }} + + + + {{ 'addon.mod_workshop.editsubmission' | translate }} + + + + + + + + +

{{ 'addon.mod_workshop.publishedsubmissions' | translate }}

+
+ + + + +
+
+ + + + + + +

{{ 'addon.mod_workshop.areainstructreviewers' | translate }}

+ + +
+
+
+ + + +

{{ 'addon.mod_workshop.assignedassessments' | translate }}

+
+ +

{{ 'addon.mod_workshop.assignedassessmentsnone' | translate }}

+
+ + + + +
+
+ + + + + +

{{ 'addon.mod_workshop.submissionsreport' | translate }}

+

{{ 'addon.mod_workshop.gradesreport' | translate }}

+
+
+ + + {{ 'core.groupsseparate' | translate }} + + + {{ 'core.groupsvisible' | translate }} + + + + {{groupOpt.name}} + + + + + + + + + + + + + + + {{ 'core.previous' | translate }} + + + + + {{ 'core.next' | translate }} + + + + + +
+
+
diff --git a/src/addons/mod/workshop/components/index/index.ts b/src/addons/mod/workshop/components/index/index.ts new file mode 100644 index 000000000..cacda71fd --- /dev/null +++ b/src/addons/mod/workshop/components/index/index.ts @@ -0,0 +1,555 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Component, Input, OnDestroy, OnInit, Optional } from '@angular/core'; +import { Params } from '@angular/router'; +import { CoreCourseModuleMainActivityComponent } from '@features/course/classes/main-activity-component'; +import { CoreCourseContentsPage } from '@features/course/pages/contents/contents'; +import { CoreCourse } from '@features/course/services/course'; +import { IonContent } from '@ionic/angular'; +import { CoreGroupInfo, CoreGroups } from '@services/groups'; +import { CoreNavigator } from '@services/navigator'; +import { CoreUtils } from '@services/utils/utils'; +import { ModalController, Platform } from '@singletons'; +import { CoreEventObserver, CoreEvents } from '@singletons/events'; +import { Subscription } from 'rxjs'; +import { AddonModWorkshopModuleHandlerService } from '../../services/handlers/module'; +import { + AddonModWorkshopProvider, + AddonModWorkshopPhase, + AddonModWorkshop, + AddonModWorkshopData, + AddonModWorkshopGetWorkshopAccessInformationWSResponse, + AddonModWorkshopPhaseData, + AddonModWorkshopGetGradesWSResponse, + AddonModWorkshopAssessmentSavedChangedEventData, + AddonModWorkshopSubmissionChangedEventData, + AddonModWorkshopGradesData, + AddonModWorkshopPhaseTaskData, + AddonModWorkshopReviewer, +} from '../../services/workshop'; +import { + AddonModWorkshopHelper, + AddonModWorkshopSubmissionAssessmentWithFormData, + AddonModWorkshopSubmissionDataWithOfflineData, +} from '../../services/workshop-helper'; +import { AddonModWorkshopOffline, AddonModWorkshopOfflineSubmission } from '../../services/workshop-offline'; +import { + AddonModWorkshopSyncProvider, + AddonModWorkshopSync, + AddonModWorkshopAutoSyncData, + AddonModWorkshopSyncResult, +} from '../../services/workshop-sync'; +import { AddonModWorkshopPhaseInfoComponent } from '../phase/phase'; + +/** + * Component that displays a workshop index page. + */ +@Component({ + selector: 'addon-mod-workshop-index', + templateUrl: 'addon-mod-workshop-index.html', +}) +export class AddonModWorkshopIndexComponent extends CoreCourseModuleMainActivityComponent implements OnInit, OnDestroy { + + @Input() group = 0; + + component = AddonModWorkshopProvider.COMPONENT; + moduleName = 'workshop'; + + workshop?: AddonModWorkshopData; + page = 0; + access?: AddonModWorkshopGetWorkshopAccessInformationWSResponse; + phases?: Record; + grades: AddonModWorkshopSubmissionDataWithOfflineData[] = []; + assessments: AddonModWorkshopSubmissionAssessmentWithFormData[] = []; + userGrades?: AddonModWorkshopGetGradesWSResponse; + publishedSubmissions: AddonModWorkshopSubmissionDataWithOfflineData[] = []; + submission?: AddonModWorkshopSubmissionDataWithOfflineData; + groupInfo: CoreGroupInfo = { + groups: [], + separateGroups: false, + visibleGroups: false, + defaultGroupId: 0, + }; + + canSubmit = false; + showSubmit = false; + canAssess = false; + hasNextPage = false; + + readonly PHASE_SETUP = AddonModWorkshopPhase.PHASE_SETUP; + readonly PHASE_SUBMISSION = AddonModWorkshopPhase.PHASE_SUBMISSION; + readonly PHASE_ASSESSMENT = AddonModWorkshopPhase.PHASE_ASSESSMENT; + readonly PHASE_EVALUATION = AddonModWorkshopPhase.PHASE_EVALUATION; + readonly PHASE_CLOSED = AddonModWorkshopPhase.PHASE_CLOSED; + + protected offlineSubmissions: AddonModWorkshopOfflineSubmission[] = []; + protected obsSubmissionChanged: CoreEventObserver; + protected obsAssessmentSaved: CoreEventObserver; + protected appResumeSubscription: Subscription; + protected syncObserver?: CoreEventObserver; + protected syncEventName = AddonModWorkshopSyncProvider.AUTO_SYNCED; + + constructor ( + @Optional() content: IonContent, + @Optional() courseContentsPage?: CoreCourseContentsPage, + ) { + super('AddonModWorkshopIndexComponent', content, courseContentsPage); + + // Listen to submission and assessment changes. + this.obsSubmissionChanged = CoreEvents.on(AddonModWorkshopProvider.SUBMISSION_CHANGED, (data) => { + this.eventReceived(data); + }, this.siteId); + + // Listen to submission and assessment changes. + this.obsAssessmentSaved = CoreEvents.on(AddonModWorkshopProvider.ASSESSMENT_SAVED, (data) => { + this.eventReceived(data); + }, this.siteId); + + // Since most actions will take the user out of the app, we should refresh the view when the app is resumed. + this.appResumeSubscription = Platform.resume.subscribe(() => { + this.showLoadingAndRefresh(true); + }); + + // Refresh workshop on sync. + this.syncObserver = CoreEvents.on(AddonModWorkshopSyncProvider.AUTO_SYNCED, (data) => { + // Update just when all database is synced. + this.eventReceived(data); + }, this.siteId); + } + + /** + * @inheritdoc + */ + async ngOnInit(): Promise { + super.ngOnInit(); + + await this.loadContent(false, true); + if (!this.workshop) { + return; + } + + try { + await AddonModWorkshop.logView(this.workshop.id, this.workshop.name); + CoreCourse.checkModuleCompletion(this.courseId, this.module.completiondata); + } catch (error) { + // Ignore errors. + } + } + + /** + * Function called when we receive an event of submission changes. + * + * @param data Data received by the event. + */ + protected eventReceived( + data: AddonModWorkshopAutoSyncData | + AddonModWorkshopSubmissionChangedEventData | + AddonModWorkshopAssessmentSavedChangedEventData, + ): void { + if (this.workshop?.id === data.workshopId) { + this.showLoadingAndRefresh(true); + + // Check completion since it could be configured to complete once the user adds a new discussion or replies. + CoreCourse.checkModuleCompletion(this.courseId, this.module.completiondata); + } + } + + /** + * Perform the invalidate content function. + * + * @return Resolved when done. + */ + protected async invalidateContent(): Promise { + const promises: Promise[] = []; + + promises.push(AddonModWorkshop.invalidateWorkshopData(this.courseId)); + if (this.workshop) { + promises.push(AddonModWorkshop.invalidateWorkshopAccessInformationData(this.workshop.id)); + promises.push(AddonModWorkshop.invalidateUserPlanPhasesData(this.workshop.id)); + if (this.canSubmit) { + promises.push(AddonModWorkshop.invalidateSubmissionsData(this.workshop.id)); + } + if (this.access?.canviewallsubmissions) { + promises.push(AddonModWorkshop.invalidateGradeReportData(this.workshop.id)); + promises.push(CoreGroups.invalidateActivityAllowedGroups(this.workshop.coursemodule)); + promises.push(CoreGroups.invalidateActivityGroupMode(this.workshop.coursemodule)); + } + if (this.canAssess) { + promises.push(AddonModWorkshop.invalidateReviewerAssesmentsData(this.workshop.id)); + } + promises.push(AddonModWorkshop.invalidateGradesData(this.workshop.id)); + promises.push(AddonModWorkshop.invalidateWorkshopWSData(this.workshop.id)); + } + + await Promise.all(promises); + } + + /** + * Compares sync event data with current data to check if refresh content is needed. + * + * @param syncEventData Data receiven on sync observer. + * @return True if refresh is needed, false otherwise. + */ + protected isRefreshSyncNeeded(syncEventData: AddonModWorkshopAutoSyncData): boolean { + if (this.workshop && syncEventData.workshopId == this.workshop.id) { + // Refresh the data. + this.content?.scrollToTop(); + + return true; + } + + return false; + } + + /** + * Download feedback contents. + * + * @param refresh If it's refreshing content. + * @param sync If it should try to sync. + * @param showErrors If show errors to the user of hide them. + * @return Promise resolved when done. + */ + protected async fetchContent(refresh = false, sync = false, showErrors = false): Promise { + try { + this.workshop = await AddonModWorkshop.getWorkshop(this.courseId, this.module.id); + + this.description = this.workshop.intro; + this.dataRetrieved.emit(this.workshop); + + if (sync) { + // Try to synchronize the feedback. + await this.syncActivity(showErrors); + } + + // Check if there are answers stored in offline. + this.access = await AddonModWorkshop.getWorkshopAccessInformation(this.workshop.id, { cmId: this.module.id }); + + if (this.access.canviewallsubmissions) { + this.groupInfo = await CoreGroups.getActivityGroupInfo(this.workshop.coursemodule); + this.group = CoreGroups.validateGroupId(this.group, this.groupInfo); + } + + this.phases = await AddonModWorkshop.getUserPlanPhases(this.workshop.id, { cmId: this.module.id }); + + this.phases[this.workshop.phase].tasks.forEach((task) => { + if (!task.link && (task.code == 'examples' || task.code == 'prepareexamples')) { + // Add links to manage examples. + task.link = this.externalUrl!; + } + }); + + // Check if there are info stored in offline. + this.hasOffline = await AddonModWorkshopOffline.hasWorkshopOfflineData(this.workshop.id); + if (this.hasOffline) { + this.offlineSubmissions = await AddonModWorkshopOffline.getSubmissions(this.workshop.id); + } else { + this.offlineSubmissions = []; + } + + await this.setPhaseInfo(); + + } finally { + this.fillContextMenu(refresh); + } + } + + /** + * Retrieves and shows submissions grade page. + * + * @param page Page number to be retrieved. + * @return Resolved when done. + */ + async gotoSubmissionsPage(page: number): Promise { + const report = await AddonModWorkshop.getGradesReport(this.workshop!.id, { + groupId: this.group, + page, + cmId: this.module.id, + }); + + const numEntries = (report && report.grades && report.grades.length) || 0; + + this.page = page; + + this.hasNextPage = numEntries >= AddonModWorkshopProvider.PER_PAGE && ((this.page + 1) * + AddonModWorkshopProvider.PER_PAGE) < report.totalcount; + + const grades: AddonModWorkshopGradesData[] = report.grades || []; + + this.grades = []; + + await Promise.all(grades.map(async (grade) => { + const submission: AddonModWorkshopSubmissionDataWithOfflineData = { + id: grade.submissionid, + workshopid: this.workshop!.id, + example: false, + authorid: grade.userid, + timecreated: grade.submissionmodified, + timemodified: grade.submissionmodified, + title: grade.submissiontitle, + content: '', + contenttrust: 0, + attachment: 0, + grade: grade.submissiongrade, + gradeover: grade.submissiongradeover, + gradeoverby: grade.submissiongradeoverby, + published: !!grade.submissionpublished, + gradinggrade: grade.gradinggrade, + late: 0, + reviewedby: this.parseReviewer(grade.reviewedby), + reviewerof: this.parseReviewer(grade.reviewerof), + }; + + if (this.workshop!.phase == AddonModWorkshopPhase.PHASE_ASSESSMENT) { + submission.reviewedbydone = grade.reviewedby?.reduce((a, b) => a + (b.grade ? 1 : 0), 0) || 0; + submission.reviewerofdone = grade.reviewerof?.reduce((a, b) => a + (b.grade ? 1 : 0), 0) || 0; + submission.reviewedbycount = grade.reviewedby?.length || 0; + submission.reviewerofcount = grade.reviewerof?.length || 0; + } + + const offlineData = await AddonModWorkshopHelper.applyOfflineData(submission, this.offlineSubmissions); + + if (typeof offlineData != 'undefined') { + this.grades!.push(offlineData); + } + })); + } + + protected parseReviewer(reviewers: AddonModWorkshopReviewer[] = []): AddonModWorkshopSubmissionAssessmentWithFormData[] { + return reviewers.map((reviewer: AddonModWorkshopReviewer) => { + const parsed: AddonModWorkshopSubmissionAssessmentWithFormData = { + grade: reviewer.grade, + gradinggrade: reviewer.gradinggrade, + gradinggradeover: reviewer.gradinggradeover, + id: reviewer.assessmentid, + reviewerid: reviewer.userid, + submissionid: reviewer.submissionid, + weight: reviewer.weight, + timecreated: 0, + timemodified: 0, + feedbackauthor: '', + gradinggradeoverby: 0, + feedbackattachmentfiles: [], + feedbackcontentfiles: [], + feedbackauthorattachment: 0, + }; + + return parsed; + }); + } + + /** + * Open task. + * + * @param task Task to be done. + */ + runTask(task: AddonModWorkshopPhaseTaskData): void { + if (task.code == 'submit') { + this.gotoSubmit(); + } else if (task.link) { + CoreUtils.openInBrowser(task.link); + } + } + + /** + * Go to submit page. + */ + gotoSubmit(): void { + if (this.canSubmit && ((this.access!.creatingsubmissionallowed && !this.submission) || + (this.access!.modifyingsubmissionallowed && this.submission))) { + const params: Params = { + module: this.module, + access: this.access, + }; + + const submissionId = this.submission?.id || 0; + CoreNavigator.navigateToSitePath( + AddonModWorkshopModuleHandlerService.PAGE_NAME + `/${this.courseId}/${this.module.id}/${submissionId}/edit`, + { params }, + ); + + } + } + + /** + * View Phase info. + */ + async viewPhaseInfo(): Promise { + if (this.phases) { + const modal = await ModalController.create({ + component: AddonModWorkshopPhaseInfoComponent, + componentProps: { + phases: CoreUtils.objectToArray(this.phases), + workshopPhase: this.workshop!.phase, + externalUrl: this.externalUrl, + showSubmit: this.showSubmit, + }, + }); + await modal.present(); + + const result = await modal.onDidDismiss(); + if (result.data === true) { + this.gotoSubmit(); + } + } + } + + /** + * Set group to see the workshop. + * + * @param groupId Group Id. + * @return Promise resolved when done. + */ + async setGroup(groupId: number): Promise { + this.group = groupId; + + await this.gotoSubmissionsPage(0); + } + + /** + * Convenience function to set current phase information. + * + * @return Promise resolved when done. + */ + protected async setPhaseInfo(): Promise { + this.submission = undefined; + this.canAssess = false; + this.assessments = []; + this.userGrades = undefined; + this.publishedSubmissions = []; + + this.canSubmit = AddonModWorkshopHelper.canSubmit( + this.workshop!, + this.access!, + this.phases![AddonModWorkshopPhase.PHASE_SUBMISSION].tasks, + ); + + this.showSubmit = this.workshop!.phase == AddonModWorkshopPhase.PHASE_SUBMISSION && this.canSubmit && + ((this.access!.creatingsubmissionallowed && !this.submission) || + (this.access!.modifyingsubmissionallowed && !!this.submission)); + + const promises: Promise[] = []; + + if (this.canSubmit) { + promises.push(AddonModWorkshopHelper.getUserSubmission(this.workshop!.id, { cmId: this.module.id }) + .then(async (submission) => { + this.submission = await AddonModWorkshopHelper.applyOfflineData(submission, this.offlineSubmissions); + + return; + })); + } + + if (this.access!.canviewallsubmissions && this.workshop!.phase >= AddonModWorkshopPhase.PHASE_SUBMISSION) { + promises.push(this.gotoSubmissionsPage(this.page)); + } + + let assessPromise = Promise.resolve(); + + if (this.workshop!.phase >= AddonModWorkshopPhase.PHASE_ASSESSMENT) { + this.canAssess = AddonModWorkshopHelper.canAssess(this.workshop!, this.access!); + + if (this.canAssess) { + assessPromise = AddonModWorkshopHelper.getReviewerAssessments(this.workshop!.id, { + cmId: this.module.id, + }).then(async (assessments) => { + await Promise.all(assessments.map(async (assessment) => { + assessment.strategy = this.workshop!.strategy; + if (!this.hasOffline) { + return; + } + + try { + const offlineAssessment = await AddonModWorkshopOffline.getAssessment(this.workshop!.id, assessment.id); + + assessment.offline = true; + assessment.timemodified = Math.floor(offlineAssessment.timemodified / 1000); + } catch { + // Ignore errors. + } + })); + + this.assessments = assessments; + + return; + }); + + } + } + + if (this.workshop!.phase == AddonModWorkshopPhase.PHASE_CLOSED) { + promises.push(AddonModWorkshop.getGrades(this.workshop!.id, { cmId: this.module.id }).then((grades) => { + this.userGrades = grades.submissionlongstrgrade || grades.assessmentlongstrgrade ? grades : undefined; + + return; + })); + + if (this.access!.canviewpublishedsubmissions) { + promises.push(assessPromise.then(async () => { + const submissions: AddonModWorkshopSubmissionDataWithOfflineData[] = + await AddonModWorkshop.getSubmissions(this.workshop!.id, { cmId: this.module.id }); + + this.publishedSubmissions = submissions.filter((submission) => { + if (submission.published) { + submission.reviewedby = []; + + this.assessments.forEach((assessment) => { + if (assessment.submissionid == submission.id) { + submission.reviewedby!.push(AddonModWorkshopHelper.realGradeValue(this.workshop!, assessment)); + } + }); + + return true; + } + + return false; + }); + + return; + })); + } + } + + await Promise.all(promises); + } + + /** + * Performs the sync of the activity. + * + * @return Promise resolved when done. + */ + protected sync(): Promise { + return AddonModWorkshopSync.syncWorkshop(this.workshop!.id); + } + + /** + * Checks if sync has succeed from result sync data. + * + * @param result Data returned on the sync function. + * @return If suceed or not. + */ + protected hasSyncSucceed(result: AddonModWorkshopSyncResult): boolean { + return result.updated; + } + + /** + * Component being destroyed. + */ + ngOnDestroy(): void { + super.ngOnDestroy(); + this.obsSubmissionChanged?.off(); + this.obsAssessmentSaved?.off(); + this.appResumeSubscription?.unsubscribe(); + } + +} diff --git a/src/addons/mod/workshop/components/phase/phase.html b/src/addons/mod/workshop/components/phase/phase.html new file mode 100644 index 000000000..1f98c8b52 --- /dev/null +++ b/src/addons/mod/workshop/components/phase/phase.html @@ -0,0 +1,48 @@ + + + + + + {{ 'addon.mod_workshop.userplan' | translate }} + + + + + + + + + + + + +

{{ phase.title }}

+

+ {{ 'addon.mod_workshop.userplancurrentphase' | translate }} +

+
+
+ + + +

{{ 'addon.mod_workshop.switchphase' + phase.code | translate }}

+
+ +
+ + + + + + + +

{{task.title}}

+

+
+ +
+
+
+
diff --git a/src/addons/mod/workshop/components/phase/phase.ts b/src/addons/mod/workshop/components/phase/phase.ts new file mode 100644 index 000000000..1e4976d40 --- /dev/null +++ b/src/addons/mod/workshop/components/phase/phase.ts @@ -0,0 +1,73 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Component, Input, OnInit } from '@angular/core'; +import { CoreUtils } from '@services/utils/utils'; +import { ModalController } from '@singletons'; +import { AddonModWorkshopPhaseData, AddonModWorkshopPhase, AddonModWorkshopPhaseTaskData } from '../../services/workshop'; + +/** + * Page that displays the phase info modal. + */ +@Component({ + templateUrl: 'phase.html', +}) +export class AddonModWorkshopPhaseInfoComponent implements OnInit { + + @Input() phases!: AddonModWorkshopPhaseDataWithSwitch[]; + @Input() workshopPhase!: AddonModWorkshopPhase; + @Input() showSubmit = false; + @Input() protected externalUrl!: string; + + ngOnInit(): void { + + // Treat phases. + for (const x in this.phases) { + this.phases[x].tasks.forEach((task) => { + if (!task.link && (task.code == 'examples' || task.code == 'prepareexamples')) { + // Add links to manage examples. + task.link = this.externalUrl; + } + }); + const action = this.phases[x].actions.find((action) => action.url && action.type == 'switchphase'); + this.phases[x].switchUrl = action ? action.url : ''; + } + } + + /** + * Close modal. + */ + closeModal(): void { + ModalController.dismiss(); + } + + /** + * Open task. + * + * @param task Task to be done. + */ + runTask(task: AddonModWorkshopPhaseTaskData): void { + if (task.code == 'submit') { + // This will close the modal and go to the submit. + ModalController.dismiss(true); + } else if (task.link) { + CoreUtils.openInBrowser(task.link); + } + } + +} + +type AddonModWorkshopPhaseDataWithSwitch = AddonModWorkshopPhaseData & { + switchUrl?: string; +}; diff --git a/src/addons/mod/workshop/components/submission/addon-mod-workshop-submission.html b/src/addons/mod/workshop/components/submission/addon-mod-workshop-submission.html new file mode 100644 index 000000000..fafe401f3 --- /dev/null +++ b/src/addons/mod/workshop/components/submission/addon-mod-workshop-submission.html @@ -0,0 +1,108 @@ + +
+ + + + +

+ + +

+

{{profile.fullname}}

+

+ {{ 'addon.mod_workshop.submissiongradeof' | translate:{$a: workshop.grade } }}: {{submission.grade}} +

+

+ {{ 'addon.mod_workshop.gradeover' | translate }}: {{submission.gradeover}} +

+

+ {{ 'addon.mod_workshop.gradinggradeof' | translate:{$a: workshop.gradinggrade } }}: {{submission.gradinggrade}} +

+
+ + {{ 'core.notsent' | translate }} + + + {{submission.timemodified | coreDateDayOrTime}} + + {{ 'core.notsent' | translate }} + + + {{ 'core.deletedoffline' | translate }} + + +
+ + + + + + + + + + + +

+ {{ 'addon.mod_workshop.feedbackby' | translate : {$a: evaluateByProfile.fullname} }} +

+ + +
+
+ + + + {{ 'core.showmore' | translate }} + + + + +
+ + + + + +

+ + +

+

{{profile.fullname}}

+

+ {{ 'addon.mod_workshop.receivedgrades' | translate }}: {{submission.reviewedbydone}} / {{submission.reviewedbycount}} +

+

+ {{ 'addon.mod_workshop.givengrades' | translate }}: {{submission.reviewerofdone}} / {{submission.reviewerofcount}} +

+

+ {{ 'addon.mod_workshop.submissiongradeof' | translate:{$a: workshop.grade } }}: {{submission.grade}} +

+

+ {{ 'addon.mod_workshop.submissiongradeof' | translate:{$a: workshop.grade } }}: {{submission.gradeover}} +

+

+ {{ 'addon.mod_workshop.gradinggradeof' | translate:{$a: workshop.gradinggrade } }}: {{submission.gradinggrade}} +

+ + + {{ 'addon.mod_workshop.assessedsubmission' | translate }} + + + {{ 'addon.mod_workshop.notassessed' | translate }} + + +
+ + {{submission.timemodified | coreDateDayOrTime}} +
{{ 'core.notsent' | translate }}
+
{{ 'core.deletedoffline' | translate }}
+
+
+
diff --git a/src/addons/mod/workshop/components/submission/submission.scss b/src/addons/mod/workshop/components/submission/submission.scss new file mode 100644 index 000000000..5490cd6b4 --- /dev/null +++ b/src/addons/mod/workshop/components/submission/submission.scss @@ -0,0 +1,10 @@ +:host { + p.addon-overriden-grade { + color: var(--ion-color-success); + } + + p.addon-has-overriden-grade { + color: var(--ion-color-danger); + text-decoration: line-through; + } +} diff --git a/src/addons/mod/workshop/components/submission/submission.ts b/src/addons/mod/workshop/components/submission/submission.ts new file mode 100644 index 000000000..ef3a0cf4d --- /dev/null +++ b/src/addons/mod/workshop/components/submission/submission.ts @@ -0,0 +1,138 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Component, Input, OnInit } from '@angular/core'; +import { Params } from '@angular/router'; +import { CoreCourseModule } from '@features/course/services/course-helper'; +import { CoreUser, CoreUserProfile } from '@features/user/services/user'; +import { CoreNavigator } from '@services/navigator'; +import { CoreSites } from '@services/sites'; +import { AddonModWorkshopSubmissionPage } from '../../pages/submission/submission'; +import { AddonModWorkshopModuleHandlerService } from '../../services/handlers/module'; +import { + AddonModWorkshopProvider, + AddonModWorkshopPhase, + AddonModWorkshopData, + AddonModWorkshopGetWorkshopAccessInformationWSResponse, +} from '../../services/workshop'; +import { + AddonModWorkshopHelper, + AddonModWorkshopSubmissionAssessmentWithFormData, + AddonModWorkshopSubmissionDataWithOfflineData, +} from '../../services/workshop-helper'; +import { AddonModWorkshopOffline } from '../../services/workshop-offline'; + +/** + * Component that displays workshop submission. + */ +@Component({ + selector: 'addon-mod-workshop-submission', + templateUrl: 'addon-mod-workshop-submission.html', + styleUrls: ['submission.scss'], +}) +export class AddonModWorkshopSubmissionComponent implements OnInit { + + @Input() submission!: AddonModWorkshopSubmissionDataWithOfflineData; + @Input() module!: CoreCourseModule; + @Input() workshop!: AddonModWorkshopData; + @Input() access!: AddonModWorkshopGetWorkshopAccessInformationWSResponse; + @Input() courseId!: number; + @Input() assessment?: AddonModWorkshopSubmissionAssessmentWithFormData; + @Input() summary = false; + + component = AddonModWorkshopProvider.COMPONENT; + componentId?: number; + userId: number; + loaded = false; + offline = false; + viewDetails = false; + profile?: CoreUserProfile; + showGrade: (grade?: number|string) => boolean; + evaluateByProfile?: CoreUserProfile; + + constructor() { + this.userId = CoreSites.getCurrentSiteUserId(); + this.showGrade = AddonModWorkshopHelper.showGrade; + } + + /** + * Component being initialized. + */ + ngOnInit(): void { + this.componentId = this.module.instance; + this.userId = this.submission.authorid || this.userId; + + const promises: Promise[] = []; + + this.offline = !!this.submission?.offline || !!this.assessment?.offline; + + if (this.submission.id) { + promises.push(AddonModWorkshopOffline.getEvaluateSubmission(this.workshop.id, this.submission.id) + .then((offlineSubmission) => { + this.submission.gradeover = parseInt(offlineSubmission.gradeover, 10); + this.offline = true; + + return; + }).catch(() => { + // Ignore errors. + })); + } + + if (this.userId) { + promises.push(CoreUser.getProfile(this.userId, this.courseId, true).then((profile) => { + this.profile = profile; + + return; + })); + } + + this.viewDetails = !this.summary && this.workshop.phase == AddonModWorkshopPhase.PHASE_CLOSED && + CoreNavigator.getCurrentRoute().component != AddonModWorkshopSubmissionPage; + + if (this.viewDetails && this.submission.gradeoverby) { + promises.push(CoreUser.getProfile(this.submission.gradeoverby, this.courseId, true).then((profile) => { + this.evaluateByProfile = profile; + + return; + })); + } + + Promise.all(promises).finally(() => { + this.loaded = true; + }); + } + + /** + * Navigate to the submission. + */ + gotoSubmission(): void { + if (this.submission.timemodified) { + const params: Params = { + module: this.module, + workshop: this.workshop, + access: this.access, + profile: this.profile, + submission: this.submission, + assessment: this.assessment, + }; + + CoreNavigator.navigateToSitePath( + AddonModWorkshopModuleHandlerService.PAGE_NAME + `/${this.courseId}/${this.module.id}/${this.submission.id}`, + { params }, + ); + + } + } + +} diff --git a/src/addons/mod/workshop/lang.json b/src/addons/mod/workshop/lang.json new file mode 100644 index 000000000..51cf37dfe --- /dev/null +++ b/src/addons/mod/workshop/lang.json @@ -0,0 +1,63 @@ +{ + "alreadygraded": "Already graded", + "areainstructauthors": "Instructions for submission", + "areainstructreviewers": "Instructions for assessment", + "assess": "Assess", + "assessedsubmission": "Assessed submission", + "assessmentform": "Assessment form", + "assessmentsettings": "Assessment settings", + "assessmentstrategynotsupported": "Assessment strategy {{$a}} not supported", + "assessmentweight": "Assessment weight", + "assignedassessments": "Assigned submissions to assess", + "assignedassessmentsnone": "You have no assigned submission to assess", + "conclusion": "Conclusion", + "createsubmission": "Add submission", + "deletesubmission": "Delete submission", + "editsubmission": "Edit submission", + "feedbackauthor": "Feedback for the author", + "feedbackby": "Feedback by {{$a}}", + "feedbackreviewer": "Feedback for the reviewer", + "givengrades": "Grades given", + "gradecalculated": "Calculated grade for submission", + "gradeinfo": "Grade: {{$a.received}} of {{$a.max}}", + "gradeover": "Override grade for submission", + "gradesreport": "Workshop grades report", + "gradinggrade": "Grade for assessment", + "gradinggradecalculated": "Calculated grade for assessment", + "gradinggradeof": "Grade for assessment (of {{$a}})", + "gradinggradeover": "Override grade for assessment", + "modulenameplural": "Workshops", + "nogradeyet": "No grade yet", + "notassessed": "Not assessed yet", + "notoverridden": "Not overridden", + "noyoursubmission": "You have not submitted your work yet", + "overallfeedback": "Overall feedback", + "publishedsubmissions": "Published submissions", + "publishsubmission": "Publish submission", + "publishsubmission_help": "Published submissions are available to the others when the workshop is closed.", + "reassess": "Re-assess", + "receivedgrades": "Grades received", + "submissionattachment": "Attachment", + "submissioncontent": "Submission content", + "submissiondeleteconfirm": "Are you sure you want to delete the following submission?", + "submissiongrade": "Grade for submission", + "submissiongradeof": "Grade for submission (of {{$a}})", + "submissionrequiredcontent": "You need to enter some text or add a file.", + "submissionrequiredtitle": "You need to enter a title.", + "submissionsreport": "Workshop submissions report", + "submissiontitle": "Title", + "switchphase10": "Switch to the setup phase", + "switchphase20": "Switch to the submission phase", + "switchphase30": "Switch to the assessment phase", + "switchphase40": "Switch to the evaluation phase", + "switchphase50": "Close workshop", + "userplan": "Workshop planner", + "userplancurrentphase": "Current phase", + "warningassessmentmodified": "The submission was modified on the site.", + "warningsubmissionmodified": "The assessment was modified on the site.", + "weightinfo": "Weight: {{$a}}", + "yourassessment": "Your assessment", + "yourassessmentfor": "Your assessment for {{$a}}", + "yourgrades": "Your grades", + "yoursubmission": "Your submission" +} \ No newline at end of file diff --git a/src/addons/mod/workshop/pages/index/index.html b/src/addons/mod/workshop/pages/index/index.html new file mode 100644 index 000000000..f9cefab96 --- /dev/null +++ b/src/addons/mod/workshop/pages/index/index.html @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/addons/mod/workshop/pages/index/index.ts b/src/addons/mod/workshop/pages/index/index.ts new file mode 100644 index 000000000..cdef213cc --- /dev/null +++ b/src/addons/mod/workshop/pages/index/index.ts @@ -0,0 +1,41 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Component, OnInit, ViewChild } from '@angular/core'; +import { CoreCourseModuleMainActivityPage } from '@features/course/classes/main-activity-page'; +import { CoreNavigator } from '@services/navigator'; +import { AddonModWorkshopIndexComponent } from '../../components/index/index'; + +/** + * Page that displays a workshop. + */ +@Component({ + selector: 'page-addon-mod-workshop-index', + templateUrl: 'index.html', +}) +export class AddonModWorkshopIndexPage extends CoreCourseModuleMainActivityPage implements OnInit { + + @ViewChild(AddonModWorkshopIndexComponent) activityComponent?: AddonModWorkshopIndexComponent; + + selectedGroup = 0; + + /** + * @inheritdoc + */ + ngOnInit(): void { + super.ngOnInit(); + this.selectedGroup = CoreNavigator.getRouteNumberParam('group') || 0; + } + +} diff --git a/src/addons/mod/workshop/workshop-lazy.module.ts b/src/addons/mod/workshop/workshop-lazy.module.ts new file mode 100644 index 000000000..14fec0473 --- /dev/null +++ b/src/addons/mod/workshop/workshop-lazy.module.ts @@ -0,0 +1,63 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { NgModule } from '@angular/core'; +import { RouterModule, Routes } from '@angular/router'; +import { CanLeaveGuard } from '@guards/can-leave'; + +import { CoreSharedModule } from '@/core/shared.module'; +import { AddonModWorkshopIndexPage } from './pages/index/index'; +import { AddonModWorkshopComponentsModule } from './components/components.module'; +import { AddonModWorkshopSubmissionPage } from './pages/submission/submission'; +import { CoreEditorComponentsModule } from '@features/editor/components/components.module'; +import { AddonModWorkshopAssessmentPage } from './pages/assessment/assessment'; +import { AddonModWorkshopEditSubmissionPage } from './pages/edit-submission/edit-submission'; + +const routes: Routes = [ + { + path: ':courseId/:cmId', + component: AddonModWorkshopIndexPage, + }, + { + path: ':courseId/:cmId/:submissionId', + component: AddonModWorkshopSubmissionPage, + canDeactivate: [CanLeaveGuard], + }, + { + path: ':courseId/:cmId/:submissionId/edit', // @todo + component: AddonModWorkshopEditSubmissionPage, + canDeactivate: [CanLeaveGuard], + }, + { + path: ':courseId/:cmId/:submissionId/:assessmentId', + component: AddonModWorkshopAssessmentPage, + canDeactivate: [CanLeaveGuard], + }, +]; + +@NgModule({ + imports: [ + RouterModule.forChild(routes), + CoreSharedModule, + AddonModWorkshopComponentsModule, + CoreEditorComponentsModule, + ], + declarations: [ + AddonModWorkshopIndexPage, + AddonModWorkshopSubmissionPage, + AddonModWorkshopAssessmentPage, + AddonModWorkshopEditSubmissionPage, + ], +}) +export class AddonModWorkshopLazyModule {} diff --git a/src/core/features/course/classes/main-resource-component.ts b/src/core/features/course/classes/main-resource-component.ts index e39121412..4aa56ece4 100644 --- a/src/core/features/course/classes/main-resource-component.ts +++ b/src/core/features/course/classes/main-resource-component.ts @@ -67,12 +67,12 @@ export class CoreCourseModuleMainResourceComponent implements OnInit, OnDestroy, prefetchStatus?: string; // Used when calling fillContextMenu. prefetchText?: string; // Used when calling fillContextMenu. size?: string; // Used when calling fillContextMenu. - isDestroyed?: boolean; // Whether the component is destroyed, used when calling fillContextMenu. + isDestroyed = false; // Whether the component is destroyed, used when calling fillContextMenu. contextMenuStatusObserver?: CoreEventObserver; // Observer of package status, used when calling fillContextMenu. contextFileStatusObserver?: CoreEventObserver; // Observer of file status, used when calling fillContextMenu. protected fetchContentDefaultError = 'core.course.errorgetmodule'; // Default error to show when loading contents. - protected isCurrentView?: boolean; // Whether the component is in the current view. + protected isCurrentView = false; // Whether the component is in the current view. protected siteId?: string; // Current Site ID. protected statusObserver?: CoreEventObserver; // Observer of package status. Only if setStatusListener is called. protected currentStatus?: string; // The current status of the module. Only if setStatusListener is called. diff --git a/src/core/features/grades/services/grades-helper.ts b/src/core/features/grades/services/grades-helper.ts index 15bf71d7e..c04a9ca78 100644 --- a/src/core/features/grades/services/grades-helper.ts +++ b/src/core/features/grades/services/grades-helper.ts @@ -306,20 +306,16 @@ export class CoreGradesHelperProvider { * @param selectedGrade Selected grade value. * @return Selected grade label. */ - getGradeLabelFromValue(grades: CoreGradesMenuItem[], selectedGrade: number): string { + getGradeLabelFromValue(grades: CoreGradesMenuItem[], selectedGrade?: number): string { selectedGrade = Number(selectedGrade); if (!grades || !selectedGrade || selectedGrade <= 0) { return ''; } - for (const x in grades) { - if (grades[x].value == selectedGrade) { - return grades[x].label; - } - } + const grade = grades.find((grade) => grade.value == selectedGrade); - return ''; + return grade ? grade.label : ''; } /** @@ -633,31 +629,35 @@ export class CoreGradesHelperProvider { * @param scale Scale csv list String. If not provided, it will take it from the module grade info. * @return Array with objects with value and label to create a propper HTML select. */ - makeGradesMenu( - gradingType: number, + async makeGradesMenu( + gradingType?: number, moduleId?: number, defaultLabel: string = '', defaultValue: string | number = '', scale?: string, ): Promise { + if (typeof gradingType == 'undefined') { + return []; + } + if (gradingType < 0) { if (scale) { - return Promise.resolve(CoreUtils.makeMenuFromList(scale, defaultLabel, undefined, defaultValue)); - } else if (moduleId) { - return CoreCourse.getModuleBasicGradeInfo(moduleId).then((gradeInfo) => { - if (gradeInfo && gradeInfo.scale) { - return CoreUtils.makeMenuFromList(gradeInfo.scale, defaultLabel, undefined, defaultValue); - } - - return []; - }); - } else { - return Promise.resolve([]); + return CoreUtils.makeMenuFromList(scale, defaultLabel, undefined, defaultValue); } + + if (moduleId) { + const gradeInfo = await CoreCourse.getModuleBasicGradeInfo(moduleId); + if (gradeInfo && gradeInfo.scale) { + return CoreUtils.makeMenuFromList(gradeInfo.scale, defaultLabel, undefined, defaultValue); + } + } + + return []; } if (gradingType > 0) { const grades: CoreGradesMenuItem[] = []; + if (defaultLabel) { // Key as string to avoid resorting of the object. grades.push({ @@ -665,6 +665,7 @@ export class CoreGradesHelperProvider { value: defaultValue, }); } + for (let i = gradingType; i >= 0; i--) { grades.push({ label: i + ' / ' + gradingType, @@ -672,10 +673,10 @@ export class CoreGradesHelperProvider { }); } - return Promise.resolve(grades); + return grades; } - return Promise.resolve([]); + return []; } /** diff --git a/src/core/singletons/form.ts b/src/core/singletons/form.ts index b28d861f5..f3f3d8775 100644 --- a/src/core/singletons/form.ts +++ b/src/core/singletons/form.ts @@ -63,7 +63,7 @@ export class CoreForms { * @param form Form element. * @param siteId The site affected. If not provided, no site affected. */ - static triggerFormCancelledEvent(formRef: ElementRef | HTMLFormElement | undefined, siteId?: string): void { + static triggerFormCancelledEvent(formRef?: ElementRef | HTMLFormElement | undefined, siteId?: string): void { if (!formRef) { return; } @@ -81,7 +81,7 @@ export class CoreForms { * @param online Whether the action was done in offline or not. * @param siteId The site affected. If not provided, no site affected. */ - static triggerFormSubmittedEvent(formRef: ElementRef | HTMLFormElement | undefined, online?: boolean, siteId?: string): void { + static triggerFormSubmittedEvent(formRef?: ElementRef | HTMLFormElement | undefined, online?: boolean, siteId?: string): void { if (!formRef) { return; } diff --git a/src/theme/theme.base.scss b/src/theme/theme.base.scss index 49955b3bf..17809b66b 100644 --- a/src/theme/theme.base.scss +++ b/src/theme/theme.base.scss @@ -275,6 +275,11 @@ ion-toolbar { color: $base; } } + + ion-icon.ion-color-#{$color-name} { + color: $base; + --ion-color-base: #{$base}; + } } // Avatar