MOBILE-4219 blog: Allow edition and redesign blog entries

main
Alfonso Salces 2024-03-18 10:15:55 +01:00
parent 0b9ee2d29b
commit 9d282a8f02
16 changed files with 908 additions and 101 deletions

View File

@ -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",

View File

@ -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 {}

View File

@ -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);

View File

@ -0,0 +1,16 @@
<ion-content>
<ion-list>
<ion-item button class="ion-text-wrap" (click)="action('edit')" [detail]="false">
<ion-icon name="fas-pen" slot="start" aria-hidden="true" />
<ion-label>
<p class="item-heading">{{ 'core.edit' | translate }}</p>
</ion-label>
</ion-item>
<ion-item button class="ion-text-wrap" (click)="action('delete')" [detail]="false">
<ion-icon name="fas-trash" slot="start" aria-hidden="true" color="danger" />
<ion-label color="danger">
<p class="item-heading">{{ 'core.delete' | translate }}</p>
</ion-label>
</ion-item>
</ion-list>
</ion-content>

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 { 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);
}
}

View File

@ -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';

View File

@ -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}}"
}

View File

@ -0,0 +1,93 @@
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<ion-back-button [text]="'core.back' | translate" />
</ion-buttons>
<ion-title>
<h1>{{ entry ? entry.subject : 'addon.blog.addnewentry' | translate }}</h1>
</ion-title>
<ion-buttons slot="end" />
</ion-toolbar>
</ion-header>
<ion-content>
<core-loading [hideUntil]="loaded">
<form [formGroup]="form">
<ion-item>
<ion-input labelPlacement="stacked" formControlName="subject" type="text"
[placeholder]="'addon.blog.entrytitle' | translate" name="title">
<p>{{ 'addon.blog.entrytitle' | translate }}</p>
</ion-input>
</ion-item>
<ion-item>
<ion-label position="stacked" for="addon_blog_entry_body">{{ 'addon.blog.entrybody' | translate }}</ion-label>
<core-rich-text-editor name="addon_blog_entry_body" [attr.aria-label]="'addon.blog.entrybody' | translate"
[control]="form.controls.summary" [placeholder]="'addon.blog.entrybody' | translate" [componentId]="component"
[autoSave]="true" [contextInstanceId]="contextInstanceId" [contextLevel]="contextLevel"
[elementId]="entry?.id ?? 'new_entry'" />
</ion-item>
<ion-item>
<core-combobox name="addon_blog_publish_to" formControlName="publishState" [label]="'addon.blog.publishto' | translate">
<ion-select-option class="core-select-option-title" [value]="publishState.draft">
{{ 'addon.blog.publishtonoone' | translate }}
</ion-select-option>
<ion-select-option class="core-select-option-title" [value]="publishState.site">
{{ 'addon.blog.publishtosite' | translate }}
</ion-select-option>
</core-combobox>
</ion-item>
<core-attachments [files]="files" [maxSubmissions]="maxFiles" [maxSize]="0" [component]="component" [allowOffline]="true"
[componentId]="entry?.id ?? 0" />
@if (entry && courseId && associatedCourse) {
<ion-item class="divider section" (click)="toggleAssociations()" button [detail]="false"
[attr.aria-label]="(associationsExpanded ? 'core.collapse' : 'core.expand') | translate"
[attr.aria-expanded]="associationsExpanded" aria-controls="addon-blog-associations"
[class.expandable-status-icon-expanded]="associationsExpanded">
<ion-icon [name]="associationsExpanded ? 'fas-chevron-down' : 'fas-chevron-right'" flip-rtl slot="start"
class="expandable-status-icon" />
<ion-label>
<h2>{{ 'addon.blog.associations' | translate }}</h2>
</ion-label>
</ion-item>
<div id="addon-blog-associations">
@if (associationsExpanded) {
<ion-item>
@if (associatedModule) {
<ion-toggle formControlName="associateWithModule">
<core-format-text [text]="'addon.blog.associatewithmodule' | translate: {
$a: { modtype: associatedModule.modname, modname: associatedModule.name }
}" [component]="component" [componentId]="entry.id" [contextLevel]="contextLevel"
[contextInstanceId]="contextInstanceId" [courseId]="entry.courseid" />
</ion-toggle>
} @else if (associatedCourse) {
<ion-toggle formControlName="associateWithCourse">
<core-format-text
[text]="'addon.blog.associatewithcourse' | translate: { $a: { coursename: associatedCourse.fullname } }"
[component]="component" [componentId]="entry.id" [contextLevel]="contextLevel"
[contextInstanceId]="contextInstanceId" [courseId]="entry.courseid" />
</ion-toggle>
}
</ion-item>
}
</div>
}
<ion-item class="addon-blog-entry-buttons">
<ion-label>
<ion-row>
<ion-col>
<ion-button expand="block" [attr.aria-label]="(entry ? 'core.save' : 'addon.blog.addnewentry') | translate"
[disabled]="form.invalid || (entry && !hasDataChangedForEdit)" (click)="save()">
{{ (entry ? 'core.save' : 'addon.blog.addnewentry') | translate }}
</ion-button>
</ion-col>
</ion-row>
</ion-label>
</ion-item>
</form>
</core-loading>
</ion-content>

