diff --git a/scripts/langindex.json b/scripts/langindex.json index 26f2815f3..3ab3ca3a4 100644 --- a/scripts/langindex.json +++ b/scripts/langindex.json @@ -559,6 +559,7 @@ "addon.mod_feedback.analysis": "feedback", "addon.mod_feedback.anonymous": "feedback", "addon.mod_feedback.anonymous_entries": "feedback", + "addon.mod_feedback.anonymous_user": "feedback", "addon.mod_feedback.average": "feedback", "addon.mod_feedback.captchaofflinewarning": "local_moodlemobileapp", "addon.mod_feedback.complete_the_form": "feedback", diff --git a/src/addons/badges/pages/issued-badge/issued-badge.page.ts b/src/addons/badges/pages/issued-badge/issued-badge.page.ts index 4a9285995..1fc7a21be 100644 --- a/src/addons/badges/pages/issued-badge/issued-badge.page.ts +++ b/src/addons/badges/pages/issued-badge/issued-badge.page.ts @@ -43,29 +43,30 @@ export class AddonBadgesIssuedBadgePage implements OnInit { user?: CoreUserProfile; course?: CoreEnrolledCourseData; badge?: AddonBadgesUserBadge; - badges?: CoreSwipeNavigationItemsManager; + badges: CoreSwipeNavigationItemsManager; badgeLoaded = false; currentTime = 0; - constructor(protected route: ActivatedRoute) { } - - /** - * View loaded. - */ - ngOnInit(): void { + constructor(protected route: ActivatedRoute) { this.courseId = CoreNavigator.getRouteNumberParam('courseId') || this.courseId; // Use 0 for site badges. this.userId = CoreNavigator.getRouteNumberParam('userId') || CoreSites.getRequiredCurrentSite().getUserId(); this.badgeHash = CoreNavigator.getRouteParam('badgeHash') || ''; - this.fetchIssuedBadge().finally(() => { - this.badgeLoaded = true; - }); - const source = CoreRoutedItemsManagerSourcesTracker.getOrCreateSource( AddonBadgesUserBadgesSource, [this.courseId, this.userId], ); + this.badges = new CoreSwipeNavigationItemsManager(source); + } + + /** + * View loaded. + */ + ngOnInit(): void { + this.fetchIssuedBadge().finally(() => { + this.badgeLoaded = true; + }); this.badges.start(); } diff --git a/src/addons/mod/feedback/classes/feedback-attempts-source.ts b/src/addons/mod/feedback/classes/feedback-attempts-source.ts new file mode 100644 index 000000000..4ef92a857 --- /dev/null +++ b/src/addons/mod/feedback/classes/feedback-attempts-source.ts @@ -0,0 +1,163 @@ +// (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 { CoreRoutedItemsManagerSource } from '@classes/items-management/routed-items-manager-source'; +import { CoreGroupInfo, CoreGroups } from '@services/groups'; +import { + AddonModFeedback, + AddonModFeedbackProvider, + AddonModFeedbackWSAnonAttempt, + AddonModFeedbackWSAttempt, + AddonModFeedbackWSFeedback, +} from '../services/feedback'; +import { AddonModFeedbackHelper } from '../services/feedback-helper'; + +/** + * Feedback attempts. + */ +export class AddonModFeedbackAttemptsSource extends CoreRoutedItemsManagerSource { + + readonly COURSE_ID: number; + readonly CM_ID: number; + + selectedGroup?: number; + identifiable?: AddonModFeedbackWSAttempt[]; + identifiableTotal?: number; + anonymous?: AddonModFeedbackWSAnonAttempt[]; + anonymousTotal?: number; + groupInfo?: CoreGroupInfo; + + protected feedback?: AddonModFeedbackWSFeedback; + + constructor(courseId: number, cmId: number) { + super(); + + this.COURSE_ID = courseId; + this.CM_ID = cmId; + } + + /** + * @inheritdoc + */ + getItemPath(attempt: AddonModFeedbackAttemptItem): string { + return attempt.id.toString(); + } + + /** + * @inheritdoc + */ + getPagesLoaded(): number { + if (!this.identifiable || !this.anonymous) { + return 0; + } + + const pageLength = this.getPageLength(); + + return Math.ceil(Math.max(this.anonymous.length, this.identifiable.length) / pageLength); + } + + /** + * Type guard to infer AddonModFeedbackWSAttempt objects. + * + * @param discussion Item to check. + * @return Whether the item is an identifieable attempt. + */ + isIdentifiableAttempt(attempt: AddonModFeedbackAttemptItem): attempt is AddonModFeedbackWSAttempt { + return 'fullname' in attempt; + } + + /** + * Type guard to infer AddonModFeedbackWSAnonAttempt objects. + * + * @param discussion Item to check. + * @return Whether the item is an anonymous attempt. + */ + isAnonymousAttempt(attempt: AddonModFeedbackAttemptItem): attempt is AddonModFeedbackWSAnonAttempt { + return 'number' in attempt; + } + + /** + * Invalidate feedback cache. + */ + async invalidateCache(): Promise { + await Promise.all([ + CoreGroups.invalidateActivityGroupInfo(this.CM_ID), + this.feedback && AddonModFeedback.invalidateResponsesAnalysisData(this.feedback.id), + ]); + } + + /** + * Load feedback. + */ + async loadFeedback(): Promise { + this.feedback = await AddonModFeedback.getFeedback(this.COURSE_ID, this.CM_ID); + this.groupInfo = await CoreGroups.getActivityGroupInfo(this.CM_ID); + + this.selectedGroup = CoreGroups.validateGroupId(this.selectedGroup, this.groupInfo); + } + + /** + * @inheritdoc + */ + protected getPageLength(): number { + return AddonModFeedbackProvider.PER_PAGE; + } + + /** + * @inheritdoc + */ + protected async loadPageItems(page: number): Promise<{ items: AddonModFeedbackAttemptItem[]; hasMoreItems: boolean }> { + if (!this.feedback) { + throw new Error('Can\'t load attempts without feeback'); + } + + const result = await AddonModFeedbackHelper.getResponsesAnalysis(this.feedback.id, { + page, + groupId: this.selectedGroup, + cmId: this.CM_ID, + }); + + if (page === 0) { + this.identifiableTotal = result.totalattempts; + this.anonymousTotal = result.totalanonattempts; + } + + const totalItemsLoaded = this.getPageLength() * (page + 1); + const pageAttempts: AddonModFeedbackAttemptItem[] = [ + ...result.attempts, + ...result.anonattempts, + ]; + + return { + items: pageAttempts, + hasMoreItems: result.totalattempts > totalItemsLoaded || result.totalanonattempts > totalItemsLoaded, + }; + } + + /** + * @inheritdoc + */ + protected setItems(attempts: AddonModFeedbackAttemptItem[], hasMoreItems: boolean): void { + this.identifiable = attempts.filter(this.isIdentifiableAttempt); + this.anonymous = attempts.filter(this.isAnonymousAttempt); + + super.setItems((this.identifiable as AddonModFeedbackAttemptItem[]).concat(this.anonymous), hasMoreItems); + } + +} + +/** + * Type of items that can be held in the source. + */ +export type AddonModFeedbackAttemptItem = AddonModFeedbackWSAttempt | AddonModFeedbackWSAnonAttempt; diff --git a/src/addons/mod/feedback/components/index/addon-mod-feedback-index.html b/src/addons/mod/feedback/components/index/addon-mod-feedback-index.html index c9cb04c87..685a98db0 100644 --- a/src/addons/mod/feedback/components/index/addon-mod-feedback-index.html +++ b/src/addons/mod/feedback/components/index/addon-mod-feedback-index.html @@ -72,7 +72,7 @@ -

{{ 'addon.mod_feedback.completed_feedbacks' | translate }}

diff --git a/src/addons/mod/feedback/components/index/index.ts b/src/addons/mod/feedback/components/index/index.ts index bfa04b763..3f3f94027 100644 --- a/src/addons/mod/feedback/components/index/index.ts +++ b/src/addons/mod/feedback/components/index/index.ts @@ -400,15 +400,15 @@ export class AddonModFeedbackIndexComponent extends CoreCourseModuleMainActivity } /** - * Open respondents page. + * Open attempts page. */ - openRespondents(): void { + openAttempts(): void { if (!this.access!.canviewreports || this.completedCount <= 0) { return; } CoreNavigator.navigateToSitePath( - AddonModFeedbackModuleHandlerService.PAGE_NAME + `/${this.courseId}/${this.module.id}/respondents`, + AddonModFeedbackModuleHandlerService.PAGE_NAME + `/${this.courseId}/${this.module.id}/attempts`, { params: { group: this.group, diff --git a/src/addons/mod/feedback/feedback-lazy.module.ts b/src/addons/mod/feedback/feedback-lazy.module.ts index 15bf99ecd..b055ceedd 100644 --- a/src/addons/mod/feedback/feedback-lazy.module.ts +++ b/src/addons/mod/feedback/feedback-lazy.module.ts @@ -17,7 +17,7 @@ import { RouterModule, Routes } from '@angular/router'; import { CoreSharedModule } from '@/core/shared.module'; import { AddonModFeedbackComponentsModule } from './components/components.module'; import { AddonModFeedbackIndexPage } from './pages/index/index'; -import { AddonModFeedbackRespondentsPage } from './pages/respondents/respondents'; +import { AddonModFeedbackAttemptsPage } from './pages/attempts/attempts'; import { conditionalRoutes } from '@/app/app-routing.module'; import { CoreScreen } from '@services/screen'; @@ -40,11 +40,11 @@ const commonRoutes: Routes = [ const mobileRoutes: Routes = [ ...commonRoutes, { - path: ':courseId/:cmId/respondents', - component: AddonModFeedbackRespondentsPage, + path: ':courseId/:cmId/attempts', + component: AddonModFeedbackAttemptsPage, }, { - path: ':courseId/:cmId/respondents/attempt/:attemptId', + path: ':courseId/:cmId/attempts/:attemptId', loadChildren: () => import('./pages/attempt/attempt.module').then(m => m.AddonModFeedbackAttemptPageModule), }, ]; @@ -52,11 +52,11 @@ const mobileRoutes: Routes = [ const tabletRoutes: Routes = [ ...commonRoutes, { - path: ':courseId/:cmId/respondents', - component: AddonModFeedbackRespondentsPage, + path: ':courseId/:cmId/attempts', + component: AddonModFeedbackAttemptsPage, children: [ { - path: 'attempt/:attemptId', + path: ':attemptId', loadChildren: () => import('./pages/attempt/attempt.module').then(m => m.AddonModFeedbackAttemptPageModule), }, ], @@ -76,7 +76,7 @@ const routes: Routes = [ ], declarations: [ AddonModFeedbackIndexPage, - AddonModFeedbackRespondentsPage, + AddonModFeedbackAttemptsPage, ], }) export class AddonModFeedbackLazyModule {} diff --git a/src/addons/mod/feedback/lang.json b/src/addons/mod/feedback/lang.json index 8d575a97b..903a127e3 100644 --- a/src/addons/mod/feedback/lang.json +++ b/src/addons/mod/feedback/lang.json @@ -2,6 +2,7 @@ "analysis": "Analysis", "anonymous": "Anonymous", "anonymous_entries": "Anonymous entries ({{$a}})", + "anonymous_user": "Anonymous user", "average": "Average", "captchaofflinewarning": "Feedback with CAPTCHA cannot be completed offline, or if not configured, or if the server is down.", "complete_the_form": "Answer the questions", diff --git a/src/addons/mod/feedback/pages/attempt/attempt.html b/src/addons/mod/feedback/pages/attempt/attempt.html index 909f8e6a0..3fee6027f 100644 --- a/src/addons/mod/feedback/pages/attempt/attempt.html +++ b/src/addons/mod/feedback/pages/attempt/attempt.html @@ -5,52 +5,51 @@

{{ attempt.fullname }}

-

- {{ 'addon.mod_feedback.response_nr' |translate }}: {{anonAttempt.number}} -

+

{{ 'addon.mod_feedback.anonymous_user' | translate }}

- - - - - -

{{attempt.fullname}}

-

{{attempt.timemodified * 1000 | coreFormatDate }}

-
-
+ + + + + + +

{{attempt.fullname}}

+

{{attempt.timemodified * 1000 | coreFormatDate }}

+
+
- - -

- {{ 'addon.mod_feedback.response_nr' |translate }}: {{anonAttempt.number}} - ({{ 'addon.mod_feedback.anonymous' |translate }}) -

-
-
- - - - - -

- {{item.itemnumber}}. - - -

-

- - -

-
-
+ + + +

{{ 'addon.mod_feedback.anonymous_user' |translate }}

+

{{ 'addon.mod_feedback.response_nr' | translate }}: {{anonAttempt.number}}

+
+
+ + + + + +

+ {{item.itemnumber}}. + + +

+

+ + +

+
+
+
-
-
-
+
+
+
diff --git a/src/addons/mod/feedback/pages/attempt/attempt.ts b/src/addons/mod/feedback/pages/attempt/attempt.ts index 194f22f06..35733c4ed 100644 --- a/src/addons/mod/feedback/pages/attempt/attempt.ts +++ b/src/addons/mod/feedback/pages/attempt/attempt.ts @@ -12,10 +12,14 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { Component, OnInit } from '@angular/core'; +import { Component, OnDestroy, OnInit } from '@angular/core'; +import { ActivatedRouteSnapshot } from '@angular/router'; +import { CoreRoutedItemsManagerSourcesTracker } from '@classes/items-management/routed-items-manager-sources-tracker'; +import { CoreSwipeNavigationItemsManager } from '@classes/items-management/swipe-navigation-items-manager'; import { CoreNavigator } from '@services/navigator'; import { CoreDomUtils } from '@services/utils/dom'; import { CoreTextUtils } from '@services/utils/text'; +import { AddonModFeedbackAttemptsSource } from '../../classes/feedback-attempts-source'; import { AddonModFeedback, AddonModFeedbackProvider, @@ -32,27 +36,39 @@ import { AddonModFeedbackFormItem, AddonModFeedbackHelper } from '../../services selector: 'page-addon-mod-feedback-attempt', templateUrl: 'attempt.html', }) -export class AddonModFeedbackAttemptPage implements OnInit { +export class AddonModFeedbackAttemptPage implements OnInit, OnDestroy { - protected attemptId!: number; - - cmId!: number; - courseId!: number; + cmId: number; + courseId: number; feedback?: AddonModFeedbackWSFeedback; attempt?: AddonModFeedbackWSAttempt; + attempts: AddonModFeedbackAttemptsSwipeManager; anonAttempt?: AddonModFeedbackWSAnonAttempt; items: AddonModFeedbackAttemptItem[] = []; component = AddonModFeedbackProvider.COMPONENT; loaded = false; + protected attemptId: number; + + constructor() { + this.cmId = CoreNavigator.getRequiredRouteNumberParam('cmId'); + this.courseId = CoreNavigator.getRequiredRouteNumberParam('courseId'); + this.attemptId = CoreNavigator.getRequiredRouteNumberParam('attemptId'); + + const source = CoreRoutedItemsManagerSourcesTracker.getOrCreateSource( + AddonModFeedbackAttemptsSource, + [this.courseId, this.cmId], + ); + + this.attempts = new AddonModFeedbackAttemptsSwipeManager(source); + } + /** * @inheritdoc */ ngOnInit(): void { try { - this.cmId = CoreNavigator.getRequiredRouteNumberParam('cmId'); - this.courseId = CoreNavigator.getRequiredRouteNumberParam('courseId'); - this.attemptId = CoreNavigator.getRequiredRouteNumberParam('attemptId'); + this.attempts.start(); } catch (error) { CoreDomUtils.showErrorModal(error); @@ -64,6 +80,13 @@ export class AddonModFeedbackAttemptPage implements OnInit { this.fetchData(); } + /** + * @inheritdoc + */ + ngOnDestroy(): void { + this.attempts.destroy(); + } + /** * Fetch all the data required for the view. * @@ -131,3 +154,17 @@ export class AddonModFeedbackAttemptPage implements OnInit { type AddonModFeedbackAttemptItem = AddonModFeedbackFormItem & { submittedValue?: string; }; + +/** + * Helper to manage swiping within a collection of discussions. + */ +class AddonModFeedbackAttemptsSwipeManager extends CoreSwipeNavigationItemsManager { + + /** + * @inheritdoc + */ + protected getSelectedItemPathFromRoute(route: ActivatedRouteSnapshot): string | null { + return route.params.attemptId; + } + +} diff --git a/src/addons/mod/feedback/pages/attempts/attempts.html b/src/addons/mod/feedback/pages/attempts/attempts.html new file mode 100644 index 000000000..123818d86 --- /dev/null +++ b/src/addons/mod/feedback/pages/attempts/attempts.html @@ -0,0 +1,69 @@ + + + + + + +

{{ 'addon.mod_feedback.responses' |translate }}

+
+
+
+ + + + + + + + + + {{'core.groupsseparate' | translate }} + {{'core.groupsvisible' | translate }} + + + + {{groupOpt.name}} + + + + + + + +

{{ 'addon.mod_feedback.non_anonymous_entries' | translate : {$a: identifiableAttemptsTotal } }}

+
+
+ + + +

{{ attempt.fullname }}

+

{{ attempt.timemodified * 1000 | coreFormatDate }}

+
+
+
+ + + + +

{{ 'addon.mod_feedback.anonymous_entries' | translate : {$a: anonymousAttemptsTotal } }}

+
+
+ + + +

{{ 'addon.mod_feedback.anonymous_user' | translate }}

+

{{ 'addon.mod_feedback.response_nr' | translate }}: {{attempt.number}}

+
+
+
+ + + +
+
+
+
diff --git a/src/addons/mod/feedback/pages/attempts/attempts.ts b/src/addons/mod/feedback/pages/attempts/attempts.ts new file mode 100644 index 000000000..40fe0a21f --- /dev/null +++ b/src/addons/mod/feedback/pages/attempts/attempts.ts @@ -0,0 +1,183 @@ +// (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 { AfterViewInit, Component, OnDestroy, ViewChild } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; +import { CoreRoutedItemsManagerSourcesTracker } from '@classes/items-management/routed-items-manager-sources-tracker'; +import { CoreListItemsManager } from '@classes/items-management/list-items-manager'; +import { CorePromisedValue } from '@classes/promised-value'; +import { CoreSplitViewComponent } from '@components/split-view/split-view'; +import { IonRefresher } from '@ionic/angular'; +import { CoreGroupInfo } from '@services/groups'; +import { CoreNavigator } from '@services/navigator'; +import { CoreDomUtils } from '@services/utils/dom'; +import { CoreUtils } from '@services/utils/utils'; +import { AddonModFeedbackAttemptItem, AddonModFeedbackAttemptsSource } from '../../classes/feedback-attempts-source'; +import { AddonModFeedbackWSAnonAttempt, AddonModFeedbackWSAttempt } from '../../services/feedback'; + +/** + * Page that displays feedback attempts. + */ +@Component({ + selector: 'page-addon-mod-feedback-attempts', + templateUrl: 'attempts.html', +}) +export class AddonModFeedbackAttemptsPage implements AfterViewInit, OnDestroy { + + @ViewChild(CoreSplitViewComponent) splitView!: CoreSplitViewComponent; + + promisedAttempts: CorePromisedValue>; + fetchFailed = false; + + constructor(protected route: ActivatedRoute) { + this.promisedAttempts = new CorePromisedValue(); + } + + get attempts(): CoreListItemsManager | null { + return this.promisedAttempts.value; + } + + get groupInfo(): CoreGroupInfo | undefined { + return this.attempts?.getSource().groupInfo; + } + + get selectedGroup(): number | undefined { + return this.attempts?.getSource().selectedGroup; + } + + set selectedGroup(group: number | undefined) { + if (!this.attempts) { + return; + } + + this.attempts.getSource().selectedGroup = group; + this.attempts.getSource().setDirty(true); + } + + get identifiableAttempts(): AddonModFeedbackWSAttempt[] { + return this.attempts?.getSource().identifiable ?? []; + } + + get identifiableAttemptsTotal(): number { + return this.attempts?.getSource().identifiableTotal ?? 0; + } + + get anonymousAttempts(): AddonModFeedbackWSAnonAttempt[] { + return this.attempts?.getSource().anonymous ?? []; + } + + get anonymousAttemptsTotal(): number { + return this.attempts?.getSource().anonymousTotal ?? 0; + } + + /** + * @inheritdoc + */ + async ngAfterViewInit(): Promise { + try { + const cmId = CoreNavigator.getRequiredRouteNumberParam('cmId'); + const courseId = CoreNavigator.getRequiredRouteNumberParam('courseId'); + const source = CoreRoutedItemsManagerSourcesTracker.getOrCreateSource( + AddonModFeedbackAttemptsSource, + [courseId, cmId], + ); + + source.selectedGroup = CoreNavigator.getRouteNumberParam('group') || 0; + + this.promisedAttempts.resolve(new CoreListItemsManager(source, this.route.component)); + } catch (error) { + CoreDomUtils.showErrorModal(error); + + CoreNavigator.back(); + + return; + } + + const attempts = await this.promisedAttempts; + + try { + this.fetchFailed = false; + + await attempts.getSource().loadFeedback(); + await attempts.load(); + } catch (error) { + this.fetchFailed = true; + + CoreDomUtils.showErrorModalDefault(error, 'core.course.errorgetmodule', true); + } + + await attempts.start(this.splitView); + } + + /** + * @inheritdoc + */ + ngOnDestroy(): void { + this.attempts?.destroy(); + } + + /** + * Fetch more attempts, if any. + * + * @param infiniteComplete Complete callback for infinite loader. + */ + async fetchMoreAttempts(infiniteComplete?: () => void): Promise { + const attempts = await this.promisedAttempts; + + try { + this.fetchFailed = false; + + await attempts.load(); + } catch (error) { + this.fetchFailed = true; + + CoreDomUtils.showErrorModalDefault(error, 'core.course.errorgetmodule', true); + } finally { + infiniteComplete && infiniteComplete(); + } + } + + /** + * Refresh the attempts. + * + * @param refresher Refresher. + */ + async refreshFeedback(refresher: IonRefresher): Promise { + const attempts = await this.promisedAttempts; + + try { + this.fetchFailed = false; + + await CoreUtils.ignoreErrors(attempts.getSource().invalidateCache()); + await attempts.getSource().loadFeedback(); + await attempts.reload(); + } catch (error) { + this.fetchFailed = true; + + CoreDomUtils.showErrorModalDefault(error, 'core.course.errorgetmodule', true); + } finally { + refresher.complete(); + } + } + + /** + * Reload attempts list. + */ + async reloadAttempts(): Promise { + const attempts = await this.promisedAttempts; + + await attempts.reload(); + } + +} diff --git a/src/addons/mod/feedback/pages/respondents/respondents.html b/src/addons/mod/feedback/pages/respondents/respondents.html deleted file mode 100644 index 51c2184d0..000000000 --- a/src/addons/mod/feedback/pages/respondents/respondents.html +++ /dev/null @@ -1,86 +0,0 @@ - - - - - - -

{{ 'addon.mod_feedback.responses' |translate }}

-
-
-
- - - - - - - - - - {{'core.groupsseparate' | translate }} - {{'core.groupsvisible' | translate }} - - - - {{groupOpt.name}} - - - - - - - -

{{ 'addon.mod_feedback.non_anonymous_entries' | translate : {$a: responses.responses.total } }}

-
-
- - - -

{{ attempt.fullname }}

-

{{attempt.timemodified * 1000 | coreFormatDate }}

-
-
- - - - {{ 'core.loadmore' | translate }} - - - - - - -
- - - - -

{{ 'addon.mod_feedback.anonymous_entries' |translate : {$a: responses.anonResponses.total } }}

-
-
- - -

{{ 'addon.mod_feedback.response_nr' |translate }}: {{attempt.number}}

-
-
- - - - {{ 'core.loadmore' | translate }} - - - - - - -
-
-
-
-
diff --git a/src/addons/mod/feedback/pages/respondents/respondents.ts b/src/addons/mod/feedback/pages/respondents/respondents.ts deleted file mode 100644 index 73a95a75f..000000000 --- a/src/addons/mod/feedback/pages/respondents/respondents.ts +++ /dev/null @@ -1,256 +0,0 @@ -// (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 { AfterViewInit, Component, ViewChild } from '@angular/core'; -import { ActivatedRoute } from '@angular/router'; -import { CorePageItemsListManager } from '@classes/page-items-list-manager'; -import { CoreSplitViewComponent } from '@components/split-view/split-view'; -import { IonRefresher } from '@ionic/angular'; -import { CoreGroupInfo, CoreGroups } from '@services/groups'; -import { CoreNavigator } from '@services/navigator'; -import { CoreDomUtils } from '@services/utils/dom'; -import { CoreUtils } from '@services/utils/utils'; -import { - AddonModFeedback, - AddonModFeedbackWSAnonAttempt, - AddonModFeedbackWSAttempt, - AddonModFeedbackWSFeedback, -} from '../../services/feedback'; -import { AddonModFeedbackHelper, AddonModFeedbackResponsesAnalysis } from '../../services/feedback-helper'; - -/** - * Page that displays feedback respondents. - */ -@Component({ - selector: 'page-addon-mod-feedback-respondents', - templateUrl: 'respondents.html', -}) -export class AddonModFeedbackRespondentsPage implements AfterViewInit { - - @ViewChild(CoreSplitViewComponent) splitView!: CoreSplitViewComponent; - - protected cmId!: number; - protected courseId!: number; - protected page = 0; - protected feedback?: AddonModFeedbackWSFeedback; - - responses: AddonModFeedbackResponsesManager; - selectedGroup!: number; - groupInfo?: CoreGroupInfo; - loaded = false; - loadingMore = false; - - constructor( - route: ActivatedRoute, - ) { - this.responses = new AddonModFeedbackResponsesManager( - route.component, - ); - } - - /** - * @inheritdoc - */ - async ngAfterViewInit(): Promise { - try { - this.cmId = CoreNavigator.getRequiredRouteNumberParam('cmId'); - this.courseId = CoreNavigator.getRequiredRouteNumberParam('courseId'); - this.selectedGroup = CoreNavigator.getRouteNumberParam('group') || 0; - } catch (error) { - CoreDomUtils.showErrorModal(error); - - CoreNavigator.back(); - - return; - } - - await this.fetchData(); - - this.responses.start(this.splitView); - } - - /** - * Fetch all the data required for the view. - * - * @param refresh Empty events array first. - * @return Promise resolved when done. - */ - async fetchData(refresh: boolean = false): Promise { - this.page = 0; - this.responses.resetItems(); - - try { - this.feedback = await AddonModFeedback.getFeedback(this.courseId, this.cmId); - - this.groupInfo = await CoreGroups.getActivityGroupInfo(this.cmId); - - this.selectedGroup = CoreGroups.validateGroupId(this.selectedGroup, this.groupInfo); - - await this.loadGroupAttempts(this.selectedGroup); - } catch (error) { - CoreDomUtils.showErrorModalDefault(error, 'core.course.errorgetmodule', true); - - if (!refresh) { - // Some call failed on first fetch, go back. - CoreNavigator.back(); - } - } - } - - /** - * Load Group attempts. - * - * @param groupId If defined it will change group if not, it will load more attempts for the same group. - * @return Resolved with the attempts loaded. - */ - protected async loadGroupAttempts(groupId?: number): Promise { - if (groupId === undefined) { - this.page++; - this.loadingMore = true; - } else { - this.selectedGroup = groupId; - this.page = 0; - this.responses.resetItems(); - } - - try { - const responses = await AddonModFeedbackHelper.getResponsesAnalysis(this.feedback!.id, { - groupId: this.selectedGroup, - page: this.page, - cmId: this.cmId, - }); - - this.responses.setResponses(responses); - } finally { - this.loadingMore = false; - this.loaded = true; - } - } - - /** - * Change selected group or load more attempts. - * - * @param groupId Group ID selected. If not defined, it will load more attempts. - */ - async loadAttempts(groupId?: number): Promise { - try { - await this.loadGroupAttempts(groupId); - } catch (error) { - CoreDomUtils.showErrorModalDefault(error, 'core.course.errorgetmodule', true); - } - } - - /** - * Refresh the attempts. - * - * @param refresher Refresher. - */ - async refreshFeedback(refresher: IonRefresher): Promise { - const promises: Promise[] = []; - - promises.push(CoreGroups.invalidateActivityGroupInfo(this.cmId)); - if (this.feedback) { - promises.push(AddonModFeedback.invalidateResponsesAnalysisData(this.feedback.id)); - } - - try { - await CoreUtils.ignoreErrors(Promise.all(promises)); - - await this.fetchData(true); - } finally { - refresher.complete(); - } - } - -} - -/** - * Type of items that can be held by the entries manager. - */ -type EntryItem = AddonModFeedbackWSAttempt | AddonModFeedbackWSAnonAttempt; - -/** - * Entries manager. - */ -class AddonModFeedbackResponsesManager extends CorePageItemsListManager { - - responses: AddonModFeedbackResponses = { - attempts: [], - total: 0, - canLoadMore: false, - }; - - anonResponses: AddonModFeedbackAnonResponses = { - attempts: [], - total: 0, - canLoadMore: false, - }; - - constructor(pageComponent: unknown) { - super(pageComponent); - } - - /** - * Update responses. - * - * @param responses Responses. - */ - setResponses(responses: AddonModFeedbackResponsesAnalysis): void { - this.responses.total = responses.totalattempts; - this.anonResponses.total = responses.totalanonattempts; - - if (this.anonResponses.attempts.length < responses.totalanonattempts) { - this.anonResponses.attempts = this.anonResponses.attempts.concat(responses.anonattempts); - } - if (this.responses.attempts.length < responses.totalattempts) { - this.responses.attempts = this.responses.attempts.concat(responses.attempts); - } - - this.anonResponses.canLoadMore = this.anonResponses.attempts.length < responses.totalanonattempts; - this.responses.canLoadMore = this.responses.attempts.length < responses.totalattempts; - - this.setItems(( this.responses.attempts).concat(this.anonResponses.attempts)); - } - - /** - * @inheritdoc - */ - resetItems(): void { - super.resetItems(); - this.responses.total = 0; - this.responses.attempts = []; - this.anonResponses.total = 0; - this.anonResponses.attempts = []; - } - - /** - * @inheritdoc - */ - protected getItemPath(entry: EntryItem): string { - return `attempt/${entry.id}`; - } - -} - -type AddonModFeedbackResponses = { - attempts: AddonModFeedbackWSAttempt[]; - total: number; - canLoadMore: boolean; -}; - -type AddonModFeedbackAnonResponses = { - attempts: AddonModFeedbackWSAnonAttempt[]; - total: number; - canLoadMore: boolean; -}; diff --git a/src/addons/mod/feedback/services/feedback-helper.ts b/src/addons/mod/feedback/services/feedback-helper.ts index 3c1a89ce9..18a9a50f3 100644 --- a/src/addons/mod/feedback/services/feedback-helper.ts +++ b/src/addons/mod/feedback/services/feedback-helper.ts @@ -184,7 +184,7 @@ export class AddonModFeedbackHelperProvider { if (params.showcompleted === undefined) { // Param showcompleted not defined. Show entry list. await CoreNavigator.navigateToSitePath( - AddonModFeedbackModuleHandlerService.PAGE_NAME + `/${module.course}/${module.id}/respondents`, + AddonModFeedbackModuleHandlerService.PAGE_NAME + `/${module.course}/${module.id}/attempts`, { siteId }, ); diff --git a/src/addons/mod/forum/components/index/index.html b/src/addons/mod/forum/components/index/index.html index 63cd1b7b6..5000be62a 100644 --- a/src/addons/mod/forum/components/index/index.html +++ b/src/addons/mod/forum/components/index/index.html @@ -69,8 +69,8 @@ + [lines]="discussion.groupname && 'none'" [attr.aria-current]="discussions?.getItemAriaCurrent(discussion)" + (click)="discussions?.select(discussion)" button>

diff --git a/src/addons/mod/forum/components/index/index.ts b/src/addons/mod/forum/components/index/index.ts index 3871096c8..02a14872c 100644 --- a/src/addons/mod/forum/components/index/index.ts +++ b/src/addons/mod/forum/components/index/index.ts @@ -58,6 +58,7 @@ import { ContextLevel } from '@/core/constants'; import { AddonModForumDiscussionItem, AddonModForumDiscussionsSource } from '../../classes/forum-discussions-source'; import { CoreListItemsManager } from '@classes/items-management/list-items-manager'; import { CoreRoutedItemsManagerSourcesTracker } from '@classes/items-management/routed-items-manager-sources-tracker'; +import { CorePromisedValue } from '@classes/promised-value'; /** * Component that displays a forum entry page. @@ -74,7 +75,7 @@ export class AddonModForumIndexComponent extends CoreCourseModuleMainActivityCom component = AddonModForumProvider.COMPONENT; moduleName = 'forum'; descriptionNote?: string; - discussions!: AddonModForumDiscussionsManager; + promisedDiscussions: CorePromisedValue; discussionsItems: AddonModForumDiscussionItem[] = []; fetchFailed = false; canAddDiscussion = false; @@ -104,6 +105,12 @@ export class AddonModForumIndexComponent extends CoreCourseModuleMainActivityCom @Optional() courseContentsPage?: CoreCourseContentsPage, ) { super('AddonModForumIndexComponent', content, courseContentsPage); + + this.promisedDiscussions = new CorePromisedValue(); + } + + get discussions(): AddonModForumDiscussionsManager | null { + return this.promisedDiscussions.value; } get forum(): AddonModForumData | undefined { @@ -121,7 +128,7 @@ export class AddonModForumIndexComponent extends CoreCourseModuleMainActivityCom * @return Whether the discussion is online. */ isOnlineDiscussion(discussion: AddonModForumDiscussionItem): boolean { - return this.discussions && this.discussions.getSource().isOnlineDiscussion(discussion); + return !!this.discussions?.getSource().isOnlineDiscussion(discussion); } /** @@ -131,7 +138,7 @@ export class AddonModForumIndexComponent extends CoreCourseModuleMainActivityCom * @return Whether the discussion is offline. */ isOfflineDiscussion(discussion: AddonModForumDiscussionItem): boolean { - return this.discussions && this.discussions.getSource().isOfflineDiscussion(discussion); + return !!this.discussions?.getSource().isOfflineDiscussion(discussion); } /** @@ -171,7 +178,7 @@ export class AddonModForumIndexComponent extends CoreCourseModuleMainActivityCom if (hasOffline) { // Only update new fetched discussions. const promises = discussions.map(async (discussion) => { - if (!this.discussions.getSource().isOnlineDiscussion(discussion)) { + if (!this.discussions?.getSource().isOnlineDiscussion(discussion)) { return; } @@ -189,7 +196,7 @@ export class AddonModForumIndexComponent extends CoreCourseModuleMainActivityCom }, }); - this.discussions = new AddonModForumDiscussionsManager(source, this); + this.promisedDiscussions.resolve(new AddonModForumDiscussionsManager(source, this)); // Refresh data if this forum discussion is synchronized from discussions list. this.syncManualObserver = CoreEvents.on(AddonModForumSyncProvider.MANUAL_SYNCED, (data) => { @@ -214,8 +221,8 @@ export class AddonModForumIndexComponent extends CoreCourseModuleMainActivityCom AddonModForum.invalidateDiscussionsList(this.forum.id).finally(() => { if (data.discussionId) { // Discussion changed, search it in the list of discussions. - const discussion = this.discussions.items.find( - (disc) => this.discussions.getSource().isOnlineDiscussion(disc) && data.discussionId == disc.discussion, + const discussion = this.discussions?.items.find( + disc => this.discussions?.getSource().isOnlineDiscussion(disc) && data.discussionId == disc.discussion, ) as AddonModForumDiscussion; if (discussion) { @@ -234,7 +241,7 @@ export class AddonModForumIndexComponent extends CoreCourseModuleMainActivityCom } if (data.deleted !== undefined && data.deleted) { - if (data.post?.parentid == 0 && CoreScreen.isTablet && !this.discussions.empty) { + if (data.post?.parentid == 0 && CoreScreen.isTablet && this.discussions && !this.discussions.empty) { // Discussion deleted, clear details page. this.discussions.select(this.discussions[0]); } @@ -265,7 +272,9 @@ export class AddonModForumIndexComponent extends CoreCourseModuleMainActivityCom async ngAfterViewInit(): Promise { await this.loadContent(false, true); - this.discussions.start(this.splitView); + const discussions = await this.promisedDiscussions; + + discussions.start(this.splitView); } /** @@ -282,7 +291,7 @@ export class AddonModForumIndexComponent extends CoreCourseModuleMainActivityCom this.ratingOfflineObserver && this.ratingOfflineObserver.off(); this.ratingSyncObserver && this.ratingSyncObserver.off(); this.sourceUnsubscribe && this.sourceUnsubscribe(); - this.discussions.destroy(); + this.discussions?.destroy(); } /** @@ -305,8 +314,10 @@ export class AddonModForumIndexComponent extends CoreCourseModuleMainActivityCom return; } + const discussions = await this.promisedDiscussions; + await Promise.all([ - refresh ? this.discussions.reload() : this.discussions.load(), + refresh ? discussions.reload() : discussions.load(), CoreRatingOffline.hasRatings('mod_forum', 'post', ContextLevel.MODULE, this.forum.cmid).then((hasRatings) => { this.hasOfflineRatings = hasRatings; @@ -332,7 +343,9 @@ export class AddonModForumIndexComponent extends CoreCourseModuleMainActivityCom return; } - await this.discussions.getSource().loadForum(); + const discussions = await this.promisedDiscussions; + + await discussions.getSource().loadForum(); if (!this.forum) { return; @@ -380,7 +393,7 @@ export class AddonModForumIndexComponent extends CoreCourseModuleMainActivityCom CoreGroups.instance .getActivityGroupMode(forum.cmid) .then(async mode => { - this.discussions.getSource().usesGroups = + discussions.getSource().usesGroups = mode === CoreGroupsProvider.SEPARATEGROUPS || mode === CoreGroupsProvider.VISIBLEGROUPS; return; @@ -432,10 +445,12 @@ export class AddonModForumIndexComponent extends CoreCourseModuleMainActivityCom * @return Promise resolved when done. */ async fetchMoreDiscussions(complete: () => void): Promise { + const discussions = await this.promisedDiscussions; + try { this.fetchFailed = false; - await this.discussions.load(); + await discussions.load(); } catch (error) { CoreDomUtils.showErrorModalDefault(error, 'addon.mod_forum.errorgetforum', true); @@ -463,10 +478,11 @@ export class AddonModForumIndexComponent extends CoreCourseModuleMainActivityCom return value ? parseInt(value, 10) : null; }; + const discussions = await this.promisedDiscussions; const value = await getSortOrder(); const selectedOrder = this.sortOrders.find(sortOrder => sortOrder.value === value) || this.sortOrders[0]; - this.discussions.getSource().selectedSortOrder = selectedOrder; + discussions.getSource().selectedSortOrder = selectedOrder; if (this.sortOrderSelectorModalOptions.componentProps) { this.sortOrderSelectorModalOptions.componentProps.selected = selectedOrder.value; @@ -543,19 +559,19 @@ export class AddonModForumIndexComponent extends CoreCourseModuleMainActivityCom // If it's a new discussion in tablet mode, try to open it. if (isNewDiscussion && CoreScreen.isTablet) { const newDiscussionData = data as AddonModForumNewDiscussionData; - const discussion = this.discussions.items.find(disc => { - if (this.discussions.getSource().isOfflineDiscussion(disc)) { + const discussion = this.discussions?.items.find(disc => { + if (this.discussions?.getSource().isOfflineDiscussion(disc)) { return disc.timecreated === newDiscussionData.discTimecreated; } - if (this.discussions.getSource().isOnlineDiscussion(disc)) { + if (this.discussions?.getSource().isOnlineDiscussion(disc)) { return CoreArray.contains(newDiscussionData.discussionIds ?? [], disc.discussion); } return false; }); - if (discussion || !this.discussions.empty) { + if (this.discussions && (discussion || !this.discussions.empty)) { this.discussions.select(discussion ?? this.discussions.items[0]); } } @@ -572,7 +588,7 @@ export class AddonModForumIndexComponent extends CoreCourseModuleMainActivityCom * @param timeCreated Creation time of the offline discussion. */ openNewDiscussion(): void { - this.discussions.select(AddonModForumDiscussionsSource.NEW_DISCUSSION); + this.discussions?.select(AddonModForumDiscussionsSource.NEW_DISCUSSION); } /** @@ -581,7 +597,7 @@ export class AddonModForumIndexComponent extends CoreCourseModuleMainActivityCom * @param sortOrder Sort order new data. */ async setSortOrder(sortOrder: AddonModForumSortOrder): Promise { - if (sortOrder.value != this.discussions.getSource().selectedSortOrder?.value) { + if (this.discussions && sortOrder.value != this.discussions.getSource().selectedSortOrder?.value) { this.discussions.getSource().selectedSortOrder = sortOrder; this.discussions.getSource().setDirty(true); diff --git a/src/addons/mod/glossary/components/index/index.ts b/src/addons/mod/glossary/components/index/index.ts index 7599c7afc..beb7aad9c 100644 --- a/src/addons/mod/glossary/components/index/index.ts +++ b/src/addons/mod/glossary/components/index/index.ts @@ -17,6 +17,7 @@ import { AfterViewInit, Component, OnDestroy, OnInit, Optional, ViewChild } from import { ActivatedRoute } from '@angular/router'; import { CoreListItemsManager } from '@classes/items-management/list-items-manager'; import { CoreRoutedItemsManagerSourcesTracker } from '@classes/items-management/routed-items-manager-sources-tracker'; +import { CorePromisedValue } from '@classes/promised-value'; import { CoreSplitViewComponent } from '@components/split-view/split-view'; import { CoreCourseModuleMainActivityComponent } from '@features/course/classes/main-activity-component'; import { CoreCourseContentsPage } from '@features/course/pages/contents/contents'; @@ -70,7 +71,7 @@ export class AddonModGlossaryIndexComponent extends CoreCourseModuleMainActivity canAdd = false; loadMoreError = false; loadingMessage: string; - entries!: AddonModGlossaryEntriesManager; + promisedEntries: CorePromisedValue; hasOfflineRatings = false; protected syncEventName = AddonModGlossarySyncProvider.AUTO_SYNCED; @@ -92,18 +93,23 @@ export class AddonModGlossaryIndexComponent extends CoreCourseModuleMainActivity super('AddonModGlossaryIndexComponent', content, courseContentsPage); this.loadingMessage = Translate.instant('core.loading'); + this.promisedEntries = new CorePromisedValue(); + } + + get entries(): AddonModGlossaryEntriesManager | null { + return this.promisedEntries.value; } get glossary(): AddonModGlossaryGlossary | undefined { - return this.entries.getSource().glossary; + return this.entries?.getSource().glossary; } get isSearch(): boolean { - return this.entries.getSource().isSearch; + return this.entries?.getSource().isSearch ?? false; } get hasSearched(): boolean { - return this.entries.getSource().hasSearched; + return this.entries?.getSource().hasSearched ?? false; } /** @@ -118,10 +124,10 @@ export class AddonModGlossaryIndexComponent extends CoreCourseModuleMainActivity [this.courseId, this.module.id, this.courseContentsPage ? `${AddonModGlossaryModuleHandlerService.PAGE_NAME}/` : ''], ); - this.entries = new AddonModGlossaryEntriesManager( + this.promisedEntries.resolve(new AddonModGlossaryEntriesManager( source, this.route.component, - ); + )); this.sourceUnsubscribe = source.addListener({ onItemsUpdated: items => this.hasOffline = !!items.find(item => source.isOfflineEntry(item)), @@ -156,13 +162,10 @@ export class AddonModGlossaryIndexComponent extends CoreCourseModuleMainActivity * @inheritdoc */ async ngAfterViewInit(): Promise { + const entries = await this.promisedEntries; + await this.loadContent(false, true); - - if (!this.glossary) { - return; - } - - await this.entries.start(this.splitView); + await entries.start(this.splitView); try { CoreCourse.checkModuleCompletion(this.courseId, this.module.completiondata); @@ -175,8 +178,10 @@ export class AddonModGlossaryIndexComponent extends CoreCourseModuleMainActivity * @inheritdoc */ protected async fetchContent(refresh: boolean = false, sync: boolean = false, showErrors: boolean = false): Promise { + const entries = await this.promisedEntries; + try { - await this.entries.getSource().loadGlossary(); + await entries.getSource().loadGlossary(); if (!this.glossary) { return; @@ -187,7 +192,7 @@ export class AddonModGlossaryIndexComponent extends CoreCourseModuleMainActivity this.dataRetrieved.emit(this.glossary); - if (!this.entries.getSource().fetchMode) { + if (!entries.getSource().fetchMode) { this.switchMode('letter_all'); } @@ -198,7 +203,7 @@ export class AddonModGlossaryIndexComponent extends CoreCourseModuleMainActivity const [hasOfflineRatings] = await Promise.all([ CoreRatingOffline.hasRatings('mod_glossary', 'entry', ContextLevel.MODULE, this.glossary.coursemodule), - refresh ? this.entries.reload() : this.entries.load(), + refresh ? entries.reload() : entries.load(), ]); this.hasOfflineRatings = hasOfflineRatings; @@ -211,7 +216,7 @@ export class AddonModGlossaryIndexComponent extends CoreCourseModuleMainActivity * @inheritdoc */ protected async invalidateContent(): Promise { - await this.entries.getSource().invalidateCache(); + await this.entries?.getSource().invalidateCache(); } /** @@ -250,7 +255,7 @@ export class AddonModGlossaryIndexComponent extends CoreCourseModuleMainActivity * @param mode New mode. */ protected switchMode(mode: AddonModGlossaryFetchMode): void { - this.entries.getSource().switchMode(mode); + this.entries?.getSource().switchMode(mode); switch (mode) { case 'author_all': @@ -304,10 +309,12 @@ export class AddonModGlossaryIndexComponent extends CoreCourseModuleMainActivity * @return Promise resolved when done. */ async loadMoreEntries(infiniteComplete?: () => void): Promise { + const entries = await this.promisedEntries; + try { this.loadMoreError = false; - await this.entries.load(); + await entries.load(); } catch (error) { this.loadMoreError = true; CoreDomUtils.showErrorModalDefault(error, 'addon.mod_glossary.errorloadingentries', true); @@ -326,7 +333,8 @@ export class AddonModGlossaryIndexComponent extends CoreCourseModuleMainActivity return; } - const previousMode = this.entries.getSource().fetchMode; + const entries = await this.promisedEntries; + const previousMode = entries.getSource().fetchMode; const newMode = await CoreDomUtils.openPopover({ component: AddonModGlossaryModePickerPopoverComponent, componentProps: { @@ -357,6 +365,10 @@ export class AddonModGlossaryIndexComponent extends CoreCourseModuleMainActivity * Toggles between search and fetch mode. */ toggleSearch(): void { + if (!this.entries) { + return; + } + if (this.isSearch) { const fetchMode = this.entries.getSource().fetchMode; @@ -393,7 +405,7 @@ export class AddonModGlossaryIndexComponent extends CoreCourseModuleMainActivity * Opens new entry editor. */ openNewEntry(): void { - this.entries.select(AddonModGlossaryEntriesSource.NEW_ENTRY); + this.entries?.select(AddonModGlossaryEntriesSource.NEW_ENTRY); } /** @@ -405,7 +417,7 @@ export class AddonModGlossaryIndexComponent extends CoreCourseModuleMainActivity this.loadingMessage = Translate.instant('core.searching'); this.loaded = false; - this.entries.getSource().search(query); + this.entries?.getSource().search(query); this.loadContent(); } @@ -419,7 +431,7 @@ export class AddonModGlossaryIndexComponent extends CoreCourseModuleMainActivity this.ratingOfflineObserver?.off(); this.ratingSyncObserver?.off(); this.sourceUnsubscribe?.call(null); - this.entries.destroy(); + this.entries?.destroy(); } } diff --git a/src/core/classes/promised-value.ts b/src/core/classes/promised-value.ts new file mode 100644 index 000000000..ae7bbbdec --- /dev/null +++ b/src/core/classes/promised-value.ts @@ -0,0 +1,147 @@ +// (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. + +/** + * Promise wrapper to expose result synchronously. + */ +export class CorePromisedValue implements Promise { + + /** + * Wrap an existing promise. + * + * @param promise Promise. + * @returns Promised value. + */ + static from(promise: Promise): CorePromisedValue { + const promisedValue = new CorePromisedValue(); + + promise + .then(promisedValue.resolve.bind(promisedValue)) + .catch(promisedValue.reject.bind(promisedValue)); + + return promisedValue; + } + + private _resolvedValue?: T; + private _rejectedReason?: Error; + declare private promise: Promise; + declare private _resolve: (result: T) => void; + declare private _reject: (error?: Error) => void; + + constructor() { + this.initPromise(); + } + + [Symbol.toStringTag]: string; + + get value(): T | null { + return this._resolvedValue ?? null; + } + + /** + * Check whether the promise resolved successfully. + * + * @return Whether the promise resolved successfuly. + */ + isResolved(): this is { value: T } { + return '_resolvedValue' in this; + } + + /** + * Check whether the promise was rejected. + * + * @return Whether the promise was rejected. + */ + isRejected(): boolean { + return '_rejectedReason' in this; + } + + /** + * Check whether the promise is settled. + * + * @returns Whether the promise is settled. + */ + isSettled(): boolean { + return this.isResolved() || this.isRejected(); + } + + /** + * @inheritdoc + */ + then( + onFulfilled?: ((value: T) => TResult1 | PromiseLike) | undefined | null, + onRejected?: ((reason: Error) => TResult2 | PromiseLike) | undefined | null, + ): Promise { + return this.promise.then(onFulfilled, onRejected); + } + + /** + * @inheritdoc + */ + catch( + onRejected?: ((reason: Error) => TResult | PromiseLike) | undefined | null, + ): Promise { + return this.promise.catch(onRejected); + } + + /** + * @inheritdoc + */ + finally(onFinally?: (() => void) | null): Promise { + return this.promise.finally(onFinally); + } + + /** + * Resolve the promise. + * + * @param value Promise result. + */ + resolve(value: T): void { + if (this.isSettled()) { + delete this._rejectedReason; + + this.initPromise(); + } + + this._resolvedValue = value; + this._resolve(value); + } + + /** + * Reject the promise. + * + * @param value Rejection reason. + */ + reject(reason?: Error): void { + if (this.isSettled()) { + delete this._resolvedValue; + + this.initPromise(); + } + + this._rejectedReason = reason; + this._reject(reason); + } + + /** + * Initialize the promise and the callbacks. + */ + private initPromise(): void { + this.promise = new Promise((resolve, reject) => { + this._resolve = resolve; + this._reject = reject; + }); + } + +} diff --git a/src/core/classes/tests/promised-value.test.ts b/src/core/classes/tests/promised-value.test.ts new file mode 100644 index 000000000..71b5a2821 --- /dev/null +++ b/src/core/classes/tests/promised-value.test.ts @@ -0,0 +1,44 @@ +// (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 { CorePromisedValue } from '../promised-value'; + +describe('PromisedValue', () => { + + it('works like a promise', async () => { + const promisedString = new CorePromisedValue(); + expect(promisedString.value).toBe(null); + expect(promisedString.isResolved()).toBe(false); + + promisedString.resolve('foo'); + expect(promisedString.isResolved()).toBe(true); + expect(promisedString.value).toBe('foo'); + + const resolvedValue = await promisedString; + expect(resolvedValue).toBe('foo'); + }); + + it('can update values', async () => { + const promisedString = new CorePromisedValue(); + promisedString.resolve('foo'); + promisedString.resolve('bar'); + + expect(promisedString.isResolved()).toBe(true); + expect(promisedString.value).toBe('bar'); + + const resolvedValue = await promisedString; + expect(resolvedValue).toBe('bar'); + }); + +});