diff --git a/src/core/components/icon/icon.scss b/src/core/components/icon/icon.scss index 93c842fce..097de22a6 100644 --- a/src/core/components/icon/icon.scss +++ b/src/core/components/icon/icon.scss @@ -2,6 +2,10 @@ margin: 0; } +:host-context([dir=rtl]).icon-flip-rtl { + transform: scaleX(-1); +} + :host-context(ion-item.md) ion-icon { &[slot] { font-size: 1.6em; diff --git a/src/core/features/block/block.module.ts b/src/core/features/block/block.module.ts new file mode 100644 index 000000000..8092c0dbe --- /dev/null +++ b/src/core/features/block/block.module.ts @@ -0,0 +1,24 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { NgModule } from '@angular/core'; +import { CoreBlockDefaultHandler } from './services/handlers/default-block'; + +@NgModule({ + providers: [ + CoreBlockDefaultHandler, + ], +}) +export class CoreBlockModule { +} diff --git a/src/core/features/block/classes/base-block-component.ts b/src/core/features/block/classes/base-block-component.ts new file mode 100644 index 000000000..aac65bba4 --- /dev/null +++ b/src/core/features/block/classes/base-block-component.ts @@ -0,0 +1,143 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { OnInit, Input, Component, Optional, Inject } from '@angular/core'; +import { CoreLogger } from '@singletons/logger'; +import { CoreDomUtils } from '@services/utils/dom'; +import { CoreUtils } from '@services/utils/utils'; +import { CoreTextUtils } from '@services/utils/text'; +import { CoreCourseBlock } from '../../course/services/course'; +import { IonRefresher } from '@ionic/angular'; +import { Params } from '@angular/router'; + +/** + * Template class to easily create components for blocks. + */ +@Component({ + template: '', +}) +export abstract class CoreBlockBaseComponent implements OnInit { + + @Input() title!: string; // The block title. + @Input() block!: CoreCourseBlock; // The block to render. + @Input() contextLevel!: string; // The context where the block will be used. + @Input() instanceId!: number; // The instance ID associated with the context level. + @Input() link?: string; // Link to go when clicked. + @Input() linkParams?: Params; // Link params to go when clicked. + + loaded = false; // If the component has been loaded. + protected fetchContentDefaultError = ''; // Default error to show when loading contents. + + protected logger: CoreLogger; + + constructor(@Optional() @Inject('') loggerName: string = 'AddonBlockComponent') { + this.logger = CoreLogger.getInstance(loggerName); + } + + /** + * Component being initialized. + */ + async ngOnInit(): Promise { + if (this.block.configs && this.block.configs.length > 0) { + this.block.configs.map((config) => { + config.value = CoreTextUtils.instance.parseJSON(config.value); + + return config; + }); + + this.block.configsRecord = CoreUtils.instance.arrayToObject(this.block.configs, 'name'); + } + + await this.loadContent(); + } + + /** + * Refresh the data. + * + * @param refresher Refresher. + * @param done Function to call when done. + * @param showErrors If show errors to the user of hide them. + * @return Promise resolved when done. + */ + async doRefresh(refresher?: CustomEvent, done?: () => void, showErrors: boolean = false): Promise { + if (this.loaded) { + return this.refreshContent(showErrors).finally(() => { + refresher?.detail.complete(); + done && done(); + }); + } + } + + /** + * Perform the refresh content function. + * + * @param showErrors Wether to show errors to the user or hide them. + * @return Resolved when done. + */ + protected async refreshContent(showErrors: boolean = false): Promise { + // Wrap the call in a try/catch so the workflow isn't interrupted if an error occurs. + try { + await this.invalidateContent(); + } catch (ex) { + // An error ocurred in the function, log the error and just resolve the promise so the workflow continues. + this.logger.error(ex); + } + + await this.loadContent(true, showErrors); + } + + /** + * Perform the invalidate content function. + * + * @return Resolved when done. + */ + protected async invalidateContent(): Promise { + return; + } + + /** + * Loads the component contents and shows the corresponding error. + * + * @param refresh Whether we're refreshing data. + * @param showErrors Wether to show errors to the user or hide them. + * @return Promise resolved when done. + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + protected async loadContent(refresh?: boolean, showErrors: boolean = false): Promise { + // Wrap the call in a try/catch so the workflow isn't interrupted if an error occurs. + try { + await this.fetchContent(refresh); + } catch (error) { + // An error ocurred in the function, log the error and just resolve the promise so the workflow continues. + this.logger.error(error); + + // Error getting data, fail. + CoreDomUtils.instance.showErrorModalDefault(error, this.fetchContentDefaultError, true); + } + + this.loaded = true; + } + + /** + * Download the component contents. + * + * @param refresh Whether we're refreshing data. + * @return Promise resolved when done. + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + protected async fetchContent(refresh: boolean = false): Promise { + return; + } + +} diff --git a/src/core/features/block/classes/base-block-handler.ts b/src/core/features/block/classes/base-block-handler.ts new file mode 100644 index 000000000..89c0fef81 --- /dev/null +++ b/src/core/features/block/classes/base-block-handler.ts @@ -0,0 +1,61 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { CoreCourseBlock } from '@features/course/services/course'; +import { CoreBlockPreRenderedComponent } from '../components/pre-rendered-block/pre-rendered-block'; +import { CoreBlockHandler, CoreBlockHandlerData } from '../services/block-delegate'; + +/** + * Base handler for blocks. + * + * This class is needed because parent classes cannot have @Injectable in Angular v6, so the default handler cannot be a + * parent class. + */ +export class CoreBlockBaseHandler implements CoreBlockHandler { + + name = 'CoreBlockBase'; + blockName = 'base'; + + /** + * Whether or not the handler is enabled on a site level. + * + * @return True or promise resolved with true if enabled. + */ + async isEnabled(): Promise { + return true; + } + + /** + * Returns the data needed to render the block. + * + * @param block The block to render. + * @param contextLevel The context where the block will be used. + * @param instanceId The instance ID associated with the context level. + * @return Data or promise resolved with the data. + */ + getDisplayData( + block: CoreCourseBlock, // eslint-disable-line @typescript-eslint/no-unused-vars + contextLevel: string, // eslint-disable-line @typescript-eslint/no-unused-vars + instanceId: number, // eslint-disable-line @typescript-eslint/no-unused-vars + ): CoreBlockHandlerData | Promise { + + // To be overridden. + return { + title: '', + class: '', + component: CoreBlockPreRenderedComponent, + }; + } + +} diff --git a/src/core/features/block/components/block/block.scss b/src/core/features/block/components/block/block.scss new file mode 100644 index 000000000..3acf40fdd --- /dev/null +++ b/src/core/features/block/components/block/block.scss @@ -0,0 +1,30 @@ +:host { + // @todo + position: relative; + display: block; + + core-loading.core-loading-center { + display: block; + + .core-loading-container { + margin-top: 10px; + position: relative; + } + } + + core-empty-box .core-empty-box { + position: relative; + z-index: initial; + //@include position(initial, initial, null, initial); + height: auto; + } + + ion-item-divider { + //@include padding-horizontal(null, 0px); + min-height: 60px; + } + + ion-item-divider .core-button-spinner { + margin: 0; + } +} diff --git a/src/core/features/block/components/block/block.ts b/src/core/features/block/components/block/block.ts new file mode 100644 index 000000000..595c283ad --- /dev/null +++ b/src/core/features/block/components/block/block.ts @@ -0,0 +1,163 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Component, Input, OnInit, ViewChild, OnDestroy, DoCheck, KeyValueDiffers, KeyValueDiffer, Type } from '@angular/core'; +import { CoreBlockDelegate } from '../../services/block-delegate'; +import { CoreDynamicComponent } from '@components/dynamic-component/dynamic-component'; +import { Subscription } from 'rxjs'; +import { CoreCourseBlock } from '@/core/features/course/services/course'; +import { IonRefresher } from '@ionic/angular'; + +/** + * Component to render a block. + */ +@Component({ + selector: 'core-block', + templateUrl: 'core-block.html', + styleUrls: ['block.scss'], +}) +export class CoreBlockComponent implements OnInit, OnDestroy, DoCheck { + + @ViewChild(CoreDynamicComponent) dynamicComponent?: CoreDynamicComponent; + + @Input() block!: CoreCourseBlock; // The block to render. + @Input() contextLevel!: string; // The context where the block will be used. + @Input() instanceId!: number; // The instance ID associated with the context level. + @Input() extraData: any; // Any extra data to be passed to the block. + + componentClass?: Type; // The class of the component to render. + data: any = {}; // Data to pass to the component. + class?: string; // CSS class to apply to the block. + loaded = false; + + blockSubscription?: Subscription; + + protected differ: KeyValueDiffer; // To detect changes in the data input. + + constructor( + differs: KeyValueDiffers, + ) { + this.differ = differs.find([]).create(); + } + + /** + * Component being initialized. + */ + ngOnInit(): void { + if (!this.block) { + this.loaded = true; + + return; + } + + if (this.block.visible) { + // Get the data to render the block. + this.initBlock(); + } + } + + /** + * Detect and act upon changes that Angular can’t or won’t detect on its own (objects and arrays). + */ + ngDoCheck(): void { + if (this.data) { + // Check if there's any change in the extraData object. + const changes = this.differ.diff(this.extraData); + if (changes) { + this.data = Object.assign(this.data, this.extraData || {}); + } + } + } + + /** + * Get block display data and initialises the block once this is available. If the block is not + * supported at the moment, try again if the available blocks are updated (because it comes + * from a site plugin). + */ + async initBlock(): Promise { + try { + const data = await CoreBlockDelegate.instance.getBlockDisplayData(this.block, this.contextLevel, this.instanceId); + + if (!data) { + // Block not supported, don't render it. But, site plugins might not have finished loading. + // Subscribe to the observable in block delegate that will tell us if blocks are updated. + // We can retry init later if that happens. + this.blockSubscription = CoreBlockDelegate.instance.blocksUpdateObservable.subscribe( + (): void => { + this.blockSubscription?.unsubscribe(); + delete this.blockSubscription; + this.initBlock(); + }, + ); + + return; + } + + this.class = data.class; + this.componentClass = data.component; + + // Set up the data needed by the block component. + this.data = Object.assign({ + title: data.title, + block: this.block, + contextLevel: this.contextLevel, + instanceId: this.instanceId, + link: data.link || null, + linkParams: data.linkParams || null, + }, this.extraData || {}, data.componentData || {}); + } catch { + // Ignore errors. + } + + this.loaded = true; + } + + /** + * On destroy of the component, clear up any subscriptions. + */ + ngOnDestroy(): void { + this.blockSubscription?.unsubscribe(); + delete this.blockSubscription; + } + + /** + * Refresh the data. + * + * @param refresher Refresher. Please pass this only if the refresher should finish when this function finishes. + * @param done Function to call when done. + * @param showErrors If show errors to the user of hide them. + * @return Promise resolved when done. + */ + async doRefresh( + refresher?: CustomEvent, + done?: () => void, + showErrors: boolean = false, + ): Promise { + if (this.dynamicComponent) { + await this.dynamicComponent.callComponentFunction('doRefresh', [refresher, done, showErrors]); + } + } + + /** + * Invalidate some data. + * + * @return Promise resolved when done. + */ + async invalidate(): Promise { + if (this.dynamicComponent) { + await this.dynamicComponent.callComponentFunction('invalidateContent'); + } + } + +} diff --git a/src/core/features/block/components/block/core-block.html b/src/core/features/block/components/block/core-block.html new file mode 100644 index 000000000..1c1e3a215 --- /dev/null +++ b/src/core/features/block/components/block/core-block.html @@ -0,0 +1,4 @@ + +
+ +
diff --git a/src/core/features/block/components/components.module.ts b/src/core/features/block/components/components.module.ts new file mode 100644 index 000000000..70aec8124 --- /dev/null +++ b/src/core/features/block/components/components.module.ts @@ -0,0 +1,52 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { IonicModule } from '@ionic/angular'; +import { TranslateModule } from '@ngx-translate/core'; +import { CoreDirectivesModule } from '@directives/directives.module'; +import { CoreBlockComponent } from './block/block'; +import { CoreBlockOnlyTitleComponent } from './only-title-block/only-title-block'; +import { CoreBlockPreRenderedComponent } from './pre-rendered-block/pre-rendered-block'; +import { CoreBlockCourseBlocksComponent } from './course-blocks/course-blocks'; +import { CoreComponentsModule } from '@components/components.module'; + +@NgModule({ + declarations: [ + CoreBlockComponent, + CoreBlockOnlyTitleComponent, + CoreBlockPreRenderedComponent, + CoreBlockCourseBlocksComponent, + ], + imports: [ + CommonModule, + IonicModule, + CoreDirectivesModule, + TranslateModule.forChild(), + CoreComponentsModule, + ], + exports: [ + CoreBlockComponent, + CoreBlockOnlyTitleComponent, + CoreBlockPreRenderedComponent, + CoreBlockCourseBlocksComponent, + ], + entryComponents: [ + CoreBlockOnlyTitleComponent, + CoreBlockPreRenderedComponent, + CoreBlockCourseBlocksComponent, + ], +}) +export class CoreBlockComponentsModule {} diff --git a/src/core/features/block/components/course-blocks/core-block-course-blocks.html b/src/core/features/block/components/course-blocks/core-block-course-blocks.html new file mode 100644 index 000000000..a875f46e4 --- /dev/null +++ b/src/core/features/block/components/course-blocks/core-block-course-blocks.html @@ -0,0 +1,14 @@ +
+ +
+ +
+ + + + + + + + +
diff --git a/src/core/features/block/components/course-blocks/course-blocks.scss b/src/core/features/block/components/course-blocks/course-blocks.scss new file mode 100644 index 000000000..96774170f --- /dev/null +++ b/src/core/features/block/components/course-blocks/course-blocks.scss @@ -0,0 +1,58 @@ +:host { + &.core-no-blocks .core-course-blocks-content { + height: auto; + } + + &.core-has-blocks { + @media (min-width: 768px) { + display: flex; + + flex-direction: row; + flex-wrap: nowrap; + + .core-course-blocks-content { + box-shadow: none !important; + flex-grow: 1; + max-width: 100%; + // @todo @include core-split-area-start(); + } + + div.core-course-blocks-side { + max-width: var(--side-blocks-max-width); + min-width: var(--side-blocks-min-width); + box-shadow: -4px 0px 16px rgba(0, 0, 0, 0.18); + // @todo @include core-split-area-end(); + } + + .core-course-blocks-content, + div.core-course-blocks-side { + position: relative; + height: 100%; + + .core-loading-center, + core-loading.core-loading-loaded { + position: initial; + } + } + } + + @media (max-width: 767.98px) { + // Disable scroll on individual columns. + div.core-course-blocks-side { + height: auto; + + &.core-hide-blocks { + display: none; + } + } + } + } +} + +:host-context([dir="rtl"]).core-has-blocks { + @media (min-width: 768px) { + div.core-course-blocks-side { + box-shadow: 4px 0px 16px rgba(0, 0, 0, 0.18); + } + } +} diff --git a/src/core/features/block/components/course-blocks/course-blocks.ts b/src/core/features/block/components/course-blocks/course-blocks.ts new file mode 100644 index 000000000..c95ab480c --- /dev/null +++ b/src/core/features/block/components/course-blocks/course-blocks.ts @@ -0,0 +1,111 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Component, ViewChildren, Input, OnInit, QueryList, ElementRef } from '@angular/core'; +import { IonContent } from '@ionic/angular'; +import { CoreDomUtils } from '@services/utils/dom'; +import { CoreCourse, CoreCourseBlock } from '@features/course/services/course'; +import { CoreBlockHelper } from '../../services/block-helper'; +import { CoreBlockComponent } from '../block/block'; + +/** + * Component that displays the list of course blocks. + */ +@Component({ + selector: 'core-block-course-blocks', + templateUrl: 'core-block-course-blocks.html', + styleUrls: ['course-blocks.scss'], +}) +export class CoreBlockCourseBlocksComponent implements OnInit { + + @Input() courseId!: number; + @Input() hideBlocks = false; + @Input() hideBottomBlocks = false; + @Input() downloadEnabled = false; + + @ViewChildren(CoreBlockComponent) blocksComponents?: QueryList; + + dataLoaded = false; + blocks: CoreCourseBlock[] = []; + + protected element: HTMLElement; + + constructor( + element: ElementRef, + protected content: IonContent, + ) { + this.element = element.nativeElement; + } + + /** + * Component being initialized. + */ + async ngOnInit(): Promise { + this.element.classList.add('core-no-blocks'); + this.loadContent().finally(() => { + this.dataLoaded = true; + }); + } + + /** + * Invalidate blocks data. + * + * @return Promise resolved when done. + */ + async invalidateBlocks(): Promise { + const promises: Promise[] = []; + + if (CoreBlockHelper.instance.canGetCourseBlocks()) { + promises.push(CoreCourse.instance.invalidateCourseBlocks(this.courseId)); + } + + // Invalidate the blocks. + this.blocksComponents?.forEach((blockComponent) => { + promises.push(blockComponent.invalidate().catch(() => { + // Ignore errors. + })); + }); + + await Promise.all(promises); + } + + /** + * Convenience function to fetch the data. + * + * @return Promise resolved when done. + */ + async loadContent(): Promise { + + try { + this.blocks = await CoreBlockHelper.instance.getCourseBlocks(this.courseId); + } catch (error) { + CoreDomUtils.instance.showErrorModal(error); + + this.blocks = []; + } + + const scrollElement = await this.content.getScrollElement(); + if (!this.hideBlocks && this.blocks.length > 0) { + this.element.classList.add('core-has-blocks'); + this.element.classList.remove('core-no-blocks'); + + scrollElement.classList.add('core-course-block-with-blocks'); + } else { + this.element.classList.remove('core-has-blocks'); + this.element.classList.add('core-no-blocks'); + scrollElement.classList.remove('core-course-block-with-blocks'); + } + } + +} diff --git a/src/core/features/block/components/only-title-block/core-block-only-title.html b/src/core/features/block/components/only-title-block/core-block-only-title.html new file mode 100644 index 000000000..c919a85a1 --- /dev/null +++ b/src/core/features/block/components/only-title-block/core-block-only-title.html @@ -0,0 +1,4 @@ + +

{{ title | translate }}

+ +
diff --git a/src/core/features/block/components/only-title-block/only-title-block.scss b/src/core/features/block/components/only-title-block/only-title-block.scss new file mode 100644 index 000000000..b129c351b --- /dev/null +++ b/src/core/features/block/components/only-title-block/only-title-block.scss @@ -0,0 +1,5 @@ +:host { + ion-item-divider { + cursor: pointer; + } +} diff --git a/src/core/features/block/components/only-title-block/only-title-block.ts b/src/core/features/block/components/only-title-block/only-title-block.ts new file mode 100644 index 000000000..3e2ec6d20 --- /dev/null +++ b/src/core/features/block/components/only-title-block/only-title-block.ts @@ -0,0 +1,49 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { OnInit, Component } from '@angular/core'; +import { CoreBlockBaseComponent } from '../../classes/base-block-component'; +import { CoreNavHelper } from '@services/nav-helper'; + +/** + * Component to render blocks with only a title and link. + */ +@Component({ + selector: 'core-block-only-title', + templateUrl: 'core-block-only-title.html', + styleUrls: ['only-title-block.scss'], +}) +export class CoreBlockOnlyTitleComponent extends CoreBlockBaseComponent implements OnInit { + + constructor() { + super('CoreBlockOnlyTitleComponent'); + } + + /** + * Component being initialized. + */ + async ngOnInit(): Promise { + await super.ngOnInit(); + + this.fetchContentDefaultError = 'Error getting ' + this.block.contents?.title + ' data.'; + } + + /** + * Go to the block page. + */ + gotoBlock(): void { + CoreNavHelper.instance.goInSite(this.link!, this.linkParams!, undefined, true); + } + +} diff --git a/src/core/features/block/components/pre-rendered-block/core-block-pre-rendered.html b/src/core/features/block/components/pre-rendered-block/core-block-pre-rendered.html new file mode 100644 index 000000000..c0c1ed41e --- /dev/null +++ b/src/core/features/block/components/pre-rendered-block/core-block-pre-rendered.html @@ -0,0 +1,25 @@ + + +

+ + +

+
+
+ + + + + + + + + + + + + + diff --git a/src/core/features/block/components/pre-rendered-block/pre-rendered-block.ts b/src/core/features/block/components/pre-rendered-block/pre-rendered-block.ts new file mode 100644 index 000000000..ee874bef3 --- /dev/null +++ b/src/core/features/block/components/pre-rendered-block/pre-rendered-block.ts @@ -0,0 +1,44 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { OnInit, Component } from '@angular/core'; +import { CoreBlockBaseComponent } from '../../classes/base-block-component'; + +/** + * Component to render blocks with pre-rendered HTML. + */ +@Component({ + selector: 'core-block-pre-rendered', + templateUrl: 'core-block-pre-rendered.html', +}) +export class CoreBlockPreRenderedComponent extends CoreBlockBaseComponent implements OnInit { + + courseId?: number; + + constructor() { + super('CoreBlockPreRenderedComponent'); + } + + /** + * Component being initialized. + */ + async ngOnInit(): Promise { + await super.ngOnInit(); + + this.courseId = this.contextLevel == 'course' ? this.instanceId : undefined; + + this.fetchContentDefaultError = 'Error getting ' + this.block.contents?.title + ' data.'; + } + +} diff --git a/src/core/features/block/lang.json b/src/core/features/block/lang.json new file mode 100644 index 000000000..9b136b8ee --- /dev/null +++ b/src/core/features/block/lang.json @@ -0,0 +1,3 @@ +{ + "blocks": "Blocks" +} \ No newline at end of file diff --git a/src/core/features/block/services/block-delegate.ts b/src/core/features/block/services/block-delegate.ts new file mode 100644 index 000000000..d46f34b1d --- /dev/null +++ b/src/core/features/block/services/block-delegate.ts @@ -0,0 +1,203 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable, Type } from '@angular/core'; +import { CoreSites } from '@services/sites'; +import { CoreDelegate, CoreDelegateHandler } from '@classes/delegate'; +import { CoreSite } from '@classes/site'; +import { Subject } from 'rxjs'; +import { CoreCourseBlock } from '@features/course/services/course'; +import { Params } from '@angular/router'; +import { makeSingleton } from '@singletons'; + +/** + * Interface that all blocks must implement. + */ +export interface CoreBlockHandler extends CoreDelegateHandler { + /** + * Name of the block the handler supports. E.g. 'activity_modules'. + */ + blockName: string; + + /** + * Returns the data needed to render the block. + * + * @param block The block to render. + * @param contextLevel The context where the block will be used. + * @param instanceId The instance ID associated with the context level. + * @return Data or promise resolved with the data. + */ + getDisplayData?( + block: CoreCourseBlock, + contextLevel: string, + instanceId: number, + ): CoreBlockHandlerData | Promise; +} + +/** + * Data needed to render a block. It's returned by the handler. + */ +export interface CoreBlockHandlerData { + /** + * Title to display for the block. + */ + title: string; + + /** + * Class to add to the displayed block. + */ + class?: string; + + /** + * The component to render the contents of the block. + * It's recommended to return the class of the component, but you can also return an instance of the component. + */ + component: Type; + + /** + * Data to pass to the component. All the properties in this object will be passed to the component as inputs. + */ + componentData?: Record; + + /** + * Link to go when showing only title. + */ + link?: string; + + /** + * Params of the link. + */ + linkParams?: Params; +} + +/** + * Delegate to register block handlers. + */ +@Injectable({ providedIn: 'root' }) +export class CoreBlockDelegateService extends CoreDelegate { + + protected handlerNameProperty = 'blockName'; + + protected featurePrefix = 'CoreBlockDelegate_'; + + blocksUpdateObservable: Subject; + + constructor() { + super('CoreBlockDelegate', true); + + this.blocksUpdateObservable = new Subject(); + } + + /** + * Check if blocks are disabled in a certain site. + * + * @param site Site. If not defined, use current site. + * @return Whether it's disabled. + */ + areBlocksDisabledInSite(site?: CoreSite): boolean { + site = site || CoreSites.instance.getCurrentSite(); + + return !!site && site.isFeatureDisabled('NoDelegate_SiteBlocks'); + } + + /** + * Check if blocks are disabled in a certain site for courses. + * + * @param site Site. If not defined, use current site. + * @return Whether it's disabled. + */ + areBlocksDisabledInCourses(site?: CoreSite): boolean { + site = site || CoreSites.instance.getCurrentSite(); + + return !!site && site.isFeatureDisabled('NoDelegate_CourseBlocks'); + } + + /** + * Check if blocks are disabled in a certain site. + * + * @param siteId Site Id. If not defined, use current site. + * @return Promise resolved with true if disabled, rejected or resolved with false otherwise. + */ + async areBlocksDisabled(siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + return this.areBlocksDisabledInSite(site); + } + + /** + * Get the display data for a certain block. + * + * @param injector Injector. + * @param block The block to render. + * @param contextLevel The context where the block will be used. + * @param instanceId The instance ID associated with the context level. + * @return Promise resolved with the display data. + */ + async getBlockDisplayData( + block: CoreCourseBlock, + contextLevel: string, + instanceId: number, + ): Promise { + return this.executeFunctionOnEnabled( + block.name, + 'getDisplayData', + [block, contextLevel, instanceId], + ); + } + + /** + * Check if any of the blocks in a list is supported. + * + * @param blocks The list of blocks. + * @return Whether any of the blocks is supported. + */ + hasSupportedBlock(blocks: CoreCourseBlock[]): boolean { + blocks = blocks || []; + + return !!blocks.find((block) => this.isBlockSupported(block.name)); + } + + /** + * Check if a block is supported. + * + * @param name Block "name". E.g. 'activity_modules'. + * @return Whether it's supported. + */ + isBlockSupported(name: string): boolean { + return this.hasHandler(name, true); + } + + /** + * Check if feature is enabled or disabled in the site, depending on the feature prefix and the handler name. + * + * @param handler Handler to check. + * @param site Site to check. + * @return Whether is enabled or disabled in site. + */ + protected isFeatureDisabled(handler: CoreBlockHandler, site: CoreSite): boolean { + return this.areBlocksDisabledInSite(site) || super.isFeatureDisabled(handler, site); + } + + /** + * Called when there are new block handlers available. Informs anyone who subscribed to the + * observable. + */ + updateData(): void { + this.blocksUpdateObservable.next(); + } + +} + +export class CoreBlockDelegate extends makeSingleton(CoreBlockDelegateService) {} + diff --git a/src/core/features/block/services/block-helper.ts b/src/core/features/block/services/block-helper.ts new file mode 100644 index 000000000..80c5261a6 --- /dev/null +++ b/src/core/features/block/services/block-helper.ts @@ -0,0 +1,60 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable } from '@angular/core'; +import { CoreCourse, CoreCourseBlock } from '@features/course/services/course'; +import { CoreBlockDelegate } from './block-delegate'; +import { makeSingleton } from '@singletons'; + +/** + * Service that provides helper functions for blocks. + */ +@Injectable({ providedIn: 'root' }) +export class CoreBlockHelperProvider { + + /** + * Return if it get course blocks options is enabled for the current site. + * + * @return true if enabled, false otherwise. + */ + canGetCourseBlocks(): boolean { + return CoreCourse.instance.canGetCourseBlocks() && !CoreBlockDelegate.instance.areBlocksDisabledInCourses(); + } + + /** + * Returns the list of blocks for the selected course. + * + * @param courseId Course ID. + * @return List of supported blocks. + */ + async getCourseBlocks(courseId: number): Promise { + const canGetBlocks = this.canGetCourseBlocks(); + + if (!canGetBlocks) { + return []; + } + + const blocks = await CoreCourse.instance.getCourseBlocks(courseId); + const hasSupportedBlock = CoreBlockDelegate.instance.hasSupportedBlock(blocks); + if (!hasSupportedBlock) { + return []; + } + + return blocks; + } + +} + +export class CoreBlockHelper extends makeSingleton(CoreBlockHelperProvider) {} + diff --git a/src/core/features/block/services/handlers/default-block.ts b/src/core/features/block/services/handlers/default-block.ts new file mode 100644 index 000000000..f4cb57d9d --- /dev/null +++ b/src/core/features/block/services/handlers/default-block.ts @@ -0,0 +1,27 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable } from '@angular/core'; +import { CoreBlockBaseHandler } from '../../classes/base-block-handler'; + +/** + * Default handler used when a block type doesn't have a specific implementation. + */ +@Injectable() +export class CoreBlockDefaultHandler extends CoreBlockBaseHandler { + + name = 'CoreBlockDefault'; + blockName = 'default'; + +} diff --git a/src/core/features/contentlinks/classes/module-list-handler.ts b/src/core/features/contentlinks/classes/module-list-handler.ts index e6e2d663e..e0359ae50 100644 --- a/src/core/features/contentlinks/classes/module-list-handler.ts +++ b/src/core/features/contentlinks/classes/module-list-handler.ts @@ -31,8 +31,6 @@ export class CoreContentLinksModuleListHandler extends CoreContentLinksHandlerBa /** * Construct the handler. * - * @param linkHelper The CoreContentLinksHelperProvider instance. - * @param translate The TranslateService instance. * @param addon Name of the addon as it's registered in course delegate. It'll be used to check if it's disabled. * @param modName Name of the module (assign, book, ...). */ diff --git a/src/core/features/course/services/course.ts b/src/core/features/course/services/course.ts index 02bdef195..537f2d6aa 100644 --- a/src/core/features/course/services/course.ts +++ b/src/core/features/course/services/course.ts @@ -1301,6 +1301,11 @@ export type CoreCourseBlock = { value: string; // JSON encoded representation of the config value. type: string; // Type (instance or plugin). }[]; + configsRecord?: Record; }; /** diff --git a/src/core/features/sitehome/pages/index/index.html b/src/core/features/sitehome/pages/index/index.html index e816db78e..f89fa3a0b 100644 --- a/src/core/features/sitehome/pages/index/index.html +++ b/src/core/features/sitehome/pages/index/index.html @@ -16,7 +16,7 @@ (ionRefresh)="doRefresh($event)"> - + @@ -56,7 +56,7 @@ - + diff --git a/src/core/features/sitehome/pages/index/index.module.ts b/src/core/features/sitehome/pages/index/index.module.ts index e1871fec1..b0a5457d4 100644 --- a/src/core/features/sitehome/pages/index/index.module.ts +++ b/src/core/features/sitehome/pages/index/index.module.ts @@ -20,6 +20,7 @@ import { TranslateModule } from '@ngx-translate/core'; import { CoreDirectivesModule } from '@directives/directives.module'; import { CoreComponentsModule } from '@components/components.module'; +import { CoreBlockComponentsModule } from '@/core/features/block/components/components.module'; import { CoreSiteHomeIndexPage } from '.'; @@ -38,6 +39,7 @@ const routes: Routes = [ TranslateModule.forChild(), CoreDirectivesModule, CoreComponentsModule, + CoreBlockComponentsModule, ], declarations: [ CoreSiteHomeIndexPage, diff --git a/src/theme/app.scss b/src/theme/app.scss index 106fcab25..07a599401 100644 --- a/src/theme/app.scss +++ b/src/theme/app.scss @@ -40,11 +40,6 @@ ion-icon { } } -[dir=rtl] ion-icon.icon-flip-rtl { - -webkit-transform: scale(-1, 1); - transform: scale(-1, 1); -} - // Ionic alert. ion-alert.core-alert-network-error .alert-head { position: relative; @@ -77,7 +72,11 @@ ion-alert.core-nohead { // Ionic item divider. ion-item-divider { --background: var(--gray-lighter); - border: 0; + .item-detail-icon { + font-size: 20px; + opacity: 0.25; + padding-inline-end: 16px; + } } // Ionic list. diff --git a/src/theme/variables.scss b/src/theme/variables.scss index 2c7b87910..25dd057bc 100644 --- a/src/theme/variables.scss +++ b/src/theme/variables.scss @@ -151,6 +151,11 @@ --background: var(--custom-progress-background, var(--gray-lighter)); } + core-block-course-blocks { + --side-blocks-max-width: var(--custom-side-blocks-max-width, 30%); + --side-blocks-min-width: var(--custom-side-blocks-min-width, 280px); + } + --selected-item-color: var(--custom-selected-item-color, var(--core-color)); --selected-item-border-width: var(--custom-selected-item-border-width, 5px);