View File

@ -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<string>('', { nonNullable: true, validators: [Validators.required] }),
summary: new FormControl<string>('', { nonNullable: true, validators: [Validators.required] }),
publishState: new FormControl<AddonBlogPublishState>(
ADDON_BLOG_PUBLISH_STATE.draft,
{ nonNullable: true, validators: [Validators.required] },
),
associateWithCourse: new FormControl<boolean>(false, { nonNullable: true, validators: [Validators.required] }),
associateWithModule: new FormControl<boolean>(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<void> {
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<AddonBlogPost> {
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<void> {
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<boolean> {
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<void> {
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;
};

View File

@ -1,78 +0,0 @@
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<ion-back-button [text]="'core.back' | translate" />
</ion-buttons>
<ion-title>
<h1>{{ title | translate }}</h1>
</ion-title>
<ion-buttons slot="end">
<core-user-menu-button />
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content class="limited-width">
<ion-refresher slot="fixed" [disabled]="!loaded" (ionRefresh)="refresh($event.target)">
<ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}" />
</ion-refresher>
<core-loading [hideUntil]="loaded">
<ion-item *ngIf="showMyEntriesToggle">
<ion-toggle [(ngModel)]="onlyMyEntries" (ionChange)="onlyMyEntriesToggleChanged(onlyMyEntries)">
{{ 'addon.blog.showonlyyourentries' | translate }}
</ion-toggle>
</ion-item>
<core-empty-box *ngIf="entries && entries.length === 0" icon="far-newspaper" [message]="'addon.blog.noentriesyet' | translate" />
<ng-container *ngFor="let entry of entries">
<ion-card *ngIf="!onlyMyEntries || entry.userid === currentUserId">
<ion-item class="ion-text-wrap">
<core-user-avatar [user]="entry.user" slot="start" [courseId]="entry.courseid" />
<ion-label>
<div class="flex-row ion-justify-content-between ion-align-items-center">
<h2>
<core-format-text [text]="entry.subject" [contextLevel]="contextLevel"
[contextInstanceId]="contextInstanceId" [courseId]="entry.courseid" />
</h2>
<ion-note class="ion-text-end">
{{ 'addon.blog.' + entry.publishTranslated! | translate}}
</ion-note>
</div>
<div class="flex-row ion-justify-content-between ion-align-items-center">
{{entry.user && entry.user.fullname}}
<ion-note class="ion-text-end">
{{entry.created | coreDateDayOrTime}}
</ion-note>
</div>
</ion-label>
</ion-item>
<ion-card-content>
<ion-item class="ion-text-wrap">
<ion-label>
<core-format-text [text]="entry.summary" [component]="component" [componentId]="entry.id"
[contextLevel]="contextLevel" [contextInstanceId]="contextInstanceId" [courseId]="entry.courseid" />
</ion-label>
</ion-item>
<ion-item class="ion-text-wrap" *ngIf="tagsEnabled && entry.tags && entry.tags!.length > 0">
<ion-label>
<div slot="start">{{ 'core.tag.tags' | translate }}:</div>
<core-tag-list [tags]="entry.tags" />
</ion-label>
</ion-item>
<core-comments *ngIf="commentsEnabled" [component]="component" [itemId]="entry.id" area="format_blog"
[instanceId]="entry.userid" contextLevel="user" [showItem]="true" [courseId]="entry.courseid" />
<core-file *ngFor="let file of entry.attachmentfiles" [file]="file" [component]="component" [componentId]="entry.id" />
<ion-item *ngIf="entry.uniquehash" [href]="entry.uniquehash" core-link [detail]="true">
<ion-label>{{ 'addon.blog.linktooriginalentry' | translate }}</ion-label>
</ion-item>
</ion-card-content>
<div class="ion-text-center ion-margin-bottom" *ngIf="entry.lastmodified > entry.created">
<ion-note>
<ion-icon name="fas-clock" [attr.aria-label]="'core.lastmodified' | translate" /> {{entry.lastmodified
|
coreTimeAgo}}
</ion-note>
</div>
</ion-card>
</ng-container>
<core-infinite-loading [enabled]="canLoadMore" (action)="loadMore($event)" [error]="loadMoreError" />
</core-loading>
</ion-content>

View File

@ -0,0 +1,134 @@
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<ion-back-button [text]="'core.back' | translate" />
</ion-buttons>
<ion-title>
<h1>{{ title | translate }}</h1>
</ion-title>
<ion-buttons slot="end">
<core-user-menu-button />
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content class="limited-width">
<ion-refresher slot="fixed" [disabled]="!loaded" (ionRefresh)="refresh($event.target)">
<ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}" />
</ion-refresher>
<core-loading [hideUntil]="loaded">
@if (showMyEntriesToggle) {
<ion-item>
<ion-toggle [(ngModel)]="onlyMyEntries" (ionChange)="onlyMyEntriesToggleChanged(onlyMyEntries)">
{{ 'addon.blog.showonlyyourentries' | translate }}
</ion-toggle>
</ion-item>
}
@for (entry of entries; track entry.id) {
@if (!onlyMyEntries || entry.userid === currentUserId) {
<div class="entry ion-padding-start ion-padding-top ion-padding-end" [id]="'entry-' + entry.id">
<div class="entry-subject flex ion-text-wrap ion-justify-content-between ion-align-items-center">
<h3>
<core-format-text [text]="entry.subject" [contextLevel]="contextLevel" [contextInstanceId]="contextInstanceId"
[courseId]="entry.courseid" />
@if (entry.userid === currentUserId && entry.publishTranslated === 'publishtonoone') {
<span class="entry-draft">
<ion-badge color="warning"> {{ 'addon.blog.publishtonoone' | translate }} </ion-badge>
</span>
}
</h3>
@if (entry.userid === currentUserId && optionsAvailable) {
<div class="core-button-spinner">
<ion-button fill="clear" [attr.aria-label]="'core.displayoptions' | translate"
(click)="showEntryActionsPopover($event, entry)">
<ion-icon slot="icon-only" aria-hidden="true" name="ellipsis-vertical" />
</ion-button>
</div>
}
</div>
<div class="entry-creation-info flex ion-align-items-center">
<span>
<core-user-avatar [user]="entry.user" [courseId]="entry.courseid" />
</span>
<span [innerHTML]="'core.bynameondate' | translate: {
'$a': { name: '<strong>' + entry?.user?.fullname + '</strong>', date: (entry.created | coreDateDayOrTime) }
}">
</span>
</div>
<ion-label>
<div class="entry-summary" [ngClass]="{ 'border-bottom': entry.lastmodified <= entry.created }" [collapsible-item]="64">
<div class="ion-margin-bottom">
<core-format-text [text]="entry.summary" [component]="component" [componentId]="entry.id"
[contextLevel]="contextLevel" [contextInstanceId]="contextInstanceId" [courseId]="entry.courseid" />
</div>
@if (tagsEnabled && entry.tags && entry.tags!.length > 0) {
<ion-item class="ion-text-wrap">
<ion-label>
<div slot="start">{{ 'core.tag.tags' | translate }}:</div>
<core-tag-list [tags]="entry.tags" />
</ion-label>
</ion-item>
}
@for (file of entry.attachmentfiles; track $index) {
<core-file [file]="file" [component]="this.component" [componentId]="entry.id" />
}
@if (entry.uniquehash) {
<ion-item [href]="entry.uniquehash" core-link [detail]="true">
<ion-label>{{ 'addon.blog.linktooriginalentry' | translate }}</ion-label>
</ion-item>
}
</div>
</ion-label>
@if (entry.lastmodified > entry.created) {
<div class="entry-last-modification flex ion-justify-content-between border-bottom ion-padding-top ion-padding-bottom">
<ion-note class="flex ion-align-items-center">
<ion-icon name="fas-clock" [attr.aria-label]="'core.lastmodified' | translate" />
{{ entry.lastmodified | coreTimeAgo }}
</ion-note>
@if (entry.userid === currentUserId && entry.publishstate !== 'draft') {
<ion-badge class="entry-visibility-permission" color="success">
<ion-icon name="fas-eye" />
{{ 'addon.blog.' + entry.publishTranslated | translate }}
</ion-badge>
}
</div>
}
@if (commentsEnabled) {
<core-comments [component]="this.component" [itemId]="entry.id" area="format_blog" [instanceId]="entry.userid"
contextLevel="user" [showItem]="true" [courseId]="entry.courseid" />
}
</div>
}
} @empty {
<core-empty-box icon="far-newspaper" [message]="'addon.blog.noentriesyet' | translate" />
}
<core-infinite-loading [enabled]="canLoadMore" (action)="loadMore($event)" [error]="loadMoreError" />
</core-loading>
<!-- Create a blog entry. -->
<ion-fab slot="fixed" core-fab vertical="bottom" horizontal="end" *ngIf="loaded && optionsAvailable">
<ion-fab-button (click)="createNewEntry()" [attr.aria-label]="'addon.blog.addnewentry' | translate">
<ion-icon name="fas-pen-to-square" aria-hidden="true" />
<span class="sr-only">{{ 'addon.blog.addnewentry' | translate }}</span>
</ion-fab-button>
</ion-fab>
</ion-content>

View File

@ -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);
}
}

View File

@ -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<void> {
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<void> {
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<void> {
event.preventDefault();
event.stopPropagation();
const popoverData = await CoreDomUtils.openPopover<string>({
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();
}
}

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 { 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<string, string>): CoreContentLinksAction[] {
const pageParams: Params = {};
pageParams.courseId = params.courseid;
pageParams.cmId = params.modid;
return [{
action: async (siteId: string): Promise<void> => {
await CoreNavigator.navigateToSitePath(`/blog/edit/${params.entryid ?? 0}`, { params: pageParams, siteId });
},
}];
}
/**
* @inheritdoc
*/
isEnabled(siteId: string): Promise<boolean> {
return AddonBlog.isPluginEnabled(siteId);
}
}
export const AddonBlogEditEntryLinkHandler = makeSingleton(AddonBlogEditEntryLinkHandlerService);

View File

@ -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 },
});
},

View File

@ -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",