MOBILE-3371 search: Implement global search result

main
Noel De Martin 2023-06-28 12:35:56 +02:00
parent 9e1bcaf581
commit 00f6ec3d46
12 changed files with 488 additions and 1 deletions

View File

@ -2310,6 +2310,7 @@
"core.scanqr": "local_moodlemobileapp",
"core.scrollbackward": "local_moodlemobileapp",
"core.scrollforward": "local_moodlemobileapp",
"core.search.resultby": "local_moodlemobileapp",
"core.search": "moodle",
"core.searching": "local_moodlemobileapp",
"core.searchresults": "moodle",

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

@ -16,16 +16,19 @@ import { NgModule } from '@angular/core';
import { CoreSharedModule } from '@/core/shared.module';
import { CoreSearchBoxComponent } from './search-box/search-box';
import { CoreSearchGlobalSearchResultComponent } from '@features/search/components/global-search-result/global-search-result';
@NgModule({
declarations: [
CoreSearchBoxComponent,
CoreSearchGlobalSearchResultComponent,
],
imports: [
CoreSharedModule,
],
exports: [
CoreSearchBoxComponent,
CoreSearchGlobalSearchResultComponent,
],
})
export class CoreSearchComponentsModule {}

View File

@ -0,0 +1,23 @@
<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>
<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,86 @@
: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);
--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: 16px;
--filter: var(--mod-icon-filter);
margin-inline-end: var(--spacing-2);
margin-top: 0px;
margin-bottom: 0px;
padding: 0px;
background: transparent;
}
ion-icon {
width: 16px;
height: 16px;
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 {
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,3 @@
{
"resultby": "By {{$a}}"
}

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 { CoreCourseListItem } from '@features/courses/services/courses';
import { CoreUserWithAvatar } from '@components/user-avatar/user-avatar';
export type CoreSearchGlobalSearchResult = {
id: number;
title: string;
url: string;
content?: string;
context?: CoreSearchGlobalSearchResultContext;
module?: CoreSearchGlobalSearchResultModule;
course?: CoreCourseListItem;
user?: CoreUserWithAvatar;
};
export type CoreSearchGlobalSearchResultContext = {
userName?: string;
courseName?: string;
};
export type CoreSearchGlobalSearchResultModule = {
name: string;
iconurl: string;
area: string;
};

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 { 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';
@NgModule({
declarations: [
CoreSearchGlobalSearchResultsPageComponent,
],
imports: [
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,97 @@
// (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',
},
},
];
/**
* 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 }));