Merge pull request #3956 from alfonso-salces/MOBILE-4219

MOBILE-4219 blog: Add and edit blog entries
main
Dani Palou 2024-03-20 16:13:10 +01:00 committed by GitHub
commit 8dfd363d6f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 1153 additions and 108 deletions

View File

@ -86,6 +86,7 @@ jobs:
"@addon_mod_survey"
"@addon_mod_workshop"
"@addon_notifications"
"@addon_blog"
"@core"
"@core_comments"
"@core_course"

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

@ -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<CoreBlogGetEntriesWSResponse> {
const site = await CoreSites.getSite(siteId);
async getEntries(filter: AddonBlogFilter = {}, options?: AddonBlogGetEntriesOptions): Promise<CoreBlogGetEntriesWSResponse> {
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<number> {
const site = await CoreSites.getSite(siteId);
return await site.write<number>('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<void> {
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<AddonBlogPrepareEntryForEditionWSResponse> {
const site = await CoreSites.getSite(siteId);
return await site.write<AddonBlogPrepareEntryForEditionWSResponse>('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<AddonBlogDeleteEntryWSResponse> {
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<boolean> {
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];

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

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

View File

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

View File

@ -0,0 +1 @@
This is the stub file 6 created at 13/03/2025.

View File

@ -0,0 +1 @@
This is the stub file 7 created at 13/03/2025.

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