Merge pull request #3724 from NoelDeMartin/MOBILE-3371

MOBILE-3371: Global Search
main
Dani Palou 2023-09-14 17:28:19 +02:00 committed by GitHub
commit cbfc866af4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
83 changed files with 3917 additions and 272 deletions

View File

@ -1,5 +1,11 @@
module.exports = {
framework: '@storybook/angular',
addons: ['@storybook/addon-controls'],
addons: [
'@storybook/addon-controls',
'@storybook/addon-viewport',
'storybook-addon-designs',
'storybook-addon-rtl-direction',
'storybook-dark-mode',
],
stories: ['../src/**/*.stories.ts'],
}

View File

@ -3,4 +3,9 @@ import '!style-loader!css-loader!sass-loader!./styles.scss';
export const parameters = {
layout: 'centered',
darkMode: {
darkClass: 'dark',
classTarget: 'html',
stylePreview: true,
},
};

View File

@ -1,3 +1,7 @@
storybook-dynamic-app-root {
color: var(--ion-text-color);
}
.core-error-info {
max-width: 300px;
}

View File

@ -7,7 +7,7 @@
"",
"@Component({",
" selector: '$2${TM_FILENAME_BASE}',",
" templateUrl: '$2${TM_FILENAME_BASE}.html',",
" templateUrl: '${TM_FILENAME_BASE}.html',",
"})",
"export class ${1:${TM_FILENAME_BASE}}Component {",
"",
@ -110,6 +110,24 @@
],
"description": "[Moodle] Create a Pure Singleton"
},
"[Moodle] Events": {
"prefix": "maeventsdeclaration",
"body": [
"declare module '@singletons/events' {",
"",
" /**",
" * Augment CoreEventsData interface with events specific to this service.",
" *",
" * @see https://www.typescriptlang.org/docs/handbook/declaration-merging.html#module-augmentation",
" */",
" export interface CoreEventsData {",
" [$1]: $2;",
" }",
"",
"}"
],
"description": ""
},
"Innherit doc": {
"prefix": "inheritdoc",
"body": [

728
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -149,9 +149,11 @@
"@ionic/angular-toolkit": "^2.3.3",
"@ionic/cli": "^6.19.0",
"@storybook/addon-controls": "~6.1.21",
"@storybook/addon-viewport": "~6.1.21",
"@storybook/angular": "~6.1.21",
"@types/faker": "^5.1.3",
"@types/jest": "^26.0.24",
"@types/marked": "^4.3.1",
"@types/node": "^12.12.64",
"@types/resize-observer-browser": "^0.1.5",
"@types/webpack-env": "^1.16.0",
@ -183,9 +185,13 @@
"jest-preset-angular": "^8.3.1",
"jest-raw-loader": "^1.0.1",
"jsonc-parser": "^2.3.1",
"marked": "^4.3.0",
"minimatch": "^5.1.0",
"native-run": "^1.4.0",
"patch-package": "^6.5.0",
"storybook-addon-designs": "~6.1.0",
"storybook-addon-rtl-direction": "0.0.19",
"storybook-dark-mode": "^3.0.0",
"terser-webpack-plugin": "^4.2.3",
"ts-jest": "^26.4.1",
"ts-node": "~8.3.0",

View File

@ -36,6 +36,7 @@
"addon.block_calendarupcoming.pluginname": "block_calendar_upcoming",
"addon.block_comments.pluginname": "block_comments",
"addon.block_completionstatus.pluginname": "block_completionstatus",
"addon.block_globalsearch.pluginname": "block_globalsearch",
"addon.block_glossaryrandom.pluginname": "block_glossary_random",
"addon.block_learningplans.pluginname": "block_lp",
"addon.block_myoverview.all": "block_myoverview",
@ -2310,6 +2311,16 @@
"core.scanqr": "local_moodlemobileapp",
"core.scrollbackward": "local_moodlemobileapp",
"core.scrollforward": "local_moodlemobileapp",
"core.search.allcourses": "search",
"core.search.allcategories": "local_moodlemobileapp",
"core.search.empty": "local_moodlemobileapp",
"core.search.filtercategories": "local_moodlemobileapp",
"core.search.filtercourses": "local_moodlemobileapp",
"core.search.filterheader": "search",
"core.search.globalsearch": "search",
"core.search.noresults": "local_moodlemobileapp",
"core.search.noresultshelp": "local_moodlemobileapp",
"core.search.resultby": "local_moodlemobileapp",
"core.search": "moodle",
"core.searching": "local_moodlemobileapp",
"core.searchresults": "moodle",

View File

@ -41,6 +41,7 @@ import { AddonBlockSiteMainMenuModule } from './sitemainmenu/sitemainmenu.module
import { AddonBlockStarredCoursesModule } from './starredcourses/starredcourses.module';
import { AddonBlockTagsModule } from './tags/tags.module';
import { AddonBlockTimelineModule } from './timeline/timeline.module';
import { AddonBlockGlobalSearchModule } from '@addons/block/globalsearch/globalsearch.module';
@NgModule({
imports: [
@ -55,6 +56,7 @@ import { AddonBlockTimelineModule } from './timeline/timeline.module';
AddonBlockCommentsModule,
AddonBlockCompletionStatusModule,
AddonBlockCourseListModule,
AddonBlockGlobalSearchModule,
AddonBlockGlossaryRandomModule,
AddonBlockHtmlModule,
AddonBlockLearningPlansModule,

View File

@ -0,0 +1,38 @@
// (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 { APP_INITIALIZER, NgModule } from '@angular/core';
import { IonicModule } from '@ionic/angular';
import { TranslateModule } from '@ngx-translate/core';
import { CoreBlockDelegate } from '@features/block/services/block-delegate';
import { AddonBlockGlobalSearchHandler } from './services/block-handler';
import { CoreBlockComponentsModule } from '@features/block/components/components.module';
@NgModule({
imports: [
IonicModule,
CoreBlockComponentsModule,
TranslateModule.forChild(),
],
providers: [
{
provide: APP_INITIALIZER,
multi: true,
useValue: () => {
CoreBlockDelegate.registerHandler(AddonBlockGlobalSearchHandler.instance);
},
},
],
})
export class AddonBlockGlobalSearchModule {}

View File

@ -0,0 +1,3 @@
{
"pluginname": "Global search"
}

View File

@ -0,0 +1,57 @@
// (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 { CoreBlockHandlerData } from '@features/block/services/block-delegate';
import { CoreBlockOnlyTitleComponent } from '@features/block/components/only-title-block/only-title-block';
import { CoreBlockBaseHandler } from '@features/block/classes/base-block-handler';
import { makeSingleton } from '@singletons';
import { CoreCourseBlock } from '@features/course/services/course';
import { CORE_SEARCH_PAGE_NAME } from '@features/search/services/handlers/mainmenu';
import { CoreSearchGlobalSearch } from '@features/search/services/global-search';
/**
* Block handler.
*/
@Injectable({ providedIn: 'root' })
export class AddonBlockGlobalSearchHandlerService extends CoreBlockBaseHandler {
name = 'AddonBlockGlobalSearch';
blockName = 'globalsearch';
/**
* @inheritdoc
*/
async isEnabled(): Promise<boolean> {
return CoreSearchGlobalSearch.isEnabled();
}
/**
* @inheritdoc
*/
getDisplayData(block: CoreCourseBlock, contextLevel: string, instanceId: number): CoreBlockHandlerData | undefined {
return {
title: 'addon.block_globalsearch.pluginname',
class: 'addon-block-globalsearch',
component: CoreBlockOnlyTitleComponent,
link: CORE_SEARCH_PAGE_NAME,
linkParams: contextLevel === 'course'
? { courseId: instanceId }
: {},
};
}
}
export const AddonBlockGlobalSearchHandler = makeSingleton(AddonBlockGlobalSearchHandlerService);

View File

@ -21,7 +21,6 @@ import {
} from '../../services/recentlyaccesseditems';
import { CoreTextUtils } from '@services/utils/text';
import { CoreDomUtils } from '@services/utils/dom';
import { CoreContentLinksHelper } from '@features/contentlinks/services/contentlinks-helper';
import { CoreUtils } from '@services/utils/utils';
/**
@ -88,10 +87,7 @@ export class AddonBlockRecentlyAccessedItemsComponent extends CoreBlockBaseCompo
const modal = await CoreDomUtils.showModalLoading();
try {
const treated = await CoreContentLinksHelper.handleLink(url);
if (!treated) {
return CoreSites.getCurrentSite()?.openInBrowserWithAutoLogin(url);
}
await CoreSites.visitLink(url);
} finally {
modal.dismiss();
}

View File

@ -16,7 +16,6 @@ import { Component, Input, Output, EventEmitter } from '@angular/core';
import { CoreSites } from '@services/sites';
import { CoreDomUtils } from '@services/utils/dom';
import { CoreTextUtils } from '@services/utils/text';
import { CoreContentLinksHelper } from '@features/contentlinks/services/contentlinks-helper';
import { CoreEnrolledCourseDataWithOptions } from '@features/courses/services/courses-helper';
import { AddonBlockTimelineDayEvents } from '@addons/block/timeline/classes/section';
@ -54,10 +53,7 @@ export class AddonBlockTimelineEventsComponent {
const modal = await CoreDomUtils.showModalLoading();
try {
const treated = await CoreContentLinksHelper.handleLink(url);
if (!treated) {
return CoreSites.getRequiredCurrentSite().openInBrowserWithAutoLogin(url);
}
await CoreSites.visitLink(url);
} finally {
modal.dismiss();
}

View File

@ -14,7 +14,6 @@
import { Component, OnDestroy, OnInit, ViewChild } from '@angular/core';
import { CoreSite } from '@classes/site';
import { CoreContentLinksHelper } from '@features/contentlinks/services/contentlinks-helper';
import { CoreCourse, CoreCourseCommonModWSOptions } from '@features/course/services/course';
import { CoreCourseModuleData } from '@features/course/services/course-helper';
import { CanLeave } from '@guards/can-leave';
@ -428,11 +427,7 @@ export class AddonModFeedbackFormPage implements OnInit, OnDestroy, CanLeave {
const modal = await CoreDomUtils.showModalLoading();
try {
const treated = await CoreContentLinksHelper.handleLink(this.siteAfterSubmit);
if (!treated) {
await this.currentSite.openInBrowserWithAutoLogin(this.siteAfterSubmit);
}
await CoreSites.visitLink(this.siteAfterSubmit, { siteId: this.currentSite.id });
} finally {
modal.dismiss();
}

View File

@ -19,7 +19,6 @@ import { CoreEvents } from '@singletons/events';
import { CoreSites, CoreSitesReadingStrategy } from '@services/sites';
import { CoreCourseModuleHandler, CoreCourseModuleHandlerData } from '@features/course/services/module-delegate';
import { CoreConstants, ModPurpose } from '@/core/constants';
import { AddonModForumIndexComponent } from '../../components/index';
import { CoreModuleHandlerBase } from '@features/course/classes/module-base-handler';
import { CoreCourseModuleData } from '@features/course/services/course-helper';
import { CoreIonicColorNames } from '@singletons/colors';
@ -86,6 +85,8 @@ export class AddonModForumModuleHandlerService extends CoreModuleHandlerBase imp
* @inheritdoc
*/
async getMainComponent(): Promise<Type<unknown> | undefined> {
const { AddonModForumIndexComponent } = await import('../../components/index');
return AddonModForumIndexComponent;
}

View File

@ -13,7 +13,6 @@
// limitations under the License.
import { Injectable } from '@angular/core';
import { CoreContentLinksHelper } from '@features/contentlinks/services/contentlinks-helper';
import { CoreSites } from '@services/sites';
import { CoreDomUtils } from '@services/utils/dom';
import { makeSingleton } from '@singletons';
@ -33,11 +32,10 @@ export class AddonModUrlHelperProvider {
const modal = await CoreDomUtils.showModalLoading();
try {
const treated = await CoreContentLinksHelper.handleLink(url, undefined, true, true);
if (!treated) {
await CoreSites.getCurrentSite()?.openInBrowserWithAutoLogin(url);
}
await CoreSites.visitLink(url, {
checkRoot: true,
openBrowserRoot: true,
});
} finally {
modal.dismiss();
}

View File

@ -16,7 +16,6 @@ import { Injectable } from '@angular/core';
import { CoreContentLinksHandlerBase } from '@features/contentlinks/classes/base-handler';
import { CoreContentLinksAction } from '@features/contentlinks/services/contentlinks-delegate';
import { CoreContentLinksHelper } from '@features/contentlinks/services/contentlinks-helper';
import { CoreSites } from '@services/sites';
import { CoreDomUtils } from '@services/utils/dom';
import { makeSingleton, Translate } from '@singletons';
@ -75,13 +74,7 @@ export class AddonReportInsightsActionLinkHandlerService extends CoreContentLink
// Try to open the link in the app.
const forwardUrl = decodeURIComponent(params.forwardurl);
const treated = await CoreContentLinksHelper.handleLink(forwardUrl);
if (!treated) {
// Cannot be opened in the app, open in browser.
const site = await CoreSites.getSite(siteId);
await site.openInBrowserWithAutoLogin(forwardUrl);
}
await CoreSites.visitLink(forwardUrl, { siteId });
}
},
}];

View File

@ -0,0 +1 @@
[{"id":1,"courseimage":"https://picsum.photos/500/500","shortname":"Moodle and Mountaineering","summary":"This course will introduce you to the basics of Alpine Mountaineering, while at the same time highlighting some of the great features of Moodle."},{"id":2,"courseimage":"assets/storybook/geopattern.svg","shortname":"Digital Literacy","summary":"This course explores Digital Literacy and its importance for teachers and students. The course is optimised for the Moodle App. Please try it out!"},{"id":3,"shortname":"Class and Conflict in World Cinema","summary":"In this module we will analyse two very significant films - City of God and La Haine, both of which depict violent lives in poor conditions, the former in the favelas of Brazil and the latter in a Parisian banlieue. We will look at how conflict and class are portrayed, focusing particularly on the use of mise en scène."}]

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 14 KiB

View File

@ -0,0 +1 @@
{"id":"123456","info":{"version":"2022041900","sitename":"School","username":"barbara","firstname":"Barbara","lastname":"Gardner","fullname":"Barbara Gardner","lang":"en","userid":1,"siteurl":"https://campus.example.edu","userpictureurl":"","functions":[]}}

View File

@ -36,6 +36,15 @@ export abstract class CoreItemsManagerSource<Item = unknown> {
this.loadedPromise = new Promise(resolve => this.resolveLoaded = resolve);
}
/**
* Check whether the source is dirty.
*
* @returns Whether the source is dirty.
*/
isDirty(): boolean {
return this.dirty;
}
/**
* Check whether data is loaded.
*
@ -88,6 +97,7 @@ export abstract class CoreItemsManagerSource<Item = unknown> {
reset(): void {
this.items = null;
this.dirty = false;
this.loaded = false;
this.listeners.forEach(listener => listener.onReset?.call(listener));
}

View File

@ -0,0 +1,129 @@
// (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 { CoreItemsManagerSource } from './items-manager-source';
/**
* Paginated items collection source data.
*/
export abstract class CorePaginatedItemsManagerSource<Item = unknown> extends CoreItemsManagerSource<Item> {
protected hasMoreItems = true;
/**
* Check whether there are more pages to be loaded.
*
* @returns Whether there are more pages to be loaded.
*/
isCompleted(): boolean {
return !this.hasMoreItems;
}
/**
* Check whether the source is empty or not.
*
* @returns Whether the source is empty.
*/
isEmpty(): boolean {
return !this.isLoaded() || (this.getItems() ?? []).length === 0;
}
/**
* Get the count of pages that have been loaded.
*
* @returns Pages loaded.
*/
getPagesLoaded(): number {
if (this.items === null) {
return 0;
}
const pageLength = this.getPageLength();
if (pageLength === null) {
return 1;
}
return Math.ceil(this.items.length / pageLength);
}
/**
* Reset collection data.
*/
reset(): void {
this.hasMoreItems = true;
super.reset();
}
/**
* Reload the collection, this resets the data to the first page.
*/
async reload(): Promise<void> {
this.dirty = true;
await this.load();
}
/**
* Load more items, if any.
*/
async load(): Promise<void> {
if (this.dirty) {
const { items, hasMoreItems } = await this.loadPageItems(0);
this.dirty = false;
this.setItems(items, hasMoreItems ?? false);
return;
}
if (!this.hasMoreItems) {
return;
}
const { items, hasMoreItems } = await this.loadPageItems(this.getPagesLoaded());
this.setItems((this.items ?? []).concat(items), hasMoreItems ?? false);
}
/**
* Load page items.
*
* @param page Page number (starting at 0).
* @returns Page items data.
*/
protected abstract loadPageItems(page: number): Promise<{ items: Item[]; hasMoreItems?: boolean }>;
/**
* Get the length of each page in the collection.
*
* @returns Page length; null for collections that don't support pagination.
*/
protected getPageLength(): number | null {
return null;
}
/**
* Update the collection items.
*
* @param items Items.
* @param hasMoreItems Whether there are more pages to be loaded.
*/
protected setItems(items: Item[], hasMoreItems = false): void {
this.hasMoreItems = hasMoreItems;
super.setItems(items);
}
}

View File

@ -13,14 +13,12 @@
// limitations under the License.
import { Params } from '@angular/router';
import { CoreItemsManagerSource } from './items-manager-source';
import { CorePaginatedItemsManagerSource } from './paginated-items-manager-source';
/**
* Routed items collection source data.
*/
export abstract class CoreRoutedItemsManagerSource<Item = unknown> extends CoreItemsManagerSource<Item> {
protected hasMoreItems = true;
export abstract class CoreRoutedItemsManagerSource<Item = unknown> extends CorePaginatedItemsManagerSource<Item> {
/**
* Get a string to identify instances constructed with the given arguments as being reusable.
@ -32,102 +30,6 @@ export abstract class CoreRoutedItemsManagerSource<Item = unknown> extends CoreI
return args.map(argument => String(argument)).join('-');
}
/**
* Check whether there are more pages to be loaded.
*
* @returns Whether there are more pages to be loaded.
*/
isCompleted(): boolean {
return !this.hasMoreItems;
}
/**
* Get the count of pages that have been loaded.
*
* @returns Pages loaded.
*/
getPagesLoaded(): number {
if (this.items === null) {
return 0;
}
const pageLength = this.getPageLength();
if (pageLength === null) {
return 1;
}
return Math.ceil(this.items.length / pageLength);
}
/**
* Reset collection data.
*/
reset(): void {
this.hasMoreItems = true;
super.reset();
}
/**
* Reload the collection, this resets the data to the first page.
*/
async reload(): Promise<void> {
this.dirty = true;
await this.load();
}
/**
* Load more items, if any.
*/
async load(): Promise<void> {
if (this.dirty) {
const { items, hasMoreItems } = await this.loadPageItems(0);
this.dirty = false;
this.setItems(items, hasMoreItems ?? false);
return;
}
if (!this.hasMoreItems) {
return;
}
const { items, hasMoreItems } = await this.loadPageItems(this.getPagesLoaded());
this.setItems((this.items ?? []).concat(items), hasMoreItems ?? false);
}
/**
* Load page items.
*
* @param page Page number (starting at 0).
* @returns Page items data.
*/
protected abstract loadPageItems(page: number): Promise<{ items: Item[]; hasMoreItems?: boolean }>;
/**
* Get the length of each page in the collection.
*
* @returns Page length; null for collections that don't support pagination.
*/
protected getPageLength(): number | null {
return null;
}
/**
* Update the collection items.
*
* @param items Items.
* @param hasMoreItems Whether there are more pages to be loaded.
*/
protected setItems(items: Item[], hasMoreItems = false): void {
this.hasMoreItems = hasMoreItems;
super.setItems(items);
}
/**
* Get the query parameters to use when navigating to an item page.
*

View File

@ -2747,6 +2747,8 @@ export const enum CoreSiteConfigSupportAvailability {
*/
export type CoreSiteConfig = Record<string, string> & {
supportavailability?: string; // String representation of CoreSiteConfigSupportAvailability.
searchbanner?: string; // Search banner text.
searchbannerenable?: string; // Whether search banner is enabled.
};
/**

View File

@ -64,6 +64,7 @@ import { CoreMessageComponent } from './message/message';
import { CoreGroupSelectorComponent } from './group-selector/group-selector';
import { CoreRefreshButtonModalComponent } from './refresh-button-modal/refresh-button-modal';
import { CoreSheetModalComponent } from '@components/sheet-modal/sheet-modal';
import { CoreCourseImageComponent } from '@components/course-image/course-image';
@NgModule({
declarations: [
@ -75,6 +76,7 @@ import { CoreSheetModalComponent } from '@components/sheet-modal/sheet-modal';
CoreContextMenuComponent,
CoreContextMenuItemComponent,
CoreContextMenuPopoverComponent,
CoreCourseImageComponent,
CoreDownloadRefreshComponent,
CoreDynamicComponent,
CoreEmptyBoxComponent,
@ -128,6 +130,7 @@ import { CoreSheetModalComponent } from '@components/sheet-modal/sheet-modal';
CoreContextMenuComponent,
CoreContextMenuItemComponent,
CoreContextMenuPopoverComponent,
CoreCourseImageComponent,
CoreDownloadRefreshComponent,
CoreDynamicComponent,
CoreEmptyBoxComponent,

View File

@ -0,0 +1,5 @@
<ion-icon *ngIf="!course.courseimage" name="fas-graduation-cap" slot="start" aria-hidden="true">
</ion-icon>
<ion-avatar *ngIf="course.courseimage" slot="start">
<img [src]="course.courseimage" core-external-content alt="" (error)="loadFallbackCourseIcon()" />
</ion-avatar>

View File

@ -0,0 +1,65 @@
@import "~theme/globals";
:host {
--core-image-radius: var(--core-courseimage-radius);
--core-image-size: 60px;
display: flex;
justify-content: center;
align-items: center;
background: var(--course-color, white);
border-radius: var(--core-image-radius);
@for $i from 0 to length($core-course-image-background) {
&.course-color-#{$i} {
--course-color: var(--core-course-color-#{$i});
--course-color-tint: var(--core-course-color-#{$i}-tint);
}
}
ion-icon {
--padding: 12px;
padding: var(--padding);
font-size: calc(var(--core-image-size) - var(--padding) * 2);
color: var(--course-color-tint);
}
ion-avatar {
--border-radius: var(--core-image-radius);
width: var(--core-image-size);
height: var(--core-image-size);
img {
background: transparent;
}
}
img[src$=".svg"] {
min-width: 100%;
}
&.fill-container {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
--core-image-radius: 0px;
--core-image-size: 100%;
ion-icon {
opacity: 0.5;
width: 80px;
height: 80px;
}
}
}
:host-context(ion-item) {
@include margin(6px, 8px, 6px, 0px);
}

View File

@ -0,0 +1,81 @@
// (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, ElementRef, OnInit, OnChanges, HostBinding } from '@angular/core';
import { CoreCourseListItem } from '@features/courses/services/courses';
import { CoreCoursesHelper } from '@features/courses/services/courses-helper';
import { CoreColors } from '@singletons/colors';
@Component({
selector: 'core-course-image',
templateUrl: 'course-image.html',
styleUrls: ['./course-image.scss'],
})
export class CoreCourseImageComponent implements OnInit, OnChanges {
@Input() course!: CoreCourseListItem;
@Input() fill = false;
protected element: HTMLElement;
constructor(element: ElementRef) {
this.element = element.nativeElement;
}
@HostBinding('class.fill-container')
get fillContainer(): boolean {
return this.fill;
}
/**
* @inheritdoc
*/
ngOnInit(): void {
this.setCourseColor();
}
/**
* @inheritdoc
*/
ngOnChanges(): void {
this.setCourseColor();
}
/**
* Removes the course image set because it cannot be loaded and set the fallback icon color.
*/
loadFallbackCourseIcon(): void {
this.course.courseimage = undefined;
// Set the color because it won't be set at this point.
this.setCourseColor();
}
/**
* Set course color.
*/
protected async setCourseColor(): Promise<void> {
await CoreCoursesHelper.loadCourseColorAndImage(this.course);
if (this.course.color) {
this.element.style.setProperty('--course-color', this.course.color);
const tint = CoreColors.lighter(this.course.color, 50);
this.element.style.setProperty('--course-color-tint', tint);
} else if(this.course.colorNumber !== undefined) {
this.element.classList.add('course-color-' + this.course.colorNumber);
}
}
}

View File

@ -1,22 +1,23 @@
@import "~theme/globals";
:host {
--image-size: 120px;
--icon-color: var(--text-color);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
flex-grow: 1;
color: var(--text-color);
margin: 0 auto;
text-align: center;
padding: 16px;
--image-size: 120px;
height: 100%;
ion-icon {
font-size: var(--image-size);
color: var(--icon-color);
}
img {
height: var(--image-size);
@ -28,6 +29,20 @@
&.core-empty-box-clickable {
z-index: 0;
}
&.dimmed {
--icon-color: var(--gray-400);
--text-color: var(--gray-700);
}
}
:host-context(html.dark) {
&.dimmed {
--text-color: var(--gray-300);
}
}
@include media-breakpoint-down(sm) {

View File

@ -12,7 +12,7 @@
// See the License for the specific language governing permissions and
// limitations under the License.
import { Component, Input } from '@angular/core';
import { Component, HostBinding, 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.
@ -30,6 +30,7 @@ import { Component, Input } from '@angular/core';
export class CoreEmptyBoxComponent {
@Input() message = ''; // Message to display.
@Input() dimmed = false; // Wether the box is dimmed or not.
@Input() icon?: string; // Name of the icon to use.
@Input() image?: string; // Image source. If an icon is provided, image won't be used.
@Input() flipIconRtl = false; // Whether to flip the icon in RTL. Defaults to false.
@ -39,4 +40,9 @@ export class CoreEmptyBoxComponent {
*/
@Input() inline = false;
@HostBinding('class.dimmed')
get isDimmed(): boolean {
return this.dimmed;
}
}

View File

@ -0,0 +1,39 @@
// (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 { CoreEmptyBoxPageComponent } from './empty-box-page/empty-box-page';
import { CoreEmptyBoxWrapperComponent } from './empty-box-wrapper/empty-box-wrapper';
import { StorybookModule } from '@/storybook/storybook.module';
import { CoreSearchComponentsModule } from '@features/search/components/components.module';
import { CoreComponentsModule } from '@components/components.module';
import { CommonModule } from '@angular/common';
import { CoreCourseImageCardsPageComponent } from '@components/stories/components/course-image-cards-page/course-image-cards-page';
import { CoreCourseImageListPageComponent } from '@components/stories/components/course-image-list-page/course-image-list-page';
@NgModule({
declarations: [
CoreCourseImageCardsPageComponent,
CoreCourseImageListPageComponent,
CoreEmptyBoxPageComponent,
CoreEmptyBoxWrapperComponent,
],
imports: [
CommonModule,
StorybookModule,
CoreComponentsModule,
CoreSearchComponentsModule,
],
})
export class CoreComponentsStorybookModule {}

View File

@ -0,0 +1,22 @@
<ion-app>
<ion-header>
<ion-toolbar>
<ion-title>
<h1>Course Cards</h1>
</ion-title>
</ion-toolbar>
</ion-header>
<ion-content>
<ion-card *ngFor="let course of courses">
<div class="course-image-wrapper">
<core-course-image [course]="course" [fill]="true"></core-course-image>
</div>
<ion-card-header>
<ion-card-title>{{ course.shortname }}</ion-card-title>
</ion-card-header>
<ion-card-content>
{{ course.summary }}
</ion-card-content>
</ion-card>
</ion-content>
</ion-app>

View File

@ -0,0 +1,16 @@
:host {
ion-card {
max-width: 350px;
margin-right: auto;
margin-left: auto;
}
.course-image-wrapper {
width: 100%;
height: 0;
padding-top: 40%;
position: relative;
}
}

View File

@ -0,0 +1,28 @@
// (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 } from '@angular/core';
import { CoreCourseListItem } from '@features/courses/services/courses';
import courses from '@/assets/storybook/courses.json';
@Component({
selector: 'core-course-image-cards-page',
templateUrl: 'course-image-cards-page.html',
styleUrls: ['./course-image-cards-page.scss'],
})
export class CoreCourseImageCardsPageComponent {
courses: Partial<CoreCourseListItem>[] = courses;
}

View File

@ -0,0 +1,19 @@
<ion-app>
<ion-header>
<ion-toolbar>
<ion-title>
<h1>Courses List</h1>
</ion-title>
</ion-toolbar>
</ion-header>
<ion-content>
<ion-list>
<ion-item class="ion-text-wrap" *ngFor="let course of courses">
<core-course-image [course]="course" slot="start"></core-course-image>
<ion-label>
{{ course.shortname }}
</ion-label>
</ion-item>
</ion-list>
</ion-content>
</ion-app>

View File

@ -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 { Component } from '@angular/core';
import { CoreCourseListItem } from '@features/courses/services/courses';
import courses from '@/assets/storybook/courses.json';
@Component({
selector: 'core-course-image-list-page',
templateUrl: 'course-image-list-page.html',
})
export class CoreCourseImageListPageComponent {
courses: Partial<CoreCourseListItem>[] = courses;
}

View File

@ -0,0 +1,16 @@
<ion-app>
<ion-header>
<ion-toolbar>
<ion-title>
<h1>Search</h1>
</ion-title>
</ion-toolbar>
</ion-header>
<ion-content>
<div class="core-flex-fill">
<core-search-box></core-search-box>
<core-empty-box-wrapper [icon]="icon" [content]="content" [dimmed]="dimmed" class="core-flex-fill">
</core-empty-box-wrapper>
</div>
</ion-content>
</ion-app>

View File

@ -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 { Component, Input } from '@angular/core';
@Component({
selector: 'core-empty-box-page',
templateUrl: 'empty-box-page.html',
})
export class CoreEmptyBoxPageComponent {
@Input() icon!: string;
@Input() content!: string;
@Input() dimmed!: boolean;
}

View File

@ -0,0 +1,3 @@
<core-empty-box [icon]="icon" [dimmed]="dimmed">
<div [innerHTML]="html"></div>
</core-empty-box>

View File

@ -0,0 +1,38 @@
// (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, OnChanges } from '@angular/core';
import { DomSanitizer } from '@singletons';
import { SafeHtml } from '@angular/platform-browser';
@Component({
selector: 'core-empty-box-wrapper',
templateUrl: 'empty-box-wrapper.html',
})
export class CoreEmptyBoxWrapperComponent implements OnChanges {
@Input() icon!: string;
@Input() content!: string;
@Input() dimmed!: boolean;
html?: SafeHtml;
/**
* @inheritdoc
*/
ngOnChanges(): void {
this.html = DomSanitizer.bypassSecurityTrustHtml(this.content);
}
}

View File

@ -0,0 +1,104 @@
// (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 { Meta, moduleMetadata } from '@storybook/angular';
import { story } from '@/storybook/utils/helpers';
import { CoreCourseImageComponent } from '@components/course-image/course-image';
import { APP_INITIALIZER } from '@angular/core';
import { CoreSitesStub } from '@/storybook/stubs/services/sites';
import { CoreCourseImageListPageComponent } from '@components/stories/components/course-image-list-page/course-image-list-page';
import { CoreComponentsStorybookModule } from '@components/stories/components/components.module';
import { CoreCourseImageCardsPageComponent } from '@components/stories/components/course-image-cards-page/course-image-cards-page';
interface Args {
type: 'image' | 'geopattern' | 'color';
fill: boolean;
}
export default <Meta> {
title: 'Core/Course Image',
component: CoreCourseImageComponent,
decorators: [
moduleMetadata({
imports: [CoreComponentsStorybookModule],
providers: [
{
provide: APP_INITIALIZER,
multi: true,
useValue: () => {
const site = CoreSitesStub.getRequiredCurrentSite();
site.stubWSResponse('tool_mobile_get_config', {
settings: [
{ name: 'core_admin_coursecolor1', value: '#F9B000' },
{ name: 'core_admin_coursecolor2', value: '#EF4B00' },
{ name: 'core_admin_coursecolor3', value: '#4338FB' },
{ name: 'core_admin_coursecolor4', value: '#E142FB' },
{ name: 'core_admin_coursecolor5', value: '#FF0064' },
{ name: 'core_admin_coursecolor6', value: '#FF0F18' },
{ name: 'core_admin_coursecolor7', value: '#039B06' },
{ name: 'core_admin_coursecolor8', value: '#039B88' },
{ name: 'core_admin_coursecolor9', value: '#EF009B' },
{ name: 'core_admin_coursecolor10', value: '#020B6E' },
],
warnings: [],
});
},
},
],
}),
],
argTypes: {
type: {
control: {
type: 'select',
options: ['image', 'geopattern', 'color'],
},
},
},
args: {
type: 'image',
fill: false,
},
};
const Template = story<Args>(({ type, ...args }) => {
const getImageSource = () => {
switch (type) {
case 'image':
return 'https://picsum.photos/500/500';
case 'geopattern':
return 'assets/storybook/geopattern.svg';
case 'color':
return undefined;
}
};
return {
component: CoreCourseImageComponent,
props: {
...args,
course: {
id: 1,
courseimage: getImageSource(),
},
},
};
});
export const Primary = story(Template);
export const ListPage = story(() => ({ component: CoreCourseImageListPageComponent }));
export const CardsPage = story(() => ({ component: CoreCourseImageCardsPageComponent }));

View File

@ -0,0 +1,79 @@
// (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 { Meta, moduleMetadata } from '@storybook/angular';
import { marked } from 'marked';
import { story } from '@/storybook/utils/helpers';
import { CoreEmptyBoxComponent } from '@components/empty-box/empty-box';
import { CoreEmptyBoxWrapperComponent } from './components/empty-box-wrapper/empty-box-wrapper';
import { CoreEmptyBoxPageComponent } from './components/empty-box-page/empty-box-page';
import { CoreComponentsStorybookModule } from './components/components.module';
interface Args {
icon: string;
content: string;
dimmed: boolean;
}
export default <Meta<Args>> {
title: 'Core/Empty Box',
component: CoreEmptyBoxComponent,
decorators: [
moduleMetadata({ imports: [CoreComponentsStorybookModule] }),
],
argTypes: {
icon: {
control: {
type: 'select',
options: ['fas-magnifying-glass', 'fas-user', 'fas-check'],
},
},
},
args: {
icon: 'fas-user',
content: 'No users',
dimmed: false,
},
};
const WrapperTemplate = story<Args>((args) => ({
component: CoreEmptyBoxWrapperComponent,
props: {
...args,
content: marked(args.content),
},
}));
const PageTemplate = story<Args>((args) => ({
component: CoreEmptyBoxPageComponent,
props: {
...args,
content: marked(args.content),
},
}));
export const Primary = story<Args>(WrapperTemplate);
export const Example = story<Args>(PageTemplate, {
icon: 'fas-magnifying-glass',
content: '**No results for "Test Search"**\n\n<small>Check for typos or try using different keywords</small>',
});
export const DimmedExample = story<Args>(PageTemplate, {
icon: 'fas-magnifying-glass',
content: 'What are you searching for?',
dimmed: true,
});

View File

@ -12,7 +12,7 @@
// See the License for the specific language governing permissions and
// limitations under the License.
import { OnInit, Component } from '@angular/core';
import { OnInit, Component, HostBinding } from '@angular/core';
import { CoreBlockBaseComponent } from '../../classes/base-block-component';
/**
@ -26,6 +26,8 @@ export class CoreBlockPreRenderedComponent extends CoreBlockBaseComponent implem
courseId?: number;
@HostBinding('attr.id') id?: string;
constructor() {
super('CoreBlockPreRenderedComponent');
}
@ -39,6 +41,8 @@ export class CoreBlockPreRenderedComponent extends CoreBlockBaseComponent implem
this.courseId = this.contextLevel == 'course' ? this.instanceId : undefined;
this.fetchContentDefaultError = 'Error getting ' + this.block.contents?.title + ' data.';
this.id = `block-${this.block.instanceid}`;
}
}

View File

@ -12,7 +12,7 @@
// See the License for the specific language governing permissions and
// limitations under the License.
import { Component, ViewChildren, Input, OnInit, QueryList } from '@angular/core';
import { Component, ViewChildren, Input, OnInit, QueryList, ElementRef } from '@angular/core';
import { ModalController } from '@singletons';
import { CoreDomUtils } from '@services/utils/dom';
import { CoreCourse, CoreCourseBlock } from '@features/course/services/course';
@ -22,6 +22,7 @@ import { CoreUtils } from '@services/utils/utils';
import { IonRefresher } from '@ionic/angular';
import { CoreCoursesDashboard } from '@features/courses/services/dashboard';
import { CoreTextUtils } from '@services/utils/text';
import { CoreDom } from '@singletons/dom';
/**
* Component that displays the list of side blocks.
@ -35,6 +36,7 @@ export class CoreBlockSideBlocksComponent implements OnInit {
@Input() contextLevel!: string;
@Input() instanceId!: number;
@Input() initialBlockInstanceId?: number;
@Input() myDashboardPage?: string;
@ViewChildren(CoreBlockComponent) blocksComponents?: QueryList<CoreBlockComponent>;
@ -42,12 +44,16 @@ export class CoreBlockSideBlocksComponent implements OnInit {
loaded = false;
blocks: CoreCourseBlock[] = [];
constructor(protected elementRef: ElementRef<HTMLElement>) {}
/**
* @inheritdoc
*/
async ngOnInit(): Promise<void> {
this.loadContent().finally(() => {
this.loaded = true;
this.focusInitialBlock();
});
}
@ -119,4 +125,20 @@ export class CoreBlockSideBlocksComponent implements OnInit {
ModalController.dismiss();
}
/**
* Focus the initial block, if any.
*/
private async focusInitialBlock(): Promise<void> {
if (!this.initialBlockInstanceId) {
return;
}
const selector = '#block-' + this.initialBlockInstanceId;
await CoreUtils.waitFor(() => !!this.elementRef.nativeElement.querySelector(selector));
await CoreUtils.wait(200);
CoreDom.scrollToElement(this.elementRef.nativeElement, selector, { addYAxis: -10 });
}
}

View File

@ -52,6 +52,7 @@ import { CoreDom } from '@singletons/dom';
import { CoreUserTourDirectiveOptions } from '@directives/user-tour';
import { CoreAnalytics, CoreAnalyticsEventType } from '@services/analytics';
import { CorePlatform } from '@services/platform';
import { CoreBlockSideBlocksComponent } from '@features/block/components/side-blocks/side-blocks';
/**
* Component to display course contents using a certain format. If the format isn't found, use default one.
@ -76,6 +77,7 @@ export class CoreCourseFormatComponent implements OnInit, OnChanges, OnDestroy {
@Input() sections: CoreCourseSectionToDisplay[] = []; // List of course sections.
@Input() initialSectionId?: number; // The section to load first (by ID).
@Input() initialSectionNumber?: number; // The section to load first (by number).
@Input() initialBlockInstanceId?: number; // The instance to focus.
@Input() moduleId?: number; // The module ID to scroll to. Must be inside the initial selected section.
@Input() isGuest?: boolean; // If user is accessing using an ACCESS_GUEST enrolment method.
@ -298,16 +300,26 @@ export class CoreCourseFormatComponent implements OnInit, OnChanges, OnDestroy {
// Always load "All sections" to display the section title. If it isn't there just load the section.
this.loaded = true;
this.sectionChanged(sections[0]);
} else if (this.initialSectionId || this.initialSectionNumber) {
} else if (this.initialSectionId || this.initialSectionNumber !== undefined) {
// We have an input indicating the section ID to load. Search the section.
const section = sections.find((section) =>
section.id == this.initialSectionId || (section.section && section.section == this.initialSectionNumber));
section.id == this.initialSectionId ||
(section.section !== undefined && section.section == this.initialSectionNumber));
// Don't load the section if it cannot be viewed by the user.
if (section && this.canViewSection(section)) {
this.loaded = true;
this.sectionChanged(section);
}
} else if (this.initialBlockInstanceId && this.displayBlocks && this.hasBlocks) {
CoreDomUtils.openSideModal({
component: CoreBlockSideBlocksComponent,
componentProps: {
contextLevel: 'course',
instanceId: this.course.id,
initialBlockInstanceId: this.initialBlockInstanceId,
},
});
}
if (!this.loaded) {
@ -666,8 +678,8 @@ export class CoreCourseFormatComponent implements OnInit, OnChanges, OnDestroy {
CoreCourse.logView(this.course.id, sectionNumber),
);
let extraParams = sectionNumber ? `&section=${sectionNumber}` : '';
if (firstLoad && sectionNumber) {
let extraParams = sectionNumber !== undefined ? `&section=${sectionNumber}` : '';
if (firstLoad && sectionNumber !== undefined) {
// If course is configured to show all sections in one page, don't include section in URL in first load.
const courseDisplay = 'courseformatoptions' in this.course &&
this.course.courseformatoptions?.find(option => option.name === 'coursedisplay');

View File

@ -5,7 +5,8 @@
<core-loading [hideUntil]="dataLoaded && !updatingData">
<core-course-format [course]="course" [sections]="sections" [initialSectionId]="sectionId" [initialSectionNumber]="sectionNumber"
[moduleId]="moduleId" class="core-course-format-{{course.format}}" *ngIf="dataLoaded && sections" [isGuest]="isGuest">
[initialBlockInstanceId]="blockInstanceId" [moduleId]="moduleId" class="core-course-format-{{course.format}}"
*ngIf="dataLoaded && sections" [isGuest]="isGuest">
</core-course-format>
</core-loading>
</ion-content>

View File

@ -58,6 +58,7 @@ export class CoreCourseContentsPage implements OnInit, OnDestroy, CoreRefreshCon
sections?: CoreCourseSection[];
sectionId?: number;
sectionNumber?: number;
blockInstanceId?: number;
dataLoaded = false;
updatingData = false;
downloadCourseEnabled = false;
@ -92,6 +93,7 @@ export class CoreCourseContentsPage implements OnInit, OnDestroy, CoreRefreshCon
this.sectionId = CoreNavigator.getRouteNumberParam('sectionId');
this.sectionNumber = CoreNavigator.getRouteNumberParam('sectionNumber');
this.blockInstanceId = CoreNavigator.getRouteNumberParam('blockInstanceId');
this.moduleId = CoreNavigator.getRouteNumberParam('moduleId');
this.isGuest = CoreNavigator.getRouteBooleanParam('isGuest');

View File

@ -74,7 +74,7 @@ export class CoreCourseIndexPage implements OnInit, OnDestroy {
if (data.sectionId) {
this.contentsTab.pageParams.sectionId = data.sectionId;
}
if (data.sectionNumber) {
if (data.sectionNumber !== undefined) {
this.contentsTab.pageParams.sectionNumber = data.sectionNumber;
}
@ -162,12 +162,13 @@ export class CoreCourseIndexPage implements OnInit, OnDestroy {
course: this.course,
sectionId: CoreNavigator.getRouteNumberParam('sectionId'),
sectionNumber: CoreNavigator.getRouteNumberParam('sectionNumber'),
blockInstanceId: CoreNavigator.getRouteNumberParam('blockInstanceId'),
isGuest: this.isGuest,
};
if (this.module) {
this.contentsTab.pageParams.moduleId = this.module.id;
if (!this.contentsTab.pageParams.sectionId && !this.contentsTab.pageParams.sectionNumber) {
if (!this.contentsTab.pageParams.sectionId && this.contentsTab.pageParams.sectionNumber === undefined) {
// No section specified, use module section.
this.contentsTab.pageParams.sectionId = this.module.section;
}

View File

@ -69,6 +69,12 @@ export class CoreCoursesCourseLinkHandlerService extends CoreContentLinksHandler
if (!isNaN(sectionNumber)) {
pageParams.sectionNumber = sectionNumber;
} else {
const matches = url.match(/#inst(\d+)/);
if (matches && matches[1]) {
pageParams.blockInstanceId = parseInt(matches[1], 10);
}
}
return [{
@ -136,7 +142,7 @@ export class CoreCoursesCourseLinkHandlerService extends CoreContentLinksHandler
// Direct access.
const course = await CoreUtils.ignoreErrors(CoreCourses.getUserCourse(courseId), { id: courseId });
CoreCourseHelper.openCourse(course, pageParams);
CoreCourseHelper.openCourse(course, { params: pageParams });
} else {
this.navigateCourseSummary(courseId, pageParams);

View File

@ -22,7 +22,6 @@ import { CoreMainMenu, CoreMainMenuCustomItem } from '../../services/mainmenu';
import { CoreEventObserver, CoreEvents } from '@singletons/events';
import { CoreNavigator } from '@services/navigator';
import { CoreCustomURLSchemes } from '@services/urlschemes';
import { CoreContentLinksHelper } from '@features/contentlinks/services/contentlinks-helper';
import { CoreTextUtils } from '@services/utils/text';
import { Translate } from '@singletons';
import { CoreMainMenuDeepLinkManager } from '@features/mainmenu/classes/deep-link-manager';
@ -161,13 +160,10 @@ export class CoreMainMenuMorePage implements OnInit, OnDestroy {
CoreCustomURLSchemes.treatHandleCustomURLError(error);
});
} else if (/^[^:]{2,}:\/\/[^ ]+$/i.test(text)) { // Check if it's a URL.
// Check if the app can handle the URL.
const treated = await CoreContentLinksHelper.handleLink(text, undefined, true, true);
if (!treated) {
// Can't handle it, open it in browser.
CoreSites.getCurrentSite()?.openInBrowserWithAutoLogin(text);
}
await CoreSites.visitLink(text, {
checkRoot: true,
openBrowserRoot: true,
});
} else {
// It's not a URL, open it in a modal so the user can see it and copy it.
CoreTextUtils.viewText(Translate.instant('core.qrscanner'), text, {

View File

@ -0,0 +1,145 @@
// (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 {
CoreSearchGlobalSearchResult,
CoreSearchGlobalSearch,
CORE_SEARCH_GLOBAL_SEARCH_PAGE_LENGTH,
CoreSearchGlobalSearchFilters,
} from '@features/search/services/global-search';
import { CorePaginatedItemsManagerSource } from '@classes/items-management/paginated-items-manager-source';
/**
* Provides a collection of global search results.
*/
export class CoreSearchGlobalSearchResultsSource extends CorePaginatedItemsManagerSource<CoreSearchGlobalSearchResult> {
private query: string;
private filters: CoreSearchGlobalSearchFilters;
private pagesLoaded = 0;
private topResultsIds?: number[];
constructor(query: string, filters: CoreSearchGlobalSearchFilters) {
super();
this.query = query;
this.filters = filters;
}
/**
* Check whether the source has an empty query.
*
* @returns Whether the source has an empty query.
*/
hasEmptyQuery(): boolean {
return !this.query || this.query.trim().length === 0;
}
/**
* Get search query.
*
* @returns Search query.
*/
getQuery(): string {
return this.query;
}
/**
* Get search filters.
*
* @returns Search filters.
*/
getFilters(): CoreSearchGlobalSearchFilters {
return this.filters;
}
/**
* Set search query.
*
* @param query Search query.
*/
setQuery(query: string): void {
this.query = query;
this.setDirty(true);
}
/**
* Set search filters.
*
* @param filters Search filters.
*/
setFilters(filters: CoreSearchGlobalSearchFilters): void {
this.filters = filters;
this.setDirty(true);
}
/**
* @inheritdoc
*/
getPagesLoaded(): number {
return this.pagesLoaded;
}
/**
* @inheritdoc
*/
async reload(): Promise<void> {
this.pagesLoaded = 0;
await super.reload();
}
/**
* Reset collection data.
*/
reset(): void {
this.pagesLoaded = 0;
delete this.topResultsIds;
super.reset();
}
/**
* @inheritdoc
*/
protected async loadPageItems(page: number): Promise<{ items: CoreSearchGlobalSearchResult[]; hasMoreItems: boolean }> {
this.pagesLoaded++;
const results: CoreSearchGlobalSearchResult[] = [];
if (page === 0) {
const topResults = await CoreSearchGlobalSearch.getTopResults(this.query, this.filters);
results.push(...topResults);
this.topResultsIds = topResults.map(result => result.id);
}
const pageResults = await CoreSearchGlobalSearch.getResults(this.query, this.filters, page);
results.push(...pageResults.results.filter(result => !this.topResultsIds?.includes(result.id)));
return { items: results, hasMoreItems: pageResults.canLoadMore };
}
/**
* @inheritdoc
*/
protected getPageLength(): number {
return CORE_SEARCH_GLOBAL_SEARCH_PAGE_LENGTH;
}
}

View File

@ -0,0 +1,267 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { Component, OnInit, Input } from '@angular/core';
import { CoreEnrolledCourseData, CoreCourses } from '@features/courses/services/courses';
import {
CoreSearchGlobalSearchFilters,
CoreSearchGlobalSearch,
CoreSearchGlobalSearchSearchAreaCategory,
CORE_SEARCH_GLOBAL_SEARCH_FILTERS_UPDATED,
} from '@features/search/services/global-search';
import { CoreEvents } from '@singletons/events';
import { ModalController } from '@singletons';
import { IonRefresher } from '@ionic/angular';
import { CoreUtils } from '@services/utils/utils';
type Filter<T=unknown> = T & { checked: boolean };
@Component({
selector: 'core-search-global-search-filters',
templateUrl: 'global-search-filters.html',
styleUrls: ['./global-search-filters.scss'],
})
export class CoreSearchGlobalSearchFiltersComponent implements OnInit {
allSearchAreaCategories: boolean | null = true;
searchAreaCategories: Filter<CoreSearchGlobalSearchSearchAreaCategory>[] = [];
allCourses: boolean | null = true;
courses: Filter<CoreEnrolledCourseData>[] = [];
@Input() filters?: CoreSearchGlobalSearchFilters;
private newFilters: CoreSearchGlobalSearchFilters = {};
/**
* @inheritdoc
*/
async ngOnInit(): Promise<void> {
this.newFilters = this.filters ?? {};
await this.updateSearchAreaCategories();
await this.updateCourses();
}
/**
* Close popover.
*/
close(): void {
ModalController.dismiss();
}
/**
* Checkbox for all search area categories has been updated.
*/
allSearchAreaCategoriesUpdated(): void {
if (this.allSearchAreaCategories === null) {
return;
}
const checked = this.allSearchAreaCategories;
this.searchAreaCategories.forEach(searchAreaCategory => {
if (searchAreaCategory.checked === checked) {
return;
}
searchAreaCategory.checked = checked;
});
}
/**
* Checkbox for one search area category has been updated.
*
* @param searchAreaCategory Filter status.
*/
onSearchAreaCategoryInputChanged(searchAreaCategory: Filter<CoreSearchGlobalSearchSearchAreaCategory>): void {
if (
!searchAreaCategory.checked &&
this.newFilters.searchAreaCategoryIds &&
!this.newFilters.searchAreaCategoryIds.includes(searchAreaCategory.id)
) {
return;
}
if (
searchAreaCategory.checked &&
(!this.newFilters.searchAreaCategoryIds || this.newFilters.searchAreaCategoryIds.includes(searchAreaCategory.id))
) {
return;
}
this.searchAreaCategoryUpdated();
}
/**
* Checkbox for all courses has been updated.
*/
allCoursesUpdated(): void {
if (this.allCourses === null) {
return;
}
const checked = this.allCourses;
this.courses.forEach(course => {
if (course.checked === checked) {
return;
}
course.checked = checked;
});
}
/**
* Checkbox for one course has been updated.
*
* @param course Filter status.
*/
onCourseInputChanged(course: Filter<CoreEnrolledCourseData>): void {
if (!course.checked && this.newFilters.courseIds && !this.newFilters.courseIds.includes(course.id)) {
return;
}
if (course.checked && (!this.newFilters.courseIds || this.newFilters.courseIds.includes(course.id))) {
return;
}
this.courseUpdated();
}
/**
* Refresh filters.
*
* @param refresher Refresher.
*/
async refreshFilters(refresher?: IonRefresher): Promise<void> {
await CoreUtils.ignoreErrors(Promise.all([
CoreSearchGlobalSearch.invalidateSearchAreas(),
CoreCourses.invalidateUserCourses(),
]));
await this.updateSearchAreaCategories();
await this.updateCourses();
refresher?.complete();
}
/**
* Update search area categories.
*/
private async updateSearchAreaCategories(): Promise<void> {
const searchAreas = await CoreSearchGlobalSearch.getSearchAreas();
const searchAreaCategoryIds = new Set();
this.searchAreaCategories = [];
for (const searchArea of searchAreas) {
if (searchAreaCategoryIds.has(searchArea.category.id)) {
continue;
}
searchAreaCategoryIds.add(searchArea.category.id);
this.searchAreaCategories.push({
...searchArea.category,
checked: this.filters?.searchAreaCategoryIds?.includes(searchArea.category.id) ?? true,
});
}
this.allSearchAreaCategories = this.getGroupFilterStatus(this.searchAreaCategories);
}
/**
* Update courses.
*/
private async updateCourses(): Promise<void> {
const courses = await CoreCourses.getUserCourses();
this.courses = courses
.sort((a, b) => (a.shortname?.toLowerCase() ?? '').localeCompare(b.shortname?.toLowerCase() ?? ''))
.map(course => ({
...course,
checked: this.filters?.courseIds?.includes(course.id) ?? true,
}));
this.allCourses = this.getGroupFilterStatus(this.courses);
}
/**
* Checkbox for one search area category has been updated.
*/
private searchAreaCategoryUpdated(): void {
const filterStatus = this.getGroupFilterStatus(this.searchAreaCategories);
if (filterStatus !== this.allSearchAreaCategories) {
this.allSearchAreaCategories = filterStatus;
}
this.emitFiltersUpdated();
}
/**
* Course filter status has been updated.
*/
private courseUpdated(): void {
const filterStatus = this.getGroupFilterStatus(this.courses);
if (filterStatus !== this.allCourses) {
this.allCourses = filterStatus;
}
this.emitFiltersUpdated();
}
/**
* Get the status for a filter representing a group of filters.
*
* @param filters Filters in the group.
* @returns Group filter status. This will be true if all filters are checked, false if all filters are unchecked,
* or null if filters have mixed states.
*/
private getGroupFilterStatus(filters: Filter[]): boolean | null {
if (filters.length === 0) {
return null;
}
const firstChecked = filters[0].checked;
for (const filter of filters) {
if (filter.checked === firstChecked) {
continue;
}
return null;
}
return firstChecked;
}
/**
* Emit filters updated event.
*/
private emitFiltersUpdated(): void {
this.newFilters = {};
if (!this.allSearchAreaCategories) {
this.newFilters.searchAreaCategoryIds = this.searchAreaCategories.filter(({ checked }) => checked).map(({ id }) => id);
}
if (!this.allCourses) {
this.newFilters.courseIds = this.courses.filter(({ checked }) => checked).map(({ id }) => id);
}
CoreEvents.trigger(CORE_SEARCH_GLOBAL_SEARCH_FILTERS_UPDATED, this.newFilters);
}
}

View File

@ -0,0 +1,58 @@
<ion-header>
<ion-toolbar>
<ion-title>
<h1>{{ 'core.search.filterheader' | translate }}</h1>
</ion-title>
<ion-buttons slot="end">
<ion-button fill="clear" (click)="close()" [attr.aria-label]="'core.close' | translate">
<ion-icon name="fas-xmark" slot="icon-only" aria-hidden=true></ion-icon>
</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content [fullscreen]="true">
<ion-refresher slot="fixed" (ionRefresh)="refreshFilters($event.target)">
<ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}"></ion-refresher-content>
</ion-refresher>
<ion-list>
<ng-container *ngIf="searchAreaCategories.length > 0">
<core-spacer></core-spacer>
<ion-item class="ion-text-wrap help">
<ion-label>
{{ 'core.search.filtercategories' | translate }}
</ion-label>
</ion-item>
<ion-item class="ion-text-wrap">
<ion-label>{{ 'core.search.allcategories' | translate }}</ion-label>
<ion-checkbox slot="end" [(ngModel)]="allSearchAreaCategories" [indeterminate]="allSearchAreaCategories === null"
(ionChange)="allSearchAreaCategoriesUpdated()"></ion-checkbox>
</ion-item>
<ion-item class="ion-text-wrap" *ngFor="let searchAreaCategory of searchAreaCategories">
<ion-label>
<core-format-text [text]="searchAreaCategory.name"></core-format-text>
</ion-label>
<ion-checkbox slot="end" [(ngModel)]="searchAreaCategory.checked"
(ionChange)="onSearchAreaCategoryInputChanged(searchAreaCategory)"></ion-checkbox>
</ion-item>
</ng-container>
<ng-container *ngIf="courses.length > 0">
<core-spacer></core-spacer>
<ion-item class="ion-text-wrap help">
<ion-label>
{{ 'core.search.filtercourses' | translate }}
</ion-label>
</ion-item>
<ion-item class="ion-text-wrap">
<ion-label>{{ 'core.search.allcourses' | translate }}</ion-label>
<ion-checkbox slot="end" [(ngModel)]="allCourses" [indeterminate]="allCourses === null" (ionChange)="allCoursesUpdated()">
</ion-checkbox>
</ion-item>
<ion-item class="ion-text-wrap" *ngFor="let course of courses">
<ion-label>
<core-format-text [text]="course.shortname"></core-format-text>
</ion-label>
<ion-checkbox slot="end" [(ngModel)]="course.checked" (ionChange)="onCourseInputChanged(course)"></ion-checkbox>
</ion-item>
</ng-container>
</ion-list>
</ion-content>

View File

@ -0,0 +1,30 @@
// (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 { CoreSharedModule } from '@/core/shared.module';
import { NgModule } from '@angular/core';
import { CoreSearchGlobalSearchFiltersComponent } from './global-search-filters.component';
export { CoreSearchGlobalSearchFiltersComponent };
@NgModule({
imports: [
CoreSharedModule,
],
declarations: [
CoreSearchGlobalSearchFiltersComponent,
],
})
export class CoreSearchGlobalSearchFiltersComponentModule {}

View File

@ -0,0 +1,21 @@
:host {
--help-text-color: var(--gray-700);
ion-item.help {
color: var(--help-text-color);
ion-label {
margin-bottom: 0;
}
}
ion-item:not(.help) {
font-size: 16px;
}
}
:host-context(html.dark) {
--help-text-color: var(--gray-400);
}

View File

@ -0,0 +1,25 @@
<ion-item lines="inset" button (click)="onClick.emit($event)">
<core-course-image *ngIf="result.course" [course]="result.course"></core-course-image>
<core-user-avatar *ngIf="result.user" [user]="result.user" [linkProfile]="false"></core-user-avatar>
<ion-label>
<h3 *ngIf="result.title">
<ion-icon *ngIf="renderedIcon" [name]="renderedIcon" aria-hidden="true"></ion-icon>
<core-mod-icon *ngIf="!renderedIcon && result.module" [modicon]="result.module.iconurl"
[modname]="result.module.name"></core-mod-icon>
<img *ngIf="!renderedIcon && !result.module && result.component" [src]="result.component.iconurl" alt="" class="result-icon"
core-external-content [component]="result.component.name">
<core-format-text [text]="result.title"></core-format-text>
</h3>
<core-format-text *ngIf="result.content && !result.course && !result.user" [text]="result.content"></core-format-text>
<div *ngIf="result.context" class="flex-row">
<div *ngIf="result.context.courseName" class="result-context">
<ion-icon name="fas-graduation-cap" aria-hidden="true"></ion-icon>
<core-format-text [text]="result.context.courseName"></core-format-text>
</div>
<div *ngIf="result.context.userName" class="result-context">
<ion-icon name="fas-user" aria-hidden="true"></ion-icon>
<span>{{ 'core.search.resultby' | translate: { $a: result.context.userName } }}</span>
</div>
</div>
</ion-label>
</ion-item>

View File

@ -0,0 +1,90 @@
:host ion-item {
--core-global-search-result-image-size: 40px;
--core-global-search-result-title-color: var(--text);
--core-global-search-result-content-color: var(--gray-700);
--core-global-search-result-context-color: var(--gray-600);
--core-global-search-result-icon-size: 16px;
--mod-icon-filter: brightness(0);
h3 {
font-size: 16px;
display: flex;
align-items: center;
color: var(--core-global-search-result-title-color);
core-mod-icon {
--size: var(--core-global-search-result-icon-size);
--filter: var(--mod-icon-filter);
margin-inline-end: var(--spacing-2);
margin-top: 0px;
margin-bottom: 0px;
padding: 0px;
background: transparent;
}
ion-icon, .result-icon {
width: var(--core-global-search-result-icon-size);
height: var(--core-global-search-result-icon-size);
margin-inline-end: var(--spacing-2);
}
}
core-user-avatar {
--core-avatar-size: var(--core-global-search-result-image-size);
margin-top: var(--spacing-3);
margin-bottom: var(--spacing-3);
}
core-course-image {
--core-image-size: var(--core-global-search-result-image-size);
margin-top: var(--spacing-3);
margin-bottom: var(--spacing-3);
}
ion-label {
core-format-text {
color: var(--core-global-search-result-content-color);
@supports (-webkit-line-clamp: 2) {
white-space: normal;
overflow: hidden;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
}
.result-context {
display: flex;
justify-items: center;
align-items: center;
color: var(--core-global-search-result-context-color);
margin-top: var(--spacing-2);
font-size: 12px;
ion-icon {
margin-inline-end: var(--spacing-1);
}
+ .result-context {
margin-inline-start: var(--spacing-4);
}
}
}
}
:host-context(html.dark) ion-item {
--core-global-search-result-content-color: var(--gray-400);
--core-global-search-result-context-color: var(--gray-500);
--mod-icon-filter: brightness(0) invert(1);
}

View File

@ -0,0 +1,48 @@
// (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, Output, EventEmitter, OnChanges } from '@angular/core';
import { CoreSearchGlobalSearchResult } from '@features/search/services/global-search';
@Component({
selector: 'core-search-global-search-result',
templateUrl: 'global-search-result.html',
styleUrls: ['./global-search-result.scss'],
})
export class CoreSearchGlobalSearchResultComponent implements OnChanges {
@Input() result!: CoreSearchGlobalSearchResult;
renderedIcon: string | null = null;
@Output() onClick = new EventEmitter();
/**
* @inheritdoc
*/
ngOnChanges(): void {
this.renderedIcon = this.computeRenderedIcon();
}
/**
* Calculate the value of the icon to render.
*
* @returns Rendered icon.
*/
private computeRenderedIcon(): string | null {
return this.result.module?.name === 'forum' && this.result.module.area === 'post'
? 'fa-message'
: null;
}
}

View File

@ -0,0 +1,12 @@
{
"allcourses": "All courses",
"allcategories": "All categories",
"empty": "What are you searching for?",
"filtercategories": "Filter results by",
"filtercourses": "Search in",
"filterheader": "Filter",
"globalsearch": "Global search",
"noresults": "No results for \"{{$a}}\"",
"noresultshelp": "Check for typos or try using different keywords",
"resultby": "By {{$a}}"
}

View File

@ -0,0 +1,52 @@
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<ion-back-button [text]="'core.back' | translate"></ion-back-button>
</ion-buttons>
<ion-title>
<h1>{{ 'core.search.globalsearch' | translate }}</h1>
</ion-title>
<ion-buttons slot="end">
<core-user-menu-button></core-user-menu-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content class="limited-width">
<div>
<ion-card class="core-danger-card" *ngIf="searchBanner">
<ion-item>
<ion-icon name="fas-triangle-exclamation" slot="start" aria-hidden="true"></ion-icon>
<ion-label>
<core-format-text [text]="searchBanner"></core-format-text>
</ion-label>
</ion-item>
</ion-card>
<core-search-box (onSubmit)="search($event)" (onClear)="clearSearch()" [placeholder]="'core.search' | translate"
[searchLabel]="'core.search' | translate" [autoFocus]="true" searchArea="CoreSearchGlobalSearch"></core-search-box>
<ion-list *ngIf="resultsSource.isLoaded()">
<core-search-global-search-result *ngFor="let result of resultsSource.getItems()" [result]="result"
(onClick)="visitResult(result)">
</core-search-global-search-result>
</ion-list>
<core-infinite-loading [enabled]="resultsSource.isLoaded() && !resultsSource.isCompleted()" (action)="loadMoreResults($event)"
[error]="loadMoreError">
</core-infinite-loading>
<core-empty-box *ngIf="resultsSource.isEmpty()" icon="fas-magnifying-glass" [dimmed]="!resultsSource.isLoaded()">
<p *ngIf="!resultsSource.isLoaded()">{{ 'core.search.empty' | translate }}</p>
<ng-container *ngIf="resultsSource.isLoaded()">
<p><strong>{{ 'core.search.noresults' | translate: { $a: resultsSource.getQuery() } }}</strong></p>
<p><small>{{ 'core.search.noresultshelp' | translate }}</small></p>
</ng-container>
</core-empty-box>
<ion-fab slot="fixed" core-fab vertical="bottom" horizontal="end">
<ion-fab-button (click)="openFilters()" [attr.aria-label]="'core.filter' | translate">
<ion-icon name="fas-filter" aria-hidden="true"></ion-icon>
</ion-fab-button>
</ion-fab>
</div>
</ion-content>

View File

@ -0,0 +1,174 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { Component, OnInit, OnDestroy, AfterViewInit, ViewChild } from '@angular/core';
import { CoreDomUtils } from '@services/utils/dom';
import { CoreSearchGlobalSearchResultsSource } from '@features/search/classes/global-search-results-source';
import { CoreSites } from '@services/sites';
import { CoreUtils } from '@services/utils/utils';
import { CoreAnalytics, CoreAnalyticsEventType } from '@services/analytics';
import { Translate } from '@singletons';
import { CoreUrlUtils } from '@services/utils/url';
import { CoreEvents, CoreEventObserver } from '@singletons/events';
import {
CoreSearchGlobalSearchResult,
CoreSearchGlobalSearchFilters,
CORE_SEARCH_GLOBAL_SEARCH_FILTERS_UPDATED,
CoreSearchGlobalSearch,
} from '@features/search/services/global-search';
import { CoreNavigator } from '@services/navigator';
import { CoreSearchBoxComponent } from '@features/search/components/search-box/search-box';
@Component({
selector: 'page-core-search-global-search',
templateUrl: 'global-search.html',
})
export class CoreSearchGlobalSearchPage implements OnInit, OnDestroy, AfterViewInit {
loadMoreError: string | null = null;
searchBanner: string | null = null;
resultsSource = new CoreSearchGlobalSearchResultsSource('', {});
private filtersObserver?: CoreEventObserver;
@ViewChild(CoreSearchBoxComponent) searchBox?: CoreSearchBoxComponent;
/**
* @inheritdoc
*/
ngOnInit(): void {
const site = CoreSites.getRequiredCurrentSite();
const searchBanner = site.config?.searchbanner?.trim() ?? '';
const courseId = CoreNavigator.getRouteNumberParam('courseId');
if (CoreUtils.isTrueOrOne(site.config?.searchbannerenable) && searchBanner.length > 0) {
this.searchBanner = searchBanner;
}
if (courseId) {
this.resultsSource.setFilters({ courseIds: [courseId] });
}
this.filtersObserver = CoreEvents.on(
CORE_SEARCH_GLOBAL_SEARCH_FILTERS_UPDATED,
filters => this.resultsSource.setFilters(filters),
);
}
/**
* @inheritdoc
*/
ngAfterViewInit(): void {
const query = CoreNavigator.getRouteParam('query');
if (query) {
if (this.searchBox) {
this.searchBox.searchText = query;
}
this.search(query);
}
}
/**
* @inheritdoc
*/
ngOnDestroy(): void {
this.filtersObserver?.off();
}
/**
* Perform a new search.
*
* @param query Search query.
*/
async search(query: string): Promise<void> {
this.resultsSource.setQuery(query);
if (this.resultsSource.hasEmptyQuery()) {
return;
}
await CoreDomUtils.showOperationModals('core.searching', true, async () => {
await this.resultsSource.reload();
await CoreUtils.ignoreErrors(
CoreSearchGlobalSearch.logViewResults(this.resultsSource.getQuery(), this.resultsSource.getFilters()),
);
CoreAnalytics.logEvent({
type: CoreAnalyticsEventType.VIEW_ITEM_LIST,
ws: 'core_search_view_results',
name: Translate.instant('core.search.globalsearch'),
data: {
query,
filters: JSON.stringify(this.resultsSource.getFilters()),
},
url: CoreUrlUtils.addParamsToUrl('/search/index.php', {
q: query,
}),
});
});
}
/**
* Clear search results.
*/
clearSearch(): void {
this.loadMoreError = null;
this.resultsSource.setQuery('');
this.resultsSource.reset();
}
/**
* Open filters.
*/
async openFilters(): Promise<void> {
const { CoreSearchGlobalSearchFiltersComponent } =
await import('@features/search/components/global-search-filters/global-search-filters.module');
await CoreDomUtils.openSideModal<CoreSearchGlobalSearchFilters>({
component: CoreSearchGlobalSearchFiltersComponent,
componentProps: { filters: this.resultsSource.getFilters() },
});
if (!this.resultsSource.hasEmptyQuery() && this.resultsSource.isDirty()) {
await CoreDomUtils.showOperationModals('core.searching', true, () => this.resultsSource.reload());
}
}
/**
* Visit a result's origin.
*
* @param result Result to visit.
*/
async visitResult(result: CoreSearchGlobalSearchResult): Promise<void> {
await CoreSites.visitLink(result.url);
}
/**
* Load more results.
*
* @param complete Notify completion.
*/
async loadMoreResults(complete: () => void ): Promise<void> {
try {
await this.resultsSource?.load();
} catch (error) {
this.loadMoreError = CoreDomUtils.getErrorMessage(error);
} finally {
complete();
}
}
}

View File

@ -0,0 +1,56 @@
// (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 { CoreSharedModule } from '@/core/shared.module';
import { NgModule, Injector } from '@angular/core';
import { RouterModule, Routes, ROUTES } from '@angular/router';
import { CoreSearchGlobalSearchPage } from './pages/global-search/global-search';
import { CoreSearchComponentsModule } from '@features/search/components/components.module';
import { CoreMainMenuComponentsModule } from '@features/mainmenu/components/components.module';
import { buildTabMainRoutes } from '@features/mainmenu/mainmenu-tab-routing.module';
import { CoreSearchGlobalSearchResultComponent } from '@features/search/components/global-search-result/global-search-result';
/**
* Build module routes.
*
* @param injector Injector.
* @returns Routes.
*/
function buildRoutes(injector: Injector): Routes {
return buildTabMainRoutes(injector, {
component: CoreSearchGlobalSearchPage,
});
}
@NgModule({
imports: [
CoreSharedModule,
CoreSearchComponentsModule,
CoreMainMenuComponentsModule,
],
exports: [RouterModule],
declarations: [
CoreSearchGlobalSearchPage,
CoreSearchGlobalSearchResultComponent,
],
providers: [
{
provide: ROUTES,
multi: true,
deps: [Injector],
useFactory: buildRoutes,
},
],
})
export class CoreSearchLazyModule {}

View File

@ -12,24 +12,50 @@
// See the License for the specific language governing permissions and
// limitations under the License.
import { NgModule, Type } from '@angular/core';
import { APP_INITIALIZER, NgModule, Type } from '@angular/core';
import { Routes } from '@angular/router';
import { CoreMainMenuRoutingModule } from '@features/mainmenu/mainmenu-routing.module';
import { CoreMainMenuTabRoutingModule } from '@features/mainmenu/mainmenu-tab-routing.module';
import { CoreMainMenuDelegate } from '@features/mainmenu/services/mainmenu-delegate';
import { CoreSearchGlobalSearchService } from '@features/search/services/global-search';
import { CoreSearchMainMenuHandler, CORE_SEARCH_PAGE_NAME } from '@features/search/services/handlers/mainmenu';
import { CORE_SITE_SCHEMAS } from '@services/sites';
import { CoreSearchComponentsModule } from './components/components.module';
import { SITE_SCHEMA } from './services/search-history-db';
import { CoreSearchHistoryProvider } from './services/search-history.service';
import { CoreContentLinksDelegate } from '@features/contentlinks/services/contentlinks-delegate';
import { CoreSearchGlobalSearchLinkHandler } from '@features/search/services/handlers/global-search-link';
export const CORE_SEARCH_SERVICES: Type<unknown>[] = [
CoreSearchHistoryProvider,
CoreSearchGlobalSearchService,
];
const mainMenuChildrenRoutes: Routes = [
{
path: CORE_SEARCH_PAGE_NAME,
loadChildren: () => import('./search-lazy.module').then(m => m.CoreSearchLazyModule),
},
];
@NgModule({
imports: [
CoreSearchComponentsModule,
CoreMainMenuTabRoutingModule.forChild(mainMenuChildrenRoutes),
CoreMainMenuRoutingModule.forChild({ children: mainMenuChildrenRoutes }),
],
providers: [
{ provide: CORE_SITE_SCHEMAS, useValue: [SITE_SCHEMA], multi: true },
{
provide: APP_INITIALIZER,
multi: true,
useValue() {
CoreMainMenuDelegate.registerHandler(CoreSearchMainMenuHandler.instance);
CoreContentLinksDelegate.registerHandler(CoreSearchGlobalSearchLinkHandler.instance);
},
},
],
})
export class CoreSearchModule {}

View File

@ -0,0 +1,420 @@
// (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 { makeSingleton } from '@singletons';
import { CoreSites, CoreSitesReadingStrategy } from '@services/sites';
import { CoreWSExternalWarning } from '@services/ws';
import { CoreCourseListItem, CoreCourses } from '@features/courses/services/courses';
import { CoreUserWithAvatar } from '@components/user-avatar/user-avatar';
import { CoreUser } from '@features/user/services/user';
import { CoreSite } from '@classes/site';
declare module '@singletons/events' {
/**
* Augment CoreEventsData interface with events specific to this service.
*
* @see https://www.typescriptlang.org/docs/handbook/declaration-merging.html#module-augmentation
*/
export interface CoreEventsData {
[CORE_SEARCH_GLOBAL_SEARCH_FILTERS_UPDATED]: CoreSearchGlobalSearchFilters;
}
}
export const CORE_SEARCH_GLOBAL_SEARCH_PAGE_LENGTH = 10;
export const CORE_SEARCH_GLOBAL_SEARCH_FILTERS_UPDATED = 'core-search-global-search-filters-updated';
export type CoreSearchGlobalSearchResult = {
id: number;
title: string;
url: string;
content?: string;
context?: CoreSearchGlobalSearchResultContext;
module?: CoreSearchGlobalSearchResultModule;
component?: CoreSearchGlobalSearchResultComponent;
course?: CoreCourseListItem;
user?: CoreUserWithAvatar;
};
export type CoreSearchGlobalSearchResultContext = {
userName?: string;
courseName?: string;
};
export type CoreSearchGlobalSearchResultModule = {
name: string;
iconurl: string;
area: string;
};
export type CoreSearchGlobalSearchResultComponent = {
name: string;
iconurl: string;
};
export type CoreSearchGlobalSearchSearchAreaCategory = {
id: string;
name: string;
};
export type CoreSearchGlobalSearchSearchArea = {
id: string;
name: string;
category: CoreSearchGlobalSearchSearchAreaCategory;
};
export interface CoreSearchGlobalSearchFilters {
searchAreaCategoryIds?: string[];
courseIds?: number[];
}
/**
* Service to perform global searches.
*/
@Injectable({ providedIn: 'root' })
export class CoreSearchGlobalSearchService {
private static readonly SEARCH_AREAS_CACHE_KEY = 'CoreSearchGlobalSearch:SearchAreas';
/**
* Check whether global search is enabled or not.
*
* @returns Whether global search is enabled or not.
*/
async isEnabled(siteId?: string): Promise<boolean> {
const site = siteId
? await CoreSites.getSite(siteId)
: CoreSites.getRequiredCurrentSite();
return !site?.isFeatureDisabled('CoreNoDelegate_GlobalSearch')
&& site?.wsAvailable('core_search_get_results') // @since 4.3
&& site?.canUseAdvancedFeature('enableglobalsearch');
}
/**
* Get results.
*
* @param query Search query.
* @param filters Search filters.
* @param page Page.
* @returns Search results.
*/
async getResults(
query: string,
filters: CoreSearchGlobalSearchFilters,
page: number,
): Promise<{ results: CoreSearchGlobalSearchResult[]; canLoadMore: boolean }> {
if (this.filtersYieldEmptyResults(filters)) {
return {
results: [],
canLoadMore: false,
};
}
const site = CoreSites.getRequiredCurrentSite();
const params: CoreSearchGetResultsWSParams = {
query,
page,
filters: await this.prepareWSFilters(filters),
};
const preSets = CoreSites.getReadingStrategyPreSets(CoreSitesReadingStrategy.PREFER_NETWORK);
const { totalcount, results } = await site.read<CoreSearchGetResultsWSResponse>('core_search_get_results', params, preSets);
return {
results: await Promise.all((results ?? []).map(result => this.formatWSResult(result))),
canLoadMore: totalcount > (page + 1) * CORE_SEARCH_GLOBAL_SEARCH_PAGE_LENGTH,
};
}
/**
* Get top results.
*
* @param query Search query.
* @param filters Search filters.
* @returns Top search results.
*/
async getTopResults(query: string, filters: CoreSearchGlobalSearchFilters): Promise<CoreSearchGlobalSearchResult[]> {
if (this.filtersYieldEmptyResults(filters)) {
return [];
}
const site = CoreSites.getRequiredCurrentSite();
const params: CoreSearchGetTopResultsWSParams = {
query,
filters: await this.prepareWSFilters(filters),
};
const preSets = CoreSites.getReadingStrategyPreSets(CoreSitesReadingStrategy.PREFER_NETWORK);
const { results } = await site.read<CoreSearchGetTopResultsWSResponse>('core_search_get_top_results', params, preSets);
return await Promise.all((results ?? []).map(result => this.formatWSResult(result)));
}
/**
* Get available search areas.
*
* @returns Search areas.
*/
async getSearchAreas(): Promise<CoreSearchGlobalSearchSearchArea[]> {
const site = CoreSites.getRequiredCurrentSite();
const params: CoreSearchGetSearchAreasListWSParams = {};
const { areas } = await site.read<CoreSearchGetSearchAreasListWSResponse>('core_search_get_search_areas_list', params, {
updateFrequency: CoreSite.FREQUENCY_RARELY,
cacheKey: CoreSearchGlobalSearchService.SEARCH_AREAS_CACHE_KEY,
});
return areas.map(area => ({
id: area.id,
name: area.name,
category: {
id: area.categoryid,
name: area.categoryname,
},
}));
}
/**
* Invalidate search areas cache.
*/
async invalidateSearchAreas(): Promise<void> {
const site = CoreSites.getRequiredCurrentSite();
await site.invalidateWsCacheForKey(CoreSearchGlobalSearchService.SEARCH_AREAS_CACHE_KEY);
}
/**
* Log event for viewing results.
*
* @param query Search query.
* @param filters Search filters.
*/
async logViewResults(query: string, filters: CoreSearchGlobalSearchFilters): Promise<void> {
const site = CoreSites.getRequiredCurrentSite();
const params: CoreSearchViewResultsWSParams = {
query,
filters: await this.prepareWSFilters(filters),
};
await site.write<CoreSearchViewResultsWSResponse>('core_search_view_results', params);
}
/**
* Format a WS result to be used in the app.
*
* @param wsResult WS result.
* @returns App result.
*/
protected async formatWSResult(wsResult: CoreSearchWSResult): Promise<CoreSearchGlobalSearchResult> {
const result: CoreSearchGlobalSearchResult = {
id: wsResult.itemid,
title: wsResult.title,
url: wsResult.docurl,
content: wsResult.content,
};
if (wsResult.componentname === 'core_user') {
const user = await CoreUser.getProfile(wsResult.itemid);
result.user = user;
} else if (wsResult.componentname === 'core_course' && wsResult.areaname === 'course') {
const course = await CoreCourses.getCourseByField('id', wsResult.itemid);
result.course = course;
} else {
if (wsResult.userfullname || wsResult.coursefullname) {
result.context = {
userName: wsResult.userfullname,
courseName: wsResult.coursefullname,
};
}
if (wsResult.iconurl) {
if (wsResult.componentname.startsWith('mod_')) {
result.module = {
name: wsResult.componentname.substring(4),
iconurl: wsResult.iconurl,
area: wsResult.areaname,
};
} else {
result.component = {
name: wsResult.componentname,
iconurl: wsResult.iconurl,
};
}
}
}
return result;
}
/**
* Check whether the given filter will necessarily yield an empty list of results.
*
* @param filters Filters.
* @returns Whether the given filters will return 0 results.
*/
protected filtersYieldEmptyResults(filters: CoreSearchGlobalSearchFilters): boolean {
return filters.courseIds?.length === 0 || filters.searchAreaCategoryIds?.length === 0;
}
/**
* Prepare search filters before sending to WS.
*
* @param filters App filters.
* @returns WS filters.
*/
protected async prepareWSFilters(filters: CoreSearchGlobalSearchFilters): Promise<CoreSearchBasicWSFilters> {
const wsFilters: CoreSearchBasicWSFilters = {};
if (filters.courseIds) {
wsFilters.courseids = filters.courseIds;
}
if (filters.searchAreaCategoryIds) {
const searchAreas = await this.getSearchAreas();
wsFilters.areaids = searchAreas
.filter(({ category }) => filters.searchAreaCategoryIds?.includes(category.id))
.map(({ id }) => id);
}
return wsFilters;
}
}
export const CoreSearchGlobalSearch = makeSingleton(CoreSearchGlobalSearchService);
/**
* Params of core_search_get_results WS.
*/
type CoreSearchGetResultsWSParams = {
query: string; // The search query.
filters?: CoreSearchAdvancedWSFilters; // Filters to apply.
page?: number; // Results page number starting from 0, defaults to the first page.
};
/**
* Params of core_search_get_search_areas_list WS.
*/
type CoreSearchGetSearchAreasListWSParams = {
cat?: string; // Category to filter areas.
};
/**
* Params of core_search_view_results WS.
*/
type CoreSearchViewResultsWSParams = {
query: string; // The search query.
filters?: CoreSearchBasicWSFilters; // Filters to apply.
page?: number; // Results page number starting from 0, defaults to the first page.
};
/**
* Params of core_search_get_top_results WS.
*/
type CoreSearchGetTopResultsWSParams = {
query: string; // The search query.
filters?: CoreSearchAdvancedWSFilters; // Filters to apply.
};
/**
* Search result returned in WS.
*/
type CoreSearchWSResult = { // Search results.
itemid: number; // Unique id in the search area scope.
componentname: string; // Component name.
areaname: string; // Search area name.
courseurl: string; // Result course url.
coursefullname: string; // Result course fullname.
timemodified: number; // Result modified time.
title: string; // Result title.
docurl: string; // Result url.
iconurl?: string; // Icon url.
content?: string; // Result contents.
contextid: number; // Result context id.
contexturl: string; // Result context url.
description1?: string; // Extra result contents, depends on the search area.
description2?: string; // Extra result contents, depends on the search area.
multiplefiles?: number; // Whether multiple files are returned or not.
filenames?: string[]; // Result file names if present.
filename?: string; // Result file name if present.
userid?: number; // User id.
userurl?: string; // User url.
userfullname?: string; // User fullname.
textformat: number; // Text fields format, it is the same for all of them.
};
/**
* Basic search filters used in WS.
*/
type CoreSearchBasicWSFilters = {
title?: string; // Result title.
areaids?: string[]; // Restrict results to these areas.
courseids?: number[]; // Restrict results to these courses.
timestart?: number; // Docs modified after this date.
timeend?: number; // Docs modified before this date.
};
/**
* Advanced search filters used in WS.
*/
type CoreSearchAdvancedWSFilters = CoreSearchBasicWSFilters & {
contextids?: number[]; // Restrict results to these contexts.
cat?: string; // Category to filter areas.
userids?: number[]; // Restrict results to these users.
groupids?: number[]; // Restrict results to these groups.
mycoursesonly?: boolean; // Only results from my courses.
order?: string; // How to order.
};
/**
* Data returned by core_search_get_results WS.
*/
type CoreSearchGetResultsWSResponse = {
totalcount: number; // Total number of results.
results?: CoreSearchWSResult[];
};
/**
* Data returned by core_search_get_search_areas_list WS.
*/
type CoreSearchGetSearchAreasListWSResponse = {
areas: { // Search areas.
id: string; // Search area id.
categoryid: string; // Category id.
categoryname: string; // Category name.
name: string; // Search area name.
}[];
warnings?: CoreWSExternalWarning[];
};
/**
* Data returned by core_search_view_results WS.
*/
type CoreSearchViewResultsWSResponse = {
status: boolean; // Status: true if success.
warnings?: CoreWSExternalWarning[];
};
/**
* Data returned by core_search_get_top_results WS.
*/
type CoreSearchGetTopResultsWSResponse = {
results?: CoreSearchWSResult[];
};

View File

@ -0,0 +1,56 @@
// (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 { CoreContentLinksHandlerBase } from '@features/contentlinks/classes/base-handler';
import { CoreContentLinksAction } from '@features/contentlinks/services/contentlinks-delegate';
import { CoreSearchGlobalSearch } from '@features/search/services/global-search';
import { CORE_SEARCH_PAGE_NAME } from '@features/search/services/handlers/mainmenu';
import { CoreNavigator } from '@services/navigator';
import { makeSingleton } from '@singletons';
/**
* Handler to treat links to search page.
*/
@Injectable( { providedIn: 'root' })
export class CoreSearchGlobalSearchLinkHandlerService extends CoreContentLinksHandlerBase {
name = 'CoreSearchSearchLinkHandler';
pattern = /\/search\/index\.php.*/;
/**
* @inheritdoc
*/
async isEnabled(siteId: string): Promise<boolean> {
return CoreSearchGlobalSearch.isEnabled(siteId);
}
/**
* @inheritdoc
*/
getActions(siteIds: string[], url: string, params: Record<string, string>): CoreContentLinksAction[] {
return [{
action: (siteId: string): void => {
CoreNavigator.navigateToSitePath(CORE_SEARCH_PAGE_NAME, {
siteId,
params: {
query: params.q,
},
});
},
}];
}
}
export const CoreSearchGlobalSearchLinkHandler = makeSingleton(CoreSearchGlobalSearchLinkHandlerService);

View File

@ -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 { Injectable } from '@angular/core';
import { makeSingleton } from '@singletons';
import { CoreMainMenuHandler, CoreMainMenuHandlerData } from '@features/mainmenu/services/mainmenu-delegate';
import { CoreSearchGlobalSearch } from '@features/search/services/global-search';
export const CORE_SEARCH_PAGE_NAME = 'search';
/**
* Handler to inject an option into main menu.
*/
@Injectable({ providedIn: 'root' })
export class CoreSearchMainMenuHandlerService implements CoreMainMenuHandler {
name = 'CoreSearch';
priority = 575;
/**
* @inheritdoc
*/
async isEnabled(): Promise<boolean> {
return CoreSearchGlobalSearch.isEnabled();
}
/**
* @inheritdoc
*/
getDisplayData(): CoreMainMenuHandlerData {
return {
icon: 'fas-magnifying-glass',
title: 'core.search.globalsearch',
page: CORE_SEARCH_PAGE_NAME,
class: 'core-search-handler',
};
}
}
export const CoreSearchMainMenuHandler = makeSingleton(CoreSearchMainMenuHandlerService);

View File

@ -0,0 +1,39 @@
// (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 { StorybookModule } from '@/storybook/storybook.module';
import { CoreSearchComponentsModule } from '@features/search/components/components.module';
import { CoreComponentsModule } from '@components/components.module';
import { CommonModule } from '@angular/common';
import {
CoreSearchGlobalSearchResultsPageComponent,
} from '@features/search/stories/components/global-search-results-page/global-search-results-page';
import { CoreSearchGlobalSearchResultComponent } from '@features/search/components/global-search-result/global-search-result';
import { CoreSharedModule } from '@/core/shared.module';
@NgModule({
declarations: [
CoreSearchGlobalSearchResultsPageComponent,
CoreSearchGlobalSearchResultComponent,
],
imports: [
CoreSharedModule,
CommonModule,
StorybookModule,
CoreComponentsModule,
CoreSearchComponentsModule,
],
})
export class CoreSearchComponentsStorybookModule {}

View File

@ -0,0 +1,18 @@
<ion-app>
<ion-header>
<ion-toolbar>
<ion-title>
<h1>Search Results</h1>
</ion-title>
</ion-toolbar>
</ion-header>
<ion-content class="limited-width">
<div>
<core-search-box></core-search-box>
<ion-list>
<core-search-global-search-result *ngFor="let result of results" [result]="result" (onClick)="resultClicked(result.title)">
</core-search-global-search-result>
</ion-list>
</div>
</ion-content>
</ion-app>

View File

@ -0,0 +1,121 @@
// (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 } from '@angular/core';
import { CoreCourseListItem } from '@features/courses/services/courses';
import { CoreUserWithAvatar } from '@components/user-avatar/user-avatar';
import { CoreSearchGlobalSearchResult } from '@features/search/services/global-search';
import courses from '@/assets/storybook/courses.json';
@Component({
selector: 'core-search-global-search-results-page',
templateUrl: 'global-search-results-page.html',
})
export class CoreSearchGlobalSearchResultsPageComponent {
results: CoreSearchGlobalSearchResult[] = [
{
id: 1,
url: '',
title: 'Activity forum test',
content: 'this is a content test for a forum to see in the search result.',
context: {
courseName: 'Course 102',
userName: 'Stephania Krovalenko',
},
module: {
name: 'forum',
iconurl: 'assets/img/mod/forum.svg',
area: 'activity',
},
},
{
id: 2,
url: '',
title: 'Activity assignment test',
content: 'this is a content test for a forum to see in the search result.',
context: {
courseName: 'Course 102',
},
module: {
name: 'assign',
iconurl: 'assets/img/mod/assign.svg',
area: '',
},
},
{
id: 3,
url: '',
title: 'Course 101',
course: courses[0] as CoreCourseListItem,
},
{
id: 4,
url: '',
title: 'John the Tester',
user: {
fullname: 'John Doe',
profileimageurl: 'https://placekitten.com/300/300',
} as CoreUserWithAvatar,
},
{
id: 5,
url: '',
title: 'Search result title',
content: 'this is a content test for a forum to see in the search result.',
context: {
userName: 'Stephania Krovalenko',
},
module: {
name: 'forum',
iconurl: 'assets/img/mod/forum.svg',
area: 'post',
},
},
{
id: 6,
url: '',
title: 'Side block',
context: {
courseName: 'Moodle Site',
},
component: {
name: 'block_html',
iconurl: 'https://master.mm.moodledemo.net/theme/image.php?theme=boost&component=core&image=e%2Fanchor',
},
},
{
id: 7,
url: '',
title: 'Course section',
context: {
courseName: 'Course 101',
},
component: {
name: 'core_course',
iconurl: 'https://master.mm.moodledemo.net/theme/image.php?theme=boost&component=core&image=i%2Fsection',
},
},
];
/**
* Result clicked.
*
* @param title Result title.
*/
resultClicked(title: string): void {
alert(`clicked on ${title}`);
}
}

View File

@ -0,0 +1,134 @@
// (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 { Meta, moduleMetadata } from '@storybook/angular';
import { story } from '@/storybook/utils/helpers';
import { CoreSearchGlobalSearchResultComponent } from '@features/search/components/global-search-result/global-search-result';
import { CoreSearchComponentsStorybookModule } from '@features/search/stories/components/components.module';
import {
CoreSearchGlobalSearchResultsPageComponent,
} from '@features/search/stories/components/global-search-results-page/global-search-results-page';
import { APP_INITIALIZER } from '@angular/core';
import { CoreCourseModuleDelegate } from '@features/course/services/module-delegate';
import { AddonModForumModuleHandler } from '@addons/mod/forum/services/handlers/module';
import { AddonModAssignModuleHandler } from '@addons/mod/assign/services/handlers/module';
import { CoreSearchGlobalSearchResult } from '@features/search/services/global-search';
import { CoreUserWithAvatar } from '@components/user-avatar/user-avatar';
import { CoreCourseListItem } from '@features/courses/services/courses';
import courses from '@/assets/storybook/courses.json';
interface Args {
title: string;
content: string;
image: 'course' | 'user' | 'none';
module: 'forum-activity' | 'forum-post' | 'assign' | 'none';
courseContext: boolean;
userContext: boolean;
}
export default <Meta<Args>> {
title: 'Core/Search/Global Search Result',
component: CoreSearchGlobalSearchResultComponent,
decorators: [
moduleMetadata({
imports: [CoreSearchComponentsStorybookModule],
providers: [
{
provide: APP_INITIALIZER,
multi: true,
useValue() {
CoreCourseModuleDelegate.registerHandler(AddonModForumModuleHandler.instance);
CoreCourseModuleDelegate.registerHandler(AddonModAssignModuleHandler.instance);
CoreCourseModuleDelegate.updateHandlers();
},
},
],
}),
],
argTypes: {
image: {
control: {
type: 'select',
options: ['course', 'user', 'none'],
},
},
module: {
control: {
type: 'select',
options: ['forum-activity', 'forum-post', 'assign', 'none'],
},
},
},
args: {
title: 'Result #1',
content: 'This item seems really interesting, maybe you should click through',
image: 'none',
module: 'none',
courseContext: false,
userContext: false,
},
parameters: {
design: {
type: 'figma',
url: 'https://www.figma.com/file/h3E7pkfgyImJPaYmTfnwuF/Global-Search?node-id=118%3A4610',
},
},
};
const Template = story<Args>(({ image, courseContext, userContext, module, ...args }) => {
const result: CoreSearchGlobalSearchResult = {
...args,
id: 1,
url: '',
};
if (courseContext || userContext) {
result.context = {
courseName: courseContext ? 'Course 101' : undefined,
userName: userContext ? 'John Doe' : undefined,
};
}
if (module !== 'none') {
const name = module.startsWith('forum') ? 'forum' : module;
result.module = {
name,
iconurl: `assets/img/mod/${name}.svg`,
area: module.startsWith('forum') ? module.substring(6) : '',
};
}
switch (image) {
case 'course':
result.course = courses[0] as CoreCourseListItem;
break;
case 'user':
result.user = {
fullname: 'John Doe',
profileimageurl: 'https://placekitten.com/300/300',
} as CoreUserWithAvatar;
break;
}
return {
component: CoreSearchGlobalSearchResultComponent,
props: { result },
};
});
export const Primary = story<Args>(Template);
export const ResultsPage = story<Args>(() => ({ component: CoreSearchGlobalSearchResultsPageComponent }));

View File

@ -0,0 +1,147 @@
@core @core_search @app @javascript @lms_from4.3
Feature: Test Global Search
Background:
Given solr is installed
And the following config values are set as admin:
| enableglobalsearch | 1 |
| searchengine | solr |
And the following "courses" exist:
| fullname | shortname |
| Course 1 | C1 |
| Course 2 | C2 |
And the following "users" exist:
| username |
| student1 |
And the following "course enrolments" exist:
| user | course | role |
| student1 | C1 | student |
| student1 | C2 | student |
And the following "activities" exist:
| activity | name | course | idnumber |
| page | Test page 01 | C1 | page01 |
| page | Test page 02 | C1 | page02 |
| page | Test page 03 | C1 | page03 |
| page | Test page 04 | C1 | page04 |
| page | Test page 05 | C1 | page05 |
| page | Test page 06 | C1 | page06 |
| page | Test page 07 | C1 | page07 |
| page | Test page 08 | C1 | page08 |
| page | Test page 09 | C1 | page09 |
| page | Test page 10 | C1 | page10 |
| page | Test page 11 | C1 | page11 |
| page | Test page 12 | C1 | page12 |
| page | Test page 13 | C1 | page13 |
| page | Test page 14 | C1 | page14 |
| page | Test page 15 | C1 | page15 |
| page | Test page 16 | C1 | page16 |
| page | Test page 17 | C1 | page17 |
| page | Test page 18 | C1 | page18 |
| page | Test page 19 | C1 | page19 |
| page | Test page 20 | C1 | page20 |
| page | Test page 21 | C1 | page21 |
| page | Test page C2 | C2 | pagec2 |
And the following "activities" exist:
| activity | name | intro | course | idnumber |
| forum | Test forum | Test forum intro | C1 | forum |
Scenario: Search in a site
Given global search expects the query "page" and will return:
| type | idnumber |
| activity | page01 |
| activity | page02 |
| activity | page03 |
| activity | page04 |
| activity | page05 |
| activity | page06 |
| activity | page07 |
| activity | page08 |
| activity | page09 |
| activity | page10 |
| activity | page11 |
| activity | page12 |
| activity | pagec2 |
And I entered the app as "student1"
When I press the more menu button in the app
And I press "Global search" in the app
And I set the field "Search" to "page" in the app
And I press "Search" "button" in the app
Then I should find "Test page 01" in the app
And I should find "Test page 10" in the app
When I load more items in the app
Then I should find "Test page 11" in the app
When I press "Test page 01" in the app
Then I should find "Test page content" in the app
When I press the back button in the app
And global search expects the query "forum" and will return:
| type | idnumber |
| activity | forum |
And I set the field "Search" to "forum" in the app
And I press "Search" "button" in the app
Then I should find "Test forum" in the app
But I should not find "Test page" in the app
When I press "Test forum" in the app
Then I should find "Test forum intro" in the app
When I press the back button in the app
And I press "Clear search" in the app
Then I should find "What are you searching for?" in the app
But I should not find "Test forum" in the app
Given global search expects the query "noresults" and will return:
| type | idnumber |
And I set the field "Search" to "noresults" in the app
And I press "Search" "button" in the app
Then I should find "No results for" in the app
# TODO test other results like course, user, and messages (global search generator not supported)
Scenario: Filter results
Given global search expects the query "page" and will return:
| type | idnumber |
| activity | page01 |
And I entered the app as "student1"
When I press the more menu button in the app
And I press "Global search" in the app
And I set the field "Search" to "page" in the app
And I press "Search" "button" in the app
Then I should find "Test page 01" in the app
When I press "Filter" in the app
And I press "C1" in the app
And I press "Users" in the app
And global search expects the query "page" and will return:
| type | idnumber |
| activity | page02 |
And I press "Close" in the app
Then I should find "Test page 02" in the app
But I should not find "Test page 01" in the app
Scenario: See search banner
Given the following config values are set as admin:
| searchbannerenable | 1 |
| searchbanner | Search indexing is under maintentance! |
And I entered the app as "student1"
When I press the more menu button in the app
And I press "Global search" in the app
Then I should find "Search indexing is under maintentance!" in the app
Scenario: Open from side block
Given global search expects the query "message" and will return:
| type | idnumber |
| activity | page01 |
And the following "blocks" exist:
| blockname | contextlevel | reference |
| globalsearch | Course | C1 |
And I entered the course "Course 1" as "student1" in the app
When I press "Open block drawer" in the app
And I press "Global search" in the app
Then I should find "What are you searching for?" in the app
When I press "Filter" in the app
Then "C1" should be selected in the app
But "C2" should not be selected in the app

View File

@ -14,7 +14,7 @@
import { Component, OnDestroy, OnInit } from '@angular/core';
import { IonRefresher } from '@ionic/angular';
import { Params } from '@angular/router';
import { ActivatedRoute, Params } from '@angular/router';
import { CoreSite, CoreSiteConfig } from '@classes/site';
import { CoreCourse, CoreCourseWSSection } from '@features/course/services/course';
@ -31,6 +31,7 @@ import { CoreBlockHelper } from '@features/block/services/block-helper';
import { CoreUtils } from '@services/utils/utils';
import { CoreTime } from '@singletons/time';
import { CoreAnalytics, CoreAnalyticsEventType } from '@services/analytics';
import { CoreBlockSideBlocksComponent } from '@features/block/components/side-blocks/side-blocks';
/**
* Page that displays site home index.
@ -58,7 +59,7 @@ export class CoreSiteHomeIndexPage implements OnInit, OnDestroy {
protected updateSiteObserver: CoreEventObserver;
protected logView: () => void;
constructor() {
constructor(protected route: ActivatedRoute) {
// Refresh the enabled flags if site is updated.
this.updateSiteObserver = CoreEvents.on(CoreEvents.SITE_UPDATED, () => {
this.searchEnabled = !CoreCourses.isSearchCoursesDisabledInSite();
@ -102,6 +103,10 @@ export class CoreSiteHomeIndexPage implements OnInit, OnDestroy {
this.loadContent().finally(() => {
this.dataLoaded = true;
});
this.openFocusedInstance();
this.route.queryParams.subscribe(() => this.openFocusedInstance());
}
/**
@ -226,4 +231,22 @@ export class CoreSiteHomeIndexPage implements OnInit, OnDestroy {
this.updateSiteObserver.off();
}
/**
* Check whether there is a focused instance in the page parameters and open it.
*/
private openFocusedInstance() {
const blockInstanceId = CoreNavigator.getRouteNumberParam('blockInstanceId');
if (blockInstanceId) {
CoreDomUtils.openSideModal({
component: CoreBlockSideBlocksComponent,
componentProps: {
contextLevel: 'course',
instanceId: this.siteHomeId,
initialBlockInstanceId: blockInstanceId,
},
});
}
}
}

View File

@ -22,6 +22,7 @@ import { makeSingleton } from '@singletons';
import { CoreNavigator } from '@services/navigator';
import { CoreSiteHomeHomeHandlerService } from './sitehome-home';
import { CoreMainMenuHomeHandlerService } from '@features/mainmenu/services/handlers/mainmenu';
import { Params } from '@angular/router';
/**
* Handler to treat links to site home index.
@ -36,7 +37,14 @@ export class CoreSiteHomeIndexLinkHandlerService extends CoreContentLinksHandler
/**
* @inheritdoc
*/
getActions(): CoreContentLinksAction[] | Promise<CoreContentLinksAction[]> {
getActions(siteIds: string[], url: string): CoreContentLinksAction[] | Promise<CoreContentLinksAction[]> {
const pageParams: Params = {};
const matches = url.match(/#inst(\d+)/);
if (matches && matches[1]) {
pageParams.blockInstanceId = parseInt(matches[1], 10);
}
return [{
action: (siteId: string): void => {
CoreNavigator.navigateToSitePath(
@ -44,6 +52,7 @@ export class CoreSiteHomeIndexLinkHandlerService extends CoreContentLinksHandler
{
preferCurrentTab: false,
siteId,
params: pageParams,
},
);
},

View File

@ -45,6 +45,7 @@ describe('Site Home link handlers', () => {
expect(CoreNavigator.navigateToSitePath).toHaveBeenCalledWith('/home/site', {
siteId,
preferCurrentTab: false,
params: {},
});
});

View File

@ -64,6 +64,7 @@ import { CoreNetwork } from '@services/network';
import { CoreUserGuestSupportConfig } from '@features/user/classes/support/guest-support-config';
import { CoreLang, CoreLangFormat } from '@services/lang';
import { CoreNative } from '@features/native/services/native';
import { CoreContentLinksHelper } from '@features/contentlinks/services/contentlinks-helper';
export const CORE_SITE_SCHEMAS = new InjectionToken<CoreSiteSchema[]>('CORE_SITE_SCHEMAS');
export const CORE_SITE_CURRENT_SITE_ID_CONFIG = 'current_site_id';
@ -707,6 +708,39 @@ export class CoreSitesProvider {
return CoreConstants.CONFIG.wsservice;
}
/**
* Visit a site link.
*
* @param url URL to handle.
* @param options Behaviour options.
* @param options.siteId Site Id.
* @param options.username Username related with the URL. E.g. in 'http://myuser@m.com', url would be 'http://m.com' and
* the username 'myuser'. Don't use it if you don't want to filter by username.
* @param options.checkRoot Whether to check if the URL is the root URL of a site.
* @param options.openBrowserRoot Whether to open in browser if it's root URL and it belongs to current site.
*/
async visitLink(
url: string,
options: {
siteId?: string;
username?: string;
checkRoot?: boolean;
openBrowserRoot?: boolean;
} = {},
): Promise<void> {
const treated = await CoreContentLinksHelper.handleLink(url, options.username, options.checkRoot, options.openBrowserRoot);
if (treated) {
return;
}
const site = options.siteId
? await CoreSites.getSite(options.siteId)
: CoreSites.getCurrentSite();
await site?.openInBrowserWithAutoLogin(url);
}
/**
* Check for the minimum required version.
*

View File

@ -1506,6 +1506,28 @@ export class CoreDomUtilsProvider {
return loading;
}
/**
* Show a loading modal whilst an operation is running, and an error modal if it fails.
*
* @param text Loading dialog text.
* @param needsTranslate Whether the 'text' needs to be translated.
* @param operation Operation.
* @returns Operation result.
*/
async showOperationModals<T>(text: string, needsTranslate: boolean, operation: () => Promise<T>): Promise<T | null> {
const modal = await this.showModalLoading(text, needsTranslate);
try {
return await operation();
} catch (error) {
CoreDomUtils.showErrorModal(error);
return null;
} finally {
modal.dismiss();
}
}
/**
* Show a modal warning the user that he should use a different app.
*

View File

@ -20,6 +20,14 @@ import { TranslateLoader, TranslateModule } from '@ngx-translate/core';
import englishTranslations from '@/assets/lang/en.json';
import { CoreApplicationInitStatus } from '@classes/application-init-status';
import { Translate } from '@singletons';
import { CoreSitesProviderStub, CoreSitesStub } from '@/storybook/stubs/services/sites';
import { CoreSitesProvider } from '@services/sites';
import { CoreDbProviderStub } from '@/storybook/stubs/services/db';
import { CoreDbProvider } from '@services/db';
import { CoreFilepoolProviderStub } from '@/storybook/stubs/services/filepool';
import { CoreFilepoolProvider } from '@services/filepool';
import { HttpClientStub } from '@/storybook/stubs/services/http';
import { HttpClient } from '@angular/common/http';
// For translate loader. AoT requires an exported function for factories.
export class StaticTranslateLoader extends TranslateLoader {
@ -45,12 +53,17 @@ export class StaticTranslateLoader extends TranslateLoader {
],
providers: [
{ provide: ApplicationInitStatus, useClass: CoreApplicationInitStatus },
{ provide: CoreSitesProvider, useClass: CoreSitesProviderStub },
{ provide: CoreDbProvider, useClass: CoreDbProviderStub },
{ provide: CoreFilepoolProvider, useClass: CoreFilepoolProviderStub },
{ provide: HttpClient, useClass: HttpClientStub },
{
provide: APP_INITIALIZER,
multi: true,
useValue: () => {
Translate.setDefaultLang('en');
Translate.use('en');
CoreSitesStub.stubCurrentSite();
},
},
],

View File

@ -0,0 +1,57 @@
// (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 { CoreSite, CoreSiteConfigResponse, CoreSiteInfo, CoreSiteWSPreSets, WSObservable } from '@classes/site';
import { of } from 'rxjs';
export interface CoreSiteFixture {
id: string;
info: CoreSiteInfo;
}
export class CoreSiteStub extends CoreSite {
protected wsStubs: Record<string, unknown> = {};
constructor (fixture: CoreSiteFixture) {
super(fixture.id, fixture.info.siteurl, undefined, fixture.info);
this.stubWSResponse<CoreSiteConfigResponse>('tool_mobile_get_config', {
settings: [],
warnings: [],
});
}
/**
* @inheritdoc
*/
readObservable<T = unknown>(wsFunction: string, data: unknown, preSets?: CoreSiteWSPreSets): WSObservable<T> {
if (wsFunction in this.wsStubs) {
return of(this.wsStubs[wsFunction] as T);
}
return super.readObservable<T>(wsFunction, data, preSets);
}
/**
* Prepare as stubbed response for a given WS.
*
* @param wsFunction WS function.
* @param response Response.
*/
stubWSResponse<T=unknown>(wsFunction: string, response: T): void {
this.wsStubs[wsFunction] = response;
}
}

View File

@ -0,0 +1,32 @@
// (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 { SQLiteDB } from '@classes/sqlitedb';
import { SQLiteObject } from '@ionic-native/sqlite/ngx';
/**
* SQlite database stub.
*/
export class SQLiteDBStub extends SQLiteDB {
/**
* @inheritdoc
*/
async createDatabase(): Promise<SQLiteObject> {
return new Proxy({
executeSql: () => Promise.resolve({ insertId: Math.random().toString() }),
}, {}) as unknown as SQLiteObject;
}
}

View File

@ -0,0 +1,35 @@
// (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 { SQLiteDBStub } from '@/storybook/stubs/classes/sqlitedb';
import { SQLiteDB } from '@classes/sqlitedb';
import { CoreDbProvider } from '@services/db';
/**
* Database provider stub.
*/
export class CoreDbProviderStub extends CoreDbProvider {
/**
* @inheritdoc
*/
getDB(name: string, forceNew?: boolean): SQLiteDB {
if (this.dbInstances[name] === undefined || forceNew) {
this.dbInstances[name] = new SQLiteDBStub(name);
}
return this.dbInstances[name];
}
}

View File

@ -0,0 +1,32 @@
// (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 { makeSingleton } from '@singletons';
import { CoreFilepoolProvider } from '@services/filepool';
/**
* Filepool provider stub.
*/
export class CoreFilepoolProviderStub extends CoreFilepoolProvider {
/**
* @inheritdoc
*/
async getSrcByUrl(siteId: string, fileUrl: string): Promise<string> {
return fileUrl;
}
}
export const CoreFilepoolStub = makeSingleton<CoreFilepoolProviderStub>(CoreFilepoolProvider);

View File

@ -0,0 +1,38 @@
// (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 { makeSingleton } from '@singletons';
import { HttpClient, HttpHandler } from '@angular/common/http';
import { from, Observable } from 'rxjs';
/**
* Http service stub.
*/
export class HttpClientStub extends HttpClient {
constructor() {
super(null as unknown as HttpHandler);
}
/**
* @inheritdoc
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
get(url: string): Observable<any> {
return from(fetch(url).then(response => response.text()));
}
}
export const HttpStub = makeSingleton<HttpClientStub>(HttpClient);

View File

@ -0,0 +1,43 @@
// (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 school from '@/assets/storybook/sites/school.json';
import { CoreSiteFixture, CoreSiteStub } from '@/storybook/stubs/classes/site';
import { CoreSitesProvider } from '@services/sites';
import { makeSingleton } from '@singletons';
/**
* Sites provider stub.
*/
export class CoreSitesProviderStub extends CoreSitesProvider {
/**
* @inheritdoc
*/
getRequiredCurrentSite!: () => CoreSiteStub;
/**
* @inheritdoc
*/
stubCurrentSite(fixture?: CoreSiteFixture): CoreSiteStub {
if (!this.currentSite) {
this.currentSite = new CoreSiteStub(fixture ?? school);
}
return this.getRequiredCurrentSite();
}
}
export const CoreSitesStub = makeSingleton<CoreSitesProviderStub>(CoreSitesProvider);