diff --git a/src/app/app.component.ts b/src/app/app.component.ts index ebc4e3bc8..72c1f61d5 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -12,10 +12,11 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { Component, ViewChild, AfterViewInit } from '@angular/core'; -import { Platform, Nav } from 'ionic-angular'; +import { Component, OnInit } from '@angular/core'; +import { Platform } from 'ionic-angular'; import { StatusBar } from '@ionic-native/status-bar'; import { SplashScreen } from '@ionic-native/splash-screen'; +import { CoreAppProvider } from '../providers/app'; import { CoreEventsProvider } from '../providers/events'; import { CoreLoggerProvider } from '../providers/logger'; import { CoreLoginHelperProvider } from '../core/login/providers/helper'; @@ -23,8 +24,7 @@ import { CoreLoginHelperProvider } from '../core/login/providers/helper'; @Component({ templateUrl: 'app.html' }) -export class MyApp implements AfterViewInit { - @ViewChild(Nav) navCtrl; +export class MoodleMobileApp implements OnInit { // Use the page name (string) because the page is lazy loaded (Ionic feature). That way we can load pages without // having to import them. The downside is that each page needs to implement a ngModule. rootPage:any = 'CoreLoginInitPage'; @@ -32,7 +32,8 @@ export class MyApp implements AfterViewInit { protected lastUrls = {}; constructor(private platform: Platform, statusBar: StatusBar, splashScreen: SplashScreen, logger: CoreLoggerProvider, - private eventsProvider: CoreEventsProvider, private loginHelper: CoreLoginHelperProvider) { + private eventsProvider: CoreEventsProvider, private loginHelper: CoreLoginHelperProvider, + private appProvider: CoreAppProvider) { this.logger = logger.getInstance('AppComponent'); platform.ready().then(() => { @@ -45,14 +46,12 @@ export class MyApp implements AfterViewInit { } /** - * View has been initialized. + * Component being initialized. */ - ngAfterViewInit() { - this.loginHelper.setNavCtrl(this.navCtrl); - + ngOnInit() { // Go to sites page when user is logged out. this.eventsProvider.on(CoreEventsProvider.LOGOUT, () => { - this.navCtrl.setRoot('CoreLoginSitesPage'); + this.appProvider.getRootNavController().setRoot('CoreLoginSitesPage'); }); // Listen for session expired events. diff --git a/src/app/app.ios.scss b/src/app/app.ios.scss index 9b7ffd32d..829e70af8 100644 --- a/src/app/app.ios.scss +++ b/src/app/app.ios.scss @@ -8,9 +8,25 @@ color: color($colors, primary, base); } +.col[align-self-stretch] .card-ios { + height: calc(100% - #{($card-ios-margin-end + $card-ios-margin-start)}); +} + +// Top tabs +// ------------------------- +.ios .core-top-tabbar { + -webkit-box-pack: center; + -webkit-justify-content: center; + -ms-flex-pack: center; + justify-content: center; + > a { + font-size: 1.6rem; + } +} + // Highlights inside the input element. -@if ($mm-text-input-ios-show-highlight) { +@if ($core-text-input-ios-show-highlight) { .card-ios, .list-ios { // In order to get a 2px border we need to add an inset // box-shadow 1px (this is to avoid the div resizing) diff --git a/src/app/app.md.scss b/src/app/app.md.scss index 552f800a2..c62a2141f 100644 --- a/src/app/app.md.scss +++ b/src/app/app.md.scss @@ -8,8 +8,12 @@ color: color($colors, primary, base); } +.col[align-self-stretch] .card-md { + height: calc(100% - #{($card-md-margin-end + $card-md-margin-start)}); +} + // Highlights inside the input element. -@if ($mm-text-input-md-show-highlight) { +@if ($core-text-input-md-show-highlight) { .card-md, .list-md { // In order to get a 2px border we need to add an inset // box-shadow 1px (this is to avoid the div resizing) diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 5bf196048..979d5f135 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -18,20 +18,15 @@ import { IonicApp, IonicModule, Platform } from 'ionic-angular'; import { HttpModule } from '@angular/http'; import { HttpClient, HttpClientModule, HTTP_INTERCEPTORS } from '@angular/common/http'; -import { SplashScreen } from '@ionic-native/splash-screen'; -import { StatusBar } from '@ionic-native/status-bar'; -import { SQLite } from '@ionic-native/sqlite'; -import { Keyboard } from '@ionic-native/keyboard'; import { TranslateModule, TranslateLoader } from '@ngx-translate/core'; import { TranslateHttpLoader } from '@ngx-translate/http-loader'; -import { MyApp } from './app.component'; +import { MoodleMobileApp } from './app.component'; import { CoreInterceptor } from '../classes/interceptor'; import { CoreLoggerProvider } from '../providers/logger'; import { CoreDbProvider } from '../providers/db'; import { CoreAppProvider } from '../providers/app'; import { CoreConfigProvider } from '../providers/config'; -import { CoreEmulatorModule } from '../core/emulator/emulator.module'; import { CoreLangProvider } from '../providers/lang'; import { CoreTextUtilsProvider } from '../providers/utils/text'; import { CoreDomUtilsProvider } from '../providers/utils/dom'; @@ -53,7 +48,12 @@ import { CoreFilepoolProvider } from '../providers/filepool'; import { CoreUpdateManagerProvider } from '../providers/update-manager'; import { CorePluginFileDelegate } from '../providers/plugin-file-delegate'; +import { CoreComponentsModule } from '../components/components.module'; +import { CoreEmulatorModule } from '../core/emulator/emulator.module'; import { CoreLoginModule } from '../core/login/login.module'; +import { CoreMainMenuModule } from '../core/mainmenu/mainmenu.module'; +import { CoreCoursesModule } from '../core/courses/courses.module'; + // For translate loader. AoT requires an exported function for factories. export function createTranslateLoader(http: HttpClient) { @@ -62,13 +62,13 @@ export function createTranslateLoader(http: HttpClient) { @NgModule({ declarations: [ - MyApp + MoodleMobileApp ], imports: [ BrowserModule, HttpClientModule, // HttpClient is used to make JSON requests. It fails for HEAD requests because there is no content. HttpModule, - IonicModule.forRoot(MyApp, { + IonicModule.forRoot(MoodleMobileApp, { pageTransition: 'ios-transition' }), TranslateModule.forRoot({ @@ -79,11 +79,14 @@ export function createTranslateLoader(http: HttpClient) { } }), CoreEmulatorModule, - CoreLoginModule + CoreLoginModule, + CoreMainMenuModule, + CoreCoursesModule, + CoreComponentsModule ], bootstrap: [IonicApp], entryComponents: [ - MyApp + MoodleMobileApp ], providers: [ { @@ -91,10 +94,6 @@ export function createTranslateLoader(http: HttpClient) { useClass: CoreInterceptor, multi: true, }, - StatusBar, - SplashScreen, - SQLite, - Keyboard, CoreLoggerProvider, CoreDbProvider, CoreAppProvider, diff --git a/src/app/app.scss b/src/app/app.scss index 1ef2a9457..3b5ab9aad 100644 --- a/src/app/app.scss +++ b/src/app/app.scss @@ -23,10 +23,16 @@ .text-right { text-align: right; } .text-center { text-align: center; } .text-justify { text-align: justify; } - +.clearfix { + &:after { + content: ""; + display: table; + clear: both; + } +} @media only screen and (min-width: 430px) { - .mm-center-view .scroll-content { + .core-center-view .scroll-content { display: flex!important; align-content: center !important; align-items: center !important; @@ -37,14 +43,27 @@ } } +@media only screen and (max-width: 768px) { + .hidden-phone { + display: none !important; + } +} + +@media only screen and (min-width: 769px) { + .hidden-tablet { + display: none !important; + } +} + // Define an alternative way to set a heading in an item without using a heading tag. // This is done for accessibility reasons when a heading is semantically incorrect. .item .item-heading { @extend h6; + margin: 0; } -.mm-oauth-icon, .item.mm-oauth-icon, .list .item.mm-oauth-icon { +.core-oauth-icon, .item.core-oauth-icon, .list .item.core-oauth-icon { min-height: 32px; img, .label { max-height: 32px; @@ -59,7 +78,7 @@ } } -.mm-bold, .mm-bold .label { +.core-bold, .core-bold .label { font-weight: bold; } @@ -113,3 +132,145 @@ ion-avatar ion-img, ion-avatar img { font-style: italic; } +/** Format Text */ +core-format-text[maxHeight], *[core-format-text][maxHeight] { + display: block; + position: relative; + width: 100%; + overflow: hidden; + + /* Force display inline */ + &.inline { + display: inline-block; + width: auto; + } + + // This is to allow clicks in radio/checkbox content. + &.core-text-formatted { + cursor: pointer; + + .core-show-more { + display: none; + } + + &:not(.core-shortened) { + max-height: none !important; + } + + &.core-shortened { + color: $gray-darker; + overflow: hidden; + min-height: 50px; + + .core-show-more { + color: color($colors, dark); + text-align: right; + font-size: 14px; + display: block; + position: absolute; + bottom: 0; + right: 0; + z-index: 1001; + background-color: $white; + padding-left: 10px; + + /* @todo + &:after { + @extend .ion; + content: $ionicon-var-chevron-down; + margin-left: 10px; + color: $item-icon-accessory-color; + } + */ + } + + &.core-expand-in-fullview .core-show-more:after { + // content: $ionicon-var-chevron-right; @todo + } + + &:before { + content: ''; + height: 100%; + position: absolute; + left: 0; + right: 0; + bottom: 0; + background: -moz-linear-gradient(top, rgba(255, 255, 255, 0) calc(100% - 50px), white calc(100% - 15px)); + background: -webkit-gradient(left top, left bottom, color-stop(calc(100% - 50px), rgba(255, 255, 255, 0)), color-stop(calc(100% - 15px), white)); + background: -webkit-linear-gradient(top, rgba(255, 255, 255, 0) calc(100% - 50px), white calc(100% - 15px)); + background: -o-linear-gradient(top, rgba(255, 255, 255, 0) calc(100% - 50px), white calc(100% - 15px)); + background: -ms-linear-gradient(top, rgba(255, 255, 255, 0) calc(100% - 50px), white calc(100% - 15px)); + background: linear-gradient(to bottom, rgba(255, 255, 255, 0) calc(100% - 50px), white calc(100% - 15px)); + z-index: 1000; + } + } + } +} + +core-format-text, *[core-format-text] { + audio, video, a, iframe { + pointer-events: auto; + } + + // Fix lists styles in core-format-text. + ul, ol { + -webkit-padding-start: 40px; + } + ul { + list-style: disc; + } + ol { + list-style: decimal; + } + + .badge { + position: initial !important; + } +} + +// Message item. +.item-message { + core-format-text > p:only-child { + display: inline; + } +} + +// Media item, ideal for icons. +.item-media { + min-height: $item-media-height + ($content-padding * 2); + > img:first-child { + max-width: $item-media-width; + max-height: $item-media-height; + } +} + +// Ionic fix. Button can occupy all page if not. +ion-select { + position: relative +} + +// Top tabs +// ------------------------- + +.core-top-tabbar { + @include position(null, null, 0, 0); + + z-index: $z-index-toolbar; + display: flex; + width: 100%; + background: $core-top-tabs-background; + + > a { + @extend .tab-button; + + background: $core-top-tabs-background; + color: $core-top-tabs-color !important; + border-bottom: 1px solid $core-top-tabs-border; + font-size: 1.6rem; + + &[aria-selected=true] { + color: $core-top-tabs-color-active !important; + border-bottom: 2px solid $core-top-tabs-color-active; + } + } +} diff --git a/src/app/app.wp.scss b/src/app/app.wp.scss index 29afff5a3..c0544152c 100644 --- a/src/app/app.wp.scss +++ b/src/app/app.wp.scss @@ -7,3 +7,7 @@ .button-wp-light { color: color($colors, primary, base); } + +.col[align-self-stretch] .card-wp { + height: calc(100% - #{($card-wp-margin-end + $card-wp-margin-start)}); +} diff --git a/src/assets/img/files/archive-64.png b/src/assets/img/files/archive-64.png new file mode 100644 index 000000000..ba7111a02 Binary files /dev/null and b/src/assets/img/files/archive-64.png differ diff --git a/src/assets/img/files/audio-64.png b/src/assets/img/files/audio-64.png new file mode 100644 index 000000000..e98c78dd7 Binary files /dev/null and b/src/assets/img/files/audio-64.png differ diff --git a/src/assets/img/files/avi-64.png b/src/assets/img/files/avi-64.png new file mode 100644 index 000000000..ec1a942cd Binary files /dev/null and b/src/assets/img/files/avi-64.png differ diff --git a/src/assets/img/files/base-64.png b/src/assets/img/files/base-64.png new file mode 100644 index 000000000..cf698f525 Binary files /dev/null and b/src/assets/img/files/base-64.png differ diff --git a/src/assets/img/files/bmp-64.png b/src/assets/img/files/bmp-64.png new file mode 100644 index 000000000..562e7bbab Binary files /dev/null and b/src/assets/img/files/bmp-64.png differ diff --git a/src/assets/img/files/calc-64.png b/src/assets/img/files/calc-64.png new file mode 100644 index 000000000..b813dd2cb Binary files /dev/null and b/src/assets/img/files/calc-64.png differ diff --git a/src/assets/img/files/chart-64.png b/src/assets/img/files/chart-64.png new file mode 100644 index 000000000..4b8f85bd6 Binary files /dev/null and b/src/assets/img/files/chart-64.png differ diff --git a/src/assets/img/files/database-64.png b/src/assets/img/files/database-64.png new file mode 100644 index 000000000..33d5043c1 Binary files /dev/null and b/src/assets/img/files/database-64.png differ diff --git a/src/assets/img/files/document-64.png b/src/assets/img/files/document-64.png new file mode 100644 index 000000000..0888ebbbd Binary files /dev/null and b/src/assets/img/files/document-64.png differ diff --git a/src/assets/img/files/draw-64.png b/src/assets/img/files/draw-64.png new file mode 100644 index 000000000..1b827c7c6 Binary files /dev/null and b/src/assets/img/files/draw-64.png differ diff --git a/src/assets/img/files/eps-64.png b/src/assets/img/files/eps-64.png new file mode 100644 index 000000000..c42492441 Binary files /dev/null and b/src/assets/img/files/eps-64.png differ diff --git a/src/assets/img/files/epub-64.png b/src/assets/img/files/epub-64.png new file mode 100644 index 000000000..298d5dcd9 Binary files /dev/null and b/src/assets/img/files/epub-64.png differ diff --git a/src/assets/img/files/flash-64.png b/src/assets/img/files/flash-64.png new file mode 100644 index 000000000..01d28e03e Binary files /dev/null and b/src/assets/img/files/flash-64.png differ diff --git a/src/assets/img/files/folder-64.png b/src/assets/img/files/folder-64.png new file mode 100644 index 000000000..2508ab252 Binary files /dev/null and b/src/assets/img/files/folder-64.png differ diff --git a/src/assets/img/files/folder-open-64.png b/src/assets/img/files/folder-open-64.png new file mode 100644 index 000000000..27f7271bd Binary files /dev/null and b/src/assets/img/files/folder-open-64.png differ diff --git a/src/assets/img/files/gif-64.png b/src/assets/img/files/gif-64.png new file mode 100644 index 000000000..2373292b7 Binary files /dev/null and b/src/assets/img/files/gif-64.png differ diff --git a/src/assets/img/files/html-64.png b/src/assets/img/files/html-64.png new file mode 100644 index 000000000..7f703bb83 Binary files /dev/null and b/src/assets/img/files/html-64.png differ diff --git a/src/assets/img/files/image-64.png b/src/assets/img/files/image-64.png new file mode 100644 index 000000000..2d8f9e4fa Binary files /dev/null and b/src/assets/img/files/image-64.png differ diff --git a/src/assets/img/files/impress-64.png b/src/assets/img/files/impress-64.png new file mode 100644 index 000000000..c279c62ef Binary files /dev/null and b/src/assets/img/files/impress-64.png differ diff --git a/src/assets/img/files/isf-64.png b/src/assets/img/files/isf-64.png new file mode 100644 index 000000000..ad5a18867 Binary files /dev/null and b/src/assets/img/files/isf-64.png differ diff --git a/src/assets/img/files/jpeg-64.png b/src/assets/img/files/jpeg-64.png new file mode 100644 index 000000000..b4fc0c998 Binary files /dev/null and b/src/assets/img/files/jpeg-64.png differ diff --git a/src/assets/img/files/markup-64.png b/src/assets/img/files/markup-64.png new file mode 100644 index 000000000..b89072713 Binary files /dev/null and b/src/assets/img/files/markup-64.png differ diff --git a/src/assets/img/files/math-64.png b/src/assets/img/files/math-64.png new file mode 100644 index 000000000..d98beea61 Binary files /dev/null and b/src/assets/img/files/math-64.png differ diff --git a/src/assets/img/files/moodle-64.png b/src/assets/img/files/moodle-64.png new file mode 100644 index 000000000..44ad3a37d Binary files /dev/null and b/src/assets/img/files/moodle-64.png differ diff --git a/src/assets/img/files/mp3-64.png b/src/assets/img/files/mp3-64.png new file mode 100644 index 000000000..13b8da0b8 Binary files /dev/null and b/src/assets/img/files/mp3-64.png differ diff --git a/src/assets/img/files/mpeg-64.png b/src/assets/img/files/mpeg-64.png new file mode 100644 index 000000000..05d77fa7b Binary files /dev/null and b/src/assets/img/files/mpeg-64.png differ diff --git a/src/assets/img/files/oth-64.png b/src/assets/img/files/oth-64.png new file mode 100644 index 000000000..8ffa8b466 Binary files /dev/null and b/src/assets/img/files/oth-64.png differ diff --git a/src/assets/img/files/pdf-64.png b/src/assets/img/files/pdf-64.png new file mode 100644 index 000000000..b7cdae7e9 Binary files /dev/null and b/src/assets/img/files/pdf-64.png differ diff --git a/src/assets/img/files/png-64.png b/src/assets/img/files/png-64.png new file mode 100644 index 000000000..3ecf3e5df Binary files /dev/null and b/src/assets/img/files/png-64.png differ diff --git a/src/assets/img/files/powerpoint-64.png b/src/assets/img/files/powerpoint-64.png new file mode 100644 index 000000000..4d44c7d2b Binary files /dev/null and b/src/assets/img/files/powerpoint-64.png differ diff --git a/src/assets/img/files/psd-64.png b/src/assets/img/files/psd-64.png new file mode 100644 index 000000000..0b800dffa Binary files /dev/null and b/src/assets/img/files/psd-64.png differ diff --git a/src/assets/img/files/publisher-64.png b/src/assets/img/files/publisher-64.png new file mode 100644 index 000000000..9633ef3dd Binary files /dev/null and b/src/assets/img/files/publisher-64.png differ diff --git a/src/assets/img/files/quicktime-64.png b/src/assets/img/files/quicktime-64.png new file mode 100644 index 000000000..90f2fbc0d Binary files /dev/null and b/src/assets/img/files/quicktime-64.png differ diff --git a/src/assets/img/files/sourcecode-64.png b/src/assets/img/files/sourcecode-64.png new file mode 100644 index 000000000..ad25537e4 Binary files /dev/null and b/src/assets/img/files/sourcecode-64.png differ diff --git a/src/assets/img/files/spreadsheet-64.png b/src/assets/img/files/spreadsheet-64.png new file mode 100644 index 000000000..00427c0e7 Binary files /dev/null and b/src/assets/img/files/spreadsheet-64.png differ diff --git a/src/assets/img/files/text-64.png b/src/assets/img/files/text-64.png new file mode 100644 index 000000000..7b397cea5 Binary files /dev/null and b/src/assets/img/files/text-64.png differ diff --git a/src/assets/img/files/tiff-64.png b/src/assets/img/files/tiff-64.png new file mode 100644 index 000000000..c11a85e28 Binary files /dev/null and b/src/assets/img/files/tiff-64.png differ diff --git a/src/assets/img/files/unknown-64.png b/src/assets/img/files/unknown-64.png new file mode 100644 index 000000000..7f703bb83 Binary files /dev/null and b/src/assets/img/files/unknown-64.png differ diff --git a/src/assets/img/files/video-64.png b/src/assets/img/files/video-64.png new file mode 100644 index 000000000..570c4b2b3 Binary files /dev/null and b/src/assets/img/files/video-64.png differ diff --git a/src/assets/img/files/wav-64.png b/src/assets/img/files/wav-64.png new file mode 100644 index 000000000..819781a9b Binary files /dev/null and b/src/assets/img/files/wav-64.png differ diff --git a/src/assets/img/files/wmv-64.png b/src/assets/img/files/wmv-64.png new file mode 100644 index 000000000..570c4b2b3 Binary files /dev/null and b/src/assets/img/files/wmv-64.png differ diff --git a/src/assets/img/files/writer-64.png b/src/assets/img/files/writer-64.png new file mode 100644 index 000000000..6285b6ffa Binary files /dev/null and b/src/assets/img/files/writer-64.png differ diff --git a/src/assets/img/icons/activities.svg b/src/assets/img/icons/activities.svg new file mode 100644 index 000000000..56243a53c --- /dev/null +++ b/src/assets/img/icons/activities.svg @@ -0,0 +1,178 @@ + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/img/icons/courses.svg b/src/assets/img/icons/courses.svg new file mode 100644 index 000000000..7bd9cb672 --- /dev/null +++ b/src/assets/img/icons/courses.svg @@ -0,0 +1,257 @@ + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/img/icons/paypal.png b/src/assets/img/icons/paypal.png new file mode 100644 index 000000000..058f6e4cb Binary files /dev/null and b/src/assets/img/icons/paypal.png differ diff --git a/src/classes/site.ts b/src/classes/site.ts index 9a2673b75..f24809ec9 100644 --- a/src/classes/site.ts +++ b/src/classes/site.ts @@ -482,7 +482,14 @@ export class CoreSite { // We pass back a clone of the original object, this may // prevent errors if in the callback the object is modified. - return Object.assign({}, response); + if (typeof response == 'object') { + if (Array.isArray(response)) { + return Array.from(response); + } else { + return Object.assign({}, response); + } + } + return response; }).catch((error) => { if (error.errorcode == 'invalidtoken' || (error.errorcode == 'accessexception' && error.message.indexOf('Invalid token - token expired') > -1)) { @@ -853,10 +860,10 @@ export class CoreSite { /** * Returns the URL to the documentation of the app, based on Moodle version and current language. * - * @param {string} [page] Docs page to go to. - * @return {Promise} Promise resolved with the Moodle docs URL. + * @param {string} [page] Docs page to go to. + * @return {Promise} Promise resolved with the Moodle docs URL. */ - getDocsUrl(page: string) : Promise { + getDocsUrl(page?: string) : Promise { const release = this.infos.release ? this.infos.release : undefined; return this.urlUtils.getDocsUrl(release, page); } @@ -1054,7 +1061,7 @@ export class CoreSite { } if (alertMessage) { - let alert = this.domUtils.showAlert('core.notice', alertMessage, null, 3000); + let alert = this.domUtils.showAlert('core.notice', alertMessage, undefined, 3000); alert.onDidDismiss(() => { if (inApp) { resolve(this.utils.openInApp(url, options)); diff --git a/src/classes/sqlitedb.ts b/src/classes/sqlitedb.ts index 6b718eca8..3c6c248d5 100644 --- a/src/classes/sqlitedb.ts +++ b/src/classes/sqlitedb.ts @@ -262,7 +262,7 @@ export class SQLiteDB { * @return {Promise} Promise resolved when done. */ deleteRecords(table: string, conditions?: object) : Promise { - if (conditions === null) { + if (conditions === null || typeof conditions == 'undefined') { // No conditions, delete the whole table. return this.execute(`DELETE FROM TABLE ${table}`); } @@ -329,6 +329,21 @@ export class SQLiteDB { }); } + /** + * Format the data to insert in the database. Removes undefined entries so they are stored as null instead of 'undefined'. + * + * @param {object} data Data to insert. + */ + protected formatDataToInsert(data: object) : void { + // Remove undefined entries and convert null to "NULL". + for (let name in data) { + let value = data[name]; + if (typeof value == 'undefined') { + delete data[name]; + } + } + } + /** * Get all the records from a table. * @@ -585,6 +600,8 @@ export class SQLiteDB { * @return {any[]} Array with the SQL query and the params. */ protected getSqlInsertQuery(table: string, data: object) : any[] { + this.formatDataToInsert(data); + let keys = Object.keys(data), fields = keys.join(','), questionMarks = ',?'.repeat(keys.length).substr(1); @@ -674,10 +691,10 @@ export class SQLiteDB { */ normaliseLimitFromNum(limitFrom: any, limitNum: any) : number[] { // We explicilty treat these cases as 0. - if (limitFrom === null || limitFrom === '' || limitFrom === -1) { + if (typeof limitFrom == 'undefined' || limitFrom === null || limitFrom === '' || limitFrom === -1) { limitFrom = 0; } - if (limitNum === null || limitNum === '' || limitNum === -1) { + if (typeof limitNum == 'undefined' || limitNum === null || limitNum === '' || limitNum === -1) { limitNum = 0; } @@ -773,6 +790,8 @@ export class SQLiteDB { sql, params; + this.formatDataToInsert(data); + for (let key in data) { sets.push(`${key} = ?`); } diff --git a/src/components/components.module.ts b/src/components/components.module.ts index 33b38d327..8be44288c 100644 --- a/src/components/components.module.ts +++ b/src/components/components.module.ts @@ -21,6 +21,13 @@ import { CoreMarkRequiredComponent } from './mark-required/mark-required'; import { CoreInputErrorsComponent } from './input-errors/input-errors'; import { CoreShowPasswordComponent } from './show-password/show-password'; import { CoreIframeComponent } from './iframe/iframe'; +import { CoreProgressBarComponent } from './progress-bar/progress-bar'; +import { CoreEmptyBoxComponent } from './empty-box/empty-box'; +import { CoreSearchBoxComponent } from './search-box/search-box'; +import { CoreFileComponent } from './file/file'; +import { CoreContextMenuComponent } from './context-menu/context-menu'; +import { CoreContextMenuItemComponent } from './context-menu/context-menu-item'; +import { CoreContextMenuPopoverComponent } from './context-menu/context-menu-popover'; @NgModule({ declarations: [ @@ -28,7 +35,17 @@ import { CoreIframeComponent } from './iframe/iframe'; CoreMarkRequiredComponent, CoreInputErrorsComponent, CoreShowPasswordComponent, - CoreIframeComponent + CoreIframeComponent, + CoreProgressBarComponent, + CoreEmptyBoxComponent, + CoreSearchBoxComponent, + CoreFileComponent, + CoreContextMenuComponent, + CoreContextMenuItemComponent, + CoreContextMenuPopoverComponent + ], + entryComponents: [ + CoreContextMenuPopoverComponent ], imports: [ IonicModule, @@ -40,7 +57,13 @@ import { CoreIframeComponent } from './iframe/iframe'; CoreMarkRequiredComponent, CoreInputErrorsComponent, CoreShowPasswordComponent, - CoreIframeComponent + CoreIframeComponent, + CoreProgressBarComponent, + CoreEmptyBoxComponent, + CoreSearchBoxComponent, + CoreFileComponent, + CoreContextMenuComponent, + CoreContextMenuItemComponent ] }) export class CoreComponentsModule {} diff --git a/src/components/context-menu/context-menu-item.ts b/src/components/context-menu/context-menu-item.ts new file mode 100644 index 000000000..61ab66d29 --- /dev/null +++ b/src/components/context-menu/context-menu-item.ts @@ -0,0 +1,114 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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, Output, OnInit, OnDestroy, EventEmitter, OnChanges, SimpleChange } from '@angular/core'; +import { CoreContextMenuComponent } from './context-menu'; + + +/** + * This directive adds a item to the Context Menu popover. + * + * @description + * This directive defines and item to be added to the popover generated in CoreContextMenu. + * + * It is required to place this tag inside a core-context-menu tag. + * + * + * + * + */ +@Component({ + selector: 'core-context-menu-item', + template: '' +}) +export class CoreContextMenuItemComponent implements OnInit, OnDestroy, OnChanges { + @Input() content: string; // Content of the item. + @Input() iconDescription?: string; // Name of the icon to be shown on the left side of the item. + @Input() iconAction?: string; // Name of the icon to be shown on the right side of the item. It represents the action to do on + // click. If is "spinner" an spinner will be shown. If no icon or spinner is selected, no action + // or link will work. If href but no iconAction is provided ion-arrow-right-c will be used. + @Input() ariaDescription?: string; // Aria label to add to iconDescription. + @Input() ariaAction?: string; // Aria label to add to iconAction. If not set, it will be equal to content. + @Input() href?: string; // Link to go if no action provided. + @Input() captureLink?: boolean|string; // Whether the link needs to be captured by the app. + @Input() autoLogin?: string; // Whether the link needs to be opened using auto-login. + @Input() closeOnClick?: boolean|string = true; // Whether to close the popover when the item is clicked. + @Input() priority?: number; // Used to sort items. The highest priority, the highest position. + @Input() badge?: string; // A badge to show in the item. + @Input() badgeClass?: number; // A class to set in the badge. + @Input() hidden?: boolean; // Whether the item should be hidden. + @Output() action?: EventEmitter; // Will emit an event when the item clicked. + + protected hasAction = false; + protected destroyed = false; + + constructor(private ctxtMenu: CoreContextMenuComponent) { + this.action = new EventEmitter(); + } + + /** + * Component being initialized. + */ + ngOnInit() { + // Initialize values. + this.priority = this.priority || 1; + this.closeOnClick = this.getBooleanValue(this.closeOnClick, true); + this.hasAction = this.action.observers.length > 0; + this.ariaAction = this.ariaAction || this.content; + + if (this.hasAction) { + this.href = ''; + } + + // Navigation help if href provided. + this.captureLink = this.href && this.captureLink ? this.captureLink : false; + this.autoLogin = this.autoLogin || 'check'; + + if (!this.destroyed) { + this.ctxtMenu.addItem(this); + } + } + + /** + * Get a boolean value from item. + * + * @param {any} value Value to check. + * @param {boolean} defaultValue Value to use if undefined. + * @return {boolean} Boolean value. + */ + protected getBooleanValue(value: any, defaultValue: boolean) : boolean { + if (typeof value == 'undefined') { + return defaultValue; + } + return value && value !== 'false'; + } + + /** + * Component destroyed. + */ + ngOnDestroy() { + this.destroyed = true; + this.ctxtMenu.removeItem(this); + } + + /** + * Detect changes on input properties. + */ + ngOnChanges(changes: {[name: string]: SimpleChange}) { + if (changes.hidden && !changes.hidden.firstChange) { + this.ctxtMenu.itemsChanged(); + } + } +} diff --git a/src/components/context-menu/context-menu-popover.html b/src/components/context-menu/context-menu-popover.html new file mode 100644 index 000000000..145ee534c --- /dev/null +++ b/src/components/context-menu/context-menu-popover.html @@ -0,0 +1,10 @@ + + {{title}} + + + + + + {{item.badge}} + + \ No newline at end of file diff --git a/src/components/context-menu/context-menu-popover.ts b/src/components/context-menu/context-menu-popover.ts new file mode 100644 index 000000000..fa144228e --- /dev/null +++ b/src/components/context-menu/context-menu-popover.ts @@ -0,0 +1,69 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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 } from '@angular/core'; +import { NavParams, ViewController } from 'ionic-angular'; +import { CoreContextMenuItemComponent } from './context-menu-item'; + +/** + * Component to display a list of items received by param in a popover. + */ +@Component({ + selector: 'core-context-menu-popover', + templateUrl: 'context-menu-popover.html' +}) +export class CoreContextMenuPopoverComponent { + title: string; + items: CoreContextMenuItemComponent[]; + + constructor(navParams: NavParams, private viewCtrl: ViewController) { + this.title = navParams.get('title'); + this.items = navParams.get('items') || []; + } + + /** + * Close the popover. + */ + closeMenu() : void { + this.viewCtrl.dismiss(); + } + + /** + * Function called when an item is clicked. + * + * @param {Event} event Click event. + * @param {CoreContextMenuItemComponent} item Item clicked. + * @return {boolean} Return true if success, false if error. + */ + itemClicked(event: Event, item: CoreContextMenuItemComponent) : boolean { + if (item.action.observers.length > 0) { + event.preventDefault(); + event.stopPropagation(); + + if (!item.iconAction || item.iconAction == 'spinner') { + return false; + } + + if (item.closeOnClick) { + this.closeMenu(); + } + + item.action.emit(this.closeMenu.bind(this)); + } else if (item.href && item.closeOnClick) { + this.closeMenu(); + } + + return true; + } +} diff --git a/src/components/context-menu/context-menu.html b/src/components/context-menu/context-menu.html new file mode 100644 index 000000000..94f724860 --- /dev/null +++ b/src/components/context-menu/context-menu.html @@ -0,0 +1,4 @@ + + \ No newline at end of file diff --git a/src/components/context-menu/context-menu.ts b/src/components/context-menu/context-menu.ts new file mode 100644 index 000000000..43ce298cc --- /dev/null +++ b/src/components/context-menu/context-menu.ts @@ -0,0 +1,98 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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 { PopoverController } from 'ionic-angular'; +import { TranslateService } from '@ngx-translate/core'; +import { CoreContextMenuItemComponent } from './context-menu-item'; +import { CoreContextMenuPopoverComponent } from './context-menu-popover'; +import { Subject } from 'rxjs'; + +/** + * This component adds a button (usually in the navigation bar) that displays a context menu popover. + */ +@Component({ + selector: 'core-context-menu', + templateUrl: 'context-menu.html' +}) +export class CoreContextMenuComponent implements OnInit { + @Input() icon?: string; // Icon to be shown on the navigation bar. Default: Kebab menu icon. + @Input() title?: string; // Aria label and text to be shown on the top of the popover. + + hideMenu: boolean; + ariaLabel: string; + protected items: CoreContextMenuItemComponent[] = []; + protected itemsChangedStream: Subject; // Stream to update the hideMenu boolean when items change. + + constructor(private translate: TranslateService, private popoverCtrl: PopoverController) { + // Create the stream and subscribe to it. We ignore successive changes during 250ms. + this.itemsChangedStream = new Subject(); + this.itemsChangedStream.auditTime(250).subscribe(() => { + // Hide the menu if all items are hidden. + this.hideMenu = !this.items.some((item) => { + return !item.hidden; + }); + }) + } + + /** + * Component being initialized. + */ + ngOnInit() { + this.icon = this.icon || 'more'; + this.ariaLabel = this.title || this.translate.instant('core.info'); + } + + /** + * Add a context menu item. + * + * @param {CoreContextMenuItemComponent} item The item to add. + */ + addItem(item: CoreContextMenuItemComponent) : void { + this.items.push(item); + this.itemsChanged(); + } + + /** + * Function called when the items change. + */ + itemsChanged() { + this.itemsChangedStream.next(); + } + + /** + * Remove an item from the context menu. + * + * @param {CoreContextMenuItemComponent} item The item to remove. + */ + removeItem(item: CoreContextMenuItemComponent) : void { + let index = this.items.indexOf(item); + if (index >= 0) { + this.items.splice(index, 1); + } + this.itemsChanged(); + } + + /** + * Show the context menu. + * + * @param {MouseEvent} event Event. + */ + showContextMenu(event: MouseEvent) : void { + let popover = this.popoverCtrl.create(CoreContextMenuPopoverComponent, {title: this.title, items: this.items}); + popover.present({ + ev: event + }); + } +} diff --git a/src/components/empty-box/empty-box.html b/src/components/empty-box/empty-box.html new file mode 100644 index 000000000..13229943f --- /dev/null +++ b/src/components/empty-box/empty-box.html @@ -0,0 +1,8 @@ +
+
+ + +

{{ message }}

+ +
+
\ No newline at end of file diff --git a/src/components/empty-box/empty-box.scss b/src/components/empty-box/empty-box.scss new file mode 100644 index 000000000..0b7562355 --- /dev/null +++ b/src/components/empty-box/empty-box.scss @@ -0,0 +1,57 @@ +core-empty-box { + .core-empty-box { + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; + display: table; + height: 100%; + width: 100%; + z-index: -1; + margin: 0; + padding: 0; + clear: both; + + .core-empty-box-content { + color: $black; + margin: 0; + display: table-cell; + text-align: center; + vertical-align: middle; + } + + &.core-empty-box-inline { + position: relative; + top: initial; + left: initial; + right: initial; + z-index: initial; + } + + .icon { + font-size: 120px; + } + img { + height: 125px; + width: 145px; + } + p { + font-size: 120%; + } + } + + @media only screen and (max-height: 420px) { + .core-empty-box { + position: relative; + + .icon { + font-size: 100px; + } + img { + height: 104px; + width: 121px; + } + } + } +} diff --git a/src/components/empty-box/empty-box.ts b/src/components/empty-box/empty-box.ts new file mode 100644 index 000000000..340445940 --- /dev/null +++ b/src/components/empty-box/empty-box.ts @@ -0,0 +1,33 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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 } from '@angular/core'; + +/** + * Component to show an empty box message. It will show an optional icon or image and a text centered on page. + * + * Usage: + * + */ +@Component({ + selector: 'core-empty-box', + templateUrl: 'empty-box.html' +}) +export class CoreEmptyBoxComponent { + @Input() message: string; // Message to display. + @Input() icon?: string; // Name of the icon to use. + @Input() image?: string; // Image source. If an icon is provided, image won't be used. + + constructor() {} +} diff --git a/src/components/file/file.html b/src/components/file/file.html new file mode 100644 index 000000000..e4dab9efb --- /dev/null +++ b/src/components/file/file.html @@ -0,0 +1,13 @@ + + +

{{fileName}}

+
+ + +
+ +
diff --git a/src/components/file/file.scss b/src/components/file/file.scss new file mode 100644 index 000000000..95127e438 --- /dev/null +++ b/src/components/file/file.scss @@ -0,0 +1,2 @@ +core-file { +} \ No newline at end of file diff --git a/src/components/file/file.ts b/src/components/file/file.ts new file mode 100644 index 000000000..0506bea4f --- /dev/null +++ b/src/components/file/file.ts @@ -0,0 +1,291 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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, Output, OnInit, OnDestroy, EventEmitter } from '@angular/core'; +import { TranslateService } from '@ngx-translate/core'; +import { CoreAppProvider } from '../../providers/app'; +import { CoreEventsProvider } from '../../providers/events'; +import { CoreFileProvider } from '../../providers/file'; +import { CoreFilepoolProvider } from '../../providers/filepool'; +import { CoreSitesProvider } from '../../providers/sites'; +import { CoreDomUtilsProvider } from '../../providers/utils/dom'; +import { CoreMimetypeUtilsProvider } from '../../providers/utils/mimetype'; +import { CoreUtilsProvider } from '../../providers/utils/utils'; +import { CoreConstants } from '../../core/constants'; + +/** + * Component to handle a remote file. Shows the file name, icon (depending on mimetype) and a button + * to download/refresh it. + */ +@Component({ + selector: 'core-file', + templateUrl: 'file.html' +}) +export class CoreFileComponent implements OnInit, OnDestroy { + @Input() file: any; // The file. Must have a property 'filename' and a 'fileurl' or 'url' + @Input() component?: string; // Component the file belongs to. + @Input() componentId?: string|number; // Component ID. + @Input() timemodified?: number; // If set, the value will be used to check if the file is outdated. + @Input() canDelete?: boolean|string; // Whether file can be deleted. + @Input() alwaysDownload?: boolean|string; // Whether it should always display the refresh button when the file is downloaded. + // Use it for files that you cannot determine if they're outdated or not. + @Input() canDownload?: boolean|string = true; // Whether file can be downloaded. + @Output() onDelete?: EventEmitter; // Will notify when the delete button is clicked. + + isDownloaded: boolean; + isDownloading: boolean; + showDownload: boolean; + fileIcon: string; + fileName: string; + + protected fileUrl: string; + protected siteId: string; + protected fileSize: number; + protected observer; + + constructor(private translate: TranslateService, private sitesProvider: CoreSitesProvider, private utils: CoreUtilsProvider, + private domUtils: CoreDomUtilsProvider, private filepoolProvider: CoreFilepoolProvider, + private fileProvider: CoreFileProvider, private appProvider: CoreAppProvider, + private mimeUtils: CoreMimetypeUtilsProvider, private eventsProvider: CoreEventsProvider) { + this.onDelete = new EventEmitter(); + } + + /** + * Component being initialized. + */ + ngOnInit() { + this.canDelete = this.utils.isTrueOrOne(this.canDelete); + this.alwaysDownload = this.utils.isTrueOrOne(this.alwaysDownload); + this.canDownload = this.utils.isTrueOrOne(this.canDownload); + this.timemodified = this.timemodified || 0; + + this.fileUrl = this.file.fileurl || this.file.url; + this.siteId = this.sitesProvider.getCurrentSiteId(); + this.fileSize = this.file.filesize; + this.fileName = this.file.filename; + + if (this.file.isexternalfile) { + this.alwaysDownload = true; // Always show the download button in external files. + } + + this.fileIcon = this.mimeUtils.getFileIcon(this.file.filename); + + if (this.canDownload) { + this.calculateState(); + + // Update state when receiving events about this file. + this.filepoolProvider.getFileEventNameByUrl(this.siteId, this.fileUrl).then((eventName) => { + this.observer = this.eventsProvider.on(eventName, () => { + this.calculateState(); + }); + }); + } + } + + /** + * Convenience function to get the file state and set variables based on it. + * + * @return {Promise} Promise resolved when state has been calculated. + */ + protected calculateState() : Promise { + return this.filepoolProvider.getFileStateByUrl(this.siteId, this.fileUrl, this.timemodified).then((state) => { + let canDownload = this.sitesProvider.getCurrentSite().canDownloadFiles(); + + this.isDownloaded = state === CoreConstants.downloaded || state === CoreConstants.outdated; + this.isDownloading = canDownload && state === CoreConstants.downloading; + this.showDownload = canDownload && (state === CoreConstants.notDownloaded || state === CoreConstants.outdated || + (this.alwaysDownload && state === CoreConstants.downloaded)); + }); + } + + /** + * Download the file. + * + * @return {Promise} Promise resolved when file is downloaded. + */ + protected downloadFile() : Promise { + if (!this.sitesProvider.getCurrentSite().canDownloadFiles()) { + this.domUtils.showErrorModal('core.cannotdownloadfiles', true); + return Promise.reject(null); + } + + this.isDownloading = true; + return this.filepoolProvider.downloadUrl(this.siteId, this.fileUrl, false, this.component, this.componentId, + this.timemodified, undefined, undefined, this.file).catch(() => { + + // Call calculateState to make sure we have the right state. + return this.calculateState().then(() => { + if (this.isDownloaded) { + return this.filepoolProvider.getInternalUrlByUrl(this.siteId, this.fileUrl); + } else { + return Promise.reject(null); + } + }); + }); + } + + /** + * Convenience function to open a file, downloading it if needed. + * + * @return {Promise} Promise resolved when file is opened. + */ + protected openFile() : Promise { + let fixedUrl = this.sitesProvider.getCurrentSite().fixPluginfileURL(this.fileUrl), + promise; + + if (this.fileProvider.isAvailable()) { + promise = Promise.resolve().then(() => { + // The file system is available. + let isWifi = !this.appProvider.isNetworkAccessLimited(), + isOnline = this.appProvider.isOnline(); + + if (this.isDownloaded && !this.showDownload) { + // File is downloaded, get the local file URL. + return this.filepoolProvider.getUrlByUrl(this.siteId, this.fileUrl, + this.component, this.componentId, this.timemodified, false, false, this.file); + } else { + if (!isOnline && !this.isDownloaded) { + // Not downloaded and user is offline, reject. + return Promise.reject(this.translate.instant('core.networkerrormsg')); + } + + let isDownloading = this.isDownloading; + this.isDownloading = true; // This check could take a while, show spinner. + return this.filepoolProvider.shouldDownloadBeforeOpen(fixedUrl, this.fileSize).then(() => { + if (isDownloading) { + // It's already downloading, stop. + return; + } + // Download and then return the local URL. + return this.downloadFile(); + }, () => { + // Start the download if in wifi, but return the URL right away so the file is opened. + if (isWifi && isOnline) { + this.downloadFile(); + } + + if (isDownloading || !this.isDownloaded || isOnline) { + // Not downloaded or outdated and online, return the online URL. + return fixedUrl; + } else { + // Outdated but offline, so we return the local URL. + return this.filepoolProvider.getUrlByUrl(this.siteId, this.fileUrl, + this.component, this.componentId, this.timemodified, false, false, this.file); + } + }); + } + }); + } else { + // Use the online URL. + promise = Promise.resolve(fixedUrl); + } + + return promise.then((url) => { + if (!url) { + return; + } + + if (url.indexOf('http') === 0) { + return this.utils.openOnlineFile(url).catch((error) => { + // Error opening the file, some apps don't allow opening online files. + if (!this.fileProvider.isAvailable()) { + return Promise.reject(error); + } else if (this.isDownloading) { + return Promise.reject(this.translate.instant('core.erroropenfiledownloading')); + } + + let subPromise; + + if (status === CoreConstants.notDownloaded) { + // File is not downloaded, download and then return the local URL. + subPromise = this.downloadFile(); + } else { + // File is outdated and can't be opened in online, return the local URL. + subPromise = this.filepoolProvider.getInternalUrlByUrl(this.siteId, this.fileUrl); + } + + return subPromise.then((url) => { + return this.utils.openFile(url); + }); + }); + } else { + return this.utils.openFile(url); + } + }); + } + + /** + * Download a file and, optionally, open it afterwards. + * + * @param {Event} e Click event. + * @param {boolean} openAfterDownload Whether the file should be opened after download. + */ + download(e: Event, openAfterDownload: boolean) : void { + e.preventDefault(); + e.stopPropagation(); + + let promise; + + if (this.isDownloading && !openAfterDownload) { + return; + } + + if (!this.appProvider.isOnline() && (!openAfterDownload || (openAfterDownload && !this.isDownloaded))) { + this.domUtils.showErrorModal('core.networkerrormsg', true); + return; + } + + if (openAfterDownload) { + // File needs to be opened now. + this.openFile().catch((error) => { + this.domUtils.showErrorModalDefault(error, 'core.errordownloading', true); + }); + } else { + // File doesn't need to be opened (it's a prefetch). Show confirm modal if file size is defined and it's big. + promise = this.fileSize ? this.domUtils.confirmDownloadSize({size: this.fileSize, total: true}) : Promise.resolve(); + promise.then(() => { + // User confirmed, add the file to queue. + this.filepoolProvider.invalidateFileByUrl(this.siteId, this.fileUrl).finally(() => { + this.isDownloading = true; + this.filepoolProvider.addToQueueByUrl(this.siteId, this.fileUrl, this.component, + this.componentId, this.timemodified, undefined, undefined, 0, this.file).catch((error) => { + this.domUtils.showErrorModalDefault(error, 'core.errordownloading', true); + this.calculateState(); + }); + }); + }); + } + }; + + /** + * Delete the file. + * + * @param {Event} e Click event. + */ + deleteFile(e: Event) : void { + e.preventDefault(); + e.stopPropagation(); + + if (this.canDelete) { + this.onDelete.emit(); + } + } + + /** + * Component destroyed. + */ + ngOnDestroy() { + this.observer && this.observer.off(); + } +} diff --git a/src/components/iframe/iframe.html b/src/components/iframe/iframe.html index 75cea2664..69ace4cf8 100644 --- a/src/components/iframe/iframe.html +++ b/src/components/iframe/iframe.html @@ -1,4 +1,4 @@ -
- +
+
\ No newline at end of file diff --git a/src/components/loading/loading.html b/src/components/loading/loading.html index e79daa1ff..d6a224a91 100644 --- a/src/components/loading/loading.html +++ b/src/components/loading/loading.html @@ -1,8 +1,9 @@ -
- + +
+ -

{{message}}

+

{{message}}

- + \ No newline at end of file diff --git a/src/components/loading/loading.scss b/src/components/loading/loading.scss index 6279a3b37..752623059 100644 --- a/src/components/loading/loading.scss +++ b/src/components/loading/loading.scss @@ -1,26 +1,37 @@ core-loading { - .mm-loading-container { + .core-loading-container { width: 100%; text-align: center; padding-top: 10px; clear: both; } - .mm-loading-content { + .core-loading-content { padding-bottom: 1px; /* This makes height be real */ } - &.mm-loading-noheight .mm-loading-content { + &.core-loading-noheight .core-loading-content { height: auto; } } -.scroll-content > .padding > core-loading > .mm-loading-container, -ion-content[padding] > .scroll-content > core-loading > .mm-loading-container, -.mm-loading-center .mm-loading-container { +.scroll-content > .padding > core-loading > .core-loading-container, +ion-content[padding] > .scroll-content > core-loading > .core-loading-container, +.core-loading-center .core-loading-container { + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; display: table; + height: 100%; + width: 100%; + z-index: 1; + margin: 0; + padding: 0; + clear: both; - .mm-loading-spinner { + .core-loading-spinner { display: table-cell; text-align: center; vertical-align: middle; diff --git a/src/components/progress-bar/progress-bar.html b/src/components/progress-bar/progress-bar.html new file mode 100644 index 000000000..7c7319034 --- /dev/null +++ b/src/components/progress-bar/progress-bar.html @@ -0,0 +1,8 @@ +
+ +
+ +
+
+ {{ 'core.percentagenumber' | translate: {$a: text} }} +
diff --git a/src/components/progress-bar/progress-bar.scss b/src/components/progress-bar/progress-bar.scss new file mode 100644 index 000000000..318a230bf --- /dev/null +++ b/src/components/progress-bar/progress-bar.scss @@ -0,0 +1,53 @@ +$core-progress-bar-height: 5px !default; + +core-progress-bar { + padding-right: 55px; + position: relative; + display: block; + @extend .clearfix; + + .core-progress-text { + margin-left: 10px; + line-height: normal; + font-size: 1.4rem; + color: $gray-darker; + right: 0; + top: -6px; + position: absolute; + } + + progress { + -webkit-appearance: none; + appearance: none; + height: $core-progress-bar-height; + margin: 15px 0; + padding: 0; + display: block; + width: 100%; + + .progress-bar-fallback, + &[value]::-webkit-progress-bar { + background-color: $gray-light; + border-radius: 2px; + box-shadow: 0 1px 1px rgba(0, 0, 0, 0.1) inset; + } + + .progress-bar-fallback span, + &[value]::-webkit-progress-value { + background-color: $core-color-light; + border-radius: 2px; + } + + .progress-bar-fallback { + width: 100%; + height: $core-progress-bar-height; + display: block; + position: relative; + + span { + height: $core-progress-bar-height; + display: block; + } + } + } +} diff --git a/src/components/progress-bar/progress-bar.ts b/src/components/progress-bar/progress-bar.ts new file mode 100644 index 000000000..a7b6df126 --- /dev/null +++ b/src/components/progress-bar/progress-bar.ts @@ -0,0 +1,68 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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, OnChanges, SimpleChange, ChangeDetectionStrategy } from '@angular/core'; +import { DomSanitizer, SafeStyle } from '@angular/platform-browser'; + +/** + * Component to show a progress bar and its value. + * + * Example usage: + * + */ +@Component({ + selector: 'core-progress-bar', + templateUrl: 'progress-bar.html', + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class CoreProgressBarComponent implements OnChanges { + @Input() progress: number|string; // Percentage from 0 to 100. + @Input() text?: string; // Percentage in text to be shown at the right. If not defined, progress will be used. + width: SafeStyle; + protected textSupplied = false; + + constructor(private sanitizer: DomSanitizer) {} + + /** + * Detect changes on input properties. + */ + ngOnChanges(changes: {[name: string]: SimpleChange}) { + if (changes.text && typeof changes.text.currentValue != 'undefined') { + // User provided a custom text, don't use default. + this.textSupplied = true; + } + + if (changes.progress) { + // Progress has changed. + if (typeof this.progress == 'string') { + this.progress = parseInt(this.progress, 10); + } + + if (this.progress < 0 || isNaN(this.progress)) { + this.progress = -1; + } + + if (this.progress != -1) { + // Remove decimals. + this.progress = Math.floor(this.progress); + + if (!this.textSupplied) { + this.text = String(this.progress); + } + + this.width = this.sanitizer.bypassSecurityTrustStyle(this.progress + '%'); + } + } + } +} diff --git a/src/components/search-box/search-box.html b/src/components/search-box/search-box.html new file mode 100644 index 000000000..7772b67e8 --- /dev/null +++ b/src/components/search-box/search-box.html @@ -0,0 +1,10 @@ + +
+ + + + +
+
diff --git a/src/components/search-box/search-box.scss b/src/components/search-box/search-box.scss new file mode 100644 index 000000000..c2f5de7b5 --- /dev/null +++ b/src/components/search-box/search-box.scss @@ -0,0 +1,9 @@ +core-search-box { + .button.item-button[icon-only] { + margin: 0; + padding: ($content-padding / 2) $content-padding; + } + .item.item-input.item-block .item-inner ion-input { + border-bottom: 0; + } +} diff --git a/src/components/search-box/search-box.ts b/src/components/search-box/search-box.ts new file mode 100644 index 000000000..a6e70b3f9 --- /dev/null +++ b/src/components/search-box/search-box.ts @@ -0,0 +1,67 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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, Output, EventEmitter, OnInit } from '@angular/core'; +import { TranslateService } from '@ngx-translate/core'; +import { CoreUtilsProvider } from '../../providers/utils/utils'; + +/** + * Component to display a "search box". + * + * @description + * This component will display a standalone search box with its search button in order to have a better UX. + * + * Example usage: + * + */ +@Component({ + selector: 'core-search-box', + templateUrl: 'search-box.html' +}) +export class CoreSearchBoxComponent implements OnInit { + @Input() initialValue?: string = ''; // Initial value for search text. + @Input() searchLabel?: string ; // Label to be used on action button. + @Input() placeholder?: string; // Placeholder text for search text input. + @Input() autocorrect?: string = 'on'; // Enables/disable Autocorrection on search text input. + @Input() spellcheck?: string|boolean = true; // Enables/disable Spellchecker on search text input. + @Input() autoFocus?: string|boolean; // Enables/disable Autofocus when entering view. + @Input() lengthCheck?: number = 3; // Check value length before submit. If 0, any string will be submitted. + @Output() onSubmit: EventEmitter; // Send data when submitting the search form. + + constructor(private translate: TranslateService, private utils: CoreUtilsProvider) { + this.onSubmit = new EventEmitter(); + } + + ngOnInit() { + this.searchLabel = this.searchLabel || this.translate.instant('core.search'); + this.placeholder = this.placeholder || this.translate.instant('core.search'); + this.spellcheck = this.utils.isTrueOrOne(this.spellcheck); + } + + /** + * Form submitted. + * + * @param {string} value Entered value. + */ + submitForm(value: string) { + if (value.length < this.lengthCheck) { + // The view should handle this case, but we check it here too just in case. + return; + } + + this.onSubmit.emit(value); + } + +} diff --git a/src/components/show-password/show-password.scss b/src/components/show-password/show-password.scss index 5e5621493..919b6dce4 100644 --- a/src/components/show-password/show-password.scss +++ b/src/components/show-password/show-password.scss @@ -16,6 +16,11 @@ core-show-password { margin-top: 0; margin-bottom: 0; } + + .core-ioninput-password { + padding-top: 0; + padding-bottom: 0; + } } .md { diff --git a/src/core/courses/components/components.module.ts b/src/core/courses/components/components.module.ts new file mode 100644 index 000000000..d34f17cac --- /dev/null +++ b/src/core/courses/components/components.module.ts @@ -0,0 +1,48 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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 { CommonModule } from '@angular/common'; +import { IonicModule } from 'ionic-angular'; +import { TranslateModule } from '@ngx-translate/core'; +import { CoreComponentsModule } from '../../../components/components.module'; +import { CoreDirectivesModule } from '../../../directives/directives.module'; +import { CorePipesModule } from '../../../pipes/pipes.module'; +import { CoreCoursesCourseProgressComponent } from '../components/course-progress/course-progress'; +import { CoreCoursesCourseListItemComponent } from '../components/course-list-item/course-list-item'; +import { CoreCoursesOverviewEventsComponent } from '../components/overview-events/overview-events'; + +@NgModule({ + declarations: [ + CoreCoursesCourseProgressComponent, + CoreCoursesCourseListItemComponent, + CoreCoursesOverviewEventsComponent + ], + imports: [ + CommonModule, + IonicModule, + TranslateModule.forChild(), + CoreComponentsModule, + CoreDirectivesModule, + CorePipesModule + ], + providers: [ + ], + exports: [ + CoreCoursesCourseProgressComponent, + CoreCoursesCourseListItemComponent, + CoreCoursesOverviewEventsComponent + ] +}) +export class CoreCoursesComponentsModule {} diff --git a/src/core/courses/components/course-list-item/course-list-item.html b/src/core/courses/components/course-list-item/course-list-item.html new file mode 100644 index 000000000..26bd82906 --- /dev/null +++ b/src/core/courses/components/course-list-item/course-list-item.html @@ -0,0 +1,12 @@ + + +

+
+ + + + + + +
+
diff --git a/src/core/courses/components/course-list-item/course-list-item.scss b/src/core/courses/components/course-list-item/course-list-item.scss new file mode 100644 index 000000000..d5fe709e3 --- /dev/null +++ b/src/core/courses/components/course-list-item/course-list-item.scss @@ -0,0 +1,6 @@ +core-courses-course-list-item { + .core-course-enrollment-img { + max-width: 16px; + max-height: 16px; + } +} diff --git a/src/core/courses/components/course-list-item/course-list-item.ts b/src/core/courses/components/course-list-item/course-list-item.ts new file mode 100644 index 000000000..9e67707f1 --- /dev/null +++ b/src/core/courses/components/course-list-item/course-list-item.ts @@ -0,0 +1,82 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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 { NavController } from 'ionic-angular'; +import { TranslateService } from '@ngx-translate/core'; +import { CoreCoursesProvider } from '../../providers/courses'; + +/** + * This directive is meant to display an item for a list of courses. + * + * Example usage: + * + * + */ +@Component({ + selector: 'core-courses-course-list-item', + templateUrl: 'course-list-item.html' +}) +export class CoreCoursesCourseListItemComponent implements OnInit { + @Input() course: any; // The course to render. + + constructor(private navCtrl: NavController, private translate: TranslateService, private coursesProvider: CoreCoursesProvider) {} + + /** + * Component being initialized. + */ + ngOnInit() { + // Check if the user is enrolled in the course. + return this.coursesProvider.getUserCourse(this.course.id).then(() => { + this.course.isEnrolled = true; + }).catch(() => { + this.course.isEnrolled = false; + this.course.enrollment = []; + + this.course.enrollmentmethods.forEach((instance) => { + if (instance === 'self') { + this.course.enrollment.push({ + name: this.translate.instant('core.courses.selfenrolment'), + icon: 'unlock' + }); + } else if (instance === 'guest') { + this.course.enrollment.push({ + name: this.translate.instant('core.courses.allowguests'), + icon: 'person' + }); + } else if (instance === 'paypal') { + this.course.enrollment.push({ + name: this.translate.instant('core.courses.paypalaccepted'), + img: 'assets/img/icons/paypal.png' + }); + } + }); + + if (this.course.enrollment.length == 0) { + this.course.enrollment.push({ + name: this.translate.instant('core.courses.notenrollable'), + icon: 'lock' + }); + } + }); + } + + /** + * Open a course. + */ + openCourse(course) { + this.navCtrl.push('CoreCoursesCoursePreviewPage', {course: course}); + } + +} diff --git a/src/core/courses/components/course-progress/course-progress.html b/src/core/courses/components/course-progress/course-progress.html new file mode 100644 index 000000000..08b954147 --- /dev/null +++ b/src/core/courses/components/course-progress/course-progress.html @@ -0,0 +1,22 @@ + + +

+ + + + +
+ +

+

+ + +

+
+ + + + +
diff --git a/src/core/courses/components/course-progress/course-progress.scss b/src/core/courses/components/course-progress/course-progress.scss new file mode 100644 index 000000000..4587fe8ca --- /dev/null +++ b/src/core/courses/components/course-progress/course-progress.scss @@ -0,0 +1,12 @@ +core-courses-course-progress.core-courseoverview { + @media (max-width: 576px) { + ion-card.card { + margin: 0; + border-radius: 0; + box-shadow: none; + border-bottom: 1px solid $list-border-color; + width: 100%; + height: 100% !important; + } + } +} diff --git a/src/core/courses/components/course-progress/course-progress.ts b/src/core/courses/components/course-progress/course-progress.ts new file mode 100644 index 000000000..e7a2fb49c --- /dev/null +++ b/src/core/courses/components/course-progress/course-progress.ts @@ -0,0 +1,65 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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 { NavController } from 'ionic-angular'; +import { TranslateService } from '@ngx-translate/core'; + +/** + * This component is meant to display a course for a list of courses with progress. + * + * Example usage: + * + * + * + */ +@Component({ + selector: 'core-courses-course-progress', + templateUrl: 'course-progress.html' +}) +export class CoreCoursesCourseProgressComponent implements OnInit { + @Input() course: any; // The course to render. + + isDownloading: boolean; + + protected obsStatus; + protected downloadText; + protected downloadingText; + protected downloadButton = { + isDownload: true, + className: 'core-download-course', + priority: 1000 + }; + protected buttons; + + constructor(private navCtrl: NavController, private translate: TranslateService) { + this.downloadText = this.translate.instant('core.course.downloadcourse'); + this.downloadingText = this.translate.instant('core.downloading'); + } + + /** + * Component being initialized. + */ + ngOnInit() { + // @todo: Handle course prefetch. + } + + /** + * Open a course. + */ + openCourse(course) { + this.navCtrl.push('CoreCourseSectionPage', {course: course}); + } + +} diff --git a/src/core/courses/components/overview-events/overview-events.html b/src/core/courses/components/overview-events/overview-events.html new file mode 100644 index 000000000..e39c479b2 --- /dev/null +++ b/src/core/courses/components/overview-events/overview-events.html @@ -0,0 +1,49 @@ + + + +

+

{{event.timesort * 1000 | coreFormatDate:"dfmediumdate" }}

+ + {{event.action.itemcount}} +
+
+ + + {{ 'core.courses.recentlyoverdue' | translate }} + + + + + + + {{ 'core.courses.next7days' | translate }} + + + + + + + {{ 'core.courses.next30days' | translate }} + + + + + + + {{ 'core.courses.future' | translate }} + + + + + +
+ + + +
+ + + diff --git a/src/core/courses/components/overview-events/overview-events.scss b/src/core/courses/components/overview-events/overview-events.scss new file mode 100644 index 000000000..4adcffdff --- /dev/null +++ b/src/core/courses/components/overview-events/overview-events.scss @@ -0,0 +1,24 @@ +core-courses-course-progress { + + .core-course-module-handler.item-md.item-block .item-inner { + border-bottom: 1px solid $list-md-border-color; + } + + .core-course-module-handler.item-ios.item-block .item-inner { + border-bottom: $hairlines-width solid $list-ios-border-color; + } + + .core-course-module-handler.item-wp.item-block .item-inner { + border-bottom: 1px solid $list-wp-border-color; + } + + .core-course-module-handler.item:last-child .item-inner { + border-bottom: 0; + } + + .core-course-module-handler.item .item-heading:first-child { + margin-top: 0; + } +} + + diff --git a/src/core/courses/components/overview-events/overview-events.ts b/src/core/courses/components/overview-events/overview-events.ts new file mode 100644 index 000000000..fd566c4fa --- /dev/null +++ b/src/core/courses/components/overview-events/overview-events.ts @@ -0,0 +1,135 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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, Output, OnChanges, EventEmitter, SimpleChange } from '@angular/core'; +import { CoreSitesProvider } from '../../../../providers/sites'; +import { CoreDomUtilsProvider } from '../../../../providers/utils/dom'; +import { CoreTextUtilsProvider } from '../../../../providers/utils/text'; +import { CoreUtilsProvider } from '../../../../providers/utils/utils'; +import * as moment from 'moment'; + +/** + * Directive to render a list of events in course overview. + */ +@Component({ + selector: 'core-courses-overview-events', + templateUrl: 'overview-events.html' +}) +export class CoreCoursesOverviewEventsComponent implements OnChanges { + @Input() events: any[]; // The events to render. + @Input() showCourse?: boolean|string; // Whether to show the course name. + @Input() canLoadMore?: boolean; // Whether more events can be loaded. + @Output() loadMore: EventEmitter; // Notify that more events should be loaded. + + empty: boolean; + loadingMore: boolean; + recentlyOverdue: any[] = []; + today: any[] = []; + next7Days: any[] = []; + next30Days: any[] = []; + future: any[] = []; + + constructor(private utils: CoreUtilsProvider, private textUtils: CoreTextUtilsProvider, + private domUtils: CoreDomUtilsProvider, private sitesProvider: CoreSitesProvider) { + this.loadMore = new EventEmitter(); + } + + /** + * Detect changes on input properties. + */ + ngOnChanges(changes: {[name: string]: SimpleChange}) { + this.showCourse = this.utils.isTrueOrOne(this.showCourse); + + if (changes.events) { + this.updateEvents(); + } + } + + /** + * Filter the events by time. + * + * @param {number} start Number of days to start getting events from today. E.g. -1 will get events from yesterday. + * @param {number} [end] Number of days after the start. + */ + protected filterEventsByTime(start: number, end?: number) { + start = moment().add(start, 'days').unix(); + end = typeof end != 'undefined' ? moment().add(end, 'days').unix() : end; + + return this.events.filter((event) => { + if (end) { + return start <= event.timesort && event.timesort < end; + } + + return start <= event.timesort; + }).map((event) => { + // @todo: event.iconUrl = this.courseProvider.getModuleIconSrc(event.icon.component); + return event; + }); + } + + /** + * Update the events displayed. + */ + protected updateEvents() { + this.empty = !this.events || this.events.length <= 0; + if (!this.empty) { + this.recentlyOverdue = this.filterEventsByTime(-14, 0); + this.today = this.filterEventsByTime(0, 1); + this.next7Days = this.filterEventsByTime(1, 7); + this.next30Days = this.filterEventsByTime(7, 30); + this.future = this.filterEventsByTime(30); + } + } + + /** + * Load more events clicked. + */ + loadMoreEvents() { + this.loadingMore = true; + this.loadMore.emit(); + // this.loadMore().finally(function() { + // scope.loadingMore = false; + // }); + } + + /** + * Action clicked. + * + * @param {Event} e Click event. + * @param {string} url Url of the action. + */ + action(e: Event, url: string) { + e.preventDefault(); + e.stopPropagation(); + + // Fix URL format. + url = this.textUtils.decodeHTMLEntities(url); + + let modal = this.domUtils.showModalLoading(); + this.sitesProvider.getCurrentSite().openInBrowserWithAutoLoginIfSameSite(url).finally(() => { + modal.dismiss(); + }); + + // @todo + // $mmContentLinksHelper.handleLink(url).then((treated) => { + // if (!treated) { + // return this.sitesProvider.getCurrentSite().openInBrowserWithAutoLoginIfSameSite(url); + // } + // }).finally(() => { + // modal.dismiss(); + // }); + + return false; + } +} diff --git a/src/core/courses/courses.module.ts b/src/core/courses/courses.module.ts new file mode 100644 index 000000000..39d01fba1 --- /dev/null +++ b/src/core/courses/courses.module.ts @@ -0,0 +1,38 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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 { CoreCoursesProvider } from './providers/courses'; +import { CoreCoursesMainMenuHandler } from './providers/handlers'; +import { CoreCoursesMyOverviewProvider } from './providers/my-overview'; +import { CoreCoursesDelegate } from './providers/delegate'; +import { CoreMainMenuDelegate } from '../mainmenu/providers/delegate'; + +@NgModule({ + declarations: [], + imports: [ + ], + providers: [ + CoreCoursesProvider, + CoreCoursesMainMenuHandler, + CoreCoursesMyOverviewProvider, + CoreCoursesDelegate + ], + exports: [] +}) +export class CoreCoursesModule { + constructor(mainMenuDelegate: CoreMainMenuDelegate, mainMenuHandler: CoreCoursesMainMenuHandler) { + mainMenuDelegate.registerHandler(mainMenuHandler); + } +} diff --git a/src/core/courses/lang/ar.json b/src/core/courses/lang/ar.json new file mode 100644 index 000000000..82cbdc293 --- /dev/null +++ b/src/core/courses/lang/ar.json @@ -0,0 +1,19 @@ +{ + "allowguests": "يسمح للمستخدمين الضيوف بالدخول إلى هذا المقرر الدراسي", + "availablecourses": "المقررات الدراسية المتاحة", + "categories": "تصنيفات المقررات الدراسية", + "courses": "المقررات الدراسية", + "enrolme": "سجلني", + "frontpage": "الصفحة الرئيسية", + "mycourses": "مقرراتي الدراسية", + "nocourses": "لا يوجد معلومات لمقرر دراسي ليتم اظهرها", + "nocoursesyet": "لا توجد مقررات دراسية لهذه الفئة", + "nosearchresults": "لا توجد نتائج لهذا البحث", + "notenroled": "أنت لست مسجلاً كطالب في هذا المقرر", + "password": "كلمة المرور", + "paymentrequired": "هذا المقرر الدراسي غير مجانين لذا يجب دفع القيمة للدخول.", + "paypalaccepted": "تم قبول التبرع المدفوع", + "search": "بحث", + "searchcourses": "بحث مقررات دراسية", + "sendpaymentbutton": "ارسل القيمة المدفوعة عن طريق التبرع" +} \ No newline at end of file diff --git a/src/core/courses/lang/bg.json b/src/core/courses/lang/bg.json new file mode 100644 index 000000000..704b295e7 --- /dev/null +++ b/src/core/courses/lang/bg.json @@ -0,0 +1,16 @@ +{ + "allowguests": "В този курс могат да влизат гости", + "availablecourses": "Налични курсове", + "categories": "Категории курсове", + "courses": "Курсове", + "enrolme": "Запишете ме", + "errorloadcourses": "Грешка при зареждането на курсовете.", + "frontpage": "Заглавна страница", + "mycourses": "Моите курсове", + "nocourses": "Няма информация за курса, която да бъде показана.", + "nocoursesyet": "Няма курсове в тази категория", + "nosearchresults": "Няма открити резултати за Вашето търсене", + "password": "Ключ за записване", + "search": "Търсене", + "searchcourses": "Търсене на курсове" +} \ No newline at end of file diff --git a/src/core/courses/lang/ca.json b/src/core/courses/lang/ca.json new file mode 100644 index 000000000..6cc07396f --- /dev/null +++ b/src/core/courses/lang/ca.json @@ -0,0 +1,30 @@ +{ + "allowguests": "Aquest curs permet entrar als usuaris visitants", + "availablecourses": "Cursos disponibles", + "cannotretrievemorecategories": "No es poden recuperar categories més enllà del nivell {{$a}}.", + "categories": "Categories de cursos", + "confirmselfenrol": "Segur que voleu autoinscriure-us en aquest curs?", + "courses": "Cursos", + "enrolme": "Inscriu-me", + "errorloadcategories": "S'ha produït un error en carregar les categories.", + "errorloadcourses": "S'ha produït un error carregant els cursos.", + "errorsearching": "S'ha produït un error durant la cerca.", + "errorselfenrol": "S'ha produït un error durant l'autoinscripció.", + "filtermycourses": "Filtrar els meus cursos", + "frontpage": "Pàgina principal", + "mycourses": "Els meus cursos", + "nocourses": "No hi ha informació de cursos per mostrar.", + "nocoursesyet": "No hi ha cursos en aquesta categoria", + "nosearchresults": "La cerca no ha obtingut resultats", + "notenroled": "No us heu inscrit en aquest curs", + "notenrollable": "No podeu autoinscriure-us en aquest curs.", + "password": "Contrasenya", + "paymentrequired": "Aquest curs requereix pagament.", + "paypalaccepted": "S'accepten pagaments via PayPal", + "search": "Cerca...", + "searchcourses": "Cerca cursos", + "searchcoursesadvice": "Podeu fer servir el botó de cercar cursos per accedir als cursos com a convidat o autoinscriure-us en cursos que ho permetin.", + "selfenrolment": "Autoinscripció", + "sendpaymentbutton": "Envia pagament via Paypal", + "totalcoursesearchresults": "Total de cursos: {{$a}}" +} \ No newline at end of file diff --git a/src/core/courses/lang/cs.json b/src/core/courses/lang/cs.json new file mode 100644 index 000000000..4f1701f97 --- /dev/null +++ b/src/core/courses/lang/cs.json @@ -0,0 +1,30 @@ +{ + "allowguests": "Tento kurz je otevřen i pro hosty", + "availablecourses": "Dostupné kurzy", + "cannotretrievemorecategories": "Kategorie hlubší než úroveň {{$a}} nelze načíst.", + "categories": "Kategorie kurzů", + "confirmselfenrol": "Jste si jisti, že chcete zapsat se do tohoto kurzu?", + "courses": "Kurzy", + "enrolme": "Zapsat se do kurzu", + "errorloadcategories": "Při načítání kategorií došlo k chybě.", + "errorloadcourses": "Při načítání kurzů došlo k chybě.", + "errorsearching": "Při vyhledávání došlo k chybě.", + "errorselfenrol": "Při zápisu sebe sama došlo k chybě.", + "filtermycourses": "Filtrovat mé kurzy", + "frontpage": "Titulní stránka", + "mycourses": "Moje kurzy", + "nocourses": "Žádné dostupné informace o kurzech", + "nocoursesyet": "Žádný kurz v této kategorii", + "nosearchresults": "Vaše vyhledávání nepřineslo žádný výsledek", + "notenroled": "Nejste zapsáni v tomto kurzu", + "notenrollable": "Do tohoto kurzu se nemůžete sami zapsat.", + "password": "Heslo", + "paymentrequired": "Tento kurz je placený", + "paypalaccepted": "Platby přes PayPal přijímány", + "search": "Hledat", + "searchcourses": "Vyhledat kurzy", + "searchcoursesadvice": "Můžete použít tlačítko Vyhledat kurzy, pracovat jako host nebo se zapsat do kurzů, které to umožňují.", + "selfenrolment": "Zápis sebe sama", + "sendpaymentbutton": "Poslat platbu přes službu PayPal", + "totalcoursesearchresults": "Celkem kurzů: {{$a}}" +} \ No newline at end of file diff --git a/src/core/courses/lang/da.json b/src/core/courses/lang/da.json new file mode 100644 index 000000000..7f0aaebd8 --- /dev/null +++ b/src/core/courses/lang/da.json @@ -0,0 +1,28 @@ +{ + "allowguests": "Dette kursus tillader gæster", + "availablecourses": "Tilgængelige kurser", + "categories": "Kursuskategorier", + "confirmselfenrol": "Er du sikker på at du ønsker at tilmelde dig dette kursus?", + "courses": "Alle kurser", + "enrolme": "Tilmeld mig", + "errorloadcourses": "En fejl opstod ved indlæsning af kurset.", + "errorsearching": "En fejl opstod under søgning.", + "errorselfenrol": "En fejl opstod under selvtilmelding.", + "filtermycourses": "Filtrer mit kursus", + "frontpage": "Forside", + "mycourses": "Mine kurser", + "nocourses": "Du er ikke tilmeldt nogen kurser.", + "nocoursesyet": "Der er ingen kurser i denne kategori", + "nosearchresults": "Der var ingen beskeder der opfyldte søgekriteriet", + "notenroled": "Du er ikke tilmeldt dette kursus", + "notenrollable": "Du kan ikke selv tilmelde dig dette kursus.", + "password": "Adgangskode", + "paymentrequired": "Dette kursus kræver betaling for tilmelding.", + "paypalaccepted": "PayPal-betalinger er velkomne", + "search": "Søg...", + "searchcourses": "Søg efter kurser", + "searchcoursesadvice": "Du kan bruge knappen kursussøgning for at få adgang som gæst eller tilmelde dig kurser der tillader det.", + "selfenrolment": "Selvtilmelding", + "sendpaymentbutton": "Send betaling via PayPal", + "totalcoursesearchresults": "Kurser i alt: {{$a}}" +} \ No newline at end of file diff --git a/src/core/courses/lang/de-du.json b/src/core/courses/lang/de-du.json new file mode 100644 index 000000000..13bd07bd7 --- /dev/null +++ b/src/core/courses/lang/de-du.json @@ -0,0 +1,30 @@ +{ + "allowguests": "Dieser Kurs erlaubt einen Gastzugang.", + "availablecourses": "Kursliste", + "cannotretrievemorecategories": "Kursbereiche tiefer als Level {{$a}} können nicht abgerufen werden.", + "categories": "Kursbereiche", + "confirmselfenrol": "Möchtest du dich selbst in diesen Kurs einschreiben?", + "courses": "Kurse", + "enrolme": "Einschreiben", + "errorloadcategories": "Fehler beim Laden von Kursbereichen", + "errorloadcourses": "Fehler beim Laden von Kursen", + "errorsearching": "Fehler beim Suchen", + "errorselfenrol": "Fehler bei der Selbsteinschreibung", + "filtermycourses": "Meine Kurse filtern", + "frontpage": "Startseite", + "mycourses": "Meine Kurse", + "nocourses": "Keine Kurse", + "nocoursesyet": "Keine Kurse in diesem Kursbereich", + "nosearchresults": "Keine Ergebnisse", + "notenroled": "Sie sind nicht in diesen Kurs eingeschrieben", + "notenrollable": "Du kannst dich nicht selbst in diesen Kurs einschreiben.", + "password": "Öffentliches Kennwort", + "paymentrequired": "Dieser Kurs ist gebührenpflichtig. Bitte bezahle die Teilnahmegebühr, um im Kurs eingeschrieben zu werden.", + "paypalaccepted": "PayPal-Zahlungen möglich", + "search": "Suchen", + "searchcourses": "Kurse suchen", + "searchcoursesadvice": "Du kannst Kurse suchen, um als Gast teilzunehmen oder dich selbst einzuschreiben, falls dies erlaubt ist.", + "selfenrolment": "Selbsteinschreibung", + "sendpaymentbutton": "Zahlung über PayPal", + "totalcoursesearchresults": "Alle Kurse: {{$a}}" +} \ No newline at end of file diff --git a/src/core/courses/lang/de.json b/src/core/courses/lang/de.json new file mode 100644 index 000000000..3f93d4db3 --- /dev/null +++ b/src/core/courses/lang/de.json @@ -0,0 +1,30 @@ +{ + "allowguests": "Dieser Kurs erlaubt einen Gastzugang.", + "availablecourses": "Kursliste", + "cannotretrievemorecategories": "Kursbereiche tiefer als Level {{$a}} können nicht abgerufen werden.", + "categories": "Kursbereiche", + "confirmselfenrol": "Möchten Sie sich selbst in diesen Kurs einschreiben?", + "courses": "Kurse", + "enrolme": "Einschreiben", + "errorloadcategories": "Fehler beim Laden von Kursbereichen", + "errorloadcourses": "Fehler beim Laden von Kursen", + "errorsearching": "Fehler beim Suchen", + "errorselfenrol": "Fehler bei der Selbsteinschreibung", + "filtermycourses": "Meine Kurse filtern", + "frontpage": "Startseite", + "mycourses": "Meine Kurse", + "nocourses": "Keine Kurse", + "nocoursesyet": "Keine Kurse in diesem Kursbereich", + "nosearchresults": "Keine Suchergebnisse", + "notenroled": "Sie sind nicht in diesen Kurs eingeschrieben", + "notenrollable": "Sie können sich nicht selbst in diesen Kurs einschreiben.", + "password": "Öffentliches Kennwort", + "paymentrequired": "Dieser Kurs ist entgeltpflichtig. Bitte bezahlen Sie das Teilnahmeentgelt, um in den Kurs eingeschrieben zu werden.", + "paypalaccepted": "PayPal-Zahlungen möglich", + "search": "Suchen", + "searchcourses": "Kurse suchen", + "searchcoursesadvice": "Sie können Kurse suchen, um als Gast teilzunehmen oder sich selbst einzuschreiben, falls dies erlaubt ist.", + "selfenrolment": "Selbsteinschreibung", + "sendpaymentbutton": "Zahlung über PayPal", + "totalcoursesearchresults": "Alle Kurse: {{$a}}" +} \ No newline at end of file diff --git a/src/core/courses/lang/el.json b/src/core/courses/lang/el.json new file mode 100644 index 000000000..9b9181e25 --- /dev/null +++ b/src/core/courses/lang/el.json @@ -0,0 +1,30 @@ +{ + "allowguests": "Σε αυτό το μάθημα επιτρέπονται και οι επισκέπτες", + "availablecourses": "Διαθέσιμα Μαθήματα", + "cannotretrievemorecategories": "Δεν είναι δυνατή η ανάκτηση κατηγοριών μετά από το επίπεδο {{$a}}.", + "categories": "Κατηγορίες μαθημάτων", + "confirmselfenrol": "Είστε σίγουροι ότι θέλετε να εγγραφείτε σε αυτό το μάθημα;", + "courses": "Μαθήματα", + "enrolme": "Εγγραφή", + "errorloadcategories": "Παρουσιάστηκε σφάλμα κατά την φόρτωση των κατηγοριών.", + "errorloadcourses": "Παρουσιάστηκε σφάλμα κατά τη φόρτωση των μαθημάτων.", + "errorsearching": "Παρουσιάστηκε σφάλμα κατά τη διάρκεια της αναζήτησης.", + "errorselfenrol": "Παρουσιάστηκε σφάλμα κατά τη διάρκεια της αυτο-εγγραφής.", + "filtermycourses": "Φιλτράρισμα των μαθημάτων μου", + "frontpage": "Αρχική σελίδα", + "mycourses": "Τα μαθήματά μου", + "nocourses": "Δεν υπάρχει πληροφορία του μαθήματος για προβολή.", + "nocoursesyet": "Δεν υπάρχουν μαθήματα σε αυτήν την κατηγορία", + "nosearchresults": "Δε βρέθηκαν αποτελέσματα για την αναζήτησή σας", + "notenroled": "Δεν είσαι εγγεγραμμένος σε αυτό το μάθημα", + "notenrollable": "Δεν μπορείτε να αυτο-εγγραφείτε σε αυτό το μάθημα.", + "password": "Κωδικός πρόσβασης", + "paymentrequired": "Αυτό το μάθημα απαιτεί πληρωμή για την είσοδο.", + "paypalaccepted": "Αποδεκτές οι πληρωμές μέσω PayPal", + "search": "Αναζήτηση", + "searchcourses": "Αναζήτηση μαθημάτων", + "searchcoursesadvice": "Μπορείτε να χρησιμοποιήσετε το κουμπί Αναζήτηση μαθημάτων για πρόσβαση ως επισκέπτης ή για να αυτο-εγγραφείτε σε μαθήματα που το επιτρέπουν.", + "selfenrolment": "Αυτο-εγγραφή", + "sendpaymentbutton": "Αποστολή πληρωμής με Paypal", + "totalcoursesearchresults": "Συνολικά μαθήματα: {{$a}}" +} \ No newline at end of file diff --git a/src/core/courses/lang/en.json b/src/core/courses/lang/en.json new file mode 100644 index 000000000..09a14d7f8 --- /dev/null +++ b/src/core/courses/lang/en.json @@ -0,0 +1,47 @@ +{ + "allowguests": "This course allows guest users to enter", + "availablecourses": "Available courses", + "cannotretrievemorecategories": "Categories deeper than level {{$a}} cannot be retrieved.", + "categories": "Course categories", + "confirmselfenrol": "Are you sure you want to enrol yourself in this course?", + "courseoverview": "Course overview", + "courses": "Courses", + "downloadcourses": "Download courses", + "enrolme": "Enrol me", + "errorloadcategories": "An error occurred while loading categories.", + "errorloadcourses": "An error occurred while loading courses.", + "errorsearching": "An error occurred while searching.", + "errorselfenrol": "An error occurred while self enrolling.", + "filtermycourses": "Filter my courses", + "frontpage": "Front page", + "future": "Future", + "inprogress": "In progress", + "morecourses": "More courses", + "mycourses": "My courses", + "next30days": "Next 30 days", + "next7days": "Next 7 days", + "nocourses": "No course information to show.", + "nocoursesfuture": "No future courses", + "nocoursesinprogress": "No in progress courses", + "nocoursesoverview": "No courses", + "nocoursespast": "No past courses", + "nocoursesyet": "No courses in this category", + "noevents": "No upcoming activities due", + "nosearchresults": "There were no results from your search", + "notenroled": "You are not enrolled in this course", + "notenrollable": "You cannot enrol yourself in this course.", + "password": "Enrolment key", + "past": "Past", + "paymentrequired": "This course requires a payment for entry.", + "paypalaccepted": "PayPal payments accepted", + "recentlyoverdue": "Recently overdue", + "search": "Search", + "searchcourses": "Search courses", + "searchcoursesadvice": "You can use the search courses button to find courses to access as a guest or enrol yourself in courses that allow it.", + "selfenrolment": "Self enrolment", + "sendpaymentbutton": "Send payment via PayPal", + "sortbycourses": "Sort by courses", + "sortbydates": "Sort by dates", + "timeline": "Timeline", + "totalcoursesearchresults": "Total courses: {{$a}}" +} \ No newline at end of file diff --git a/src/core/courses/lang/es-mx.json b/src/core/courses/lang/es-mx.json new file mode 100644 index 000000000..1363fb2cc --- /dev/null +++ b/src/core/courses/lang/es-mx.json @@ -0,0 +1,30 @@ +{ + "allowguests": "Este curso permite la entrada de invitados", + "availablecourses": "Cursos disponibles", + "cannotretrievemorecategories": "No se pueden recuperar categorías más profundas que el nivel {{$a}}.", + "categories": "Categorías", + "confirmselfenrol": "¿Está Usted seguro de querer inscribirse a Usted mismo en este curso?", + "courses": "Cursos", + "enrolme": "Inscribirme", + "errorloadcategories": "Ocurrió un error al cargar categorías.", + "errorloadcourses": "Ocurrió un error al cargar los cursos.", + "errorsearching": "Ocurrio un error al buscar.", + "errorselfenrol": "Ocurrio un error al auto-inscribir.", + "filtermycourses": "<< + + {{ 'core.courses.availablecourses' | translate }} + + + + + + + +
+ +
+ +
+
diff --git a/src/core/courses/pages/available-courses/available-courses.module.ts b/src/core/courses/pages/available-courses/available-courses.module.ts new file mode 100644 index 000000000..5fb00ab0a --- /dev/null +++ b/src/core/courses/pages/available-courses/available-courses.module.ts @@ -0,0 +1,33 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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 { IonicPageModule } from 'ionic-angular'; +import { TranslateModule } from '@ngx-translate/core'; +import { CoreCoursesAvailableCoursesPage } from './available-courses'; +import { CoreComponentsModule } from '../../../../components/components.module'; +import { CoreCoursesComponentsModule } from '../../components/components.module'; + +@NgModule({ + declarations: [ + CoreCoursesAvailableCoursesPage, + ], + imports: [ + CoreComponentsModule, + CoreCoursesComponentsModule, + IonicPageModule.forChild(CoreCoursesAvailableCoursesPage), + TranslateModule.forChild() + ], +}) +export class CoreCoursesAvailableCoursesPageModule {} diff --git a/src/core/courses/pages/available-courses/available-courses.ts b/src/core/courses/pages/available-courses/available-courses.ts new file mode 100644 index 000000000..47d71babf --- /dev/null +++ b/src/core/courses/pages/available-courses/available-courses.ts @@ -0,0 +1,76 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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 } from '@angular/core'; +import { IonicPage } from 'ionic-angular'; +import { CoreSitesProvider } from '../../../../providers/sites'; +import { CoreDomUtilsProvider } from '../../../../providers/utils/dom'; +import { CoreCoursesProvider } from '../../providers/courses'; + +/** + * Page that displays available courses in current site. + */ +@IonicPage() +@Component({ + selector: 'page-core-courses-available-courses', + templateUrl: 'available-courses.html', +}) +export class CoreCoursesAvailableCoursesPage { + courses: any[] = []; + coursesLoaded: boolean; + + constructor(private coursesProvider: CoreCoursesProvider, private domUtils: CoreDomUtilsProvider, + private sitesProvider: CoreSitesProvider) {} + + /** + * View loaded. + */ + ionViewDidLoad() { + this.loadCourses().finally(() => { + this.coursesLoaded = true; + }); + } + + /** + * Load the courses. + */ + protected loadCourses() { + const frontpageCourseId = this.sitesProvider.getCurrentSite().getSiteHomeId(); + return this.coursesProvider.getCoursesByField().then((courses) => { + this.courses = courses.filter((course) => { + return course.id != frontpageCourseId; + }); + }).catch((error) => { + this.domUtils.showErrorModalDefault(error, 'core.courses.errorloadcourses', true); + }); + } + + /** + * Refresh the courses. + * + * @param {any} refresher Refresher. + */ + refreshCourses(refresher: any) { + let promises = []; + + promises.push(this.coursesProvider.invalidateUserCourses()); + promises.push(this.coursesProvider.invalidateCoursesByField()); + + Promise.all(promises).finally(() => { + this.loadCourses().finally(() => { + refresher.complete(); + }); + }); + }; +} diff --git a/src/core/courses/pages/categories/categories.html b/src/core/courses/pages/categories/categories.html new file mode 100644 index 000000000..c22dfe194 --- /dev/null +++ b/src/core/courses/pages/categories/categories.html @@ -0,0 +1,39 @@ + + + + + + + + + + + + +

+
+ + + + +
+ {{ 'core.courses.categories' | translate }} +
+ + +

+ {{category.coursecount}} + +
+
+
+ +
+ {{ 'core.courses.courses' | translate }} + +
+ +

{{ 'core.courses.searchcoursesadvice' | translate }}

+
+
+
diff --git a/src/core/courses/pages/categories/categories.module.ts b/src/core/courses/pages/categories/categories.module.ts new file mode 100644 index 000000000..3033dc219 --- /dev/null +++ b/src/core/courses/pages/categories/categories.module.ts @@ -0,0 +1,35 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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 { IonicPageModule } from 'ionic-angular'; +import { TranslateModule } from '@ngx-translate/core'; +import { CoreCoursesCategoriesPage } from './categories'; +import { CoreComponentsModule } from '../../../../components/components.module'; +import { CoreDirectivesModule } from '../../../../directives/directives.module'; +import { CoreCoursesComponentsModule } from '../../components/components.module'; + +@NgModule({ + declarations: [ + CoreCoursesCategoriesPage, + ], + imports: [ + CoreComponentsModule, + CoreCoursesComponentsModule, + CoreDirectivesModule, + IonicPageModule.forChild(CoreCoursesCategoriesPage), + TranslateModule.forChild() + ], +}) +export class CoreCoursesCategoriesPageModule {} diff --git a/src/core/courses/pages/categories/categories.ts b/src/core/courses/pages/categories/categories.ts new file mode 100644 index 000000000..2253fc17a --- /dev/null +++ b/src/core/courses/pages/categories/categories.ts @@ -0,0 +1,122 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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 } from '@angular/core'; +import { IonicPage, NavController, NavParams } from 'ionic-angular'; +import { TranslateService } from '@ngx-translate/core'; +import { CoreSitesProvider } from '../../../../providers/sites'; +import { CoreDomUtilsProvider } from '../../../../providers/utils/dom'; +import { CoreUtilsProvider } from '../../../../providers/utils/utils'; +import { CoreCoursesProvider } from '../../providers/courses'; + +/** + * Page that displays a list of categories and the courses in the current category if any. + */ +@IonicPage() +@Component({ + selector: 'page-core-courses-categories', + templateUrl: 'categories.html', +}) +export class CoreCoursesCategoriesPage { + title: string; + currentCategory: any; + categories: any[] = []; + courses: any[] = []; + categoriesLoaded: boolean; + + protected categoryId: number; + + constructor(private navCtrl: NavController, navParams: NavParams, private coursesProvider: CoreCoursesProvider, + private domUtils: CoreDomUtilsProvider, private utils: CoreUtilsProvider, translate: TranslateService, + private sitesProvider: CoreSitesProvider) { + this.categoryId = navParams.get('categoryId') || 0; + this.title = translate.instant('core.courses.categories'); + } + + /** + * View loaded. + */ + ionViewDidLoad() { + this.fetchCategories().finally(() => { + this.categoriesLoaded = true; + }); + } + + /** + * Fetch the categories. + */ + protected fetchCategories() { + return this.coursesProvider.getCategories(this.categoryId, true).then((cats) => { + this.currentCategory = undefined; + + cats.forEach((cat, index) => { + if (cat.id == this.categoryId) { + this.currentCategory = cat; + // Delete current Category to avoid problems with the formatTree. + delete cats[index]; + } + }); + + // Sort by depth and sortorder to avoid problems formatting Tree. + cats.sort((a,b) => { + if (a.depth == b.depth) { + return (a.sortorder > b.sortorder) ? 1 : ((b.sortorder > a.sortorder) ? -1 : 0); + } + return a.depth > b.depth ? 1 : -1; + }); + + this.categories = this.utils.formatTree(cats, 'parent', 'id', this.categoryId); + + if (this.currentCategory) { + this.title = this.currentCategory.name; + + return this.coursesProvider.getCoursesByField('category', this.categoryId).then((courses) => { + this.courses = courses; + }).catch((error) => { + this.domUtils.showErrorModalDefault(error, 'core.courses.errorloadcourses', true); + }); + } + }).catch((error) => { + this.domUtils.showErrorModalDefault(error, 'core.courses.errorloadcategories', true); + }); + } + + /** + * Refresh the categories. + * + * @param {any} refresher Refresher. + */ + refreshCategories(refresher: any) { + let promises = []; + + promises.push(this.coursesProvider.invalidateUserCourses()); + promises.push(this.coursesProvider.invalidateCategories(this.categoryId, true)); + promises.push(this.coursesProvider.invalidateCoursesByField('category', this.categoryId)); + promises.push(this.sitesProvider.getCurrentSite().invalidateConfig()); + + Promise.all(promises).finally(() => { + this.fetchCategories().finally(() => { + refresher.complete(); + }); + }); + } + /** + * Open a category. + * + * @param {number} categoryId The category ID. + */ + openCategory(categoryId: number) { + this.navCtrl.push('CoreCoursesCategoriesPage', {categoryId: categoryId}); + } +} diff --git a/src/core/courses/pages/course-preview/course-preview.html b/src/core/courses/pages/course-preview/course-preview.html new file mode 100644 index 000000000..58db99af7 --- /dev/null +++ b/src/core/courses/pages/course-preview/course-preview.html @@ -0,0 +1,64 @@ + + + + + + + + + + + + + + +

+

{{course.categoryname}}

+

{{course.startdate * 1000 | coreFormatDate:"dfdaymonthyear"}} - {{course.enddate * 1000 | coreFormatDate:"dfdaymonthyear"}}

+
+ + + + + + +

{{ 'core.teachers' | translate }}

+

{{contact.fullname}}

+
+ +
+ +

{{ instance.name }}

+ +
+
+ +

{{ 'core.courses.paypalaccepted' | translate }}

+

{{ 'core.paymentinstant' | translate }}

+ +
+ +

{{ 'core.courses.notenrollable' | translate }}

+
+ + + +

{{ 'core.course.contents' | translate }}

+
+ + + + +
+
+
diff --git a/src/core/courses/pages/course-preview/course-preview.module.ts b/src/core/courses/pages/course-preview/course-preview.module.ts new file mode 100644 index 000000000..00e38e14b --- /dev/null +++ b/src/core/courses/pages/course-preview/course-preview.module.ts @@ -0,0 +1,35 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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 { IonicPageModule } from 'ionic-angular'; +import { TranslateModule } from '@ngx-translate/core'; +import { CoreCoursesCoursePreviewPage } from './course-preview'; +import { CoreComponentsModule } from '../../../../components/components.module'; +import { CoreDirectivesModule } from '../../../../directives/directives.module'; +import { CorePipesModule } from '../../../../pipes/pipes.module'; + +@NgModule({ + declarations: [ + CoreCoursesCoursePreviewPage, + ], + imports: [ + CoreComponentsModule, + CoreDirectivesModule, + CorePipesModule, + IonicPageModule.forChild(CoreCoursesCoursePreviewPage), + TranslateModule.forChild() + ], +}) +export class CoreCoursesCoursePreviewPageModule {} diff --git a/src/core/courses/pages/course-preview/course-preview.scss b/src/core/courses/pages/course-preview/course-preview.scss new file mode 100644 index 000000000..6667a1983 --- /dev/null +++ b/src/core/courses/pages/course-preview/course-preview.scss @@ -0,0 +1,3 @@ +page-core-courses-course-preview { + +} diff --git a/src/core/courses/pages/course-preview/course-preview.ts b/src/core/courses/pages/course-preview/course-preview.ts new file mode 100644 index 000000000..1076440cd --- /dev/null +++ b/src/core/courses/pages/course-preview/course-preview.ts @@ -0,0 +1,390 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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, OnDestroy } from '@angular/core'; +import { IonicPage, NavController, NavParams, Platform, ModalController, Modal } from 'ionic-angular'; +import { TranslateService } from '@ngx-translate/core'; +import { CoreAppProvider } from '../../../../providers/app'; +import { CoreEventsProvider } from '../../../../providers/events'; +import { CoreSitesProvider } from '../../../../providers/sites'; +import { CoreDomUtilsProvider } from '../../../../providers/utils/dom'; +import { CoreTextUtilsProvider } from '../../../../providers/utils/text'; +import { CoreCoursesProvider } from '../../providers/courses'; +import { CoreCoursesDelegate } from '../../providers/delegate'; + +/** + * Page that allows "previewing" a course and enrolling in it if enabled and not enrolled. + */ +@IonicPage() +@Component({ + selector: 'page-core-courses-course-preview', + templateUrl: 'course-preview.html', +}) +export class CoreCoursesCoursePreviewPage implements OnDestroy { + course: any; + isEnrolled: boolean; + handlersShouldBeShown: boolean = true; + handlersLoaded: boolean; + component = 'CoreCoursesCoursePreview'; + selfEnrolInstances: any[] = []; + paypalEnabled: boolean; + dataLoaded: boolean; + prefetchCourseIcon: string; + + protected guestWSAvailable: boolean; + protected isGuestEnabled: boolean = false; + protected guestInstanceId: number; + protected enrollmentMethods: any[]; + protected waitStart = 0; + protected enrolUrl: string; + protected courseUrl: string; + protected paypalReturnUrl: string; + protected isMobile: boolean; + protected isDesktop: boolean; + protected selfEnrolModal: Modal; + protected pageDestroyed = false; + protected currentInstanceId: number; + + constructor(private navCtrl: NavController, navParams: NavParams, private sitesProvider: CoreSitesProvider, + private domUtils: CoreDomUtilsProvider, private textUtils: CoreTextUtilsProvider, appProvider: CoreAppProvider, + private coursesProvider: CoreCoursesProvider, private platform: Platform, private modalCtrl: ModalController, + private translate: TranslateService, private eventsProvider: CoreEventsProvider, + private coursesDelegate: CoreCoursesDelegate) { + this.course = navParams.get('course'); + this.isMobile = appProvider.isMobile(); + this.isDesktop = appProvider.isDesktop(); + } + + /** + * View loaded. + */ + ionViewDidLoad() { + const currentSite = this.sitesProvider.getCurrentSite(), + currentSiteUrl = currentSite && currentSite.getURL(); + + this.paypalEnabled = this.course.enrollmentmethods && this.course.enrollmentmethods.indexOf('paypal') > -1; + this.guestWSAvailable = this.coursesProvider.isGuestWSAvailable(); + this.enrolUrl = this.textUtils.concatenatePaths(currentSiteUrl, 'enrol/index.php?id=' + this.course.id); + this.courseUrl = this.textUtils.concatenatePaths(currentSiteUrl, 'course/view.php?id=' + this.course.id); + this.paypalReturnUrl = this.textUtils.concatenatePaths(currentSiteUrl, 'enrol/paypal/return.php'); + + // Initialize the self enrol modal. + this.selfEnrolModal = this.modalCtrl.create('CoreCoursesSelfEnrolPasswordPage'); + this.selfEnrolModal.onDidDismiss((password: string) => { + if (typeof password != 'undefined') { + this.selfEnrolInCourse(password, this.currentInstanceId); + } + }); + + this.getCourse().finally(() => { + // @todo: Prefetch course. + }); + } + + /** + * Page destroyed. + */ + ngOnDestroy() { + this.pageDestroyed = true; + } + + /** + * Check if the user can access as guest. + * + * @return {Promise} Promise resolved if can access as guest, rejected otherwise. Resolve param indicates if + * password is required for guest access. + */ + protected canAccessAsGuest() : Promise { + if (!this.isGuestEnabled) { + return Promise.reject(null); + } + + // Search instance ID of guest enrolment method. + this.guestInstanceId = undefined; + for (let i = 0; i < this.enrollmentMethods.length; i++) { + let method = this.enrollmentMethods[i]; + if (method.type == 'guest') { + this.guestInstanceId = method.id; + break; + } + } + + if (this.guestInstanceId) { + return this.coursesProvider.getCourseGuestEnrolmentInfo(this.guestInstanceId).then((info) => { + if (!info.status) { + // Not active, reject. + return Promise.reject(null); + } + return info.passwordrequired; + }); + } + + return Promise.reject(null); + } + + /** + * Convenience function to get course. We use this to determine if a user can see the course or not. + * + * @param {boolean} refresh Whether the user is refreshing the data. + */ + protected getCourse(refresh?: boolean) : Promise { + // Get course enrolment methods. + this.selfEnrolInstances = []; + return this.coursesProvider.getCourseEnrolmentMethods(this.course.id).then((methods) => { + this.enrollmentMethods = methods; + + this.enrollmentMethods.forEach((method) => { + if (method.type === 'self') { + this.selfEnrolInstances.push(method); + } else if (this.guestWSAvailable && method.type === 'guest') { + this.isGuestEnabled = true; + } + }); + }).catch((error) => { + this.domUtils.showErrorModalDefault(error, 'Error getting enrolment data'); + }).then(() => { + // Check if user is enrolled in the course. + return this.coursesProvider.getUserCourse(this.course.id).then((course) => { + this.isEnrolled = true; + return course; + }).catch(() => { + // The user is not enrolled in the course. Use getCourses to see if it's an admin/manager and can see the course. + this.isEnrolled = false; + return this.coursesProvider.getCourse(this.course.id); + }).then((course) => { + // Success retrieving the course, we can assume the user has permissions to view it. + this.course.fullname = course.fullname || this.course.fullname; + this.course.summary = course.summary || this.course.summary; + return this.loadCourseHandlers(refresh, false); + }).catch(() => { + // The user is not an admin/manager. Check if we can provide guest access to the course. + return this.canAccessAsGuest().then((passwordRequired) => { + if (!passwordRequired) { + return this.loadCourseHandlers(refresh, true); + } else { + return Promise.reject(null); + } + }).catch(() => { + this.course._handlers = []; + this.handlersShouldBeShown = false; + }); + }); + }).finally(() => { + this.dataLoaded = true; + }); + } + + /** + * Load course nav handlers. + * + * @param {boolean} refresh Whether the user is refreshing the data. + * @param {boolean} guest Whether it's guest access. + */ + protected loadCourseHandlers(refresh: boolean, guest: boolean) : Promise { + return this.coursesDelegate.getHandlersToDisplay(this.course, refresh, guest, true).then((handlers) => { + this.course._handlers = handlers; + this.handlersShouldBeShown = true; + this.handlersLoaded = true; + }); + } + + /** + * Open the course. + */ + openCourse() { + if (!this.handlersShouldBeShown) { + // Course cannot be opened. + return; + } + + this.navCtrl.push('CoreCourseSectionPage', {course: this.course}); + } + + /** + * Enrol using PayPal. + */ + paypalEnrol() { + let window, + hasReturnedFromPaypal = false, + inAppLoadSubscription, + inAppFinishSubscription, + inAppExitSubscription, + appResumeSubscription, + urlLoaded = (event) => { + if (event.url.indexOf(this.paypalReturnUrl) != -1) { + hasReturnedFromPaypal = true; + } else if (event.url.indexOf(this.courseUrl) != -1 && hasReturnedFromPaypal) { + // User reached the course index page after returning from PayPal, close the InAppBrowser. + inAppClosed(); + window.close(); + } + }, + inAppClosed = () => { + // InAppBrowser closed, refresh data. + unsubscribeAll(); + + if (!this.dataLoaded) { + return; + } + this.dataLoaded = false; + this.refreshData(); + }, + unsubscribeAll = () => { + inAppLoadSubscription && inAppLoadSubscription.unsubscribe(); + inAppFinishSubscription && inAppFinishSubscription.unsubscribe(); + inAppExitSubscription && inAppExitSubscription.unsubscribe(); + appResumeSubscription && appResumeSubscription.unsubscribe(); + }; + + // Open the enrolment page in InAppBrowser. + this.sitesProvider.getCurrentSite().openInAppWithAutoLogin(this.enrolUrl).then((w) => { + window = w; + + if (this.isDesktop || this.isMobile) { + // Observe loaded pages in the InAppBrowser to check if the enrol process has ended. + inAppLoadSubscription = window.on('loadstart').subscribe(urlLoaded); + // Observe window closed. + inAppExitSubscription = window.on('exit').subscribe(inAppClosed); + } + + if (this.isDesktop) { + // In desktop, also observe stop loading since some pages don't throw the loadstart event. + inAppFinishSubscription = window.on('loadstop').subscribe(urlLoaded); + + // Since the user can switch windows, reload the data if he comes back to the app. + appResumeSubscription = this.platform.resume.subscribe(() => { + if (!this.dataLoaded) { + return; + } + this.dataLoaded = false; + this.refreshData(); + }); + } + }); + } + + /** + * User clicked in a self enrol button. + * + * @param {number} instanceId The instance ID of the enrolment method. + */ + selfEnrolClicked(instanceId: number) { + this.domUtils.showConfirm(this.translate.instant('core.courses.confirmselfenrol')).then(() => { + this.selfEnrolInCourse('', instanceId); + }).catch(() => { + // User cancelled. + }); + } + + /** + * Self enrol in a course. + * + * @param {string} password Password to use. + * @param {number} instanceId The instance ID. + * @return {Promise} Promise resolved when self enrolled. + */ + selfEnrolInCourse(password: string, instanceId: number) : Promise { + let modal = this.domUtils.showModalLoading('core.loading', true); + + return this.coursesProvider.selfEnrol(this.course.id, password, instanceId).then(() => { + // Close modal and refresh data. + this.isEnrolled = true; + this.dataLoaded = false; + + // Sometimes the list of enrolled courses takes a while to be updated. Wait for it. + this.waitForEnrolled(true).then(() => { + this.refreshData().finally(() => { + // My courses have been updated, trigger event. + this.eventsProvider.trigger( + CoreCoursesProvider.EVENT_MY_COURSES_UPDATED, {siteId: this.sitesProvider.getCurrentSiteId()}); + }); + }); + }).catch((error) => { + if (error && error.code === CoreCoursesProvider.ENROL_INVALID_KEY) { + // Invalid password, show the modal to enter the password. + this.selfEnrolModal.present(); + this.currentInstanceId = instanceId; + + if (!password) { + // No password entered, don't show error. + return; + } + } + + this.domUtils.showErrorModalDefault(error, 'core.courses.errorselfenrol', true); + }).finally(() => { + modal.dismiss(); + }); + } + + /** + * Refresh the data. + * + * @param {any} [refresher] The refresher if this was triggered by a Pull To Refresh. + */ + refreshData(refresher?: any) : Promise { + let promises = []; + + promises.push(this.coursesProvider.invalidateUserCourses()); + promises.push(this.coursesProvider.invalidateCourse(this.course.id)); + promises.push(this.coursesProvider.invalidateCourseEnrolmentMethods(this.course.id)); + // promises.push($mmCoursesDelegate.clearAndInvalidateCoursesOptions(course.id)); + if (this.guestInstanceId) { + promises.push(this.coursesProvider.invalidateCourseGuestEnrolmentInfo(this.guestInstanceId)); + } + + return Promise.all(promises).finally(() => { + return this.getCourse(true); + }).finally(() => { + if (refresher) { + refresher.complete(); + } + }); + } + + /** + * Wait for the user to be enrolled in the course. + * + * @param {boolean} first If it's the first call (true) or it's a recursive call (false). + */ + protected waitForEnrolled(first?: boolean) { + if (first) { + this.waitStart = Date.now(); + } + + // Check if user is enrolled in the course. + return this.coursesProvider.invalidateUserCourses().catch(() => { + // Ignore errors. + }).then(() => { + return this.coursesProvider.getUserCourse(this.course.id); + }).catch(() => { + // Not enrolled, wait a bit and try again. + if (this.pageDestroyed || (Date.now() - this.waitStart > 60000)) { + // Max time reached or the user left the view, stop. + return; + } + + return new Promise((resolve, reject) => { + setTimeout(() => { + if (!this.pageDestroyed) { + // Wait again. + this.waitForEnrolled().then(resolve); + } else { + resolve(); + } + }, 5000); + }); + }); + } +} diff --git a/src/core/courses/pages/my-courses/my-courses.html b/src/core/courses/pages/my-courses/my-courses.html new file mode 100644 index 000000000..7a2eed8d4 --- /dev/null +++ b/src/core/courses/pages/my-courses/my-courses.html @@ -0,0 +1,30 @@ + + + {{ 'core.courses.mycourses' | translate }} + + + + + + + + + + + + + + + + + + + + + +

{{ 'core.courses.searchcoursesadvice' | translate }}

+
+
+
diff --git a/src/core/courses/pages/my-courses/my-courses.module.ts b/src/core/courses/pages/my-courses/my-courses.module.ts new file mode 100644 index 000000000..45c404a78 --- /dev/null +++ b/src/core/courses/pages/my-courses/my-courses.module.ts @@ -0,0 +1,33 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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 { IonicPageModule } from 'ionic-angular'; +import { TranslateModule } from '@ngx-translate/core'; +import { CoreCoursesMyCoursesPage } from './my-courses'; +import { CoreComponentsModule } from '../../../../components/components.module'; +import { CoreCoursesComponentsModule } from '../../components/components.module'; + +@NgModule({ + declarations: [ + CoreCoursesMyCoursesPage, + ], + imports: [ + CoreComponentsModule, + CoreCoursesComponentsModule, + IonicPageModule.forChild(CoreCoursesMyCoursesPage), + TranslateModule.forChild() + ], +}) +export class CoreCoursesMyCoursesPageModule {} diff --git a/src/core/courses/pages/my-courses/my-courses.scss b/src/core/courses/pages/my-courses/my-courses.scss new file mode 100644 index 000000000..89812f8f7 --- /dev/null +++ b/src/core/courses/pages/my-courses/my-courses.scss @@ -0,0 +1,3 @@ +page-core-courses-my-courses { + +} diff --git a/src/core/courses/pages/my-courses/my-courses.ts b/src/core/courses/pages/my-courses/my-courses.ts new file mode 100644 index 000000000..ea3941f05 --- /dev/null +++ b/src/core/courses/pages/my-courses/my-courses.ts @@ -0,0 +1,153 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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, OnDestroy } from '@angular/core'; +import { IonicPage, NavController } from 'ionic-angular'; +import { CoreEventsProvider } from '../../../../providers/events'; +import { CoreSitesProvider } from '../../../../providers/sites'; +import { CoreDomUtilsProvider } from '../../../../providers/utils/dom'; +import { CoreCoursesProvider } from '../../providers/courses'; + +/** + * Page that displays the list of courses the user is enrolled in. + */ +@IonicPage() +@Component({ + selector: 'page-core-courses-my-courses', + templateUrl: 'my-courses.html', +}) +export class CoreCoursesMyCoursesPage implements OnDestroy { + courses: any[]; + filteredCourses: any[]; + searchEnabled: boolean; + filter = ''; + showFilter = false; + coursesLoaded = false; + + protected prefetchIconInitialized = false; + protected myCoursesObserver; + protected siteUpdatedObserver; + + constructor(private navCtrl: NavController, private coursesProvider: CoreCoursesProvider, + private domUtils: CoreDomUtilsProvider, private eventsProvider: CoreEventsProvider, + private sitesProvider: CoreSitesProvider) {} + + /** + * View loaded. + */ + ionViewDidLoad() { + this.searchEnabled = !this.coursesProvider.isSearchCoursesDisabledInSite(); + + this.fetchCourses().finally(() => { + this.coursesLoaded = true; + }); + + this.myCoursesObserver = this.eventsProvider.on(CoreCoursesProvider.EVENT_MY_COURSES_UPDATED, (data) => { + if (data.siteId == this.sitesProvider.getCurrentSiteId()) { + this.fetchCourses(); + } + }); + + this.siteUpdatedObserver = this.eventsProvider.on(CoreEventsProvider.SITE_UPDATED, (data) => { + if (data.siteId == this.sitesProvider.getCurrentSiteId()) { + this.searchEnabled = !this.coursesProvider.isSearchCoursesDisabledInSite(); + } + }); + } + + /** + * Fetch the user courses. + */ + protected fetchCourses() { + return this.coursesProvider.getUserCourses().then((courses) => { + + const courseIds = courses.map((course) => { + return course.id; + }); + + return this.coursesProvider.getCoursesOptions(courseIds).then((options) => { + courses.forEach((course) => { + course.navOptions = options.navOptions[course.id]; + course.admOptions = options.admOptions[course.id]; + }); + this.courses = courses; + this.filteredCourses = this.courses; + this.filter = ''; + + // this.initPrefetchCoursesIcon(); + }); + }).catch((error) => { + this.domUtils.showErrorModalDefault(error, 'core.courses.errorloadcourses', true); + }); + } + + /** + * Refresh the courses. + * + * @param {any} refresher Refresher. + */ + refreshCourses(refresher: any) { + let promises = []; + + promises.push(this.coursesProvider.invalidateUserCourses()); + // promises.push($mmCoursesDelegate.clearAndInvalidateCoursesOptions()); + + Promise.all(promises).finally(() => { + + this.prefetchIconInitialized = false; + this.fetchCourses().finally(() => { + refresher.complete(); + }); + }); + } + + /** + * Show or hide the filter. + */ + switchFilter() { + this.filter = ''; + this.showFilter = !this.showFilter; + this.filteredCourses = this.courses; + } + + /** + * Go to search courses. + */ + openSearch() { + this.navCtrl.push('CoreCoursesSearchPage'); + } + + /** + * The filter has changed. + * + * @param {string} newValue New filter value. + */ + filterChanged(newValue: string) { + if (!newValue || !this.courses) { + this.filteredCourses = this.courses; + } else { + this.filteredCourses = this.courses.filter((course) => { + return course.fullname.indexOf(newValue) > -1; + }); + } + } + + /** + * Page destroyed. + */ + ngOnDestroy() { + this.myCoursesObserver && this.myCoursesObserver.off(); + this.siteUpdatedObserver && this.siteUpdatedObserver.off(); + } +} diff --git a/src/core/courses/pages/my-overview/my-overview.html b/src/core/courses/pages/my-overview/my-overview.html new file mode 100644 index 000000000..976a68284 --- /dev/null +++ b/src/core/courses/pages/my-overview/my-overview.html @@ -0,0 +1,80 @@ + + + {{ 'core.courses.courseoverview' | translate }} + + + + + + + + + + + + + + + +
+
+ + {{ 'core.courses.sortbydates' | translate }} + {{ 'core.courses.sortbycourses' | translate }} + +
+ + + + + + + + + + + + + + + +
+ +
+ + {{ 'core.courses.inprogress' | translate }} + {{ 'core.courses.future' | translate }} + {{ 'core.courses.past' | translate }} + + +
+
+ + + + +
+
+ + + + + + + + + + + +
+
+
diff --git a/src/core/courses/pages/my-overview/my-overview.module.ts b/src/core/courses/pages/my-overview/my-overview.module.ts new file mode 100644 index 000000000..0259d3bb8 --- /dev/null +++ b/src/core/courses/pages/my-overview/my-overview.module.ts @@ -0,0 +1,33 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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 { IonicPageModule } from 'ionic-angular'; +import { TranslateModule } from '@ngx-translate/core'; +import { CoreCoursesMyOverviewPage } from './my-overview'; +import { CoreComponentsModule } from '../../../../components/components.module'; +import { CoreCoursesComponentsModule } from '../../components/components.module'; + +@NgModule({ + declarations: [ + CoreCoursesMyOverviewPage, + ], + imports: [ + CoreComponentsModule, + CoreCoursesComponentsModule, + IonicPageModule.forChild(CoreCoursesMyOverviewPage), + TranslateModule.forChild() + ], +}) +export class CoreCoursesMyOverviewPageModule {} diff --git a/src/core/courses/pages/my-overview/my-overview.scss b/src/core/courses/pages/my-overview/my-overview.scss new file mode 100644 index 000000000..89812f8f7 --- /dev/null +++ b/src/core/courses/pages/my-overview/my-overview.scss @@ -0,0 +1,3 @@ +page-core-courses-my-courses { + +} diff --git a/src/core/courses/pages/my-overview/my-overview.ts b/src/core/courses/pages/my-overview/my-overview.ts new file mode 100644 index 000000000..963afe4c5 --- /dev/null +++ b/src/core/courses/pages/my-overview/my-overview.ts @@ -0,0 +1,319 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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 } from '@angular/core'; +import { IonicPage, NavController } from 'ionic-angular'; +import { CoreDomUtilsProvider } from '../../../../providers/utils/dom'; +import { CoreCoursesProvider } from '../../providers/courses'; +import { CoreCoursesMyOverviewProvider } from '../../providers/my-overview'; +import * as moment from 'moment'; + +/** + * Page that displays My Overview. + */ +@IonicPage() +@Component({ + selector: 'page-core-courses-my-overview', + templateUrl: 'my-overview.html', +}) +export class CoreCoursesMyOverviewPage { + tabShown = 'courses'; + timeline = { + sort: 'sortbydates', + events: [], + loaded: false, + canLoadMore: undefined + }; + timelineCourses = { + courses: [], + loaded: false, + canLoadMore: false + }; + courses = { + selected: 'inprogress', + loaded: false, + filter: '', + past: [], + inprogress: [], + future: [] + }; + showFilter = false; + searchEnabled: boolean; + filteredCourses: any[]; + + protected prefetchIconInitialized = false; + protected myCoursesObserver; + protected siteUpdatedObserver; + + constructor(private navCtrl: NavController, private coursesProvider: CoreCoursesProvider, + private domUtils: CoreDomUtilsProvider, private myOverviewProvider: CoreCoursesMyOverviewProvider) {} + + /** + * View loaded. + */ + ionViewDidLoad() { + this.searchEnabled = !this.coursesProvider.isSearchCoursesDisabledInSite(); + + this.switchTab(this.tabShown); + + // @todo: Course download. + } + + /** + * Fetch the timeline. + * + * @param {number} [afterEventId] The last event id. + * @return {Promise} Promise resolved when done. + */ + protected fetchMyOverviewTimeline(afterEventId?: number) : Promise { + return this.myOverviewProvider.getActionEventsByTimesort(afterEventId).then((events) => { + this.timeline.events = events.events; + this.timeline.canLoadMore = events.canLoadMore; + }).catch((error) => { + this.domUtils.showErrorModalDefault(error, 'Error getting my overview data.'); + }); + } + + /** + * Fetch the timeline by courses. + * + * @return {Promise} Promise resolved when done. + */ + protected fetchMyOverviewTimelineByCourses() : Promise { + return this.fetchUserCourses().then((courses) => { + let today = moment().unix(), + courseIds; + courses = courses.filter((course) => { + return course.startdate <= today && (!course.enddate || course.enddate >= today); + }); + + this.timelineCourses.courses = courses; + if (courses.length > 0) { + courseIds = courses.map((course) => { + return course.id; + }); + + return this.myOverviewProvider.getActionEventsByCourses(courseIds).then((courseEvents) => { + this.timelineCourses.courses.forEach((course) => { + course.events = courseEvents[course.id].events; + course.canLoadMore = courseEvents[course.id].canLoadMore; + }); + }); + } + }).catch((error) => { + this.domUtils.showErrorModalDefault(error, 'Error getting my overview data.'); + }); + } + + /** + * Fetch the courses for my overview. + * + * @return {Promise} Promise resolved when done. + */ + protected fetchMyOverviewCourses() : Promise { + return this.fetchUserCourses().then((courses) => { + const today = moment().unix(); + + this.courses.past = []; + this.courses.inprogress = []; + this.courses.future = []; + + courses.forEach((course) => { + if (course.startdate > today) { + // Courses that have not started yet. + this.courses.future.push(course); + } else if (course.enddate && course.enddate < today) { + // Courses that have already ended. + this.courses.past.push(course); + } else { + // Courses still in progress. + this.courses.inprogress.push(course); + } + }); + + this.courses.filter = ''; + this.showFilter = false; + this.filteredCourses = this.courses[this.courses.selected]; + }).catch((error) => { + this.domUtils.showErrorModalDefault(error, 'Error getting my overview data.'); + }); + } + + /** + * Fetch user courses. + * + * @return {Promise} Promise resolved when done. + */ + protected fetchUserCourses() : Promise { + let courseIds; + return this.coursesProvider.getUserCourses().then((courses) => { + courseIds = courses.map((course) => { + return course.id; + }); + + // Load course options of the course. + return this.coursesProvider.getCoursesOptions(courseIds).then((options) => { + courses.forEach((course) => { + course.navOptions = options.navOptions[course.id]; + course.admOptions = options.admOptions[course.id]; + }); + + return courses.sort((a, b) => { + const compareA = a.fullname.toLowerCase(), + compareB = b.fullname.toLowerCase(); + + return compareA.localeCompare(compareB); + }); + }); + }); + } + + /** + * Show or hide the filter. + */ + switchFilter() { + this.showFilter = !this.showFilter; + this.courses.filter = ''; + this.filteredCourses = this.courses[this.courses.selected]; + } + + /** + * The filter has changed. + * + * @param {string} newValue New filter value. + */ + filterChanged(newValue: string) { + if (!newValue || !this.courses[this.courses.selected]) { + this.filteredCourses = this.courses[this.courses.selected]; + } else { + this.filteredCourses = this.courses[this.courses.selected].filter((course) => { + return course.fullname.toLowerCase().indexOf(newValue.toLowerCase()) > -1; + }); + } + } + + /** + * Refresh the data. + * + * @param {any} refresher Refresher. + */ + refreshMyOverview(refresher: any) { + let promises = []; + + if (this.tabShown == 'timeline') { + promises.push(this.myOverviewProvider.invalidateActionEventsByTimesort()); + promises.push(this.myOverviewProvider.invalidateActionEventsByCourses()); + } + + promises.push(this.coursesProvider.invalidateUserCourses()); + // promises.push(this.coursesDelegate.clearAndInvalidateCoursesOptions()); + + return Promise.all(promises).finally(() => { + switch (this.tabShown) { + case 'timeline': + switch (this.timeline.sort) { + case 'sortbydates': + return this.fetchMyOverviewTimeline(); + case 'sortbycourses': + return this.fetchMyOverviewTimelineByCourses(); + } + break; + case 'courses': + return this.fetchMyOverviewCourses(); + } + }).finally(() => { + refresher.complete(); + }); + } + + /** + * Change timeline sort being viewed. + */ + switchSort() { + switch (this.timeline.sort) { + case 'sortbydates': + if (!this.timeline.loaded) { + this.fetchMyOverviewTimeline().finally(() => { + this.timeline.loaded = true; + }); + } + break; + case 'sortbycourses': + if (!this.timelineCourses.loaded) { + this.fetchMyOverviewTimelineByCourses().finally(() => { + this.timelineCourses.loaded = true; + }); + } + break; + } + } + + /** + * Change tab being viewed. + * + * @param {string} tab Tab to display. + */ + switchTab(tab: string) { + this.tabShown = tab; + switch (this.tabShown) { + case 'timeline': + if (!this.timeline.loaded) { + this.fetchMyOverviewTimeline().finally(() => { + this.timeline.loaded = true; + }); + } + break; + case 'courses': + if (!this.courses.loaded) { + this.fetchMyOverviewCourses().finally(() => { + this.courses.loaded = true; + }); + } + break; + } + } + + /** + * Load more events. + */ + loadMoreTimeline() : Promise { + return this.fetchMyOverviewTimeline(this.timeline.canLoadMore); + } + + /** + * Load more events. + * + * @param {any} course Course. + */ + loadMoreCourse(course) { + return this.myOverviewProvider.getActionEventsByCourse(course.id, course.canLoadMore).then((courseEvents) => { + course.events = course.events.concat(courseEvents.events); + course.canLoadMore = courseEvents.canLoadMore; + }); + } + + /** + * Go to search courses. + */ + openSearch() { + this.navCtrl.push('CoreCoursesSearchPage'); + } + + /** + * The selected courses have changed. + */ + selectedChanged() { + this.filteredCourses = this.courses[this.courses.selected]; + } +} diff --git a/src/core/courses/pages/search/search.html b/src/core/courses/pages/search/search.html new file mode 100644 index 000000000..4df0a3845 --- /dev/null +++ b/src/core/courses/pages/search/search.html @@ -0,0 +1,18 @@ + + + {{ 'core.courses.searchcourses' | translate }} + + + + + +
+ {{ 'core.courses.totalcoursesearchresults' | translate:{$a: total} }} + + + + + +
+
+ diff --git a/src/core/courses/pages/search/search.module.ts b/src/core/courses/pages/search/search.module.ts new file mode 100644 index 000000000..c74212c21 --- /dev/null +++ b/src/core/courses/pages/search/search.module.ts @@ -0,0 +1,33 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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 { IonicPageModule } from 'ionic-angular'; +import { TranslateModule } from '@ngx-translate/core'; +import { CoreCoursesSearchPage } from './search'; +import { CoreComponentsModule } from '../../../../components/components.module'; +import { CoreCoursesComponentsModule } from '../../components/components.module'; + +@NgModule({ + declarations: [ + CoreCoursesSearchPage, + ], + imports: [ + CoreComponentsModule, + CoreCoursesComponentsModule, + IonicPageModule.forChild(CoreCoursesSearchPage), + TranslateModule.forChild() + ], +}) +export class CoreCoursesSearchPageModule {} diff --git a/src/core/courses/pages/search/search.scss b/src/core/courses/pages/search/search.scss new file mode 100644 index 000000000..1bf3fe798 --- /dev/null +++ b/src/core/courses/pages/search/search.scss @@ -0,0 +1,3 @@ +page-core-courses-search { + +} diff --git a/src/core/courses/pages/search/search.ts b/src/core/courses/pages/search/search.ts new file mode 100644 index 000000000..654b36cb5 --- /dev/null +++ b/src/core/courses/pages/search/search.ts @@ -0,0 +1,82 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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 } from '@angular/core'; +import { IonicPage } from 'ionic-angular'; +import { CoreDomUtilsProvider } from '../../../../providers/utils/dom'; +import { CoreCoursesProvider } from '../../providers/courses'; + +/** + * Page that allows searching for courses. + */ +@IonicPage() +@Component({ + selector: 'page-core-courses-search', + templateUrl: 'search.html', +}) +export class CoreCoursesSearchPage { + total = 0; + courses: any[]; + canLoadMore: boolean; + + protected page = 0; + protected currentSearch = ''; + + constructor(private domUtils: CoreDomUtilsProvider, private coursesProvider: CoreCoursesProvider) {} + + /** + * Search a new text. + * + * @param {string} text The text to search. + */ + search(text: string) { + this.currentSearch = text; + this.courses = undefined; + this.page = 0; + + let modal = this.domUtils.showModalLoading('core.searching', true); + this.searchCourses().finally(() => { + modal.dismiss(); + }); + } + + /** + * Load more results. + */ + loadMoreResults(infiniteScroll) { + this.searchCourses().finally(() => { + infiniteScroll.complete(); + }); + } + + /** + * Search courses or load the next page of current search. + */ + protected searchCourses() { + return this.coursesProvider.search(this.currentSearch, this.page).then((response) => { + if (this.page === 0) { + this.courses = response.courses; + } else { + this.courses = this.courses.concat(response.courses); + } + this.total = response.total; + + this.page++; + this.canLoadMore = this.courses.length < this.total; + }).catch((error) => { + this.canLoadMore = false; + this.domUtils.showErrorModalDefault(error, 'core.courses.errorsearching', true); + }); + } +} diff --git a/src/core/courses/pages/self-enrol-password/self-enrol-password.html b/src/core/courses/pages/self-enrol-password/self-enrol-password.html new file mode 100644 index 000000000..3294d64bc --- /dev/null +++ b/src/core/courses/pages/self-enrol-password/self-enrol-password.html @@ -0,0 +1,23 @@ + + + {{ 'core.courses.selfenrolment' | translate }} + + + + + + + +
+ + + + + + + + +
+
diff --git a/src/core/courses/pages/self-enrol-password/self-enrol-password.module.ts b/src/core/courses/pages/self-enrol-password/self-enrol-password.module.ts new file mode 100644 index 000000000..80a4f5183 --- /dev/null +++ b/src/core/courses/pages/self-enrol-password/self-enrol-password.module.ts @@ -0,0 +1,33 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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 { IonicPageModule } from 'ionic-angular'; +import { CoreCoursesSelfEnrolPasswordPage } from './self-enrol-password'; +import { TranslateModule } from '@ngx-translate/core'; +import { CoreComponentsModule } from '../../../../components/components.module'; +import { CoreDirectivesModule } from '../../../../directives/directives.module'; + +@NgModule({ + declarations: [ + CoreCoursesSelfEnrolPasswordPage + ], + imports: [ + CoreComponentsModule, + CoreDirectivesModule, + IonicPageModule.forChild(CoreCoursesSelfEnrolPasswordPage), + TranslateModule.forChild(), + ] +}) +export class CoreCoursesSelfEnrolPasswordPageModule {} diff --git a/src/core/courses/pages/self-enrol-password/self-enrol-password.ts b/src/core/courses/pages/self-enrol-password/self-enrol-password.ts new file mode 100644 index 000000000..dcdc404fe --- /dev/null +++ b/src/core/courses/pages/self-enrol-password/self-enrol-password.ts @@ -0,0 +1,44 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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 } from '@angular/core'; +import { IonicPage, ViewController } from 'ionic-angular'; + +/** + * Page that displays a form to enter a password to self enrol in a course. + */ +@IonicPage() +@Component({ + selector: 'page-core-courses-self-enrol-password', + templateUrl: 'self-enrol-password.html', +}) +export class CoreCoursesSelfEnrolPasswordPage { + constructor(private viewCtrl: ViewController) {} + + /** + * Close help modal. + */ + close() : void { + this.viewCtrl.dismiss(); + } + + /** + * Submit password. + * + * @param {string} password Password to submit. + */ + submitPassword(password: string) { + this.viewCtrl.dismiss(password); + } +} \ No newline at end of file diff --git a/src/core/courses/providers/courses.ts b/src/core/courses/providers/courses.ts new file mode 100644 index 000000000..9e8e0623b --- /dev/null +++ b/src/core/courses/providers/courses.ts @@ -0,0 +1,815 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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 { Injectable } from '@angular/core'; +import { CoreLoggerProvider } from '../../../providers/logger'; +import { CoreSitesProvider } from '../../../providers/sites'; +import { CoreSite } from '../../../classes/site'; + +/** + * Service that provides some features regarding lists of courses and categories. + */ +@Injectable() +export class CoreCoursesProvider { + public static SEARCH_PER_PAGE = 20; + public static ENROL_INVALID_KEY = 'CoreCoursesEnrolInvalidKey'; + public static EVENT_MY_COURSES_UPDATED = 'courses_my_courses_updated'; + public static EVENT_MY_COURSES_REFRESHED = 'courses_my_courses_refreshed'; + public static ACCESS_GUEST = 'courses_access_guest'; + public static ACCESS_DEFAULT = 'courses_access_default'; + protected logger; + + constructor(logger: CoreLoggerProvider, private sitesProvider: CoreSitesProvider) { + this.logger = logger.getInstance('CoreCoursesProvider'); + } + + /** + * Get categories. They can be filtered by id. + * + * @param {number} categoryId Category ID to get. + * @param {boolean} [addSubcategories] If it should add subcategories to the list. + * @param {string} [siteId] Site to get the courses from. If not defined, use current site. + * @return {Promise} Promise resolved with the categories. + */ + getCategories(categoryId: number, addSubcategories?: boolean, siteId?: string) : Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + // Get parent when id is the root category. + let criteriaKey = categoryId == 0 ? 'parent' : 'id', + data = { + criteria: [ + { key: criteriaKey, value: categoryId } + ], + addsubcategories: addSubcategories ? 1 : 0 + }, + preSets = { + cacheKey: this.getCategoriesCacheKey(categoryId, addSubcategories) + } + + return site.read('core_course_get_categories', data, preSets); + }); + } + + /** + * Get cache key for get categories methods WS call. + * + * @param {number} categoryId Category ID to get. + * @param {boolean} [addSubcategories] If add subcategories to the list. + * @return {string} Cache key. + */ + protected getCategoriesCacheKey(categoryId: number, addSubcategories?: boolean) : string { + return this.getRootCacheKey() + 'categories:' + categoryId + ':' + !!addSubcategories; + } + + /** + * Given a list of course IDs to get course options, return the list of courseIds to use. + * + * @param {number[]} courseIds Course IDs. + * @param {string} [siteId] Site Id. If not defined, use current site. + * @return {Promise} Promise resolved with the list of course IDs. + */ + protected getCourseIdsForOptions(courseIds: number[], siteId?: string) : Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + const siteHomeId = site.getSiteHomeId(); + + if (courseIds.length == 1) { + // Only 1 course, check if it belongs to the user courses. If so, use all user courses. + return this.getUserCourses(true, siteId).then((courses) => { + let courseId = courseIds[0], + useAllCourses = false; + + if (courseId == siteHomeId) { + // It's site home, use all courses. + useAllCourses = true; + } else { + for (let i = 0; i < courses.length; i++) { + if (courses[i].id == courseId) { + useAllCourses = true; + break; + } + } + } + + if (useAllCourses) { + // User is enrolled, retrieve all the courses. + courseIds = courses.map((course) => { + return course.id; + }); + + // Always add the site home ID. + courseIds.push(siteHomeId); + } + + return courseIds; + }).catch(() => { + // Ignore errors. + return courseIds; + }); + } else { + return courseIds; + } + }); + } + + /** + * Get the root cache key for the WS calls related to courses. + * + * @return {string} Root cache key. + */ + protected getRootCacheKey() : string { + return 'mmCourses:'; + } + + /** + * Check if My Courses is disabled in a certain site. + * + * @param {string} [siteId] Site Id. If not defined, use current site. + * @return {Promise} Promise resolved with true if disabled, rejected or resolved with false otherwise. + */ + isMyCoursesDisabled(siteId?: string) : Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return this.isMyCoursesDisabledInSite(site); + }); + } + + /** + * Check if My Courses is disabled in a certain site. + * + * @param {CoreSite} [site] Site. If not defined, use current site. + * @return {boolean} Whether it's disabled. + */ + isMyCoursesDisabledInSite(site?: CoreSite) : boolean { + site = site || this.sitesProvider.getCurrentSite(); + return site.isFeatureDisabled('$mmSideMenuDelegate_mmCourses'); + } + + /** + * Check if Search Courses is disabled in a certain site. + * + * @param {string} [siteId] Site Id. If not defined, use current site. + * @return {Promise} Promise resolved with true if disabled, rejected or resolved with false otherwise. + */ + isSearchCoursesDisabled(siteId?: string) : Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return this.isSearchCoursesDisabledInSite(site); + }); + } + + /** + * Check if Search Courses is disabled in a certain site. + * + * @param {CoreSite} [site] Site. If not defined, use current site. + * @return {boolean} Whether it's disabled. + */ + isSearchCoursesDisabledInSite(site?: CoreSite) : boolean { + site = site || this.sitesProvider.getCurrentSite(); + return site.isFeatureDisabled('$mmCoursesDelegate_search'); + } + + /** + * Get course. + * + * @param {number} id ID of the course to get. + * @param {string} [siteId] Site to get the courses from. If not defined, use current site. + * @return {Promise} Promise resolved with the course. + */ + getCourse(id: number, siteId?: string) : Promise { + return this.getCourses([id], siteId).then((courses) => { + if (courses && courses.length > 0) { + return courses[0]; + } + return Promise.reject(null); + }); + } + + /** + * Get the enrolment methods from a course. + * + * @param {number} id ID of the course. + * @param {string} [siteId] Site ID. If not defined, use current site. + * @return {Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + let params = { + courseid: id + }, + preSets = { + cacheKey: this.getCourseEnrolmentMethodsCacheKey(id) + } + + return site.read('core_enrol_get_course_enrolment_methods', params, preSets); + }); + } + + /** + * Get cache key for get course enrolment methods WS call. + * + * @param {number} id Course ID. + * @return {string} Cache key. + */ + protected getCourseEnrolmentMethodsCacheKey(id: number) : string { + return this.getRootCacheKey() + 'enrolmentmethods:' + id; + } + + /** + * Get info from a course guest enrolment method. + * + * @param {number} instanceId Guest instance ID. + * @param {string} [siteId] Site ID. If not defined, use current site. + * @return {Promise} Promise resolved when the info is retrieved. + */ + getCourseGuestEnrolmentInfo(instanceId: number, siteId?: string) : Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + let params = { + instanceid: instanceId + }, + preSets = { + cacheKey: this.getCourseGuestEnrolmentInfoCacheKey(instanceId) + } + + return site.read('enrol_guest_get_instance_info', params, preSets).then((response) => { + return response.instanceinfo; + }); + }); + } + + /** + * Get cache key for get course guest enrolment methods WS call. + * + * @param {number} instanceId Guest instance ID. + * @return {string} Cache key. + */ + protected getCourseGuestEnrolmentInfoCacheKey(instanceId: number) : string { + return this.getRootCacheKey() + 'guestinfo:' + instanceId; + } + + /** + * Get courses. + * Warning: if the user doesn't have permissions to view some of the courses passed the WS call will fail. + * The user must be able to view ALL the courses passed. + * + * @param {number[]} ids List of IDs of the courses to get. + * @param {string} [siteId] Site to get the courses from. If not defined, use current site. + * @return {Promise} Promise resolved with the courses. + */ + getCourses(ids: number[], siteId?: string) : Promise { + if (!Array.isArray(ids)) { + return Promise.reject(null); + } else if (ids.length === 0) { + return Promise.resolve([]); + } + + return this.sitesProvider.getSite(siteId).then((site) => { + let data = { + options: { + ids: ids + } + }, + preSets = { + cacheKey: this.getCoursesCacheKey(ids) + } + + return site.read('core_course_get_courses', data, preSets); + }); + } + + /** + * Get cache key for get courses WS call. + * + * @param {number[]} ids Courses IDs. + * @return {string} Cache key. + */ + protected getCoursesCacheKey(ids: number[]) : string { + return this.getRootCacheKey() + 'course:' + JSON.stringify(ids); + } + + /** + * Get courses. They can be filtered by field. + * + * @param {string} [field] The field to search. Can be left empty for all courses or: + * id: course id. + * ids: comma separated course ids. + * shortname: course short name. + * idnumber: course id number. + * category: category id the course belongs to. + * @param {any} [value] The value to match. + * @param {string} [siteId] Site ID. If not defined, use current site. + * @return {Promise} Promise resolved with the courses. + */ + getCoursesByField(field?: string, value?: any, siteId?: string) : Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + let data = { + field: field || '', + value: field ? value : '' + }, + preSets = { + cacheKey: this.getCoursesByFieldCacheKey(field, value) + } + + return site.read('core_course_get_courses_by_field', data, preSets).then((courses) => { + if (courses.courses) { + // Courses will be sorted using sortorder if avalaible. + return courses.courses.sort((a, b) => { + if (typeof a.sortorder == 'undefined' && typeof b.sortorder == 'undefined') { + return b.id - a.id; + } + + if (typeof a.sortorder == 'undefined') { + return 1; + } + + if (typeof b.sortorder == 'undefined') { + return -1; + } + + return a.sortorder - b.sortorder; + }); + } + + return Promise.reject(null); + }); + }); + } + + /** + * Get cache key for get courses WS call. + * + * @param {string} [field] The field to search. + * @param {any} [value] The value to match. + * @return {string} Cache key. + */ + protected getCoursesByFieldCacheKey(field?: string, value?: any) : string { + field = field || ''; + value = field ? value : ''; + return this.getRootCacheKey() + 'coursesbyfield:' + field + ':' + value; + } + + /** + * Check if get courses by field WS is available. + * + * @return {boolean} Whether get courses by field is available. + */ + isGetCoursesByFieldAvailable() : boolean { + let currentSite = this.sitesProvider.getCurrentSite(); + return currentSite.wsAvailable('core_course_get_courses_by_field'); + } + + /** + * Get the navigation and administration options for the given courses. + * + * @param {number[]} courseIds IDs of courses to get. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise<{navOptions: any, admOptions: any}>} Promise resolved with the options for each course. + */ + getCoursesOptions(courseIds: number[], siteId?: string) : Promise<{navOptions: any, admOptions: any}> { + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + + // Get the list of courseIds to use based on the param. + return this.getCourseIdsForOptions(courseIds, siteId).then((courseIds) => { + let promises = [], + navOptions, + admOptions; + + // Get user navigation and administration options. + promises.push(this.getUserNavigationOptions(courseIds, siteId).catch(() => { + // Couldn't get it, return empty options. + return {}; + }).then((options) => { + navOptions = options; + })); + + promises.push(this.getUserAdministrationOptions(courseIds, siteId).catch(() => { + // Couldn't get it, return empty options. + return {}; + }).then((options) => { + admOptions = options; + })); + + return Promise.all(promises).then(() => { + return {navOptions: navOptions, admOptions: admOptions}; + }); + }); + } + + /** + * Get the common part of the cache keys for user administration options WS calls. + * + * @return {string} Cache key. + */ + protected getUserAdministrationOptionsCommonCacheKey() : string { + return this.getRootCacheKey() + 'administrationOptions:'; + } + + /** + * Get cache key for get user administration options WS call. + * + * @param {number[]} courseIds IDs of courses to get. + * @return {string} Cache key. + */ + protected getUserAdministrationOptionsCacheKey(courseIds: number[]) : string { + return this.getUserAdministrationOptionsCommonCacheKey() + courseIds.join(','); + } + + /** + * Get user administration options for a set of courses. + * + * @param {number[]} courseIds IDs of courses to get. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with administration options for each course. + */ + getUserAdministrationOptions(courseIds: number[], siteId?: string) : Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + let params = { + courseids: courseIds + }, + preSets = { + cacheKey: this.getUserAdministrationOptionsCacheKey(courseIds) + } + + return site.read('core_course_get_user_administration_options', params, preSets).then((response) => { + // Format returned data. + return this.formatUserOptions(response.courses); + }); + }); + } + + /** + * Get the common part of the cache keys for user navigation options WS calls. + * + * @param {number[]} courseIds IDs of courses to get. + * @return {string} Cache key. + */ + protected getUserNavigationOptionsCommonCacheKey() : string { + return this.getRootCacheKey() + 'navigationOptions:'; + } + + /** + * Get cache key for get user navigation options WS call. + * + * @return {string} Cache key. + */ + protected getUserNavigationOptionsCacheKey(courseIds: number[]) : string { + return this.getUserNavigationOptionsCommonCacheKey() + courseIds.join(','); + } + + /** + * Get user navigation options for a set of courses. + * + * @param {number[]} courseIds IDs of courses to get. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with navigation options for each course. + */ + getUserNavigationOptions(courseIds: number[], siteId?: string) : Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + let params = { + courseids: courseIds + }, + preSets = { + cacheKey: this.getUserNavigationOptionsCacheKey(courseIds) + } + + return site.read('core_course_get_user_navigation_options', params, preSets).then((response) => { + // Format returned data. + return this.formatUserOptions(response.courses); + }); + }); + } + + /** + * Format user navigation or administration options. + * + * @param {any[]} courses Navigation or administration options for each course. + * @return {any} Formatted options. + */ + protected formatUserOptions(courses: any[]) : any { + let result = {}; + + courses.forEach((course) => { + let options = {}; + + if (course.options) { + course.options.forEach((option) => { + options[option.name] = option.available; + }); + } + + result[course.id] = options; + }); + + return result; + } + + /** + * Get a course the user is enrolled in. This function relies on getUserCourses. + * preferCache=true will try to speed up the response, but the data returned might not be updated. + * + * @param {number} id ID of the course to get. + * @param {boolean} [preferCache] True if shouldn't call WS if data is cached, false otherwise. + * @param {string} [siteId] Site to get the courses from. If not defined, use current site. + * @return {Promise} Promise resolved with the course. + */ + getUserCourse(id: number, preferCache?: boolean, siteId?: string) : Promise { + if (!id) { + return Promise.reject(null); + } + + return this.getUserCourses(preferCache, siteId).then((courses) => { + let course; + for (let i in courses) { + if (courses[i].id == id) { + course = courses[i]; + break; + } + } + + return course ? course : Promise.reject(null); + }); + } + + /** + * Get user courses. + * + * @param {boolean} [preferCache] True if shouldn't call WS if data is cached, false otherwise. + * @param {string} [siteId] Site to get the courses from. If not defined, use current site. + * @return {Promise} Promise resolved with the courses. + */ + getUserCourses(preferCache?: boolean, siteId?: string) : Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + + let userId = site.getUserId(), + data = { + userid: userId + }, + preSets = { + cacheKey: this.getUserCoursesCacheKey(), + omitExpires: !!preferCache + }; + + return site.read('core_enrol_get_users_courses', data, preSets); + }); + } + + /** + * Get cache key for get user courses WS call. + * + * @return {string} Cache key. + */ + protected getUserCoursesCacheKey() : string { + return this.getRootCacheKey() + 'usercourses'; + } + + /** + * Invalidates get categories WS call. + * + * @param {number} categoryId Category ID to get. + * @param {boolean} [addSubcategories] If it should add subcategories to the list. + * @param {string} [siteId] Site Id. If not defined, use current site. + * @return {Promise} Promise resolved when the data is invalidated. + */ + invalidateCategories(categoryId: number, addSubcategories?: boolean, siteId?: string) : Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.invalidateWsCacheForKey(this.getCategoriesCacheKey(categoryId, addSubcategories)); + }); + } + + /** + * Invalidates get course WS call. + * + * @param {number} id Course ID. + * @param {string} [siteId] Site Id. If not defined, use current site. + * @return {Promise} Promise resolved when the data is invalidated. + */ + invalidateCourse(id: number, siteId?: string) : Promise { + return this.invalidateCourses([id], siteId); + } + + /** + * Invalidates get course enrolment methods WS call. + * + * @param {number} id Course ID. + * @param {string} [siteId] Site Id. If not defined, use current site. + * @return {Promise} Promise resolved when the data is invalidated. + */ + invalidateCourseEnrolmentMethods(id: number, siteId?: string) : Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.invalidateWsCacheForKey(this.getCourseEnrolmentMethodsCacheKey(id)); + }); + } + + /** + * Invalidates get course guest enrolment info WS call. + * + * @param {number} instanceId Guest instance ID. + * @param {string} [siteId] Site Id. If not defined, use current site. + * @return {Promise} Promise resolved when the data is invalidated. + */ + invalidateCourseGuestEnrolmentInfo(instanceId: number, siteId?: string) : Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.invalidateWsCacheForKey(this.getCourseGuestEnrolmentInfoCacheKey(instanceId)); + }); + } + + /** + * Invalidates the navigation and administration options for the given courses. + * + * @param {number[]} courseIds IDs of courses to get. + * @param {string} [siteId] Site ID to invalidate. If not defined, use current site. + * @return {Promise} Promise resolved when the data is invalidated. + */ + invalidateCoursesOptions(courseIds: number[], siteId?: string) : Promise { + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + + return this.getCourseIdsForOptions(courseIds, siteId).then((ids) => { + let promises = []; + + promises.push(this.invalidateUserAdministrationOptionsForCourses(ids, siteId)); + promises.push(this.invalidateUserNavigationOptionsForCourses(ids, siteId)); + + return Promise.all(promises); + }); + } + + /** + * Invalidates get courses WS call. + * + * @param {number[]} ids Courses IDs. + * @param {string} [siteId] Site Id. If not defined, use current site. + * @return {Promise} Promise resolved when the data is invalidated. + */ + invalidateCourses(ids: number[], siteId?: string) : Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.invalidateWsCacheForKey(this.getCoursesCacheKey(ids)); + }); + } + + /** + * Invalidates get courses by field WS call. + * + * @param {string} [field] See getCoursesByField for info. + * @param {any} [value] The value to match. + * @param {string} [siteId] Site Id. If not defined, use current site. + * @return {Promise} Promise resolved when the data is invalidated. + */ + invalidateCoursesByField(field?: string, value?: any, siteId?: string) : Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.invalidateWsCacheForKey(this.getCoursesByFieldCacheKey(field, value)); + }); + } + + /** + * Invalidates all user administration options. + * + * @param {string} [siteId] Site ID to invalidate. If not defined, use current site. + * @return {Promise} Promise resolved when the data is invalidated. + */ + invalidateUserAdministrationOptions(siteId?: string) : Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.invalidateWsCacheForKeyStartingWith(this.getUserAdministrationOptionsCommonCacheKey()); + }); + } + + /** + * Invalidates user administration options for certain courses. + * + * @param {number[]} courseIds IDs of courses. + * @param {string} [siteId] Site ID to invalidate. If not defined, use current site. + * @return {Promise} Promise resolved when the data is invalidated. + */ + invalidateUserAdministrationOptionsForCourses(courseIds: number[], siteId?: string) : Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.invalidateWsCacheForKey(this.getUserAdministrationOptionsCacheKey(courseIds)); + }); + } + + /** + * Invalidates get user courses WS call. + * + * @param {string} [siteId] Site ID to invalidate. If not defined, use current site. + * @return {Promise} Promise resolved when the data is invalidated. + */ + invalidateUserCourses(siteId?: string) : Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.invalidateWsCacheForKey(this.getUserCoursesCacheKey()); + }); + } + + /** + * Invalidates all user navigation options. + * + * @param {string} [siteId] Site ID to invalidate. If not defined, use current site. + * @return {Promise} Promise resolved when the data is invalidated. + */ + invalidateUserNavigationOptions(siteId?: string) : Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.invalidateWsCacheForKeyStartingWith(this.getUserNavigationOptionsCommonCacheKey()); + }); + } + + /** + * Invalidates user navigation options for certain courses. + * + * @param {number[]} courseIds IDs of courses. + * @param {string} [siteId] Site ID to invalidate. If not defined, use current site. + * @return {Promise} Promise resolved when the data is invalidated. + */ + invalidateUserNavigationOptionsForCourses(courseIds: number[], siteId?: string) : Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.invalidateWsCacheForKey(this.getUserNavigationOptionsCacheKey(courseIds)); + }); + } + + /** + * Check if WS to retrieve guest enrolment data is available. + * + * @return {boolean} Whether guest WS is available. + */ + isGuestWSAvailable() : boolean { + let currentSite = this.sitesProvider.getCurrentSite(); + return currentSite && currentSite.wsAvailable('enrol_guest_get_instance_info'); + } + + /** + * Search courses. + * + * @param {string} text Text to search. + * @param {number} [page=0] Page to get. + * @param {number} [perPage] Number of courses per page. Defaults to CoreCoursesProvider.SEARCH_PER_PAGE. + * @param {string} [siteId] Site ID. If not defined, use current site. + * @return {Promise<{total: number, courses: any[]}>} Promise resolved with the courses and the total of matches. + */ + search(text: string, page = 0, perPage?: number, siteId?: string) : Promise<{total: number, courses: any[]}> { + perPage = perPage || CoreCoursesProvider.SEARCH_PER_PAGE; + + return this.sitesProvider.getSite(siteId).then((site) => { + let params = { + criterianame: 'search', + criteriavalue: text, + page: page, + perpage: perPage + }, preSets = { + getFromCache: false + } + + return site.read('core_course_search_courses', params, preSets).then((response) => { + return {total: response.total, courses: response.courses}; + }); + }); + } + + /** + * Self enrol current user in a certain course. + * + * @param {number} courseId Course ID. + * @param {string} [password] Password to use. + * @param {number} [instanceId] Enrol instance ID. + * @param {string} [siteId] Site ID. If not defined, use current site. + * @return {Promise} Promise resolved if the user is enrolled. If the password is invalid, the promise is rejected + * with an object with code = CoreCoursesProvider.ENROL_INVALID_KEY. + */ + selfEnrol(courseId: number, password = '', instanceId?: number, siteId?: string) : Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + + let params: any = { + courseid: courseId, + password: password + } + if (instanceId) { + params.instanceid = instanceId; + } + + return site.write('enrol_self_enrol_user', params).then((response) : any => { + if (response) { + if (response.status) { + return true; + } else if (response.warnings && response.warnings.length) { + let message; + response.warnings.forEach((warning) => { + // Invalid password warnings. + if (warning.warningcode == '2' || warning.warningcode == '3' || warning.warningcode == '4') { + message = warning.message; + } + }); + + if (message) { + return Promise.reject({code: CoreCoursesProvider.ENROL_INVALID_KEY, message: message}); + } else { + return Promise.reject(response.warnings[0]); + } + } + } + return Promise.reject(null); + }); + }); + } +} diff --git a/src/core/courses/providers/delegate.ts b/src/core/courses/providers/delegate.ts new file mode 100644 index 000000000..68c11e4dd --- /dev/null +++ b/src/core/courses/providers/delegate.ts @@ -0,0 +1,495 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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 { Injectable } from '@angular/core'; +import { CoreEventsProvider } from '../../../providers/events'; +import { CoreLoggerProvider } from '../../../providers/logger'; +import { CoreSitesProvider } from '../../../providers/sites'; +import { CoreUtilsProvider, PromiseDefer } from '../../../providers/utils/utils'; +import { CoreCoursesProvider } from './courses'; + +export interface CoreCoursesHandler { + name: string; // Name of the handler. + priority: number; // The highest priority is displayed first. + isEnabled(): boolean|Promise; // Whether or not the handler is enabled on a site level. + isEnabledForCourse(courseId: number, accessData: any, navOptions?: any, admOptions?: any) : + boolean|Promise; // Whether the handler is enabled on a course level. For perfomance reasons, do NOT call + // WebServices in here, call them in shouldDisplayForCourse. + shouldDisplayForCourse(courseId: number, accessData: any, navOptions?: any, admOptions?: any) : + boolean|Promise; // Whether the handler should be displayed in a course. If not implemented, assume it's true. + getDisplayData?(courseId: number): CoreCoursesHandlerData; // Returns the data needed to render the handler. + invalidateEnabledForCourse?(courseId: number, navOptions?: any, admOptions?: any) : Promise; // Should invalidate data + // to determine if handler is enabled for a certain course. + prefetch?(course: any) : Promise; // Will be called when a course is downloaded, and it should prefetch all the data + // to be able to see the addon in offline. +}; + +export interface CoreCoursesHandlerData { + title: string; // Title to display for the handler. + icon: string; // Name of the icon to display for the handler. + class?: string; // Class to add to the displayed handler. + action(course: any): void; // Action to perform when the handler is clicked. +}; + +export interface CoreCoursesHandlerToDisplay { + data: CoreCoursesHandlerData; // Data to display. + priority?: number; // Handler's priority. + prefetch?(course: any) : Promise; // Function to prefetch the handler. +}; + +/** + * Service to interact with plugins to be shown in each course. + */ +@Injectable() +export class CoreCoursesDelegate { + protected logger; + protected handlers: {[s: string]: CoreCoursesHandler} = {}; // All registered handlers. + protected enabledHandlers: {[s: string]: CoreCoursesHandler} = {}; // Handlers enabled for the current site. + protected loaded: {[courseId: number]: boolean} = {}; + protected lastUpdateHandlersStart: number; + protected lastUpdateHandlersForCoursesStart: any = {}; + protected coursesHandlers: {[courseId: number]: { + access?: any, navOptions?: any, admOptions?: any, deferred?: PromiseDefer, enabledHandlers?: CoreCoursesHandler[]}} = {}; + + constructor(logger: CoreLoggerProvider, private sitesProvider: CoreSitesProvider, private eventsProvider: CoreEventsProvider, + private coursesProvider: CoreCoursesProvider, private utils: CoreUtilsProvider) { + this.logger = logger.getInstance('CoreMainMenuDelegate'); + + eventsProvider.on(CoreEventsProvider.LOGIN, this.updateHandlers.bind(this)); + eventsProvider.on(CoreEventsProvider.SITE_UPDATED, this.updateHandlers.bind(this)); + eventsProvider.on(CoreEventsProvider.REMOTE_ADDONS_LOADED, this.updateHandlers.bind(this)); + eventsProvider.on(CoreEventsProvider.LOGOUT, () => { + this.clearCoursesHandlers(); + }); + } + + /** + * Check if handlers are loaded for a certain course. + * + * @param {number} courseId The course ID to check. + * @return {boolean} True if handlers are loaded, false otherwise. + */ + areHandlersLoaded(courseId: number) : boolean { + return !!this.loaded[courseId]; + } + + /** + * Clear all courses handlers. + * + * @param {number} [courseId] The course ID. If not defined, all handlers will be cleared. + */ + protected clearCoursesHandlers(courseId?: number) : void { + if (courseId) { + this.loaded[courseId] = false; + delete this.coursesHandlers[courseId]; + } else { + this.loaded = {}; + this.coursesHandlers = {}; + } + } + + /** + * Clear all courses handlers and invalidate its options. + * + * @param {number} [courseId] The course ID. If not defined, all handlers will be cleared. + * @return {Promise} Promise resolved when done. + */ + clearAndInvalidateCoursesOptions(courseId?: number) : Promise { + var promises = []; + + this.eventsProvider.trigger(CoreCoursesProvider.EVENT_MY_COURSES_REFRESHED); + + // Invalidate course enabled data for the handlers that are enabled at site level. + if (courseId) { + // Invalidate only options for this course. + promises.push(this.coursesProvider.invalidateCoursesOptions([courseId])); + promises.push(this.invalidateCourseHandlers(courseId)); + } else { + // Invalidate all options. + promises.push(this.coursesProvider.invalidateUserNavigationOptions()); + promises.push(this.coursesProvider.invalidateUserAdministrationOptions()); + + for (let cId in this.coursesHandlers) { + promises.push(this.invalidateCourseHandlers(parseInt(cId, 10))); + } + } + + this.clearCoursesHandlers(courseId); + + return Promise.all(promises); + } + + /** + * Get the handlers for a course using a certain access type. + * + * @param {number} courseId The course ID. + * @param {boolean} refresh True if it should refresh the list. + * @param {any} accessData Access type and data. Default, guest, ... + * @param {any} [navOptions] Course navigation options for current user. See CoreCoursesProvider.getUserNavigationOptions. + * @param {any} [admOptions] Course admin options for current user. See CoreCoursesProvider.getUserAdministrationOptions. + * @return {Promise} Promise resolved with array of handlers. + */ + protected getHandlersForAccess(courseId: number, refresh: boolean, accessData: any, navOptions?: any, + admOptions?: any) : Promise { + + // If the handlers aren't loaded, do not refresh. + if (!this.loaded[courseId]) { + refresh = false; + } + + if (refresh || !this.coursesHandlers[courseId] || this.coursesHandlers[courseId].access.type != accessData.type) { + if (!this.coursesHandlers[courseId]) { + this.coursesHandlers[courseId] = {}; + } + this.coursesHandlers[courseId].access = accessData; + this.coursesHandlers[courseId].navOptions = navOptions; + this.coursesHandlers[courseId].admOptions = admOptions; + this.coursesHandlers[courseId].deferred = this.utils.promiseDefer(); + this.updateHandlersForCourse(courseId, accessData, navOptions, admOptions); + } + + return this.coursesHandlers[courseId].deferred.promise.then(() => { + return this.coursesHandlers[courseId].enabledHandlers; + }); + } + + /** + * Get the list of handlers that should be displayed for a course. + * This function should be called only when the handlers need to be displayed, since it can call several WebServices. + * + * @param {any} course The course object. + * @param {boolean} [refresh] True if it should refresh the list. + * @param {boolean} [isGuest] Whether it's guest. + * @param {any} [navOptions] Course navigation options for current user. See CoreCoursesProvider.getUserNavigationOptions. + * @param {any} [admOptions] Course admin options for current user. See CoreCoursesProvider.getUserAdministrationOptions. + * @return {Promise} Promise resolved with array of handlers. + */ + getHandlersToDisplay(course: any, refresh?: boolean, isGuest?: boolean, navOptions?: any, admOptions?: any) : + Promise { + course.id = parseInt(course.id, 10); + + let accessData = { + type: isGuest ? CoreCoursesProvider.ACCESS_GUEST : CoreCoursesProvider.ACCESS_DEFAULT + }; + + if (navOptions) { + course.navOptions = navOptions; + } + if (admOptions) { + course.admOptions = admOptions; + } + + return this.loadCourseOptions(course, refresh).then(() => { + // Call getHandlersForAccess to make sure the handlers have been loaded. + return this.getHandlersForAccess(course.id, refresh, accessData, course.navOptions, course.admOptions); + }).then(() => { + let handlersToDisplay: CoreCoursesHandlerToDisplay[] = [], + promises = [], + promise; + + this.coursesHandlers[course.id].enabledHandlers.forEach((handler) => { + if (handler.shouldDisplayForCourse) { + promise = Promise.resolve(handler.shouldDisplayForCourse( + course.id, accessData, course.navOptions, course.admOptions)); + } else { + // Not implemented, assume it should be displayed. + promise = Promise.resolve(true); + } + + promises.push(promise.then((enabled) => { + if (enabled) { + handlersToDisplay.push({ + data: handler.getDisplayData(course), + priority: handler.priority, + prefetch: handler.prefetch + }); + } + })); + }); + + return this.utils.allPromises(promises).then(() => { + // Sort them by priority. + handlersToDisplay.sort((a, b) => { + return b.priority - a.priority; + }); + + return handlersToDisplay; + }); + }); + } + + /** + * Check if a course has any handler enabled for default access, using course object. + * + * @param {any} course The course object. + * @param {boolean} [refresh] True if it should refresh the list. + * @return {Promise} Promise resolved with boolean: true if it has handlers, false otherwise. + */ + hasHandlersForCourse(course: any, refresh?: boolean) : Promise { + // Load course options if missing. + return this.loadCourseOptions(course, refresh).then(() => { + return this.hasHandlersForDefault(course.id, refresh, course.navOptions, course.admOptions); + }); + } + + /** + * Check if a course has any handler enabled for default access. + * + * @param {number} courseId The course ID. + * @param {boolean} [refresh] True if it should refresh the list. + * @param {any} [navOptions] Course navigation options for current user. See CoreCoursesProvider.getUserNavigationOptions. + * @param {any} [admOptions] Course admin options for current user. See CoreCoursesProvider.getUserAdministrationOptions. + * @return {Promise} Promise resolved with boolean: true if it has handlers, false otherwise. + */ + hasHandlersForDefault(courseId: number, refresh?: boolean, navOptions?: any, admOptions?: any) : Promise { + // Default access. + let accessData = { + type: CoreCoursesProvider.ACCESS_DEFAULT + }; + return this.getHandlersForAccess(courseId, refresh, accessData, navOptions, admOptions).then((handlers) => { + return !!(handlers && handlers.length); + }); + } + + /** + * Check if a course has any handler enabled for guest access. + * + * @param {number} courseId The course ID. + * @param {boolean} [refresh] True if it should refresh the list. + * @param {any} [navOptions] Course navigation options for current user. See CoreCoursesProvider.getUserNavigationOptions. + * @param {any} [admOptions] Course admin options for current user. See CoreCoursesProvider.getUserAdministrationOptions. + * @return {Promise} Promise resolved with boolean: true if it has handlers, false otherwise. + */ + hasHandlersForGuest(courseId: number, refresh?: boolean, navOptions?: any, admOptions?: any) : Promise { + // Guest access. + var accessData = { + type: CoreCoursesProvider.ACCESS_GUEST + }; + return this.getHandlersForAccess(courseId, refresh, accessData, navOptions, admOptions).then((handlers) => { + return !!(handlers && handlers.length); + }); + } + + /** + * Invalidate the data to be able to determine if handlers are enabled for a certain course. + * + * @param {number} courseId Course ID. + * @return {Promise} Promise resolved when done. + */ + invalidateCourseHandlers(courseId: number) : Promise { + let promises = [], + courseData = this.coursesHandlers[courseId]; + + if (!courseData) { + return Promise.resolve(); + } + + courseData.enabledHandlers.forEach((handler) => { + if (handler && handler.invalidateEnabledForCourse) { + promises.push(Promise.resolve( + handler.invalidateEnabledForCourse(courseId, courseData.navOptions, courseData.admOptions))); + } + }); + + return this.utils.allPromises(promises); + } + + /** + * Check if a time belongs to the last update handlers call. + * This is to handle the cases where updateHandlers don't finish in the same order as they're called. + * + * @param {number} time Time to check. + * @return {boolean} Whether it's the last call. + */ + isLastUpdateCall(time: number) : boolean { + if (!this.lastUpdateHandlersStart) { + return true; + } + return time == this.lastUpdateHandlersStart; + } + + /** + * Check if a time belongs to the last update handlers for course call. + * This is to handle the cases where updateHandlersForCourse don't finish in the same order as they're called. + * + * @param {number} courseId Course ID. + * @param {number} time Time to check. + * @return {boolean} Whether it's the last call. + */ + isLastUpdateCourseCall(courseId: number, time: number) : boolean { + if (!this.lastUpdateHandlersForCoursesStart[courseId]) { + return true; + } + return time == this.lastUpdateHandlersForCoursesStart[courseId]; + } + + /** + * Load course options if missing. + * + * @param {any} course The course object. + * @param {boolean} [refresh] True if it should refresh the list. + * @return {Promise} Promise resolved when done. + */ + protected loadCourseOptions(course: any, refresh?: boolean) : Promise { + if (typeof course.navOptions == 'undefined' || typeof course.admOptions == 'undefined' || refresh) { + return this.coursesProvider.getCoursesOptions([course.id]).then((options) => { + course.navOptions = options.navOptions[course.id]; + course.admOptions = options.admOptions[course.id]; + }); + } else { + return Promise.resolve(); + } + } + + /** + * Register a handler. + * + * @param {CoreCoursesHandler} handler The handler to register. + * @return {boolean} True if registered successfully, false otherwise. + */ + registerHandler(handler: CoreCoursesHandler) : boolean { + if (typeof this.handlers[handler.name] !== 'undefined') { + this.logger.log(`Addon '${handler.name}' already registered`); + return false; + } + this.logger.log(`Registered addon '${handler.name}'`); + this.handlers[handler.name] = handler; + return true; + } + + /** + * Update the handler for the current site. + * + * @param {CoreInitHandler} handler The handler to check. + * @param {number} time Time this update process started. + * @return {Promise} Resolved when done. + */ + protected updateHandler(handler: CoreCoursesHandler, time: number) : Promise { + let promise, + siteId = this.sitesProvider.getCurrentSiteId(), + currentSite = this.sitesProvider.getCurrentSite(); + + if (!this.sitesProvider.isLoggedIn()) { + promise = Promise.reject(null); + } else if (currentSite.isFeatureDisabled('$mmCoursesDelegate_' + handler.name)) { + promise = Promise.resolve(false); + } else { + promise = Promise.resolve(handler.isEnabled()); + } + + // Checks if the handler is enabled. + return promise.catch(() => { + return false; + }).then((enabled: boolean) => { + // Verify that this call is the last one that was started. + // Check that site hasn't changed since the check started. + if (this.isLastUpdateCall(time) && this.sitesProvider.getCurrentSiteId() === siteId) { + if (enabled) { + this.enabledHandlers[handler.name] = handler; + } else { + delete this.enabledHandlers[handler.name]; + } + } + }); + } + + /** + * Update the handlers for the current site. + * + * @return {Promise} Resolved when done. + */ + protected updateHandlers() : Promise { + let promises = [], + siteId = this.sitesProvider.getCurrentSiteId(), + now = Date.now(); + + this.logger.debug('Updating handlers for current site.'); + + this.lastUpdateHandlersStart = now; + + // Loop over all the handlers. + for (let name in this.handlers) { + promises.push(this.updateHandler(this.handlers[name], now)); + } + + return Promise.all(promises).then(() => { + return true; + }, () => { + // Never reject. + return true; + }).then(() => { + // Verify that this call is the last one that was started. + if (this.isLastUpdateCall(now) && this.sitesProvider.getCurrentSiteId() === siteId) { + // Update handlers for all courses. + for (let courseId in this.coursesHandlers) { + let handler = this.coursesHandlers[courseId]; + this.updateHandlersForCourse(parseInt(courseId, 10), handler.access, handler.navOptions, handler.admOptions); + } + } + }); + } + + /** + * Update the handlers for a certain course. + * + * @param {number} courseId The course ID. + * @param {any} accessData Access type and data. Default, guest, ... + * @param {any} [navOptions] Course navigation options for current user. See CoreCoursesProvider.getUserNavigationOptions. + * @param {any} [admOptions] Course admin options for current user. See CoreCoursesProvider.getUserAdministrationOptions. + * @return {Promise} Resolved when updated. + * @protected + */ + updateHandlersForCourse(courseId: number, accessData: any, navOptions?: any, admOptions?: any) : Promise { + let promises = [], + enabledForCourse = [], + siteId = this.sitesProvider.getCurrentSiteId(), + now = Date.now(); + + this.lastUpdateHandlersForCoursesStart[courseId] = now; + + for (let name in this.enabledHandlers) { + let handler = this.enabledHandlers[name]; + + // Checks if the handler is enabled for the user. + promises.push(Promise.resolve(handler.isEnabledForCourse(courseId, accessData, navOptions, admOptions)) + .then(function(enabled) { + if (enabled) { + enabledForCourse.push(handler); + } else { + return Promise.reject(null); + } + }).catch(() => { + // Nothing to do here, it is not enabled for this user. + })); + } + + return Promise.all(promises).then(() => { + return true; + }).catch(() => { + // Never fails. + return true; + }).finally(() => { + // Verify that this call is the last one that was started. + // Check that site hasn't changed since the check started. + if (this.isLastUpdateCourseCall(courseId, now) && this.sitesProvider.getCurrentSiteId() === siteId) { + // Update the coursesHandlers array with the new enabled addons. + this.coursesHandlers[courseId].enabledHandlers = enabledForCourse; + this.loaded[courseId] = true; + + // Resolve the promise. + this.coursesHandlers[courseId].deferred.resolve(); + } + }); + }; +} diff --git a/src/core/courses/providers/handlers.ts b/src/core/courses/providers/handlers.ts new file mode 100644 index 000000000..dd3338802 --- /dev/null +++ b/src/core/courses/providers/handlers.ts @@ -0,0 +1,71 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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 { Injectable } from '@angular/core'; +import { CoreCoursesProvider } from './courses'; +import { CoreMainMenuHandler, CoreMainMenuHandlerData } from '../../mainmenu/providers/delegate'; +import { CoreCoursesMyOverviewProvider } from '../providers/my-overview'; + +/** + * Handler to inject an option into main menu. + */ +@Injectable() +export class CoreCoursesMainMenuHandler implements CoreMainMenuHandler { + name = 'mmCourses'; + priority = 1100; + isOverviewEnabled: boolean; + + constructor(private coursesProvider: CoreCoursesProvider, private myOverviewProvider: CoreCoursesMyOverviewProvider) {} + + /** + * Check if the handler is enabled on a site level. + * + * @return {boolean} Whether or not the handler is enabled on a site level. + */ + isEnabled(): boolean|Promise { + // Check if my overview is enabled. + return this.myOverviewProvider.isEnabled().then((enabled) => { + this.isOverviewEnabled = enabled; + if (enabled) { + return true; + } + + // My overview not enabled, check if my courses is enabled. + return !this.coursesProvider.isMyCoursesDisabledInSite(); + }); + } + + /** + * Returns the data needed to render the handler. + * + * @return {CoreMainMenuHandlerData} Data needed to render the handler. + */ + getDisplayData(): CoreMainMenuHandlerData { + if (this.isOverviewEnabled) { + return { + icon: 'ionic', + title: 'core.courses.courseoverview', + page: 'CoreCoursesMyOverviewPage', + class: 'core-courseoverview-handler' + }; + } else { + return { + icon: 'ionic', + title: 'core.courses.mycourses', + page: 'CoreCoursesMyCoursesPage', + class: 'core-mycourses-handler' + }; + } + } +} diff --git a/src/core/courses/providers/my-overview.ts b/src/core/courses/providers/my-overview.ts new file mode 100644 index 000000000..aab67f913 --- /dev/null +++ b/src/core/courses/providers/my-overview.ts @@ -0,0 +1,275 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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 { Injectable } from '@angular/core'; +import { CoreSitesProvider } from '../../../providers/sites'; +import { CoreSite } from '../../../classes/site'; +import * as moment from 'moment'; + +/** + * Service that provides some features regarding course overview. + */ +@Injectable() +export class CoreCoursesMyOverviewProvider { + public static EVENTS_LIMIT = 20; + public static EVENTS_LIMIT_PER_COURSE = 10; + + constructor(private sitesProvider: CoreSitesProvider) {} + + /** + * Get calendar action events for the given course. + * + * @param {number} courseId Only events in this course. + * @param {number} [afterEventId] The last seen event id. + * @param {string} [siteId] Site ID. If not defined, use current site. + * @return {Promise<{events: any[], canLoadMore: number}>} Promise resolved when the info is retrieved. + */ + getActionEventsByCourse(courseId: number, afterEventId?: number, siteId?: string) : + Promise<{events: any[], canLoadMore: number}> { + + return this.sitesProvider.getSite(siteId).then((site) => { + let time = moment().subtract(14, 'days').unix(), // Check two weeks ago. + data: any = { + timesortfrom: time, + courseid: courseId, + limitnum: CoreCoursesMyOverviewProvider.EVENTS_LIMIT_PER_COURSE + }, + preSets = { + cacheKey: this.getActionEventsByCourseCacheKey(courseId) + }; + + if (afterEventId) { + data.aftereventid = afterEventId; + } + + return site.read('core_calendar_get_action_events_by_course', data, preSets).then((courseEvents) : any => { + if (courseEvents && courseEvents.events) { + return this.treatCourseEvents(courseEvents, time); + } + return Promise.reject(null); + }); + }); + } + + /** + * Get cache key for get calendar action events for the given course value WS call. + * + * @param {number} courseId Only events in this course. + * @return {string} Cache key. + */ + protected getActionEventsByCourseCacheKey(courseId: number) : string { + return this.getActionEventsByCoursesCacheKey() + ':' + courseId; + } + + /** + * Get calendar action events for a given list of courses. + * + * @param {number[]} courseIds Course IDs. + * @param {string} [siteId] Site ID. If not defined, use current site. + * @return {Promise<{[s: string]: {events: any[], canLoadMore: number}}>} Promise resolved when the info is retrieved. + */ + getActionEventsByCourses(courseIds: number[], siteId?: string) : Promise<{[s: string]: {events: any[], canLoadMore: number}}> { + return this.sitesProvider.getSite(siteId).then((site) => { + let time = moment().subtract(14, 'days').unix(), // Check two weeks ago. + data = { + timesortfrom: time, + courseids: courseIds, + limitnum: CoreCoursesMyOverviewProvider.EVENTS_LIMIT_PER_COURSE + }, + preSets = { + cacheKey: this.getActionEventsByCoursesCacheKey() + }; + + return site.read('core_calendar_get_action_events_by_courses', data, preSets).then((events) : any => { + if (events && events.groupedbycourse) { + let courseEvents = {}; + + events.groupedbycourse.forEach((course) => { + courseEvents[course.courseid] = this.treatCourseEvents(course, time); + }); + + return courseEvents; + } + + return Promise.reject(null); + }); + }); + } + + /** + * Get cache key for get calendar action events for a given list of courses value WS call. + * + * @return {string} Cache key. + */ + protected getActionEventsByCoursesCacheKey() : string { + return this.getRootCacheKey() + 'bycourse'; + } + + /** + * Get calendar action events based on the timesort value. + * + * @param {number} [afterEventId] The last seen event id. + * @param {string} [siteId] Site ID. If not defined, use current site. + * @return {Promise<{events: any[], canLoadMore: number}>} Promise resolved when the info is retrieved. + */ + getActionEventsByTimesort(afterEventId: number, siteId?: string) : Promise<{events: any[], canLoadMore: number}> { + return this.sitesProvider.getSite(siteId).then((site) => { + let time = moment().subtract(14, 'days').unix(), // Check two weeks ago. + data: any = { + timesortfrom: time, + limitnum: CoreCoursesMyOverviewProvider.EVENTS_LIMIT + }, + preSets = { + cacheKey: this.getActionEventsByTimesortCacheKey(afterEventId, data.limitnum), + getCacheUsingCacheKey: true, + uniqueCacheKey: true + }; + + if (afterEventId) { + data.aftereventid = afterEventId; + } + + return site.read('core_calendar_get_action_events_by_timesort', data, preSets).then((events) : any => { + if (events && events.events) { + let canLoadMore = events.events.length >= data.limitnum ? events.lastid : undefined; + + // Filter events by time in case it uses cache. + events = events.events.filter((element) => { + return element.timesort >= time; + }); + + return { + events: events, + canLoadMore: canLoadMore + }; + } + return Promise.reject(null); + }); + }); + } + + /** + * Get prefix cache key for calendar action events based on the timesort value WS calls. + * + * @return {string} Cache key. + */ + protected getActionEventsByTimesortPrefixCacheKey() : string { + return this.getRootCacheKey() + 'bytimesort:'; + } + + /** + * Get cache key for get calendar action events based on the timesort value WS call. + * + * @param {number} [afterEventId] The last seen event id. + * @param {number} [limit] Limit num of the call. + * @return {string} Cache key. + */ + protected getActionEventsByTimesortCacheKey(afterEventId?: number, limit?: number) : string { + afterEventId = afterEventId || 0; + limit = limit || 0; + return this.getActionEventsByTimesortPrefixCacheKey() + afterEventId + ':' + limit; + } + + /** + * Get the root cache key for the WS calls related to overview. + * + * @return {string} Root cache key. + */ + protected getRootCacheKey() : string { + return 'myoverview:'; + } + + /** + * Invalidates get calendar action events for a given list of courses WS call. + * + * @param {string} [siteId] Site ID to invalidate. If not defined, use current site. + * @return {Promise} Promise resolved when the data is invalidated. + */ + invalidateActionEventsByCourses(siteId?: string) : Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.invalidateWsCacheForKeyStartingWith(this.getActionEventsByCoursesCacheKey()); + }); + } + + /** + * Invalidates get calendar action events based on the timesort value WS call. + * + * @param {string} [siteId] Site ID to invalidate. If not defined, use current site. + * @return {Promise} Promise resolved when the data is invalidated. + */ + invalidateActionEventsByTimesort(siteId?: string) : Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.invalidateWsCacheForKeyStartingWith(this.getActionEventsByTimesortPrefixCacheKey()); + }); + } + + /** + * Returns whether or not My Overview is available for a certain site. + * + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with true if available, resolved with false or rejected otherwise. + */ + isAvailable(siteId?: string) : Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.wsAvailable('core_calendar_get_action_events_by_courses'); + }); + } + + /** + * Check if My Overview is disabled in a certain site. + * + * @param {CoreSite} [site] Site. If not defined, use current site. + * @return {boolean} Whether it's disabled. + */ + isDisabledInSite(site?: CoreSite) : boolean { + site = site || this.sitesProvider.getCurrentSite(); + return site.isFeatureDisabled('$mmSideMenuDelegate_mmaMyOverview'); + } + + /** + * Check if My Overview is available and not disabled. + * + * @return {Promise} Promise resolved with true if enabled, resolved with false otherwise. + */ + isEnabled() : Promise { + if (!this.isDisabledInSite()) { + return this.isAvailable().catch(() => { + return false; + }); + } + return Promise.resolve(false); + } + + /** + * Handles course events, filtering and treating if more can be loaded. + * + * @param {any} course Object containing response course events info. + * @param {number} timeFrom Current time to filter events from. + * @return {{events: any[], canLoadMore: number}} Object with course events and last loaded event id if more can be loaded. + */ + protected treatCourseEvents(course: any, timeFrom: number) : {events: any[], canLoadMore: number} { + let canLoadMore : number = + course.events.length >= CoreCoursesMyOverviewProvider.EVENTS_LIMIT_PER_COURSE ? course.lastid : undefined; + + // Filter events by time in case it uses cache. + course.events = course.events.filter((element) => { + return element.timesort >= timeFrom; + }); + + return { + events: course.events, + canLoadMore: canLoadMore + }; + } +} diff --git a/src/core/emulator/emulator.module.ts b/src/core/emulator/emulator.module.ts index 0e18b1e1f..d4724b600 100644 --- a/src/core/emulator/emulator.module.ts +++ b/src/core/emulator/emulator.module.ts @@ -20,8 +20,12 @@ import { File } from '@ionic-native/file'; import { FileTransfer } from '@ionic-native/file-transfer'; import { Globalization } from '@ionic-native/globalization'; import { InAppBrowser } from '@ionic-native/in-app-browser'; +import { Keyboard } from '@ionic-native/keyboard'; import { LocalNotifications } from '@ionic-native/local-notifications'; import { Network } from '@ionic-native/network'; +import { SplashScreen } from '@ionic-native/splash-screen'; +import { StatusBar } from '@ionic-native/status-bar'; +import { SQLite } from '@ionic-native/sqlite'; import { Zip } from '@ionic-native/zip'; import { ClipboardMock } from './providers/clipboard'; @@ -86,6 +90,7 @@ import { CoreInitDelegate } from '../../providers/init'; return !appProvider.isDesktop() ? new InAppBrowser() : new InAppBrowserMock(appProvider, fileProvider, urlUtils); } }, + Keyboard, { provide: LocalNotifications, deps: [CoreAppProvider, CoreUtilsProvider], @@ -102,6 +107,9 @@ import { CoreInitDelegate } from '../../providers/init'; return platform.is('cordova') ? new Network() : new NetworkMock(); } }, + SplashScreen, + StatusBar, + SQLite, { provide: Zip, deps: [CoreAppProvider, File, CoreMimetypeUtilsProvider, CoreTextUtilsProvider], diff --git a/src/core/emulator/providers/clipboard.ts b/src/core/emulator/providers/clipboard.ts index 89a427f8e..2cdd7435e 100644 --- a/src/core/emulator/providers/clipboard.ts +++ b/src/core/emulator/providers/clipboard.ts @@ -34,7 +34,7 @@ export class ClipboardMock extends Clipboard { } else { // In browser the text must be selected in order to copy it. Create a hidden textarea to put the text in it. this.copyTextarea = document.createElement('textarea'); - this.copyTextarea.className = 'mm-browser-copy-area'; + this.copyTextarea.className = 'core-browser-copy-area'; this.copyTextarea.setAttribute('aria-hidden', 'true'); document.body.appendChild(this.copyTextarea); } diff --git a/src/core/emulator/providers/file.ts b/src/core/emulator/providers/file.ts index 4eef27146..70c61ba0d 100644 --- a/src/core/emulator/providers/file.ts +++ b/src/core/emulator/providers/file.ts @@ -350,7 +350,6 @@ export class FileMock extends File { (navigator).webkitPersistentStorage.requestQuota(500 * 1024 * 1024, (granted) => { window.requestFileSystem(LocalFileSystem.PERSISTENT, granted, (entry) => { basePath = entry.root.toURL(); - // this.fileProvider.setHTMLBasePath(basePath); resolve(basePath); }, reject); }, reject); diff --git a/src/core/emulator/providers/local-notifications.ts b/src/core/emulator/providers/local-notifications.ts index 35c1b6561..d7eef13db 100644 --- a/src/core/emulator/providers/local-notifications.ts +++ b/src/core/emulator/providers/local-notifications.ts @@ -261,7 +261,7 @@ export class LocalNotificationsMock extends LocalNotifications { * @returns {Promise>} */ getAll(): Promise> { - return Promise.resolve(this.getNotifications(null, true, true)); + return Promise.resolve(this.getNotifications(undefined, true, true)); } /** @@ -292,7 +292,7 @@ export class LocalNotificationsMock extends LocalNotifications { * @returns {Promise>} */ getAllScheduled(): Promise> { - return Promise.resolve(this.getNotifications(null, true, false)); + return Promise.resolve(this.getNotifications(undefined, true, false)); } /** @@ -301,7 +301,7 @@ export class LocalNotificationsMock extends LocalNotifications { * @returns {Promise>} */ getAllTriggered(): Promise> { - return Promise.resolve(this.getNotifications(null, false, true)); + return Promise.resolve(this.getNotifications(undefined, false, true)); } /** diff --git a/src/core/login/pages/credentials/credentials.html b/src/core/login/pages/credentials/credentials.html index b58d711b8..6aee0c300 100644 --- a/src/core/login/pages/credentials/credentials.html +++ b/src/core/login/pages/credentials/credentials.html @@ -3,7 +3,7 @@ {{ 'core.login.login' | translate }} - +
@@ -12,10 +12,10 @@ -

{{siteUrl}}

+

{{siteUrl}}

-

{{siteName}}

-

{{siteUrl}}

+

{{siteName}}

+

{{siteUrl}}

@@ -23,7 +23,7 @@ - + @@ -36,7 +36,7 @@ {{ 'core.login.potentialidps' | translate }} - diff --git a/src/core/login/pages/credentials/credentials.module.ts b/src/core/login/pages/credentials/credentials.module.ts index c69b2b8b4..88e6e955e 100644 --- a/src/core/login/pages/credentials/credentials.module.ts +++ b/src/core/login/pages/credentials/credentials.module.ts @@ -15,7 +15,6 @@ import { NgModule } from '@angular/core'; import { IonicPageModule } from 'ionic-angular'; import { CoreLoginCredentialsPage } from './credentials'; -import { CoreLoginModule } from '../../login.module'; import { TranslateModule } from '@ngx-translate/core'; import { CoreComponentsModule } from '../../../../components/components.module'; import { CoreDirectivesModule } from '../../../../directives/directives.module'; @@ -27,7 +26,6 @@ import { CoreDirectivesModule } from '../../../../directives/directives.module'; imports: [ CoreComponentsModule, CoreDirectivesModule, - CoreLoginModule, IonicPageModule.forChild(CoreLoginCredentialsPage), TranslateModule.forChild() ] diff --git a/src/core/login/pages/credentials/credentials.scss b/src/core/login/pages/credentials/credentials.scss index b940b3116..030525baf 100644 --- a/src/core/login/pages/credentials/credentials.scss +++ b/src/core/login/pages/credentials/credentials.scss @@ -4,11 +4,6 @@ page-core-login-credentials { background: radial-gradient(white, $gray-light); } - .mm-ioninput-password { - padding-top: 0; - padding-bottom: 0; - } - img { max-width: 100%; } @@ -25,8 +20,8 @@ page-core-login-credentials { border: 1px solid $gray; } - .mm-sitename, .mm-siteurl { - @if $mm-fixed-url { display: none; } + .core-sitename, .core-siteurl { + @if $core-fixed-url { display: none; } } .item-input { diff --git a/src/core/login/pages/credentials/credentials.ts b/src/core/login/pages/credentials/credentials.ts index 50b5d05a1..8d0dc4138 100644 --- a/src/core/login/pages/credentials/credentials.ts +++ b/src/core/login/pages/credentials/credentials.ts @@ -219,7 +219,7 @@ export class CoreLoginCredentialsPage { // } // }); } else { - return this.loginHelper.goToSiteInitialPage(this.navCtrl, true); + return this.loginHelper.goToSiteInitialPage(); } }); }).catch((error) => { diff --git a/src/core/login/pages/email-signup/email-signup.html b/src/core/login/pages/email-signup/email-signup.html index 2a737e086..e284fd771 100644 --- a/src/core/login/pages/email-signup/email-signup.html +++ b/src/core/login/pages/email-signup/email-signup.html @@ -78,7 +78,7 @@ diff --git a/src/core/login/pages/email-signup/email-signup.module.ts b/src/core/login/pages/email-signup/email-signup.module.ts index 37b1fd5b7..451715de6 100644 --- a/src/core/login/pages/email-signup/email-signup.module.ts +++ b/src/core/login/pages/email-signup/email-signup.module.ts @@ -15,7 +15,6 @@ import { NgModule } from '@angular/core'; import { IonicPageModule } from 'ionic-angular'; import { CoreLoginEmailSignupPage } from './email-signup'; -import { CoreLoginModule } from '../../login.module'; import { TranslateModule } from '@ngx-translate/core'; import { CoreComponentsModule } from '../../../../components/components.module'; import { CoreDirectivesModule } from '../../../../directives/directives.module'; @@ -27,7 +26,6 @@ import { CoreDirectivesModule } from '../../../../directives/directives.module'; imports: [ CoreComponentsModule, CoreDirectivesModule, - CoreLoginModule, IonicPageModule.forChild(CoreLoginEmailSignupPage), TranslateModule.forChild() ] diff --git a/src/core/login/pages/email-signup/email-signup.ts b/src/core/login/pages/email-signup/email-signup.ts index cd87027c3..dcf5a0f2e 100644 --- a/src/core/login/pages/email-signup/email-signup.ts +++ b/src/core/login/pages/email-signup/email-signup.ts @@ -71,7 +71,7 @@ export class CoreLoginEmailSignupPage { this.usernameErrors = this.loginHelper.getErrorMessages('core.login.usernamerequired'); this.passwordErrors = this.loginHelper.getErrorMessages('core.login.passwordrequired'); this.emailErrors = this.loginHelper.getErrorMessages('core.login.missingemail'); - this.email2Errors = this.loginHelper.getErrorMessages('core.login.missingemail', null, 'core.login.emailnotmatch'); + this.email2Errors = this.loginHelper.getErrorMessages('core.login.missingemail', undefined, 'core.login.emailnotmatch'); this.policyErrors = this.loginHelper.getErrorMessages('core.login.policyagree'); } diff --git a/src/core/login/pages/forgotten-password/forgotten-password.module.ts b/src/core/login/pages/forgotten-password/forgotten-password.module.ts index 9cbc9148d..0df3a92da 100644 --- a/src/core/login/pages/forgotten-password/forgotten-password.module.ts +++ b/src/core/login/pages/forgotten-password/forgotten-password.module.ts @@ -15,7 +15,6 @@ import { NgModule } from '@angular/core'; import { IonicPageModule } from 'ionic-angular'; import { CoreLoginForgottenPasswordPage } from './forgotten-password'; -import { CoreLoginModule } from '../../login.module'; import { TranslateModule } from '@ngx-translate/core'; @NgModule({ @@ -23,7 +22,6 @@ import { TranslateModule } from '@ngx-translate/core'; CoreLoginForgottenPasswordPage ], imports: [ - CoreLoginModule, IonicPageModule.forChild(CoreLoginForgottenPasswordPage), TranslateModule.forChild() ] diff --git a/src/core/login/pages/init/init.html b/src/core/login/pages/init/init.html index d0ef72e4a..53461969c 100644 --- a/src/core/login/pages/init/init.html +++ b/src/core/login/pages/init/init.html @@ -1,6 +1,6 @@ -