From 9d282a8f0291be69870cc906f2d1c2a310113037 Mon Sep 17 00:00:00 2001 From: Alfonso Salces Date: Mon, 18 Mar 2024 10:15:55 +0100 Subject: [PATCH 1/3] MOBILE-4219 blog: Allow edition and redesign blog entries --- scripts/langindex.json | 10 + src/addons/blog/blog-lazy.module.ts | 29 +- src/addons/blog/blog.module.ts | 2 + .../blog/components/entry-options-menu.html | 16 + .../blog/components/entry-options-menu.ts | 32 ++ src/addons/blog/constants.ts | 1 + src/addons/blog/lang.json | 11 +- .../blog/pages/edit-entry/edit-entry.html | 93 +++++ .../blog/pages/edit-entry/edit-entry.ts | 364 ++++++++++++++++++ src/addons/blog/pages/entries/entries.html | 78 ---- src/addons/blog/pages/index/index.html | 134 +++++++ src/addons/blog/pages/index/index.scss | 75 ++++ .../{entries/entries.ts => index/index.ts} | 104 ++++- .../blog/services/handlers/edit-entry-link.ts | 57 +++ src/addons/blog/services/handlers/user.ts | 2 +- src/core/lang.json | 1 + 16 files changed, 908 insertions(+), 101 deletions(-) create mode 100644 src/addons/blog/components/entry-options-menu.html create mode 100644 src/addons/blog/components/entry-options-menu.ts create mode 100644 src/addons/blog/pages/edit-entry/edit-entry.html create mode 100644 src/addons/blog/pages/edit-entry/edit-entry.ts delete mode 100644 src/addons/blog/pages/entries/entries.html create mode 100644 src/addons/blog/pages/index/index.html create mode 100644 src/addons/blog/pages/index/index.scss rename src/addons/blog/pages/{entries/entries.ts => index/index.ts} (77%) create mode 100644 src/addons/blog/services/handlers/edit-entry-link.ts diff --git a/scripts/langindex.json b/scripts/langindex.json index 009ac93f8..6e92bcbf6 100644 --- a/scripts/langindex.json +++ b/scripts/langindex.json @@ -85,16 +85,25 @@ "addon.block_timeline.searchevents": "block_timeline", "addon.block_timeline.sortbycourses": "block_timeline", "addon.block_timeline.sortbydates": "block_timeline", + "addon.blog.addnewentry": "blog", + "addon.blog.associations": "blog", + "addon.blog.associatewithcourse": "blog", + "addon.blog.associatewithmodule": "blog", + "addon.blog.attachment": "blog", "addon.blog.blog": "blog", "addon.blog.blogentries": "blog", + "addon.blog.entrybody": "blog", + "addon.blog.entrytitle": "blog", "addon.blog.errorloadentries": "local_moodlemobileapp", "addon.blog.linktooriginalentry": "blog", "addon.blog.noentriesyet": "blog", + "addon.blog.publishto": "blog", "addon.blog.publishtonoone": "blog", "addon.blog.publishtosite": "blog", "addon.blog.publishtoworld": "blog", "addon.blog.showonlyyourentries": "local_moodlemobileapp", "addon.blog.siteblogheading": "blog", + "addon.blog.tags": "blog", "addon.calendar.allday": "calendar", "addon.calendar.calendar": "calendar", "addon.calendar.calendarevent": "local_moodlemobileapp", @@ -1500,6 +1509,7 @@ "core.block.tour_navigation_dashboard_content": "tool_usertours", "core.block.tour_navigation_dashboard_title": "tool_usertours", "core.browser": "local_moodlemobileapp", + "core.bynameondate": "forum", "core.calculating": "local_moodlemobileapp", "core.cancel": "moodle", "core.cannotconnect": "local_moodlemobileapp", diff --git a/src/addons/blog/blog-lazy.module.ts b/src/addons/blog/blog-lazy.module.ts index 452aef95a..f2c1f931e 100644 --- a/src/addons/blog/blog-lazy.module.ts +++ b/src/addons/blog/blog-lazy.module.ts @@ -16,13 +16,15 @@ import { Injector, NgModule } from '@angular/core'; import { ROUTES, Routes } from '@angular/router'; import { CoreSharedModule } from '@/core/shared.module'; -import { AddonBlogEntriesPage } from './pages/entries/entries'; +import { AddonBlogIndexPage } from './pages/index'; import { CoreCommentsComponentsModule } from '@features/comments/components/components.module'; import { CoreTagComponentsModule } from '@features/tag/components/components.module'; +import { CoreMainMenuComponentsModule } from '@features/mainmenu/components/components.module'; +import { AddonBlogEntryOptionsMenuComponent } from './components/entry-options-menu'; import { buildTabMainRoutes } from '@features/mainmenu/mainmenu-tab-routing.module'; import { ADDON_BLOG_MAINMENU_PAGE_NAME } from './constants'; -import { CoreMainMenuComponentsModule } from '@features/mainmenu/components/components.module'; +import { canLeaveGuard } from '@guards/can-leave'; /** * Build module routes. @@ -30,13 +32,23 @@ import { CoreMainMenuComponentsModule } from '@features/mainmenu/components/comp * @param injector Injector. * @returns Routes. */ -function buildRoutes(injector: Injector): Routes { + function buildRoutes(injector: Injector): Routes { return [ - ...buildTabMainRoutes(injector, { + { + path: 'index', + component: AddonBlogIndexPage, data: { mainMenuTabRoot: ADDON_BLOG_MAINMENU_PAGE_NAME, }, - component: AddonBlogEntriesPage, + }, + { + path: 'edit/:id', + loadComponent: () => import('./pages/edit-entry/edit-entry').then(c => c.AddonBlogEditEntryPage), + canDeactivate: [canLeaveGuard], + }, + ...buildTabMainRoutes(injector, { + redirectTo: 'index', + pathMatch: 'full', }), ]; } @@ -48,6 +60,10 @@ function buildRoutes(injector: Injector): Routes { CoreTagComponentsModule, CoreMainMenuComponentsModule, ], + declarations: [ + AddonBlogIndexPage, + AddonBlogEntryOptionsMenuComponent, + ], providers: [ { provide: ROUTES, @@ -56,8 +72,5 @@ function buildRoutes(injector: Injector): Routes { useFactory: buildRoutes, }, ], - declarations: [ - AddonBlogEntriesPage, - ], }) export class AddonBlogLazyModule {} diff --git a/src/addons/blog/blog.module.ts b/src/addons/blog/blog.module.ts index 2d5f71ff4..49dac76c5 100644 --- a/src/addons/blog/blog.module.ts +++ b/src/addons/blog/blog.module.ts @@ -23,6 +23,7 @@ import { CoreMainMenuDelegate } from '@features/mainmenu/services/mainmenu-deleg import { CoreTagAreaDelegate } from '@features/tag/services/tag-area-delegate'; import { CoreUserDelegate } from '@features/user/services/user-delegate'; import { AddonBlogCourseOptionHandler } from './services/handlers/course-option'; +import { AddonBlogEditEntryLinkHandler } from './services/handlers/edit-entry-link'; import { AddonBlogIndexLinkHandler } from './services/handlers/index-link'; import { AddonBlogMainMenuHandler } from './services/handlers/mainmenu'; import { AddonBlogTagAreaHandler } from './services/handlers/tag-area'; @@ -48,6 +49,7 @@ const routes: Routes = [ multi: true, useValue: () => { CoreContentLinksDelegate.registerHandler(AddonBlogIndexLinkHandler.instance); + CoreContentLinksDelegate.registerHandler(AddonBlogEditEntryLinkHandler.instance); CoreMainMenuDelegate.registerHandler(AddonBlogMainMenuHandler.instance); CoreUserDelegate.registerHandler(AddonBlogUserHandler.instance); CoreTagAreaDelegate.registerHandler(AddonBlogTagAreaHandler.instance); diff --git a/src/addons/blog/components/entry-options-menu.html b/src/addons/blog/components/entry-options-menu.html new file mode 100644 index 000000000..4f1587b50 --- /dev/null +++ b/src/addons/blog/components/entry-options-menu.html @@ -0,0 +1,16 @@ + + + + + + + + diff --git a/src/addons/blog/components/entry-options-menu.ts b/src/addons/blog/components/entry-options-menu.ts new file mode 100644 index 000000000..c52b4cc97 --- /dev/null +++ b/src/addons/blog/components/entry-options-menu.ts @@ -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 { Component } from '@angular/core'; +import { PopoverController } from '@singletons'; + +@Component({ + selector: 'addon-blog-entry-options-menu', + templateUrl: './entry-options-menu.html', +}) +export class AddonBlogEntryOptionsMenuComponent { + + /** + * Do an action over the course. + * + * @param action Action name to take. + */ + action(action: string): void { + PopoverController.dismiss(action); + } + +} diff --git a/src/addons/blog/constants.ts b/src/addons/blog/constants.ts index 572cd8f9e..de45e0da1 100644 --- a/src/addons/blog/constants.ts +++ b/src/addons/blog/constants.ts @@ -13,3 +13,4 @@ // limitations under the License. export const ADDON_BLOG_MAINMENU_PAGE_NAME = 'blog'; +export const ADDON_BLOG_ENTRY_UPDATED = 'blog_entry_updated'; diff --git a/src/addons/blog/lang.json b/src/addons/blog/lang.json index c4b04081d..e52f092ac 100644 --- a/src/addons/blog/lang.json +++ b/src/addons/blog/lang.json @@ -1,12 +1,21 @@ { + "addnewentry": "Add a new entry", + "associations": "Associations", + "attachment": "Attachment", "blog": "Blog", "blogentries": "Blog entries", + "entrybody": "Blog entry body", + "entrytitle": "Entry title", "errorloadentries": "Error loading blog entries.", "linktooriginalentry": "Link to original blog entry", "noentriesyet": "No visible entries here", + "publishto": "Publish to", "publishtonoone": "Yourself (draft)", "publishtosite": "Anyone on this site", "publishtoworld": "Anyone in the world", "showonlyyourentries": "Show only your entries", - "siteblogheading": "Site blog" + "siteblogheading": "Site blog", + "tags": "Tags", + "associatewithcourse": "Blog about course {{$a.coursename}}", + "associatewithmodule": "Blog about {{$a.modtype}}: {{$a.modname}}" } diff --git a/src/addons/blog/pages/edit-entry/edit-entry.html b/src/addons/blog/pages/edit-entry/edit-entry.html new file mode 100644 index 000000000..5d2ab8f7c --- /dev/null +++ b/src/addons/blog/pages/edit-entry/edit-entry.html @@ -0,0 +1,93 @@ + + + + + + +

{{ entry ? entry.subject : 'addon.blog.addnewentry' | translate }}

+
+ +
+
+ + +
+ + +

{{ 'addon.blog.entrytitle' | translate }}

+
+
+ + + {{ 'addon.blog.entrybody' | translate }} + + + + + + + {{ 'addon.blog.publishtonoone' | translate }} + + + {{ 'addon.blog.publishtosite' | translate }} + + + + + + + @if (entry && courseId && associatedCourse) { + + + +

{{ 'addon.blog.associations' | translate }}

+
+
+
+ @if (associationsExpanded) { + + @if (associatedModule) { + + + + } @else if (associatedCourse) { + + + + } + + } +
+ } + + + + + + + {{ (entry ? 'core.save' : 'addon.blog.addnewentry') | translate }} + + + + + + + +
+
diff --git a/src/addons/blog/pages/edit-entry/edit-entry.ts b/src/addons/blog/pages/edit-entry/edit-entry.ts new file mode 100644 index 000000000..cf43e9ccf --- /dev/null +++ b/src/addons/blog/pages/edit-entry/edit-entry.ts @@ -0,0 +1,364 @@ +// (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 { ContextLevel } from '@/core/constants'; +import { CoreSharedModule } from '@/core/shared.module'; +import { ADDON_BLOG_ENTRY_UPDATED } from '@addons/blog/constants'; +import { + AddonBlog, + AddonBlogAddEntryOption, + AddonBlogFilter, + AddonBlogPost, + AddonBlogProvider, + AddonBlogPublishState, + ADDON_BLOG_PUBLISH_STATE, +} from '@addons/blog/services/blog'; +import { Component, OnInit } from '@angular/core'; +import { FormControl, FormGroup, Validators } from '@angular/forms'; +import { CoreError } from '@classes/errors/error'; +import { CoreCommentsComponentsModule } from '@features/comments/components/components.module'; +import { CoreCourse } from '@features/course/services/course'; +import { CoreCourseHelper, CoreCourseModuleData } from '@features/course/services/course-helper'; +import { CoreCourseBasicData } from '@features/courses/services/courses'; +import { CoreEditorComponentsModule } from '@features/editor/components/components.module'; +import { CoreFileUploader } from '@features/fileuploader/services/fileuploader'; +import { CoreTagComponentsModule } from '@features/tag/components/components.module'; +import { CanLeave } from '@guards/can-leave'; +import { CoreNavigator } from '@services/navigator'; +import { CoreSites, CoreSitesReadingStrategy } from '@services/sites'; +import { CoreDomUtils } from '@services/utils/dom'; +import { CoreUtils } from '@services/utils/utils'; +import { CoreWSFile } from '@services/ws'; +import { Translate } from '@singletons'; +import { CoreEvents } from '@singletons/events'; + +@Component({ + selector: 'addon-blog-edit-entry', + templateUrl: './edit-entry.html', + standalone: true, + imports: [ + CoreEditorComponentsModule, + CoreSharedModule, + CoreCommentsComponentsModule, + CoreTagComponentsModule, + ], +}) +export class AddonBlogEditEntryPage implements CanLeave, OnInit { + + publishState = ADDON_BLOG_PUBLISH_STATE; + form = new FormGroup({ + subject: new FormControl('', { nonNullable: true, validators: [Validators.required] }), + summary: new FormControl('', { nonNullable: true, validators: [Validators.required] }), + publishState: new FormControl( + ADDON_BLOG_PUBLISH_STATE.draft, + { nonNullable: true, validators: [Validators.required] }, + ), + associateWithCourse: new FormControl(false, { nonNullable: true, validators: [Validators.required] }), + associateWithModule: new FormControl(false, { nonNullable: true, validators: [Validators.required] }), + }); + + entry?: AddonBlogPost; + loaded = false; + maxFiles = 99; + initialFiles: CoreWSFile[] = []; + files: CoreWSFile[] = []; + courseId?: number; + modId?: number; + userId?: number; + associatedCourse?: CoreCourseBasicData; + associatedModule?: CoreCourseModuleData; + associationsExpanded = false; + contextLevel: ContextLevel = ContextLevel.SYSTEM; + contextInstanceId = 0; + component = AddonBlogProvider.COMPONENT; + siteHomeId?: number; + forceLeave = false; + + /** + * Gives if the form is not pristine. (only for existing entries) + * + * @returns Data has changed or not. + */ + get hasDataChangedForEdit(): boolean { + const form = this.form.controls; + + return form.summary.value !== this.entry?.summary || + form.subject.value !== this.entry?.subject || + form.publishState.value !== this.entry?.publishstate || + CoreFileUploader.areFileListDifferent(this.files, this.initialFiles) || + form.associateWithModule.value !== (this.entry?.moduleid !== 0) || + form.associateWithCourse.value !== (this.entry?.courseid !== 0); + } + + /** + * Gives if the form is not pristine. (only for new entries) + * + * @returns Data has changed or not. + */ + get hasDataChangedForNewEntry(): boolean { + const form = this.form.controls; + + return form.subject.value !== '' || + form.summary.value !== '' || + form.publishState.value !== ADDON_BLOG_PUBLISH_STATE.draft || + CoreFileUploader.areFileListDifferent(this.files, this.initialFiles); + } + + /** + * @inheritdoc + */ + async ngOnInit(): Promise { + const site = await CoreSites.getSite(); + const isEditingEnabled = await AddonBlog.isEditingEnabled(); + + if (!site || !isEditingEnabled) { + return CoreNavigator.back(); + } + + const entryId = CoreNavigator.getRouteNumberParam('id'); + const lastModified = CoreNavigator.getRouteNumberParam('lastModified'); + const filters: AddonBlogFilter | undefined = CoreNavigator.getRouteParam('filters'); + this.userId = CoreNavigator.getRouteNumberParam('userId'); + this.siteHomeId = CoreSites.getCurrentSiteHomeId(); + + if (!entryId) { + this.loaded = true; + + return; + } + + try { + this.entry = await this.getEntry({ filters, lastModified, entryId }); + this.files = this.entry.attachmentfiles ?? []; + this.initialFiles = [...this.files]; + this.courseId = this.entry.courseid; + this.modId = this.entry.coursemoduleid ? this.entry.coursemoduleid : CoreNavigator.getRouteNumberParam('cmId'); + + if (this.courseId) { + this.form.controls.associateWithCourse.setValue(true); + const { course } = await CoreCourseHelper.getCourse(this.courseId); + this.associatedCourse = course; + } + + if (this.modId) { + this.form.controls.associateWithModule.setValue(true); + this.associatedModule = await CoreCourse.getModule(this.modId); + } + } catch (error) { + CoreDomUtils.showErrorModalDefault(error, 'Error retrieving data.'); + this.forceLeave = true; + CoreNavigator.back(); + + return; + } + + this.form.setValue({ + subject: this.entry?.subject ?? '', + summary: this.entry?.summary ?? '', + publishState: this.entry?.publishstate ?? ADDON_BLOG_PUBLISH_STATE.draft, + associateWithCourse: this.form.controls.associateWithCourse.value, + associateWithModule: this.form.controls.associateWithModule.value, + }); + + this.calculateContext(); + this.loaded = true; + } + + /** + * Retrieves blog entry. + * + * @returns Blog entry. + */ + protected async getEntry(params: AddonBlogEditEntryGetEntryParams): Promise { + try { + const { entries } = await AddonBlog.getEntries( + { entryid: params.entryId }, + { readingStrategy: CoreSitesReadingStrategy.PREFER_NETWORK }, + ); + + const selectedEntry = entries.find(entry => entry.id === params.entryId); + + if (!selectedEntry) { + throw new CoreError('Entry not found'); + } + + if (params.filters && params.lastModified && selectedEntry.lastmodified < params.lastModified) { + throw new CoreError('Entry is outdated'); + } + + return selectedEntry; + } catch (error) { + if (!params.filters || CoreUtils.isWebServiceError(error)) { + // Cannot get the entry, reject. + throw error; + } + + const updatedEntries = await AddonBlog.getEntries(params.filters); + const entry = updatedEntries.entries.find(entry => entry.id === params.entryId); + + if (!entry) { + throw error; + } + + return entry; + } + + } + + /** + * Calculate context level and context instance. + */ + calculateContext(): void { + // Calculate the context level. + if (this.userId && !this.courseId && !this.modId) { + this.contextLevel = ContextLevel.USER; + this.contextInstanceId = this.userId; + } else if (this.courseId && this.courseId != this.siteHomeId) { + this.contextLevel = ContextLevel.COURSE; + this.contextInstanceId = this.courseId; + } else { + this.contextLevel = ContextLevel.SYSTEM; + this.contextInstanceId = 0; + } + } + + /** + * Update or create entry. + * + * @returns Promise resolved when done. + */ + async save(): Promise { + const { summary, subject, publishState } = this.form.value; + + if (!subject || !summary || !publishState) { + return; + } + + const loading = await CoreDomUtils.showModalLoading('core.sending', true); + + if (this.entry) { + try { + if (!CoreFileUploader.areFileListDifferent(this.files, this.initialFiles)) { + return await this.saveEntry(); + } + + const { attachmentsid } = await AddonBlog.prepareEntryForEdition({ entryid: this.entry.id }); + const removedFiles = CoreFileUploader.getFilesToDelete(this.initialFiles, this.files); + + if (removedFiles.length) { + await CoreFileUploader.deleteDraftFiles(attachmentsid, removedFiles); + } + + await CoreFileUploader.uploadFiles(attachmentsid, this.files); + + return await this.saveEntry(attachmentsid); + } catch (error) { + CoreDomUtils.showErrorModalDefault(error, 'Error updating entry.'); + } finally { + await loading.dismiss(); + } + + return; + } + + try { + if (!this.files.length) { + return await this.saveEntry(); + } + + const attachmentId = await CoreFileUploader.uploadOrReuploadFiles(this.files, this.component); + await this.saveEntry(attachmentId); + } catch (error) { + CoreDomUtils.showErrorModalDefault(error, 'Error creating entry.'); + } finally { + await loading.dismiss(); + } + } + + /** + * Expand or collapse associations. + */ + toggleAssociations(): void { + this.associationsExpanded = !this.associationsExpanded; + } + + /** + * Check if the user can leave the view. If there are changes to be saved, it will ask for confirm. + * + * @returns Promise resolved with true if can leave the view, rejected otherwise. + */ + async canLeave(): Promise { + if (this.forceLeave) { + return true; + } + + if ((!this.entry && this.hasDataChangedForNewEntry) || (this.entry && this.hasDataChangedForEdit)) { + // Modified, confirm user wants to go back. + await CoreDomUtils.showConfirm(Translate.instant('core.confirmcanceledit')); + } + + return true; + } + + /** + * Add attachment to options list. + * + * @param attachmentsId Attachment ID. + * @param options Options list. + */ + addAttachments(attachmentsId: number | undefined, options: AddonBlogAddEntryOption[]): void { + if (attachmentsId === undefined) { + return; + } + + options.push({ name: 'attachmentsid', value: attachmentsId }); + } + + /** + * Create or update entry. + * + * @param attachmentsId Attachments. + * @returns Promise resolved when done. + */ + async saveEntry(attachmentsId?: number): Promise { + const { summary, subject, publishState } = this.form.value; + + if (!summary || !subject || !publishState) { + return; + } + + const options: AddonBlogAddEntryOption[] = [ + { name: 'publishstate', value: publishState }, + { name: 'courseassoc', value: this.form.controls.associateWithCourse.value && this.courseId ? this.courseId : 0 }, + { name: 'modassoc', value: this.form.controls.associateWithModule.value && this.modId ? this.modId : 0 }, + ]; + + this.addAttachments(attachmentsId, options); + + this.entry + ? await AddonBlog.updateEntry({ subject, summary, summaryformat: 1, options , entryid: this.entry.id }) + : await AddonBlog.addEntry({ subject, summary, summaryformat: 1, options }); + + CoreEvents.trigger(ADDON_BLOG_ENTRY_UPDATED); + this.forceLeave = true; + + return CoreNavigator.back(); + } + +} + +type AddonBlogEditEntryGetEntryParams = { + entryId: number; + filters?: AddonBlogFilter; + lastModified?: number; +}; diff --git a/src/addons/blog/pages/entries/entries.html b/src/addons/blog/pages/entries/entries.html deleted file mode 100644 index 67120df9f..000000000 --- a/src/addons/blog/pages/entries/entries.html +++ /dev/null @@ -1,78 +0,0 @@ - - - - - - -

{{ title | translate }}

-
- - - -
-
- - - - - - - - {{ 'addon.blog.showonlyyourentries' | translate }} - - - - - - - - -
-

- -

- - {{ 'addon.blog.' + entry.publishTranslated! | translate}} - -
-
- {{entry.user && entry.user.fullname}} - - {{entry.created | coreDateDayOrTime}} - -
-
-
- - - - - - - - -
{{ 'core.tag.tags' | translate }}:
- -
-
- - - - {{ 'addon.blog.linktooriginalentry' | translate }} - -
-
- - {{entry.lastmodified - | - coreTimeAgo}} - -
-
-
- -
-
diff --git a/src/addons/blog/pages/index/index.html b/src/addons/blog/pages/index/index.html new file mode 100644 index 000000000..7baa5871f --- /dev/null +++ b/src/addons/blog/pages/index/index.html @@ -0,0 +1,134 @@ + + + + + + +

{{ title | translate }}

+
+ + + +
+
+ + + + + + + + + @if (showMyEntriesToggle) { + + + {{ 'addon.blog.showonlyyourentries' | translate }} + + + } + + @for (entry of entries; track entry.id) { + @if (!onlyMyEntries || entry.userid === currentUserId) { +
+
+

+ + @if (entry.userid === currentUserId && entry.publishTranslated === 'publishtonoone') { + + {{ 'addon.blog.publishtonoone' | translate }} + + } +

+ + @if (entry.userid === currentUserId && optionsAvailable) { +
+ + +
+ } +
+ +
+ + + + + + +
+ + + +
+
+ +
+ + @if (tagsEnabled && entry.tags && entry.tags!.length > 0) { + + +
{{ 'core.tag.tags' | translate }}:
+ +
+
+ } + + @for (file of entry.attachmentfiles; track $index) { + + } + + @if (entry.uniquehash) { + + {{ 'addon.blog.linktooriginalentry' | translate }} + + } + +
+
+ + @if (entry.lastmodified > entry.created) { +
+ + + {{ entry.lastmodified | coreTimeAgo }} + + + @if (entry.userid === currentUserId && entry.publishstate !== 'draft') { + + + {{ 'addon.blog.' + entry.publishTranslated | translate }} + + } +
+ } + + @if (commentsEnabled) { + + } + +
+ } + } @empty { + + } + + + +
+ + + + + + + +
diff --git a/src/addons/blog/pages/index/index.scss b/src/addons/blog/pages/index/index.scss new file mode 100644 index 000000000..ab579b29e --- /dev/null +++ b/src/addons/blog/pages/index/index.scss @@ -0,0 +1,75 @@ +@use "theme/globals" as *; + +:host { + + ion-card { + padding: .5rem 1rem; + } + + .entry { + border-top: 1px solid var(--stroke); + + &-visibility-permission { + display: flex; + align-items: center; + font-size: 0.875rem; + font-weight: 500; + + ion-icon { + margin-right: .3rem; + } + } + + &-draft { + margin-left: .3rem; + position: relative; + top: 4px; + } + + &-subject { + core-format-text { + font-size: 1.25rem; + font-weight: 500; + } + + &::part(native) { + padding-left: 0; + } + } + + &-creation-info { + core-user-avatar { + --userpicture-padding: .6rem; + margin-left: -.5rem; + } + } + + &-last-modification { + ion-icon { + margin-right: .3rem; + } + } + } + + .core-button-spinner { + margin-right: -.5rem; + align-self: start; + + ion-button::part(native) { + --padding-end: 0; + --padding-start: 0; + --padding-left: 0; + --padding-right: 0; + } + } + + core-comments ::ng-deep { + &::part(native) { + --padding-start: 0; + } + } + + .border-bottom { + border-bottom: 1px solid var(--stroke); + } +} diff --git a/src/addons/blog/pages/entries/entries.ts b/src/addons/blog/pages/index/index.ts similarity index 77% rename from src/addons/blog/pages/entries/entries.ts rename to src/addons/blog/pages/index/index.ts index 57527c2a9..186c5b25c 100644 --- a/src/addons/blog/pages/entries/entries.ts +++ b/src/addons/blog/pages/index/index.ts @@ -13,29 +13,33 @@ // limitations under the License. import { ContextLevel } from '@/core/constants'; +import { AddonBlogEntryOptionsMenuComponent } from '@addons/blog/components/entry-options-menu'; +import { ADDON_BLOG_ENTRY_UPDATED } from '@addons/blog/constants'; import { AddonBlog, AddonBlogFilter, AddonBlogPost, AddonBlogProvider } from '@addons/blog/services/blog'; -import { Component, OnInit } from '@angular/core'; +import { Component, OnDestroy, OnInit } from '@angular/core'; import { CoreComments } from '@features/comments/services/comments'; import { CoreMainMenuDeepLinkManager } from '@features/mainmenu/classes/deep-link-manager'; import { CoreTag } from '@features/tag/services/tag'; import { CoreUser, CoreUserProfile } from '@features/user/services/user'; import { CoreAnalytics, CoreAnalyticsEventType } from '@services/analytics'; import { CoreNavigator } from '@services/navigator'; -import { CoreSites } from '@services/sites'; +import { CoreSites, CoreSitesReadingStrategy } from '@services/sites'; import { CoreDomUtils } from '@services/utils/dom'; import { CoreTextUtils } from '@services/utils/text'; import { CoreUrlUtils } from '@services/utils/url'; import { CoreUtils } from '@services/utils/utils'; +import { CoreEventObserver, CoreEvents } from '@singletons/events'; import { CoreTime } from '@singletons/time'; /** * Page that displays the list of blog entries. */ @Component({ - selector: 'page-addon-blog-entries', - templateUrl: 'entries.html', + selector: 'page-addon-blog-index', + templateUrl: 'index.html', + styleUrl: './index.scss', }) -export class AddonBlogEntriesPage implements OnInit { +export class AddonBlogIndexPage implements OnInit, OnDestroy { title = ''; @@ -59,6 +63,8 @@ export class AddonBlogEntriesPage implements OnInit { tagsEnabled = false; contextLevel: ContextLevel = ContextLevel.SYSTEM; contextInstanceId = 0; + entryUpdateObserver: CoreEventObserver; + optionsAvailable = false; constructor() { this.currentUserId = CoreSites.getCurrentSiteUserId(); @@ -82,6 +88,12 @@ export class AddonBlogEntriesPage implements OnInit { }), }); }); + + this.entryUpdateObserver = CoreEvents.on(ADDON_BLOG_ENTRY_UPDATED, async () => { + this.loaded = false; + await CoreUtils.ignoreErrors(this.refresh()); + this.loaded = true; + }); } /** @@ -146,6 +158,7 @@ export class AddonBlogEntriesPage implements OnInit { deepLinkManager.treatLink(); await this.fetchEntries(); + this.optionsAvailable = await AddonBlog.isEditingEnabled(); } /** @@ -165,7 +178,15 @@ export class AddonBlogEntriesPage implements OnInit { const loadPage = this.onlyMyEntries ? this.userPageLoaded : this.pageLoaded; try { - const result = await AddonBlog.getEntries(this.filter, loadPage); + const result = await AddonBlog.getEntries( + this.filter, + { + page: loadPage, + readingStrategy: refresh + ? CoreSitesReadingStrategy.PREFER_NETWORK + : undefined, + }, + ); const promises = result.entries.map(async (entry: AddonBlogPostFormatted) => { switch (entry.publishstate) { @@ -271,7 +292,7 @@ export class AddonBlogEntriesPage implements OnInit { * * @param refresher Refresher instance. */ - refresh(refresher?: HTMLIonRefresherElement): void { + async refresh(refresher?: HTMLIonRefresherElement): Promise { const promises = this.entries.map((entry) => CoreComments.invalidateCommentsData(ContextLevel.USER, entry.userid, this.component, entry.id, 'format_blog')); @@ -287,13 +308,70 @@ export class AddonBlogEntriesPage implements OnInit { } - CoreUtils.allPromises(promises).finally(() => { - this.fetchEntries(true).finally(() => { - if (refresher) { - refresher?.complete(); - } - }); + await CoreUtils.allPromises(promises); + await this.fetchEntries(true); + refresher?.complete(); + } + + /** + * Redirect to entry creation form. + */ + createNewEntry(): void { + CoreNavigator.navigateToSitePath('blog/edit/0'); + } + + /** + * Delete entry by id. + * + * @param id Entry id. + */ + async deleteEntry(id: number): Promise { + const loading = await CoreDomUtils.showModalLoading(); + try { + await AddonBlog.deleteEntry({ entryid: id }); + await this.refresh(); + } catch (error) { + CoreDomUtils.showErrorModalDefault(error, 'addon.blog.errorloadentries', true); + } finally { + loading.dismiss(); + } + } + + /** + * Show the context menu. + * + * @param event Click Event. + */ + async showEntryActionsPopover(event: Event, entry: AddonBlogPostFormatted): Promise { + event.preventDefault(); + event.stopPropagation(); + + const popoverData = await CoreDomUtils.openPopover({ + component: AddonBlogEntryOptionsMenuComponent, + event, }); + + switch (popoverData) { + case 'edit': + await CoreNavigator.navigateToSitePath(`blog/edit/${entry.id}`, { + params: this.filter.cmid + ? { cmId: this.filter.cmid, filters: this.filter, lastModified: entry.lastmodified } + : { filters: this.filter, lastModified: entry.lastmodified }, + }); + break; + case 'delete': + await this.deleteEntry(entry.id); + break; + default: + break; + } + } + + /** + * @inheritdoc + */ + ngOnDestroy(): void { + this.entryUpdateObserver.off(); } } diff --git a/src/addons/blog/services/handlers/edit-entry-link.ts b/src/addons/blog/services/handlers/edit-entry-link.ts new file mode 100644 index 000000000..fa8d0b6ea --- /dev/null +++ b/src/addons/blog/services/handlers/edit-entry-link.ts @@ -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 { Params } from '@angular/router'; +import { CoreContentLinksHandlerBase } from '@features/contentlinks/classes/base-handler'; +import { CoreContentLinksAction } from '@features/contentlinks/services/contentlinks-delegate'; +import { CoreNavigator } from '@services/navigator'; +import { makeSingleton } from '@singletons'; +import { AddonBlog } from '../blog'; + +/** + * Handler to treat links to edit blog entry page. + */ +@Injectable({ providedIn: 'root' }) +export class AddonBlogEditEntryLinkHandlerService extends CoreContentLinksHandlerBase { + + name = 'AddonBlogEditEntryLinkHandler'; + featureName = 'CoreUserDelegate_AddonBlog:blogs'; + pattern = /\/blog\/(add|edit)\.php/; + + /** + * @inheritdoc + */ + getActions(siteIds: string[], url: string, params: Record): CoreContentLinksAction[] { + const pageParams: Params = {}; + + pageParams.courseId = params.courseid; + pageParams.cmId = params.modid; + + return [{ + action: async (siteId: string): Promise => { + await CoreNavigator.navigateToSitePath(`/blog/edit/${params.entryid ?? 0}`, { params: pageParams, siteId }); + }, + }]; + } + + /** + * @inheritdoc + */ + isEnabled(siteId: string): Promise { + return AddonBlog.isPluginEnabled(siteId); + } + +} +export const AddonBlogEditEntryLinkHandler = makeSingleton(AddonBlogEditEntryLinkHandlerService); diff --git a/src/addons/blog/services/handlers/user.ts b/src/addons/blog/services/handlers/user.ts index 85b81f942..bd172554a 100644 --- a/src/addons/blog/services/handlers/user.ts +++ b/src/addons/blog/services/handlers/user.ts @@ -73,7 +73,7 @@ export class AddonBlogUserHandlerService implements CoreUserProfileHandler { action: (event, user, context, contextId): void => { event.preventDefault(); event.stopPropagation(); - CoreNavigator.navigateToSitePath('/blog', { + CoreNavigator.navigateToSitePath('/blog/index', { params: { courseId: contextId, userId: user.id }, }); }, diff --git a/src/core/lang.json b/src/core/lang.json index 5138ebfac..884444ea0 100644 --- a/src/core/lang.json +++ b/src/core/lang.json @@ -12,6 +12,7 @@ "areyousure": "Are you sure?", "back": "Back", "browser": "Browser", + "bynameondate": "by {{$a.name}} - {{$a.date}}", "calculating": "Calculating", "cancel": "Cancel", "cannotconnect": "Can't connect to site", From ed8223d761ddb1e40958160877cb1116f3a037cd Mon Sep 17 00:00:00 2001 From: Alfonso Salces Date: Mon, 18 Mar 2024 10:15:37 +0100 Subject: [PATCH 2/3] MOBILE-4219 blog: Add some ws to create and edit blog entries --- src/addons/blog/services/blog.ts | 151 +++++++++++++++++++++++++++++-- 1 file changed, 144 insertions(+), 7 deletions(-) diff --git a/src/addons/blog/services/blog.ts b/src/addons/blog/services/blog.ts index 9b91ee307..7779d534b 100644 --- a/src/addons/blog/services/blog.ts +++ b/src/addons/blog/services/blog.ts @@ -16,7 +16,7 @@ import { Injectable } from '@angular/core'; import { CoreSiteWSPreSets } from '@classes/sites/authenticated-site'; import { CoreSite } from '@classes/sites/site'; import { CoreTagItem } from '@features/tag/services/tag'; -import { CoreSites } from '@services/sites'; +import { CoreSites, CoreSitesCommonWSOptions } from '@services/sites'; import { CoreUtils } from '@services/utils/utils'; import { CoreStatusWithWarningsWSResponse, CoreWSExternalFile, CoreWSExternalWarning } from '@services/ws'; import { makeSingleton } from '@singletons'; @@ -62,27 +62,84 @@ export class AddonBlogProvider { * Get blog entries. * * @param filter Filter to apply on search. - * @param page Page of the blog entries to fetch. - * @param siteId Site ID. If not defined, current site. + * @param options WS Options. * @returns Promise to be resolved when the entries are retrieved. */ - async getEntries(filter: AddonBlogFilter = {}, page: number = 0, siteId?: string): Promise { - const site = await CoreSites.getSite(siteId); + async getEntries(filter: AddonBlogFilter = {}, options?: AddonBlogGetEntriesOptions): Promise { + const site = await CoreSites.getSite(options?.siteId); const data: CoreBlogGetEntriesWSParams = { filters: CoreUtils.objectToArrayOfObjects(filter, 'name', 'value'), - page: page, + page: options?.page ?? 0, perpage: AddonBlogProvider.ENTRIES_PER_PAGE, }; const preSets: CoreSiteWSPreSets = { cacheKey: this.getEntriesCacheKey(filter), updateFrequency: CoreSite.FREQUENCY_SOMETIMES, + ...CoreSites.getReadingStrategyPreSets(options?.readingStrategy), }; return site.read('core_blog_get_entries', data, preSets); } + /** + * Create a new entry. + * + * @param params WS Params. + * @param siteId Site ID where the entry should be created. + * @returns Entry id. + * @since 4.4 + */ + async addEntry(params: AddonBlogAddEntryWSParams, siteId?: string): Promise { + const site = await CoreSites.getSite(siteId); + + return await site.write('core_blog_add_entry', params); + } + + /** + * Update an entry. + * + * @param params WS Params. + * @param siteId Site ID of the entry. + * @since 4.4 + */ + async updateEntry(params: AddonBlogUpdateEntryWSParams, siteId?: string): Promise { + const site = await CoreSites.getSite(siteId); + await site.write('core_blog_update_entry', params); + } + + /** + * Prepare entry for edition by entry id. + * + * @param params WS Params. + * @param siteId Site ID of the entry. + * @returns WS Response + * @since 4.4 + */ + async prepareEntryForEdition( + params: AddonBlogPrepareEntryForEditionWSParams, + siteId?: string, + ): Promise { + const site = await CoreSites.getSite(siteId); + + return await site.write('core_blog_prepare_entry_for_edition', params); + } + + /** + * Delete entry by id. + * + * @param params WS params. + * @param siteId Site ID of the entry. + * @returns Entry deleted successfully or not. + * @since 4.4 + */ + async deleteEntry(params: AddonBlogDeleteEntryWSParams, siteId?: string): Promise { + const site = await CoreSites.getSite(siteId); + + return await site.write('core_blog_delete_entry', params); + } + /** * Invalidate blog entries WS call. * @@ -96,6 +153,19 @@ export class AddonBlogProvider { await site.invalidateWsCacheForKey(this.getEntriesCacheKey(filter)); } + /** + * Is editing blog entry enabled. + * + * @param siteId Site ID. + * @returns is enabled or not. + * @since 4.4 + */ + async isEditingEnabled(siteId?: string): Promise { + const site = await CoreSites.getSite(siteId); + + return site.wsAvailable('core_blog_update_entry'); + } + /** * Trigger the blog_entries_viewed event. * @@ -164,7 +234,7 @@ export type AddonBlogPost = { rating: number; // Post rating. format: number; // Post content format. attachment: string; // Post atachment. - publishstate: string; // Post publish state. + publishstate: AddonBlogPublishState; // Post publish state. lastmodified: number; // When it was last modified. created: number; // When it was created. usermodified: number; // User that updated the post. @@ -201,3 +271,70 @@ export type AddonBlogFilter = { courseid?: number; // Course id search?: string; // Search term. }; + +/** + * core_blog_add_entry & core_blog_update_entry ws params. + */ +export type AddonBlogAddEntryWSParams = { + subject: string; + summary: string; + summaryformat: number; + options: AddonBlogAddEntryOption[]; +}; + +export type AddonBlogUpdateEntryWSParams = AddonBlogAddEntryWSParams & { entryid: number }; + +/** + * Add entry options. + */ +export type AddonBlogAddEntryOption = { + name: 'inlineattachmentsid' | 'attachmentsid' | 'publishstate' | 'courseassoc' | 'modassoc' | 'tags'; + value: string | number; +}; + +/** + * core_blog_prepare_entry_for_edition ws params. + */ +export type AddonBlogPrepareEntryForEditionWSResponse = { + inlineattachmentsid: number; + attachmentsid: number; + areas: AddonBlogPrepareEntryForEditionArea[]; + warnings: string[]; +}; + +export type AddonBlogPrepareEntryForEditionWSParams = { + entryid: number; +}; + +/** + * core_blog_prepare_entry_for_edition Area object. + */ +export type AddonBlogPrepareEntryForEditionArea = { + area: string; + options: AddonBlogPrepareEntryForEditionOption[]; +}; + +/** + * core_blog_prepare_entry_for_edition Option object. + */ +export type AddonBlogPrepareEntryForEditionOption = { + name: string; + value: unknown; +}; + +export type AddonBlogDeleteEntryWSParams = { + entryid: number; +}; + +export type AddonBlogDeleteEntryWSResponse = { + status: boolean; // Status: true only if we set the policyagreed to 1 for the user. + warnings?: CoreWSExternalWarning[]; +}; + +export type AddonBlogGetEntriesOptions = CoreSitesCommonWSOptions & { + page?: number; +}; + +export const ADDON_BLOG_PUBLISH_STATE = { draft: 'draft', site: 'site', public: 'public' } as const; + +export type AddonBlogPublishState = typeof ADDON_BLOG_PUBLISH_STATE[keyof typeof ADDON_BLOG_PUBLISH_STATE]; From 5d7c520de4c58bc7464dd31533f0b1b2155bbdec Mon Sep 17 00:00:00 2001 From: Alfonso Salces Date: Mon, 18 Mar 2024 10:15:00 +0100 Subject: [PATCH 3/3] MOBILE-4219 blog: Create acceptance testing --- .github/workflows/acceptance.yml | 1 + .../blog/tests/behat/edit-entry.feature | 68 +++++++++++++++++++ src/addons/blog/tests/behat/entries.feature | 30 ++++++++ .../blog/tests/behat/fixtures/stub6.txt | 1 + .../blog/tests/behat/fixtures/stub7.txt | 1 + 5 files changed, 101 insertions(+) create mode 100644 src/addons/blog/tests/behat/edit-entry.feature create mode 100644 src/addons/blog/tests/behat/entries.feature create mode 100644 src/addons/blog/tests/behat/fixtures/stub6.txt create mode 100644 src/addons/blog/tests/behat/fixtures/stub7.txt diff --git a/.github/workflows/acceptance.yml b/.github/workflows/acceptance.yml index a2d2e1330..c352ec0c3 100644 --- a/.github/workflows/acceptance.yml +++ b/.github/workflows/acceptance.yml @@ -86,6 +86,7 @@ jobs: "@addon_mod_survey" "@addon_mod_workshop" "@addon_notifications" + "@addon_blog" "@core" "@core_comments" "@core_course" diff --git a/src/addons/blog/tests/behat/edit-entry.feature b/src/addons/blog/tests/behat/edit-entry.feature new file mode 100644 index 000000000..2dae7244b --- /dev/null +++ b/src/addons/blog/tests/behat/edit-entry.feature @@ -0,0 +1,68 @@ +@addon_blog @core_blog @app @javascript @lms_from4.4 +Feature: Edit blog entries + In order to add or edit blog entries as User + + Background: + Given the following "users" exist: + | username | firstname | lastname | email | + | testuser | Test | User | moodle@example.com | + | testuser2 | Test | User2 | moodle@example.com | + And the following "core_blog > entries" exist: + | subject | body | user | + | Blog post one | User 1 blog post content | testuser | + | Blog post two | User 1 blog post content | testuser | + + Scenario: Edit blog entry + Given I entered the app as "testuser" + When I press the user menu button in the app + And I press "Blog entries" in the app + Then I should find "Blog post one" in the app + And I press "Display options" in the app + And I press "Edit" in the app + + Then I should find "Blog post one" in the app + And I set the field "Entry title" to "Blog post one (updated)" in the app + And I set the field "Blog entry body" to "User 1 blog post content (updated)" in the app + And I press "Publish to" in the app + And I press "Yourself (draft)" in the app + And I press "Save" in the app + + Then I should find "Blog post one (updated)" in the app + And I should find "User 1 blog post content (updated)" in the app + And I should find "Yourself (draft)" near "User 1 blog post content (updated)" in the app + + Scenario: Add a blog entry + Given I entered the app as "testuser" + When I press the user menu button in the app + And I press "Blog entries" in the app + And I press "Add a new entry" in the app + + And I set the field "Entry title" to "New blog entry" in the app + And I set the field "Blog entry body" to "This is a new blog entry." in the app + And I press "Publish to" in the app + And I press "Anyone on this site" in the app + And I press "Add a new entry" "button" in the app + + Then I should find "Blog entries" in the app + And I should find "New blog entry" in the app + + Scenario: Add a blog entry with attachments + Given I entered the app as "testuser" + When I press the user menu button in the app + And I press "Blog entries" in the app + And I press "Add a new entry" in the app + + And I set the field "Entry title" to "Entry with attachments" in the app + And I set the field "Blog entry body" to "This is a new blog entry with attachments." in the app + + And I press "Add file" in the app + And I upload "stub6.txt" to "File" ".action-sheet-button" in the app + And I press "Add file" in the app + And I upload "stub7.txt" to "File" ".action-sheet-button" in the app + + And I press "Add a new entry" "button" in the app + + Then I should find "Blog entries" in the app + And I should find "Entry with attachments" in the app + And I should find "stub6.txt" in the app + And I should find "stub7.txt" in the app diff --git a/src/addons/blog/tests/behat/entries.feature b/src/addons/blog/tests/behat/entries.feature new file mode 100644 index 000000000..a1a153384 --- /dev/null +++ b/src/addons/blog/tests/behat/entries.feature @@ -0,0 +1,30 @@ +@addon_blog @core_blog @app @javascript @lms_from4.4 +Feature: Blog entries + In order to modify or delete a blog entry + As a user + + Background: + Given the following "users" exist: + | username | firstname | lastname | email | + | testuser | Test | User | moodle@example.com | + And the following "core_blog > entries" exist: + | subject | body | user | + | Blog post one | User 1 blog post content | testuser | + | Blog post two | User 1 blog post content | testuser | + + Scenario: List every blog entry + Given I entered the app as "testuser" + When I press the user menu button in the app + And I press "Blog entries" in the app + Then I should find "Blog post one" in the app + And I should find "Blog post two" in the app + + Scenario: Delete blog entry + Given I entered the app as "testuser" + When I press the user menu button in the app + And I press "Blog entries" in the app + Then I should find "Blog post one" in the app + When I press "Display options" near "Blog post one" in the app + And I press "Delete" in the app + And I pull to refresh in the app + And I should not find "Blog post one" in the app diff --git a/src/addons/blog/tests/behat/fixtures/stub6.txt b/src/addons/blog/tests/behat/fixtures/stub6.txt new file mode 100644 index 000000000..c96555531 --- /dev/null +++ b/src/addons/blog/tests/behat/fixtures/stub6.txt @@ -0,0 +1 @@ +This is the stub file 6 created at 13/03/2025. diff --git a/src/addons/blog/tests/behat/fixtures/stub7.txt b/src/addons/blog/tests/behat/fixtures/stub7.txt new file mode 100644 index 000000000..6360631d2 --- /dev/null +++ b/src/addons/blog/tests/behat/fixtures/stub7.txt @@ -0,0 +1 @@ +This is the stub file 7 created at 13/03/2025.