diff --git a/src/addons/mod/assign/services/assign-sync.ts b/src/addons/mod/assign/services/assign-sync.ts
index 14e91e5a7..2ea3fd466 100644
--- a/src/addons/mod/assign/services/assign-sync.ts
+++ b/src/addons/mod/assign/services/assign-sync.ts
@@ -186,7 +186,7 @@ export class AddonModAssignSyncProvider extends CoreCourseActivitySyncBaseProvid
      * Perform the assign submission.
      *
      * @param assignId Assign ID.
-     * @param siteId Site ID. If not defined, current site.
+     * @param siteId Site ID.
      * @return Promise resolved in success.
      */
     protected async performSyncAssign(assignId: number, siteId: string): Promise<AddonModAssignSyncResult> {
diff --git a/src/addons/mod/data/lang.json b/src/addons/mod/data/lang.json
new file mode 100644
index 000000000..920d6f014
--- /dev/null
+++ b/src/addons/mod/data/lang.json
@@ -0,0 +1,50 @@
+{
+    "addentries": "Add entries",
+    "advancedsearch": "Advanced search",
+    "alttext": "Alternative text",
+    "approve": "Approve",
+    "approved": "Approved",
+    "ascending": "Ascending",
+    "authorfirstname": "Author first name",
+    "authorlastname": "Author surname",
+    "confirmdeleterecord": "Are you sure you want to delete this entry?",
+    "descending": "Descending",
+    "disapprove": "Undo approval",
+    "edittagsnotsupported": "Sorry, editing tags is not supported by the app.",
+    "emptyaddform": "You did not fill out any fields!",
+    "entrieslefttoadd": "You must add {{$a.entriesleft}} more entry/entries in order to complete this activity",
+    "entrieslefttoaddtoview": "You must add {{$a.entrieslefttoview}} more entry/entries before you can view other participants' entries.",
+    "errorapproving": "Error approving or unapproving entry.",
+    "errordeleting": "Error deleting entry.",
+    "errormustsupplyvalue": "You must supply a value here.",
+    "expired": "Sorry, this activity closed on {{$a}} and is no longer available",
+    "fields": "Fields",
+    "foundrecords": "Found records: {{$a.num}}/{{$a.max}} (<a href=\"{{$a.reseturl}}\">Reset filters</a>)",
+    "gettinglocation": "Getting location",
+    "latlongboth": "Both latitude and longitude are required.",
+    "locationpermissiondenied": "Permission to access your location has been denied.",
+    "locationnotenabled": "Location is not enabled",
+    "menuchoose": "Choose...",
+    "modulenameplural": "Databases",
+    "more": "More",
+    "mylocation": "My location",
+    "noaccess": "You do not have access to this page",
+    "nomatch": "No matching entries found!",
+    "norecords": "No entries in database",
+    "notapproved": "Entry is not approved yet.",
+    "notopenyet": "Sorry, this activity is not available until {{$a}}",
+    "numrecords": "{{$a}} entries",
+    "other": "Other",
+    "recordapproved": "Entry approved",
+    "recorddeleted": "Entry deleted",
+    "recorddisapproved": "Entry unapproved",
+    "resetsettings": "Reset filters",
+    "search": "Search",
+    "searchbytagsnotsupported": "Sorry, searching by tags is not supported by the app.",
+    "selectedrequired": "All selected required",
+    "single": "View single",
+    "tagarea_data_records": "Data records",
+    "timeadded": "Time added",
+    "timemodified": "Time modified",
+    "usedate": "Include in search."
+}
\ No newline at end of file
diff --git a/src/addons/mod/data/services/data-fields-delegate.ts b/src/addons/mod/data/services/data-fields-delegate.ts
new file mode 100644
index 000000000..0c79e29a0
--- /dev/null
+++ b/src/addons/mod/data/services/data-fields-delegate.ts
@@ -0,0 +1,267 @@
+// (C) Copyright 2015 Moodle Pty Ltd.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import { Injectable, Type } from '@angular/core';
+import { CoreDelegate, CoreDelegateHandler } from '@classes/delegate';
+import { AddonModDataDefaultFieldHandler } from './handlers/default-field';
+import { makeSingleton } from '@singletons';
+import { AddonModDataEntryField,
+    AddonModDataField,
+    AddonModDataSearchEntriesAdvancedFieldFormatted,
+    AddonModDataSubfieldData,
+} from './data';
+import { CoreFormFields } from '@singletons/form';
+import { CoreWSExternalFile } from '@services/ws';
+import { FileEntry } from '@ionic-native/file';
+
+/**
+ * Interface that all fields handlers must implement.
+ */
+export interface AddonModDataFieldHandler extends CoreDelegateHandler {
+
+    /**
+     * Name of the type of data field the handler supports. E.g. 'checkbox'.
+     */
+    type: string;
+
+    /**
+     * Return the Component to use to display the plugin data.
+     * It's recommended to return the class of the component, but you can also return an instance of the component.
+     *
+     * @param field The field object.
+     * @return The component to use, undefined if not found.
+     */
+    getComponent?(plugin: AddonModDataField): Type<unknown> | undefined;
+
+    /**
+     * Get field search data in the input data.
+     *
+     * @param field Defines the field to be rendered.
+     * @param inputData Data entered in the search form.
+     * @return With name and value of the data to be sent.
+     */
+    getFieldSearchData?(
+        field: AddonModDataField,
+        inputData: CoreFormFields,
+    ): AddonModDataSearchEntriesAdvancedFieldFormatted[];
+
+    /**
+     * Get field edit data in the input data.
+     *
+     * @param field Defines the field to be rendered.
+     * @param inputData Data entered in the edit form.
+     * @return With name and value of the data to be sent.
+     */
+    getFieldEditData?(
+        field: AddonModDataField,
+        // eslint-disable-next-line @typescript-eslint/no-explicit-any
+        inputData: CoreFormFields<any>,
+        originalFieldData: AddonModDataEntryField,
+    ): AddonModDataSubfieldData[];
+
+    /**
+     * Get field data in changed.
+     *
+     * @param field Defines the field to be rendered.
+     * @param inputData Data entered in the edit form.
+     * @param originalFieldData Original field entered data.
+     * @return If the field has changes.
+     */
+    hasFieldDataChanged?(
+        field: AddonModDataField,
+        inputData: CoreFormFields,
+        originalFieldData: AddonModDataEntryField,
+    ): boolean;
+
+    /**
+     * Get field edit files in the input data.
+     *
+     * @param field Defines the field..
+     * @return With name and value of the data to be sent.
+     */
+    getFieldEditFiles?(
+        field: AddonModDataField,
+        inputData: CoreFormFields,
+        originalFieldData: AddonModDataEntryField,
+    ): (CoreWSExternalFile | FileEntry)[];
+
+    /**
+     * Check and get field requeriments.
+     *
+     * @param field Defines the field to be rendered.
+     * @param inputData Data entered in the edit form.
+     * @return String with the notification or false.
+     */
+    getFieldsNotifications?(field: AddonModDataField, inputData: AddonModDataSubfieldData[]): string | undefined;
+
+    /**
+     * Override field content data with offline submission.
+     *
+     * @param originalContent Original data to be overriden.
+     * @param offlineContent Array with all the offline data to override.
+     * @param offlineFiles Array with all the offline files in the field.
+     * @return Data overriden
+     */
+    overrideData?(
+        originalContent: AddonModDataEntryField,
+        offlineContent: CoreFormFields,
+        offlineFiles?: FileEntry[],
+    ): AddonModDataEntryField;
+}
+
+/**
+ * Delegate to register database fields handlers.
+ */
+@Injectable({ providedIn: 'root' })
+export class AddonModDataFieldsDelegateService extends CoreDelegate<AddonModDataFieldHandler> {
+
+    protected handlerNameProperty = 'type';
+
+    constructor(
+        protected defaultHandler: AddonModDataDefaultFieldHandler,
+    ) {
+        super('AddonModDataFieldsDelegate', true);
+    }
+
+    /**
+     * Get the component to use for a certain field field.
+     *
+     * @param field The field object.
+     * @return Promise resolved with the component to use, undefined if not found.
+     */
+    getComponentForField(field: AddonModDataField): Promise<Type<unknown> | undefined> {
+        return Promise.resolve(this.executeFunctionOnEnabled(field.type, 'getComponent', [field]));
+    }
+
+    /**
+     * Get database data in the input data to search.
+     *
+     * @param field Defines the field to be rendered.
+     * @param inputData Data entered in the search form.
+     * @return Name and data field.
+     */
+    getFieldSearchData(field: AddonModDataField, inputData: CoreFormFields): AddonModDataSearchEntriesAdvancedFieldFormatted[] {
+        return this.executeFunctionOnEnabled(field.type, 'getFieldSearchData', [field, inputData]) || [];
+    }
+
+    /**
+     * Get database data in the input data to add or update entry.
+     *
+     * @param field Defines the field to be rendered.
+     * @param inputData Data entered in the search form.
+     * @param originalFieldData Original field entered data.
+     * @return Name and data field.
+     */
+    getFieldEditData(
+        field: AddonModDataField,
+        inputData: CoreFormFields,
+        originalFieldData: AddonModDataEntryField,
+    ): AddonModDataSubfieldData[] {
+        return this.executeFunctionOnEnabled(field.type, 'getFieldEditData', [field, inputData, originalFieldData]) || [];
+    }
+
+    /**
+     * Get database data in the input files to add or update entry.
+     *
+     * @param field Defines the field to be rendered.
+     * @param inputData Data entered in the search form.
+     * @param originalFieldData Original field entered data.
+     * @return Name and data field.
+     */
+    getFieldEditFiles(
+        field: AddonModDataField,
+        inputData: CoreFormFields,
+        originalFieldData: CoreFormFields,
+    ): (CoreWSExternalFile | FileEntry)[] {
+        return this.executeFunctionOnEnabled(field.type, 'getFieldEditFiles', [field, inputData, originalFieldData]) || [];
+    }
+
+    /**
+     * Check and get field requeriments.
+     *
+     * @param field Defines the field to be rendered.
+     * @param inputData Data entered in the edit form.
+     * @return String with the notification or false.
+     */
+    getFieldsNotifications(field: AddonModDataField, inputData: AddonModDataSubfieldData[]): string | undefined {
+        return this.executeFunctionOnEnabled(field.type, 'getFieldsNotifications', [field, inputData]);
+    }
+
+    /**
+     * Check if field type manage files or not.
+     *
+     * @param field Defines the field to be checked.
+     * @return If the field type manages files.
+     */
+    hasFiles(field: AddonModDataField): boolean {
+        return this.hasFunction(field.type, 'getFieldEditFiles');
+    }
+
+    /**
+     * Check if the data has changed for a certain field.
+     *
+     * @param field Defines the field to be rendered.
+     * @param inputData Data entered in the search form.
+     * @param originalFieldData Original field entered data.
+     * @return If the field has changes.
+     */
+    hasFieldDataChanged(
+        field: AddonModDataField,
+        inputData: CoreFormFields,
+        originalFieldData: CoreFormFields,
+    ): boolean {
+        return !!this.executeFunctionOnEnabled(
+            field.type,
+            'hasFieldDataChanged',
+            [field, inputData, originalFieldData],
+        );
+    }
+
+    /**
+     * Check if a field plugin is supported.
+     *
+     * @param pluginType Type of the plugin.
+     * @return True if supported, false otherwise.
+     */
+    isPluginSupported(pluginType: string): boolean {
+        return this.hasHandler(pluginType, true);
+    }
+
+    /**
+     * Override field content data with offline submission.
+     *
+     * @param field Defines the field to be rendered.
+     * @param originalContent Original data to be overriden.
+     * @param offlineContent Array with all the offline data to override.
+     * @param offlineFiles Array with all the offline files in the field.
+     * @return Data overriden
+     */
+    overrideData(
+        field: AddonModDataField,
+        originalContent: AddonModDataEntryField,
+        offlineContent: CoreFormFields,
+        offlineFiles?: FileEntry[],
+    ): AddonModDataEntryField {
+        originalContent = originalContent || {};
+
+        if (!offlineContent) {
+            return originalContent;
+        }
+
+        return this.executeFunctionOnEnabled(field.type, 'overrideData', [originalContent, offlineContent, offlineFiles]) ||
+            originalContent;
+    }
+
+}
+export const AddonModDataFieldsDelegate = makeSingleton(AddonModDataFieldsDelegateService);
diff --git a/src/addons/mod/data/services/data-helper.ts b/src/addons/mod/data/services/data-helper.ts
new file mode 100644
index 000000000..b2a1f4c3b
--- /dev/null
+++ b/src/addons/mod/data/services/data-helper.ts
@@ -0,0 +1,790 @@
+// (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 { Injectable } from '@angular/core';
+import { CoreCourse } from '@features/course/services/course';
+import { CoreFileUploader, CoreFileUploaderStoreFilesResult } from '@features/fileuploader/services/fileuploader';
+import { CoreRatingOffline } from '@features/rating/services/rating-offline';
+import { FileEntry } from '@ionic-native/file';
+import { CoreSites } from '@services/sites';
+import { CoreDomUtils } from '@services/utils/dom';
+import { CoreFormFields } from '@singletons/form';
+import { CoreTextUtils } from '@services/utils/text';
+import { CoreUtils } from '@services/utils/utils';
+import { CoreWSExternalFile } from '@services/ws';
+import { makeSingleton, Translate } from '@singletons';
+import { CoreEvents } from '@singletons/events';
+import {
+    AddonModDataEntry,
+    AddonModData,
+    AddonModDataProvider,
+    AddonModDataSearchEntriesOptions,
+    AddonModDataEntries,
+    AddonModDataEntryFields,
+    AddonModDataAction,
+    AddonModDataGetEntryFormatted,
+    AddonModDataData,
+    AddonModDataTemplateType,
+    AddonModDataGetDataAccessInformationWSResponse,
+    AddonModDataTemplateMode,
+    AddonModDataField,
+    AddonModDataEntryWSField,
+} from './data';
+import { AddonModDataFieldsDelegate } from './data-fields-delegate';
+import { AddonModDataOffline, AddonModDataOfflineAction } from './data-offline';
+
+/**
+ * Service that provides helper functions for datas.
+ */
+@Injectable({ providedIn: 'root' })
+export class AddonModDataHelperProvider {
+
+    /**
+     * Returns the record with the offline actions applied.
+     *
+     * @param record Entry to modify.
+     * @param offlineActions Offline data with the actions done.
+     * @param fields Entry defined fields indexed by fieldid.
+     * @return Promise resolved when done.
+     */
+    protected async applyOfflineActions(
+        record: AddonModDataEntry,
+        offlineActions: AddonModDataOfflineAction[],
+        fields: AddonModDataField[],
+    ): Promise<AddonModDataEntry> {
+        const promises: Promise<void>[] = [];
+
+        offlineActions.forEach((action) => {
+            record.timemodified = action.timemodified;
+            record.hasOffline = true;
+            const offlineContents: Record<number, CoreFormFields> = {};
+
+            switch (action.action) {
+                case AddonModDataAction.APPROVE:
+                    record.approved = true;
+                    break;
+                case AddonModDataAction.DISAPPROVE:
+                    record.approved = false;
+                    break;
+                case AddonModDataAction.DELETE:
+                    record.deleted = true;
+                    break;
+                case AddonModDataAction.ADD:
+                case AddonModDataAction.EDIT:
+                    record.groupid = action.groupid;
+
+                    action.fields.forEach((offlineContent) => {
+                        if (typeof offlineContents[offlineContent.fieldid] == 'undefined') {
+                            offlineContents[offlineContent.fieldid] = {};
+                        }
+
+                        if (offlineContent.subfield) {
+                            offlineContents[offlineContent.fieldid][offlineContent.subfield] =
+                                CoreTextUtils.parseJSON(offlineContent.value);
+                        } else {
+                            offlineContents[offlineContent.fieldid][''] = CoreTextUtils.parseJSON(offlineContent.value);
+                        }
+                    });
+
+                    // Override field contents.
+                    fields.forEach((field) => {
+                        if (AddonModDataFieldsDelegate.hasFiles(field)) {
+                            promises.push(this.getStoredFiles(record.dataid, record.id, field.id).then((offlineFiles) => {
+                                record.contents[field.id] = AddonModDataFieldsDelegate.overrideData(
+                                    field,
+                                    record.contents[field.id],
+                                    offlineContents[field.id],
+                                    offlineFiles,
+                                );
+                                record.contents[field.id].fieldid = field.id;
+
+                                return;
+                            }));
+                        } else {
+                            record.contents[field.id] = AddonModDataFieldsDelegate.overrideData(
+                                field,
+                                record.contents[field.id],
+                                offlineContents[field.id],
+                            );
+                            record.contents[field.id].fieldid = field.id;
+                        }
+                    });
+                    break;
+                default:
+                    break;
+            }
+        });
+
+        await Promise.all(promises);
+
+        return record;
+    }
+
+    /**
+     * Approve or disapprove a database entry.
+     *
+     * @param dataId Database ID.
+     * @param entryId Entry ID.
+     * @param approve True to approve, false to disapprove.
+     * @param courseId Course ID. It not defined, it will be fetched.
+     * @param siteId Site ID. If not defined, current site.
+     */
+    async approveOrDisapproveEntry(
+        dataId: number,
+        entryId: number,
+        approve: boolean,
+        courseId?: number,
+        siteId?: string,
+    ): Promise<void> {
+        siteId = siteId || CoreSites.getCurrentSiteId();
+
+        const modal = await CoreDomUtils.showModalLoading('core.sending', true);
+
+        try {
+            courseId = await this.getActivityCourseIdIfNotSet(dataId, courseId, siteId);
+
+            try {
+                // Approve/disapprove entry.
+                await AddonModData.approveEntry(dataId, entryId, approve, courseId, siteId);
+            } catch (error) {
+                CoreDomUtils.showErrorModalDefault(error, 'addon.mod_data.errorapproving', true);
+
+                throw error;
+            }
+
+            const promises: Promise<void>[] = [];
+
+            promises.push(AddonModData.invalidateEntryData(dataId, entryId, siteId));
+            promises.push(AddonModData.invalidateEntriesData(dataId, siteId));
+
+            await CoreUtils.ignoreErrors(Promise.all(promises));
+
+            CoreEvents.trigger(AddonModDataProvider.ENTRY_CHANGED, { dataId: dataId, entryId: entryId }, siteId);
+
+            CoreDomUtils.showToast(approve ? 'addon.mod_data.recordapproved' : 'addon.mod_data.recorddisapproved', true, 3000);
+        } catch {
+            // Ignore error, it was already displayed.
+        } finally {
+            modal.dismiss();
+        }
+    }
+
+    /**
+     * Displays fields for being shown.
+     *
+     * @param template Template HMTL.
+     * @param fields Fields that defines every content in the entry.
+     * @param entry Entry.
+     * @param offset Entry offset.
+     * @param mode Mode list or show.
+     * @param actions Actions that can be performed to the record.
+     * @return Generated HTML.
+     */
+    displayShowFields(
+        template: string,
+        fields: AddonModDataField[],
+        entry: AddonModDataEntry,
+        offset = 0,
+        mode: AddonModDataTemplateMode,
+        actions: Record<AddonModDataAction, boolean>,
+    ): string {
+
+        if (!template) {
+            return '';
+        }
+
+        // Replace the fields found on template.
+        fields.forEach((field) => {
+            let replace = '[[' + field.name + ']]';
+            replace = replace.replace(/[-[\]/{}()*+?.\\^$|]/g, '\\$&');
+            const replaceRegex = new RegExp(replace, 'gi');
+
+            // Replace field by a generic directive.
+            const render = '<addon-mod-data-field-plugin [field]="fields[' + field.id + ']" [value]="entries[' + entry.id +
+                    '].contents[' + field.id + ']" mode="' + mode + '" [database]="database" (gotoEntry)="gotoEntry(' + entry.id +
+                    ')"></addon-mod-data-field-plugin>';
+            template = template.replace(replaceRegex, render);
+        });
+
+        for (const action in actions) {
+            const replaceRegex = new RegExp('##' + action + '##', 'gi');
+            // Is enabled?
+            if (actions[action]) {
+                let render = '';
+                if (action == AddonModDataAction.MOREURL) {
+                    // Render more url directly because it can be part of an HTML attribute.
+                    render = CoreSites.getCurrentSite()!.getURL() + '/mod/data/view.php?d={{database.id}}&rid=' + entry.id;
+                } else if (action == 'approvalstatus') {
+                    render = Translate.instant('addon.mod_data.' + (entry.approved ? 'approved' : 'notapproved'));
+                } else {
+                    render = '<addon-mod-data-action action="' + action + '" [entry]="entries[' + entry.id + ']" mode="' + mode +
+                    '" [database]="database" [module]="module" [offset]="' + offset + '" [group]="group" ></addon-mod-data-action>';
+                }
+                template = template.replace(replaceRegex, render);
+            } else {
+                template = template.replace(replaceRegex, '');
+            }
+        }
+
+        return template;
+    }
+
+    /**
+     * Get online and offline entries, or search entries.
+     *
+     * @param database Database object.
+     * @param fields The fields that define the contents.
+     * @param options Other options.
+     * @return Promise resolved when the database is retrieved.
+     */
+    async fetchEntries(
+        database: AddonModDataData,
+        fields: AddonModDataField[],
+        options: AddonModDataSearchEntriesOptions = {},
+    ): Promise<AddonModDataEntries> {
+        const site = await CoreSites.getSite(options.siteId);
+        options.groupId = options.groupId || 0;
+        options.page = options.page || 0;
+
+        const offlineActions: Record<number, AddonModDataOfflineAction[]> = {};
+        const result: AddonModDataEntries = {
+            entries: [],
+            totalcount: 0,
+            offlineEntries: [],
+        };
+        options.siteId = site.id;
+
+        const offlinePromise = AddonModDataOffline.getDatabaseEntries(database.id, site.id).then((actions) => {
+            result.hasOfflineActions = !!actions.length;
+
+            actions.forEach((action) => {
+                if (typeof offlineActions[action.entryid] == 'undefined') {
+                    offlineActions[action.entryid] = [];
+                }
+                offlineActions[action.entryid].push(action);
+
+                // We only display new entries in the first page when not searching.
+                if (action.action == AddonModDataAction.ADD && options.page == 0 && !options.search && !options.advSearch &&
+                    (!action.groupid || !options.groupId || action.groupid == options.groupId)) {
+                    result.offlineEntries!.push({
+                        id: action.entryid,
+                        canmanageentry: true,
+                        approved: !database.approval || database.manageapproved,
+                        dataid: database.id,
+                        groupid: action.groupid,
+                        timecreated: -action.entryid,
+                        timemodified: -action.entryid,
+                        userid: site.getUserId(),
+                        fullname: site.getInfo()?.fullname,
+                        contents: {},
+                    });
+                }
+
+            });
+
+            // Sort offline entries by creation time.
+            result.offlineEntries!.sort((a, b) => b.timecreated - a.timecreated);
+
+            return;
+        });
+
+        const ratingsPromise = CoreRatingOffline.hasRatings('mod_data', 'entry', ContextLevel.MODULE, database.coursemodule)
+            .then((hasRatings) => {
+                result.hasOfflineRatings = hasRatings;
+
+                return;
+            });
+
+        let fetchPromise: Promise<void>;
+        if (options.search || options.advSearch) {
+            fetchPromise = AddonModData.searchEntries(database.id, options).then((searchResult) => {
+                result.entries = searchResult.entries;
+                result.totalcount = searchResult.totalcount;
+                result.maxcount = searchResult.maxcount;
+
+                return;
+            });
+        } else {
+            fetchPromise = AddonModData.getEntries(database.id, options).then((entriesResult) => {
+                result.entries = entriesResult.entries;
+                result.totalcount = entriesResult.totalcount;
+
+                return;
+            });
+        }
+        await Promise.all([offlinePromise, ratingsPromise, fetchPromise]);
+
+        // Apply offline actions to online and offline entries.
+        const promises: Promise<AddonModDataEntry>[] = [];
+        result.entries.forEach((entry) => {
+            promises.push(this.applyOfflineActions(entry, offlineActions[entry.id] || [], fields));
+        });
+
+        result.offlineEntries!.forEach((entry) => {
+            promises.push(this.applyOfflineActions(entry, offlineActions[entry.id] || [], fields));
+        });
+
+        await Promise.all(promises);
+
+        return result;
+    }
+
+    /**
+     * Fetch an online or offline entry.
+     *
+     * @param database Database.
+     * @param fields List of database fields.
+     * @param entryId Entry ID.
+     * @param siteId Site ID. If not defined, current site.
+     * @return Promise resolved with the entry.
+     */
+    async fetchEntry(
+        database: AddonModDataData,
+        fields: AddonModDataField[],
+        entryId: number,
+        siteId?: string,
+    ): Promise<AddonModDataGetEntryFormatted> {
+        const site = await CoreSites.getSite(siteId);
+
+        const offlineActions = await AddonModDataOffline.getEntryActions(database.id, entryId, site.id);
+
+        let response: AddonModDataGetEntryFormatted;
+        if (entryId > 0) {
+            // Online entry.
+            response = await AddonModData.getEntry(database.id, entryId, { cmId: database.coursemodule, siteId: site.id });
+        } else {
+            // Offline entry or new entry.
+            response = {
+                entry: {
+                    id: entryId,
+                    userid: site.getUserId(),
+                    groupid: 0,
+                    dataid: database.id,
+                    timecreated: -entryId,
+                    timemodified: -entryId,
+                    approved: !database.approval || database.manageapproved,
+                    canmanageentry: true,
+                    fullname: site.getInfo()?.fullname,
+                    contents: {},
+                },
+            };
+        }
+
+        await this.applyOfflineActions(response.entry, offlineActions, fields);
+
+        return response;
+    }
+
+    /**
+     * Returns an object with all the actions that the user can do over the record.
+     *
+     * @param database Database activity.
+     * @param accessInfo Access info to the activity.
+     * @param entry Entry or record where the actions will be performed.
+     * @return Keyed with the action names and boolean to evalute if it can or cannot be done.
+     */
+    getActions(
+        database: AddonModDataData,
+        accessInfo: AddonModDataGetDataAccessInformationWSResponse,
+        entry: AddonModDataEntry,
+    ):  Record<AddonModDataAction, boolean> {
+        return {
+            add: false, // Not directly used on entries.
+            more: true,
+            moreurl: true,
+            user: true,
+            userpicture: true,
+            timeadded: true,
+            timemodified: true,
+            tags: true,
+
+            edit: entry.canmanageentry && !entry.deleted, // This already checks capabilities and readonly period.
+            delete: entry.canmanageentry,
+            approve: database.approval && accessInfo.canapprove && !entry.approved && !entry.deleted,
+            disapprove: database.approval && accessInfo.canapprove && entry.approved && !entry.deleted,
+
+            approvalstatus: database.approval,
+            comments: database.comments,
+
+            // Unsupported actions.
+            delcheck: false,
+            export: false,
+        };
+    }
+
+    /**
+     * Convenience function to get the course id of the database.
+     *
+     * @param dataId Database id.
+     * @param courseId Course id, if known.
+     * @param siteId Site id, if not set, current site will be used.
+     * @return Resolved with course Id when done.
+     */
+    protected async getActivityCourseIdIfNotSet(dataId: number, courseId?: number, siteId?: string): Promise<number> {
+        if (courseId) {
+            return courseId;
+        }
+
+        const module = await CoreCourse.getModuleBasicInfoByInstance(dataId, 'data', siteId);
+
+        return module.course;
+    }
+
+    /**
+     * Returns the default template of a certain type.
+     *
+     * Based on Moodle function data_generate_default_template.
+     *
+     * @param type Type of template.
+     * @param fields List of database fields.
+     * @return Template HTML.
+     */
+    getDefaultTemplate(type: AddonModDataTemplateType, fields: AddonModDataField[]): string {
+        if (type == AddonModDataTemplateType.LIST_HEADER || type == AddonModDataTemplateType.LIST_FOOTER) {
+            return '';
+        }
+
+        const html: string[] = [];
+
+        if (type == AddonModDataTemplateType.LIST) {
+            html.push('##delcheck##<br />');
+        }
+
+        html.push(
+            '<div class="defaulttemplate">',
+            '<table class="mod-data-default-template ##approvalstatus##">',
+            '<tbody>',
+        );
+
+        fields.forEach((field) => {
+            html.push(
+                '<tr class="">',
+                '<td class="template-field cell c0" style="">',
+                field.name,
+                ': </td>',
+                '<td class="template-token cell c1 lastcol" style="">[[',
+                field.name,
+                ']]</td>',
+                '</tr>',
+            );
+        });
+
+        if (type == AddonModDataTemplateType.LIST) {
+            html.push(
+                '<tr class="lastrow">',
+                '<td class="controls template-field cell c0 lastcol" style="" colspan="2">',
+                '##edit##  ##more##  ##delete##  ##approve##  ##disapprove##  ##export##',
+                '</td>',
+                '</tr>',
+            );
+        } else if (type == AddonModDataTemplateType.SINGLE) {
+            html.push(
+                '<tr class="lastrow">',
+                '<td class="controls template-field cell c0 lastcol" style="" colspan="2">',
+                '##edit##  ##delete##  ##approve##  ##disapprove##  ##export##',
+                '</td>',
+                '</tr>',
+            );
+        } else if (type == AddonModDataTemplateType.SEARCH) {
+            html.push(
+                '<tr class="searchcontrols">',
+                '<td class="template-field cell c0" style="">Author first name: </td>',
+                '<td class="template-token cell c1 lastcol" style="">##firstname##</td>',
+                '</tr>',
+                '<tr class="searchcontrols lastrow">',
+                '<td class="template-field cell c0" style="">Author surname: </td>',
+                '<td class="template-token cell c1 lastcol" style="">##lastname##</td>',
+                '</tr>',
+            );
+        }
+
+        html.push(
+            '</tbody>',
+            '</table>',
+            '</div>',
+        );
+
+        if (type == AddonModDataTemplateType.LIST) {
+            html.push('<hr />');
+        }
+
+        return html.join('');
+    }
+
+    /**
+     * Retrieve the entered data in the edit form.
+     * We don't use ng-model because it doesn't detect changes done by JavaScript.
+     *
+     * @param inputData Array with the entered form values.
+     * @param fields Fields that defines every content in the entry.
+     * @param dataId Database Id. If set, files will be uploaded and itemId set.
+     * @param entryId Entry Id.
+     * @param entryContents Original entry contents.
+     * @param offline True to prepare the data for an offline uploading, false otherwise.
+     * @param siteId Site ID. If not defined, current site.
+     * @return That contains object with the answers.
+     */
+    async getEditDataFromForm(
+        inputData: CoreFormFields,
+        fields: AddonModDataField[],
+        dataId: number,
+        entryId: number,
+        entryContents: AddonModDataEntryFields,
+        offline: boolean = false,
+        siteId?: string,
+    ): Promise<AddonModDataEntryWSField[]> {
+        if (!inputData) {
+            return [];
+        }
+
+        siteId = siteId || CoreSites.getCurrentSiteId();
+
+        // Filter and translate fields to each field plugin.
+        const entryFieldDataToSend: AddonModDataEntryWSField[] = [];
+
+        const promises = fields.map(async (field) => {
+            const fieldData = AddonModDataFieldsDelegate.getFieldEditData(field, inputData, entryContents[field.id]);
+            if (!fieldData) {
+                return;
+            }
+            const proms = fieldData.map(async (fieldSubdata) => {
+                let value = fieldSubdata.value;
+
+                // Upload Files if asked.
+                if (dataId && fieldSubdata.files) {
+                    value = await this.uploadOrStoreFiles(
+                        dataId,
+                        0,
+                        entryId,
+                        fieldSubdata.fieldid,
+                        fieldSubdata.files,
+                        offline,
+                        siteId,
+                    );
+                }
+
+                // WS wants values in JSON format.
+                entryFieldDataToSend.push({
+                    fieldid: fieldSubdata.fieldid,
+                    subfield: fieldSubdata.subfield || '',
+                    value: fieldSubdata.value ? JSON.stringify(value) : '',
+                });
+
+                return;
+            });
+
+            await Promise.all(proms);
+        });
+
+        await Promise.all(promises);
+
+        return entryFieldDataToSend;
+    }
+
+    /**
+     * Retrieve the temp files to be updated.
+     *
+     * @param inputData Array with the entered form values.
+     * @param fields Fields that defines every content in the entry.
+     * @param entryContents Original entry contents indexed by field id.
+     * @return That contains object with the files.
+     */
+    async getEditTmpFiles(
+        inputData: CoreFormFields,
+        fields: AddonModDataField[],
+        entryContents: AddonModDataEntryFields,
+    ): Promise<(CoreWSExternalFile | FileEntry)[]> {
+        if (!inputData) {
+            return [];
+        }
+
+        // Filter and translate fields to each field plugin.
+        const promises = fields.map((field) =>
+            AddonModDataFieldsDelegate.getFieldEditFiles(field, inputData, entryContents[field.id]));
+
+        const fieldsFiles = await Promise.all(promises);
+
+        return fieldsFiles.reduce((files, fieldFiles) => files.concat(fieldFiles), []);
+    }
+
+    /**
+     * Get a list of stored attachment files for a new entry. See $mmaModDataHelper#storeFiles.
+     *
+     * @param dataId Database ID.
+     * @param entryId Entry ID or, if creating, timemodified.
+     * @param fieldId Field ID.
+     * @param siteId Site ID. If not defined, current site.
+     * @return Promise resolved with the files.
+     */
+    async getStoredFiles(dataId: number, entryId: number, fieldId: number, siteId?: string): Promise<FileEntry[]> {
+        const folderPath = await AddonModDataOffline.getEntryFieldFolder(dataId, entryId, fieldId, siteId);
+
+        try {
+            return CoreFileUploader.getStoredFiles(folderPath);
+        } catch {
+            // Ignore not found files.
+            return [];
+        }
+    }
+
+    /**
+     * Returns the template of a certain type.
+     *
+     * @param data Database object.
+     * @param type Type of template.
+     * @param fields List of database fields.
+     * @return Template HTML.
+     */
+    getTemplate(data: AddonModDataData, type: AddonModDataTemplateType, fields: AddonModDataField[]): string {
+        let template = data[type] || this.getDefaultTemplate(type, fields);
+
+        if (type != AddonModDataTemplateType.LIST_HEADER && type != AddonModDataTemplateType.LIST_FOOTER) {
+            // Try to fix syntax errors so the template can be parsed by Angular.
+            template = CoreDomUtils.fixHtml(template);
+        }
+
+        // Add core-link directive to links.
+        template = template.replace(
+            /<a ([^>]*href="[^>]*)>/ig,
+            (match, attributes) => '<a core-link capture="true" ' + attributes + '>',
+        );
+
+        return template;
+    }
+
+    /**
+     * Check if data has been changed by the user.
+     *
+     * @param inputData Object with the entered form values.
+     * @param fields Fields that defines every content in the entry.
+     * @param dataId Database Id. If set, fils will be uploaded and itemId set.
+     * @param entryContents Original entry contents indexed by field id.
+     * @return True if changed, false if not.
+     */
+    hasEditDataChanged(
+        inputData: CoreFormFields,
+        fields: AddonModDataField[],
+        entryContents: AddonModDataEntryFields,
+    ): boolean {
+        return fields.some((field) =>
+            AddonModDataFieldsDelegate.hasFieldDataChanged(field, inputData, entryContents[field.id]));
+    }
+
+    /**
+     * Displays a confirmation modal for deleting an entry.
+     *
+     * @param dataId Database ID.
+     * @param entryId Entry ID.
+     * @param courseId Course ID. It not defined, it will be fetched.
+     * @param siteId Site ID. If not defined, current site.
+     */
+    async showDeleteEntryModal(dataId: number, entryId: number, courseId?: number, siteId?: string): Promise<void> {
+        siteId = siteId || CoreSites.getCurrentSiteId();
+
+        try {
+            await CoreDomUtils.showDeleteConfirm('addon.mod_data.confirmdeleterecord');
+
+            const modal = await CoreDomUtils.showModalLoading();
+
+            try {
+                if (entryId > 0) {
+                    courseId = await this.getActivityCourseIdIfNotSet(dataId, courseId, siteId);
+                }
+
+                AddonModData.deleteEntry(dataId, entryId, courseId!, siteId);
+            } catch (message) {
+                CoreDomUtils.showErrorModalDefault(message, 'addon.mod_data.errordeleting', true);
+
+                modal.dismiss();
+
+                return;
+            }
+
+            try {
+                await AddonModData.invalidateEntryData(dataId, entryId, siteId);
+                await AddonModData.invalidateEntriesData(dataId, siteId);
+            } catch (error) {
+                // Ignore errors.
+            }
+
+            CoreEvents.trigger(AddonModDataProvider.ENTRY_CHANGED, { dataId, entryId,  deleted: true }, siteId);
+
+            CoreDomUtils.showToast('addon.mod_data.recorddeleted', true, 3000);
+
+            modal.dismiss();
+        } catch {
+            // Ignore error, it was already displayed.
+        }
+
+    }
+
+    /**
+     * Given a list of files (either online files or local files), store the local files in a local folder
+     * to be submitted later.
+     *
+     * @param dataId Database ID.
+     * @param entryId Entry ID or, if creating, timemodified.
+     * @param fieldId Field ID.
+     * @param files List of files.
+     * @param siteId Site ID. If not defined, current site.
+     * @return Promise resolved if success, rejected otherwise.
+     */
+    async storeFiles(
+        dataId: number,
+        entryId: number,
+        fieldId: number,
+        files: (CoreWSExternalFile | FileEntry)[],
+        siteId?: string,
+    ): Promise<CoreFileUploaderStoreFilesResult> {
+        // Get the folder where to store the files.
+        const folderPath = await AddonModDataOffline.getEntryFieldFolder(dataId, entryId, fieldId, siteId);
+
+        return CoreFileUploader.storeFilesToUpload(folderPath, files);
+    }
+
+    /**
+     * Upload or store some files, depending if the user is offline or not.
+     *
+     * @param dataId Database ID.
+     * @param itemId Draft ID to use. Undefined or 0 to create a new draft ID.
+     * @param entryId Entry ID or, if creating, timemodified.
+     * @param fieldId Field ID.
+     * @param files List of files.
+     * @param offline True if files sould be stored for offline, false to upload them.
+     * @param siteId Site ID. If not defined, current site.
+     * @return Promise resolved with the itemId for the uploaded file/s.
+     */
+    async uploadOrStoreFiles(
+        dataId: number,
+        itemId: number = 0,
+        entryId: number,
+        fieldId: number,
+        files: (CoreWSExternalFile | FileEntry)[],
+        offline: boolean,
+        siteId?: string,
+    ): Promise<number | CoreFileUploaderStoreFilesResult> {
+        if (!files.length) {
+            return 0;
+        }
+
+        if (offline) {
+            return this.storeFiles(dataId, entryId, fieldId, files, siteId);
+        }
+
+        return CoreFileUploader.uploadOrReuploadFiles(files, AddonModDataProvider.COMPONENT, itemId, siteId);
+    }
+
+}
+export const AddonModDataHelper = makeSingleton(AddonModDataHelperProvider);
diff --git a/src/addons/mod/data/services/data-offline.ts b/src/addons/mod/data/services/data-offline.ts
new file mode 100644
index 000000000..ffc317d4d
--- /dev/null
+++ b/src/addons/mod/data/services/data-offline.ts
@@ -0,0 +1,290 @@
+// (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 { CoreFileUploader, CoreFileUploaderStoreFilesResult } from '@features/fileuploader/services/fileuploader';
+import { CoreFile } from '@services/file';
+import { CoreSites } from '@services/sites';
+import { CoreTextUtils } from '@services/utils/text';
+import { CoreUtils } from '@services/utils/utils';
+import { makeSingleton } from '@singletons';
+import { AddonModDataAction, AddonModDataEntryWSField } from './data';
+import { AddonModDataEntryDBRecord, DATA_ENTRY_TABLE } from './database/data';
+
+/**
+ * Service to handle Offline data.
+ */
+@Injectable({ providedIn: 'root' })
+export class AddonModDataOfflineProvider {
+
+    /**
+     * Delete all the actions of an entry.
+     *
+     * @param dataId Database ID.
+     * @param entryId Database entry ID.
+     * @param siteId Site ID. If not defined, current site.
+     * @return Promise resolved if deleted, rejected if failure.
+     */
+    async deleteAllEntryActions(dataId: number, entryId: number, siteId?: string): Promise<void> {
+        const actions = await this.getEntryActions(dataId, entryId, siteId);
+
+        const promises = actions.map((action) => {
+            this.deleteEntry(dataId, entryId, action.action, siteId);
+        });
+
+        await Promise.all(promises);
+    }
+
+    /**
+     * Delete an stored entry.
+     *
+     * @param dataId Database ID.
+     * @param entryId Database entry Id.
+     * @param action Action to be done
+     * @param siteId Site ID. If not defined, current site.
+     * @return Promise resolved if deleted, rejected if failure.
+     */
+    async deleteEntry(dataId: number, entryId: number, action: AddonModDataAction, siteId?: string): Promise<void> {
+        const site = await CoreSites.getSite(siteId);
+
+        await this.deleteEntryFiles(dataId, entryId, action, site.id);
+
+        await site.getDb().deleteRecords(DATA_ENTRY_TABLE, {
+            dataid: dataId,
+            entryid: entryId,
+            action,
+        });
+    }
+
+    /**
+     * Delete entry offline files.
+     *
+     * @param dataId Database ID.
+     * @param entryId Database entry ID.
+     * @param action Action to be done.
+     * @param siteId Site ID. If not defined, current site.
+     * @return Promise resolved if deleted, rejected if failure.
+     */
+    protected async deleteEntryFiles(dataId: number, entryId: number, action: AddonModDataAction, siteId?: string): Promise<void> {
+        const site = await CoreSites.getSite(siteId);
+        const entry = await CoreUtils.ignoreErrors(this.getEntry(dataId, entryId, action, site.id));
+
+        if (!entry || !entry.fields) {
+            // Entry not found or no fields, ignore.
+            return;
+        }
+
+        const promises: Promise<void>[] = [];
+
+        entry.fields.forEach((field) => {
+            const value = CoreTextUtils.parseJSON<CoreFileUploaderStoreFilesResult>(field.value);
+
+            if (!value.offline) {
+                return;
+            }
+
+            const promise = this.getEntryFieldFolder(dataId, entryId, field.fieldid, site.id).then((folderPath) =>
+                CoreFileUploader.getStoredFiles(folderPath)).then((files) =>
+                CoreFileUploader.clearTmpFiles(files)).catch(() => { // Files not found, ignore.
+            });
+
+            promises.push(promise);
+        });
+
+        await Promise.all(promises);
+    }
+
+    /**
+     * Get all the stored entry data from all the databases.
+     *
+     * @param siteId Site ID. If not defined, current site.
+     * @return Promise resolved with entries.
+     */
+    async getAllEntries(siteId?: string): Promise<AddonModDataOfflineAction[]> {
+        const site = await CoreSites.getSite(siteId);
+        const entries = await site.getDb().getAllRecords<AddonModDataEntryDBRecord>(DATA_ENTRY_TABLE);
+
+        return entries.map(this.parseRecord.bind(this));
+    }
+
+    /**
+     * Get all the stored entry actions from a certain database, sorted by modification time.
+     *
+     * @param dataId Database ID.
+     * @param siteId Site ID. If not defined, current site.
+     * @return Promise resolved with entries.
+     */
+    async getDatabaseEntries(dataId: number, siteId?: string): Promise<AddonModDataOfflineAction[]> {
+        const site = await CoreSites.getSite(siteId);
+        const entries = await site.getDb().getRecords<AddonModDataEntryDBRecord>(
+            DATA_ENTRY_TABLE,
+            { dataid: dataId },
+            'timemodified',
+        );
+
+        return entries.map(this.parseRecord.bind(this));
+    }
+
+    /**
+     * Get an stored entry data.
+     *
+     * @param dataId Database ID.
+     * @param entryId Database entry Id.
+     * @param action Action to be done
+     * @param siteId Site ID. If not defined, current site.
+     * @return Promise resolved with entry.
+     */
+    async getEntry(
+        dataId: number,
+        entryId: number,
+        action: AddonModDataAction,
+        siteId?: string,
+    ): Promise<AddonModDataOfflineAction> {
+        const site = await CoreSites.getSite(siteId);
+
+        const entry = await site.getDb().getRecord<AddonModDataEntryDBRecord>(DATA_ENTRY_TABLE, {
+            dataid: dataId, entryid: entryId,
+            action,
+        });
+
+        return this.parseRecord(entry);
+    }
+
+    /**
+     * Get an all stored entry actions data.
+     *
+     * @param dataId Database ID.
+     * @param entryId Database entry Id.
+     * @param siteId Site ID. If not defined, current site.
+     * @return Promise resolved with entry actions.
+     */
+    async getEntryActions(dataId: number, entryId: number, siteId?: string): Promise<AddonModDataOfflineAction[]> {
+        const site = await CoreSites.getSite(siteId);
+        const entries = await site.getDb().getRecords<AddonModDataEntryDBRecord>(
+            DATA_ENTRY_TABLE,
+            { dataid: dataId, entryid: entryId },
+        );
+
+        return entries.map(this.parseRecord.bind(this));
+    }
+
+    /**
+     * Check if there are offline entries to send.
+     *
+     * @param dataId Database ID.
+     * @param siteId Site ID. If not defined, current site.
+     * @return Promise resolved with boolean: true if has offline answers, false otherwise.
+     */
+    async hasOfflineData(dataId: number, siteId?: string): Promise<boolean> {
+        const site = await CoreSites.getSite(siteId);
+
+        return CoreUtils.promiseWorks(
+            site.getDb().recordExists(DATA_ENTRY_TABLE, { dataid: dataId }),
+        );
+    }
+
+    /**
+     * Get the path to the folder where to store files for offline files in a database.
+     *
+     * @param dataId Database ID.
+     * @param siteId Site ID. If not defined, current site.
+     * @return Promise resolved with the path.
+     */
+    protected async getDatabaseFolder(dataId: number, siteId?: string): Promise<string> {
+        const site = await CoreSites.getSite(siteId);
+        const siteFolderPath = CoreFile.getSiteFolder(site.getId());
+        const folderPath = 'offlinedatabase/' + dataId;
+
+        return CoreTextUtils.concatenatePaths(siteFolderPath, folderPath);
+    }
+
+    /**
+     * Get the path to the folder where to store files for a new offline entry.
+     *
+     * @param dataId Database ID.
+     * @param entryId The ID of the entry.
+     * @param fieldId Field ID.
+     * @param siteId Site ID. If not defined, current site.
+     * @return Promise resolved with the path.
+     */
+    async getEntryFieldFolder(dataId: number, entryId: number, fieldId: number, siteId?: string): Promise<string> {
+        const folderPath = await this.getDatabaseFolder(dataId, siteId);
+
+        return CoreTextUtils.concatenatePaths(folderPath, entryId + '_' + fieldId);
+    }
+
+    /**
+     * Parse "fields" of an offline record.
+     *
+     * @param record Record object
+     * @return Record object with columns parsed.
+     */
+    protected parseRecord(record: AddonModDataEntryDBRecord): AddonModDataOfflineAction {
+        return Object.assign(record, {
+            fields: CoreTextUtils.parseJSON<AddonModDataEntryWSField[]>(record.fields),
+        });
+    }
+
+    /**
+     * Save an entry data to be sent later.
+     *
+     * @param dataId Database ID.
+     * @param entryId Database entry Id. If action is add entryId should be 0 and -timemodified will be used.
+     * @param action Action to be done to the entry: [add, edit, delete, approve, disapprove]
+     * @param courseId Course ID of the database.
+     * @param groupId Group ID. Only provided when adding.
+     * @param fields Array of field data of the entry if needed.
+     * @param timemodified The time the entry was modified. If not defined, current time.
+     * @param siteId Site ID. If not defined, current site.
+     * @return Promise resolved if stored, rejected if failure.
+     */
+    async saveEntry(
+        dataId: number,
+        entryId: number,
+        action: AddonModDataAction,
+        courseId: number,
+        groupId = 0,
+        fields?: AddonModDataEntryWSField[],
+        timemodified?: number,
+        siteId?: string,
+    ): Promise<AddonModDataEntryDBRecord> {
+        const site = await CoreSites.getSite(siteId);
+
+        timemodified = timemodified || new Date().getTime();
+        entryId = typeof entryId == 'undefined' || entryId === null ? -timemodified : entryId;
+
+        const entry: AddonModDataEntryDBRecord = {
+            dataid: dataId,
+            courseid: courseId,
+            groupid: groupId,
+            action,
+            entryid: entryId,
+            fields: JSON.stringify(fields || []),
+            timemodified,
+        };
+
+        await site.getDb().insertRecord(DATA_ENTRY_TABLE, entry);
+
+        return entry;
+    }
+
+}
+export const AddonModDataOffline = makeSingleton(AddonModDataOfflineProvider);
+
+/**
+ * Entry action stored offline.
+ */
+export type AddonModDataOfflineAction = Omit<AddonModDataEntryDBRecord, 'fields'> & {
+    fields: AddonModDataEntryWSField[];
+};
diff --git a/src/addons/mod/data/services/data-sync.ts b/src/addons/mod/data/services/data-sync.ts
new file mode 100644
index 000000000..92d0d94c9
--- /dev/null
+++ b/src/addons/mod/data/services/data-sync.ts
@@ -0,0 +1,499 @@
+// (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 { Injectable } from '@angular/core';
+import { CoreSyncBlockedError } from '@classes/base-sync';
+import { CoreNetworkError } from '@classes/errors/network-error';
+import { CoreCourseActivitySyncBaseProvider } from '@features/course/classes/activity-sync';
+import { CoreCourseCommonModWSOptions } from '@features/course/services/course';
+import { CoreCourseLogHelper } from '@features/course/services/log-helper';
+import { CoreFileUploaderStoreFilesResult } from '@features/fileuploader/services/fileuploader';
+import { CoreRatingSync } from '@features/rating/services/rating-sync';
+import { FileEntry } from '@ionic-native/file';
+import { CoreApp } from '@services/app';
+import { CoreSites, CoreSitesReadingStrategy } from '@services/sites';
+import { CoreSync } from '@services/sync';
+import { CoreTextUtils } from '@services/utils/text';
+import { CoreUtils } from '@services/utils/utils';
+import { CoreWSExternalFile } from '@services/ws';
+import { Translate, makeSingleton } from '@singletons';
+import { CoreEvents } from '@singletons/events';
+import { AddonModDataProvider, AddonModData, AddonModDataData, AddonModDataAction } from './data';
+import { AddonModDataHelper } from './data-helper';
+import { AddonModDataOffline, AddonModDataOfflineAction } from './data-offline';
+
+/**
+ * Service to sync databases.
+ */
+@Injectable({ providedIn: 'root' })
+export class AddonModDataSyncProvider extends CoreCourseActivitySyncBaseProvider<AddonModDataSyncResult> {
+
+    static readonly AUTO_SYNCED = 'addon_mod_data_autom_synced';
+
+    protected componentTranslatableString = 'data';
+
+    constructor() {
+        super('AddonModDataSyncProvider');
+    }
+
+    /**
+     * Check if a database has data to synchronize.
+     *
+     * @param dataId Database ID.
+     * @param siteId Site ID. If not defined, current site.
+     * @return Promise resolved with boolean: true if has data to sync, false otherwise.
+     */
+    hasDataToSync(dataId: number, siteId?: string): Promise<boolean> {
+        return AddonModDataOffline.hasOfflineData(dataId, siteId);
+    }
+
+    /**
+     * Try to synchronize all the databases in a certain site or in all sites.
+     *
+     * @param siteId Site ID to sync. If not defined, sync all sites.
+     * @param force Wether to force sync not depending on last execution.
+     * @return Promise resolved if sync is successful, rejected if sync fails.
+     */
+    syncAllDatabases(siteId?: string, force?: boolean): Promise<void> {
+        return this.syncOnSites('all databases', this.syncAllDatabasesFunc.bind(this, !!force), siteId);
+    }
+
+    /**
+     * Sync all pending databases on a site.
+     *
+     * @param force Wether to force sync not depending on last execution.
+     * @param siteId Site ID to sync. If not defined, sync all sites.
+     * @param Promise resolved if sync is successful, rejected if sync fails.
+     */
+    protected async syncAllDatabasesFunc(force: boolean, siteId: string): Promise<void> {
+        const promises: Promise<unknown>[] = [];
+
+        // Get all data answers pending to be sent in the site.
+        promises.push(AddonModDataOffline.getAllEntries(siteId).then(async (offlineActions) => {
+            // Get data id.
+            let dataIds: number[] = offlineActions.map((action) => action.dataid);
+            // Get unique values.
+            dataIds = dataIds.filter((id, pos) => dataIds.indexOf(id) == pos);
+
+            const entriesPromises = dataIds.map(async (dataId) => {
+                const result = force
+                    ? await this.syncDatabase(dataId, siteId)
+                    : await this.syncDatabaseIfNeeded(dataId, siteId);
+
+                if (result && result.updated) {
+                    // Sync done. Send event.
+                    CoreEvents.trigger(AddonModDataSyncProvider.AUTO_SYNCED, {
+                        dataId: dataId,
+                        warnings: result.warnings,
+                    }, siteId);
+                }
+            });
+
+            await Promise.all(entriesPromises);
+
+            return;
+        }));
+
+        promises.push(this.syncRatings(undefined, force, siteId));
+
+        await Promise.all(promises);
+    }
+
+    /**
+     * Sync a database only if a certain time has passed since the last time.
+     *
+     * @param dataId Database ID.
+     * @param siteId Site ID. If not defined, current site.
+     * @return Promise resolved when the data is synced or if it doesn't need to be synced.
+     */
+    async syncDatabaseIfNeeded(dataId: number, siteId?: string): Promise<AddonModDataSyncResult | undefined> {
+        const needed = await this.isSyncNeeded(dataId, siteId);
+
+        if (needed) {
+            return this.syncDatabase(dataId, siteId);
+        }
+    }
+
+    /**
+     * Synchronize a data.
+     *
+     * @param dataId Data ID.
+     * @param siteId Site ID. If not defined, current site.
+     * @return Promise resolved if sync is successful, rejected otherwise.
+     */
+    syncDatabase(dataId: number, siteId?: string): Promise<AddonModDataSyncResult> {
+        siteId = siteId || CoreSites.getCurrentSiteId();
+
+        if (this.isSyncing(dataId, siteId)) {
+            // There's already a sync ongoing for this database, return the promise.
+            return this.getOngoingSync(dataId, siteId)!;
+        }
+
+        // Verify that database isn't blocked.
+        if (CoreSync.isBlocked(AddonModDataProvider.COMPONENT, dataId, siteId)) {
+            this.logger.debug(`Cannot sync database '${dataId}' because it is blocked.`);
+
+            throw new CoreSyncBlockedError(Translate.instant('core.errorsyncblocked', { $a: this.componentTranslate }));
+        }
+
+        this.logger.debug(`Try to sync data '${dataId}' in site ${siteId}'`);
+
+        const syncPromise = this.performSyncDatabase(dataId, siteId);
+
+        return this.addOngoingSync(dataId, syncPromise, siteId);
+    }
+
+    /**
+     * Perform the database syncronization.
+     *
+     * @param dataId Data ID.
+     * @param siteId Site ID.
+     * @return Promise resolved if sync is successful, rejected otherwise.
+     */
+    protected async performSyncDatabase(dataId: number, siteId: string): Promise<AddonModDataSyncResult> {
+        // Sync offline logs.
+        await CoreUtils.ignoreErrors(
+            CoreCourseLogHelper.syncActivity(AddonModDataProvider.COMPONENT, dataId, siteId),
+        );
+
+        const result: AddonModDataSyncResult = {
+            warnings: [],
+            updated: false,
+        };
+
+        // Get answers to be sent.
+        const offlineActions: AddonModDataOfflineAction[] =
+            await CoreUtils.ignoreErrors(AddonModDataOffline.getDatabaseEntries(dataId, siteId), []);
+
+        if (!offlineActions.length) {
+            // Nothing to sync.
+            await CoreUtils.ignoreErrors(this.setSyncTime(dataId, siteId));
+
+            return result;
+        }
+
+        if (!CoreApp.isOnline()) {
+            // Cannot sync in offline.
+            throw new CoreNetworkError();
+        }
+
+        const courseId = offlineActions[0].courseid;
+
+        // Send the answers.
+        const database = await AddonModData.getDatabaseById(courseId, dataId, { siteId });
+
+        const offlineEntries: Record<number, AddonModDataOfflineAction[]> = {};
+
+        offlineActions.forEach((entry) => {
+            if (typeof offlineEntries[entry.entryid] == 'undefined') {
+                offlineEntries[entry.entryid] = [];
+            }
+
+            offlineEntries[entry.entryid].push(entry);
+        });
+
+        const promises = CoreUtils.objectToArray(offlineEntries).map((entryActions) =>
+            this.syncEntry(database, entryActions, result, siteId));
+
+        await Promise.all(promises);
+
+        if (result.updated) {
+            // Data has been sent to server. Now invalidate the WS calls.
+            await CoreUtils.ignoreErrors(AddonModData.invalidateContent(database.coursemodule, courseId, siteId));
+        }
+
+        // Sync finished, set sync time.
+        await CoreUtils.ignoreErrors(this.setSyncTime(dataId, siteId));
+
+        return result;
+    }
+
+    /**
+     * Synchronize an entry.
+     *
+     * @param database Database.
+     * @param entryActions Entry actions.
+     * @param result Object with the result of the sync.
+     * @param siteId Site ID.
+     * @return Promise resolved if success, rejected otherwise.
+     */
+    protected async syncEntry(
+        database: AddonModDataData,
+        entryActions: AddonModDataOfflineAction[],
+        result: AddonModDataSyncResult,
+        siteId: string,
+    ): Promise<void> {
+        const synEntryResult = await this.performSyncEntry(database, entryActions, result, siteId);
+
+        if (synEntryResult.discardError) {
+            // Submission was discarded, add a warning.
+            const message = Translate.instant('core.warningofflinedatadeleted', {
+                component: this.componentTranslate,
+                name: database.name,
+                error: synEntryResult.discardError,
+            });
+
+            if (result.warnings.indexOf(message) == -1) {
+                result.warnings.push(message);
+            }
+        }
+
+        // Sync done. Send event.
+        CoreEvents.trigger(AddonModDataSyncProvider.AUTO_SYNCED, {
+            dataId: database.id,
+            entryId: synEntryResult.entryId,
+            offlineEntryId: synEntryResult.offlineId,
+            warnings: result.warnings,
+            deleted: synEntryResult.deleted,
+        }, siteId);
+    }
+
+    /**
+     * Perform the synchronization of an entry.
+     *
+     * @param database Database.
+     * @param entryActions Entry actions.
+     * @param result Object with the result of the sync.
+     * @param siteId Site ID.
+     * @return Promise resolved if success, rejected otherwise.
+     */
+    protected async performSyncEntry(
+        database: AddonModDataData,
+        entryActions: AddonModDataOfflineAction[],
+        result: AddonModDataSyncResult,
+        siteId: string,
+    ): Promise<AddonModDataSyncEntryResult> {
+        let entryId = entryActions[0].entryid;
+
+        const entryResult: AddonModDataSyncEntryResult = {
+            deleted: false,
+            entryId: entryId,
+        };
+
+        const editAction = entryActions.find((action) =>
+            action.action == AddonModDataAction.ADD || action.action == AddonModDataAction.EDIT);
+        const approveAction = entryActions.find((action) =>
+            action.action == AddonModDataAction.APPROVE || action.action == AddonModDataAction.DISAPPROVE);
+        const deleteAction = entryActions.find((action) => action.action == AddonModDataAction.DELETE);
+
+        const options: CoreCourseCommonModWSOptions = {
+            cmId: database.coursemodule,
+            readingStrategy: CoreSitesReadingStrategy.OnlyNetwork,
+            siteId,
+        };
+
+        let timemodified = 0;
+        if (entryId > 0) {
+            try {
+                const entry = await AddonModData.getEntry(database.id, entryId, options);
+
+                timemodified = entry.entry.timemodified;
+            } catch (error) {
+                if (error && CoreUtils.isWebServiceError(error)) {
+                    // The WebService has thrown an error, this means the entry has been deleted.
+                    timemodified = -1;
+                } else {
+                    throw error;
+                }
+            }
+
+        } else if (editAction) {
+            // New entry.
+            entryResult.offlineId = entryId;
+            timemodified = 0;
+        } else {
+            // New entry but the add action is missing, discard.
+            timemodified = -1;
+        }
+
+        if (timemodified < 0 || timemodified >= entryActions[0].timemodified) {
+            // The entry was not found in Moodle or the entry has been modified, discard the action.
+            result.updated = true;
+            entryResult.discardError = Translate.instant('addon.mod_data.warningsubmissionmodified');
+
+            await AddonModDataOffline.deleteAllEntryActions(database.id, entryId, siteId);
+
+            return entryResult;
+        }
+
+        if (deleteAction) {
+            try {
+                await AddonModData.deleteEntryOnline(entryId, siteId);
+                entryResult.deleted = true;
+            } catch (error) {
+                if (error && CoreUtils.isWebServiceError(error)) {
+                    // The WebService has thrown an error, this means it cannot be performed. Discard.
+                    entryResult.discardError = CoreTextUtils.getErrorMessageFromError(error);
+                } else {
+                    // Couldn't connect to server, reject.
+                    throw error;
+                }
+            }
+
+            // Delete the offline data.
+            result.updated = true;
+
+            await AddonModDataOffline.deleteAllEntryActions(deleteAction.dataid, deleteAction.entryid, siteId);
+
+            return entryResult;
+        }
+
+        if (editAction) {
+            try {
+                await Promise.all(editAction.fields.map(async (field) => {
+                    // Upload Files if asked.
+                    const value = CoreTextUtils.parseJSON<CoreFileUploaderStoreFilesResult>(field.value || '');
+                    if (value.online || value.offline) {
+                        let files: (CoreWSExternalFile | FileEntry)[] = value.online || [];
+
+                        const offlineFiles = value.offline
+                            ? await AddonModDataHelper.getStoredFiles(editAction.dataid, entryId, field.fieldid)
+                            : [];
+
+                        files = files.concat(offlineFiles);
+
+                        const filesResult = await AddonModDataHelper.uploadOrStoreFiles(
+                            editAction.dataid,
+                            0,
+                            entryId,
+                            field.fieldid,
+                            files,
+                            false,
+                            siteId,
+                        );
+
+                        field.value = JSON.stringify(filesResult);
+                    }
+                }));
+
+                if (editAction.action == AddonModDataAction.ADD) {
+                    const result = await AddonModData.addEntryOnline(
+                        editAction.dataid,
+                        editAction.fields,
+                        editAction.groupid,
+                        siteId,
+                    );
+                    entryId = result.newentryid;
+                    entryResult.entryId = entryId;
+                } else {
+                    await AddonModData.editEntryOnline(entryId, editAction.fields, siteId);
+                }
+            } catch (error) {
+                if (error && CoreUtils.isWebServiceError(error)) {
+                    // The WebService has thrown an error, this means it cannot be performed. Discard.
+                    entryResult.discardError = CoreTextUtils.getErrorMessageFromError(error);
+                } else {
+                    // Couldn't connect to server, reject.
+                    throw error;
+                }
+            }
+            // Delete the offline data.
+            result.updated = true;
+
+            await AddonModDataOffline.deleteEntry(editAction.dataid, editAction.entryid, editAction.action, siteId);
+        }
+
+        if (approveAction) {
+            try {
+                await AddonModData.approveEntryOnline(entryId, approveAction.action == AddonModDataAction.APPROVE, siteId);
+            } catch (error) {
+                if (error && CoreUtils.isWebServiceError(error)) {
+                    // The WebService has thrown an error, this means it cannot be performed. Discard.
+                    entryResult.discardError = CoreTextUtils.getErrorMessageFromError(error);
+                } else {
+                    // Couldn't connect to server, reject.
+                    throw error;
+                }
+            }
+            // Delete the offline data.
+            result.updated = true;
+
+            await AddonModDataOffline.deleteEntry(approveAction.dataid, approveAction.entryid, approveAction.action, siteId);
+        }
+
+        return entryResult;
+    }
+
+    /**
+     * Synchronize offline ratings.
+     *
+     * @param cmId Course module to be synced. If not defined, sync all databases.
+     * @param force Wether to force sync not depending on last execution.
+     * @param siteId Site ID. If not defined, current site.
+     * @return Promise resolved if sync is successful, rejected otherwise.
+     */
+    async syncRatings(cmId?: number, force?: boolean, siteId?: string): Promise<AddonModDataSyncResult> {
+        siteId = siteId || CoreSites.getCurrentSiteId();
+
+        const results = await CoreRatingSync.syncRatings('mod_data', 'entry', ContextLevel.MODULE, cmId, 0, force, siteId);
+        let updated = false;
+        const warnings = [];
+
+        const promises = results.map((result) =>
+            AddonModData.getDatabase(result.itemSet.courseId, result.itemSet.instanceId, { siteId })
+                .then((database) => {
+                    const subPromises: Promise<void>[] = [];
+
+                    if (result.updated.length) {
+                        updated = true;
+
+                        // Invalidate entry of updated ratings.
+                        result.updated.forEach((itemId) => {
+                            subPromises.push(AddonModData.invalidateEntryData(database.id, itemId, siteId));
+                        });
+                    }
+
+                    if (result.warnings.length) {
+                        result.warnings.forEach((warning) => {
+                            this.addOfflineDataDeletedWarning(warnings, database.name, warning);
+                        });
+                    }
+
+                    return CoreUtils.allPromises(subPromises);
+                }));
+
+        await Promise.all(promises);
+
+        return ({ updated, warnings });
+    }
+
+}
+export const AddonModDataSync = makeSingleton(AddonModDataSyncProvider);
+
+/**
+ * Data returned by a database sync.
+ */
+export type AddonModDataSyncEntryResult = {
+    discardError?: string;
+    offlineId?: number;
+    entryId: number;
+    deleted: boolean;
+};
+
+/**
+ * Data returned by a database sync.
+ */
+export type AddonModDataSyncResult = {
+    warnings: string[]; // List of warnings.
+    updated: boolean; // Whether some data was sent to the server or offline data was updated.
+};
+
+export type AddonModDataAutoSyncData =  {
+    dataId: number;
+    warnings: string[];
+    entryId?: number;
+    offlineEntryId?: number;
+    deleted?: boolean;
+};
diff --git a/src/addons/mod/data/services/data.ts b/src/addons/mod/data/services/data.ts
new file mode 100644
index 000000000..4b3af40c1
--- /dev/null
+++ b/src/addons/mod/data/services/data.ts
@@ -0,0 +1,1460 @@
+// (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 { CoreError } from '@classes/errors/error';
+import { CoreSite, CoreSiteWSPreSets } from '@classes/site';
+import { CoreCourseCommonModWSOptions } from '@features/course/services/course';
+import { CoreCourseLogHelper } from '@features/course/services/log-helper';
+import { CoreRatingInfo } from '@features/rating/services/rating';
+import { CoreTagItem } from '@features/tag/services/tag';
+import { FileEntry } from '@ionic-native/file';
+import { CoreApp } from '@services/app';
+import { CoreFilepool } from '@services/filepool';
+import { CoreSites, CoreSitesCommonWSOptions, CoreSitesReadingStrategy } from '@services/sites';
+import { CoreUtils } from '@services/utils/utils';
+import { CoreWSExternalFile, CoreWSExternalWarning } from '@services/ws';
+import { makeSingleton } from '@singletons';
+import { AddonModDataFieldsDelegate } from './data-fields-delegate';
+import { AddonModDataOffline } from './data-offline';
+import { AddonModDataAutoSyncData, AddonModDataSyncProvider } from './data-sync';
+
+const ROOT_CACHE_KEY = 'mmaModData:';
+
+declare module '@singletons/events' {
+
+    /**
+     * Augment CoreEventsData interface with events specific to this service.
+     *
+     * @see https://www.typescriptlang.org/docs/handbook/declaration-merging.html#module-augmentation
+     */
+    export interface CoreEventsData {
+        [AddonModDataSyncProvider.AUTO_SYNCED]: AddonModDataAutoSyncData;
+        [AddonModDataProvider.ENTRY_CHANGED]: AddonModDataEntryChangedEventData;
+    }
+}
+
+export enum AddonModDataAction {
+    ADD = 'add',
+    EDIT = 'edit',
+    DELETE = 'delete',
+    APPROVE = 'approve',
+    DISAPPROVE = 'disapprove',
+    USER = 'user',
+    USERPICTURE = 'userpicture',
+    MORE = 'more',
+    MOREURL = 'moreurl',
+    COMMENTS = 'comments',
+    TIMEADDED = 'timeadded',
+    TIMEMODIFIED = 'timemodified',
+    TAGS = 'tags',
+    APPROVALSTATUS = 'approvalstatus',
+    DELCHECK = 'delcheck', // Unused.
+    EXPORT = 'export', // Unused.
+}
+
+export enum AddonModDataTemplateType {
+    LIST_HEADER = 'listtemplateheader',
+    LIST = 'listtemplate',
+    LIST_FOOTER = 'listtemplatefooter',
+    ADD = 'addtemplate',
+    SEARCH = 'asearchtemplate',
+    SINGLE = 'singletemplate',
+}
+
+export enum AddonModDataTemplateMode {
+    LIST = 'list',
+    EDIT = 'edit',
+    SHOW = 'show',
+    SEARCH = 'search',
+}
+
+/**
+ * Service that provides some features for databases.
+ */
+@Injectable({ providedIn: 'root' })
+export class AddonModDataProvider {
+
+    static readonly COMPONENT = 'mmaModData';
+    static readonly PER_PAGE = 25;
+    static readonly ENTRY_CHANGED = 'addon_mod_data_entry_changed';
+
+    /**
+     * Adds a new entry to a database.
+     *
+     * @param dataId Data instance ID.
+     * @param entryId EntryId or provisional entry ID when offline.
+     * @param courseId Course ID.
+     * @param contents The fields data to be created.
+     * @param groupId Group id, 0 means that the function will determine the user group.
+     * @param fields The fields that define the contents.
+     * @param siteId Site ID. If not defined, current site.
+     * @param forceOffline Force editing entry in offline.
+     * @return Promise resolved when the action is done.
+     */
+    async addEntry(
+        dataId: number,
+        entryId: number,
+        courseId: number,
+        contents: AddonModDataEntryWSField[],
+        groupId: number = 0,
+        fields: AddonModDataField[],
+        siteId?: string,
+        forceOffline: boolean = false,
+    ): Promise<AddonModDataAddEntryResult> {
+        siteId = siteId || CoreSites.getCurrentSiteId();
+
+        // Convenience function to store a data to be synchronized later.
+        const storeOffline = async (): Promise<AddonModDataAddEntryResult> => {
+            const entry = await AddonModDataOffline.saveEntry(
+                dataId,
+                entryId,
+                AddonModDataAction.ADD,
+                courseId,
+                groupId,
+                contents,
+                undefined,
+                siteId,
+            );
+
+            return {
+                // Return provissional entry Id.
+                newentryid: entry.entryid,
+                sent: false,
+            };
+        };
+
+        // Checks to store offline.
+        if (!CoreApp.isOnline() || forceOffline) {
+            const notifications = this.checkFields(fields, contents);
+            if (notifications.length > 0) {
+                return { fieldnotifications: notifications };
+            }
+        }
+
+        // Remove unnecessary not synced actions.
+        await this.deleteEntryOfflineAction(dataId, entryId, AddonModDataAction.ADD, siteId);
+
+        // App is offline, store the action.
+        if (!CoreApp.isOnline() || forceOffline) {
+            return storeOffline();
+        }
+
+        try {
+            const result: AddonModDataAddEntryResult = await this.addEntryOnline(dataId, contents, groupId, siteId);
+            result.sent = true;
+
+            return result;
+        } catch (error) {
+            if (CoreUtils.isWebServiceError(error)) {
+                // The WebService has thrown an error, this means that responses cannot be submitted.
+                throw error;
+            }
+
+            // Couldn't connect to server, store in offline.
+            return storeOffline();
+        }
+    }
+
+    /**
+     * Adds a new entry to a database. It does not cache calls. It will fail if offline or cannot connect.
+     *
+     * @param dataId Database ID.
+     * @param data The fields data to be created.
+     * @param groupId Group id, 0 means that the function will determine the user group.
+     * @param siteId Site ID. If not defined, current site.
+     * @return Promise resolved when the action is done.
+     */
+    async addEntryOnline(
+        dataId: number,
+        data: AddonModDataEntryWSField[],
+        groupId?: number,
+        siteId?: string,
+    ): Promise<AddonModDataAddEntryWSResponse> {
+        const site = await CoreSites.getSite(siteId);
+        const params: AddonModDataAddEntryWSParams = {
+            databaseid: dataId,
+            data,
+        };
+
+        if (typeof groupId !== 'undefined') {
+            params.groupid = groupId;
+        }
+
+        return site.write<AddonModDataAddEntryWSResponse>('mod_data_add_entry', params);
+    }
+
+    /**
+     * Approves or unapproves an entry.
+     *
+     * @param dataId Database ID.
+     * @param entryId Entry ID.
+     * @param approve Whether to approve (true) or unapprove the entry.
+     * @param courseId Course ID.
+     * @param siteId Site ID. If not defined, current site.
+     * @return Promise resolved when the action is done.
+     */
+    async approveEntry(
+        dataId: number,
+        entryId: number,
+        approve: boolean,
+        courseId: number,
+        siteId?: string,
+    ): Promise<AddonModDataApproveEntryResult | undefined> {
+        siteId = siteId || CoreSites.getCurrentSiteId();
+
+        // Convenience function to store a data to be synchronized later.
+        const storeOffline = async (): Promise<AddonModDataApproveEntryResult> => {
+            const action = approve ? AddonModDataAction.APPROVE : AddonModDataAction.DISAPPROVE;
+
+            await AddonModDataOffline.saveEntry(dataId, entryId, action, courseId, undefined, undefined, undefined, siteId);
+
+            return {
+                sent: false,
+            };
+        };
+
+        // Get if the opposite action is not synced.
+        const oppositeAction = approve ? AddonModDataAction.DISAPPROVE : AddonModDataAction.APPROVE;
+
+        const found = await this.deleteEntryOfflineAction(dataId, entryId, oppositeAction, siteId);
+        if (found) {
+            // Offline action has been found and deleted. Stop here.
+            return;
+        }
+
+        if (!CoreApp.isOnline()) {
+            // App is offline, store the action.
+            return storeOffline();
+        }
+
+        try {
+            await this.approveEntryOnline(entryId, approve, siteId);
+
+            return {
+                sent: true,
+            };
+        } catch (error) {
+            if (CoreUtils.isWebServiceError(error)) {
+                // The WebService has thrown an error, this means that responses cannot be submitted.
+                throw error;
+            }
+
+            // Couldn't connect to server, store in offline.
+            return storeOffline();
+        }
+    }
+
+    /**
+     * Approves or unapproves an entry. It does not cache calls. It will fail if offline or cannot connect.
+     *
+     * @param entryId Entry ID.
+     * @param approve Whether to approve (true) or unapprove the entry.
+     * @param siteId Site ID. If not defined, current site.
+     * @return Promise resolved when the action is done.
+     */
+    async approveEntryOnline(entryId: number, approve: boolean, siteId?: string): Promise<void> {
+        const site = await CoreSites.getSite(siteId);
+        const params: AddonModDataApproveEntryWSParams = {
+            entryid: entryId,
+            approve,
+        };
+
+        await site.write('mod_data_approve_entry', params);
+    }
+
+    /**
+     * Convenience function to check fields requeriments here named "notifications".
+     *
+     * @param fields The fields that define the contents.
+     * @param contents The contents data of the fields.
+     * @return Array of notifications if any or false.
+     */
+    protected checkFields(fields: AddonModDataField[], contents: AddonModDataSubfieldData[]): AddonModDataFieldNotification[] {
+        const notifications: AddonModDataFieldNotification[] = [];
+        const contentsIndexed = CoreUtils.arrayToObjectMultiple(contents, 'fieldid');
+
+        // App is offline, check required fields.
+        fields.forEach((field) => {
+            const notification = AddonModDataFieldsDelegate.getFieldsNotifications(field, contentsIndexed[field.id]);
+
+            if (notification) {
+                notifications.push({
+                    fieldname: field.name,
+                    notification,
+                });
+            }
+        });
+
+        return notifications;
+    }
+
+    /**
+     * Deletes an entry.
+     *
+     * @param dataId Database ID.
+     * @param entryId Entry ID.
+     * @param courseId Course ID.
+     * @param siteId Site ID. If not defined, current site.
+     * @return Promise resolved when the action is done.
+     */
+    async deleteEntry(dataId: number, entryId: number, courseId: number, siteId?: string): Promise<void> {
+        siteId = siteId || CoreSites.getCurrentSiteId();
+
+        // Convenience function to store a data to be synchronized later.
+        const storeOffline = async (): Promise<void> => {
+            await AddonModDataOffline.saveEntry(
+                dataId,
+                entryId,
+                AddonModDataAction.DELETE,
+                courseId,
+                undefined,
+                undefined,
+                undefined,
+                siteId,
+            );
+        };
+
+        // Check if the opposite action is not synced and just delete it.
+        const addedOffline = await this.deleteEntryOfflineAction(dataId, entryId, AddonModDataAction.ADD, siteId);
+        if (addedOffline) {
+            // Offline add action found and deleted. Stop here.
+            return;
+        }
+
+        if (!CoreApp.isOnline()) {
+            // App is offline, store the action.
+            return storeOffline();
+        }
+
+        try {
+            await this.deleteEntryOnline(entryId, siteId);
+        } catch (error) {
+            if (CoreUtils.isWebServiceError(error)) {
+                // The WebService has thrown an error, this means that responses cannot be submitted.
+                throw error;
+            }
+
+            // Couldn't connect to server, store in offline.
+            return storeOffline();
+        }
+    }
+
+    /**
+     * Deletes an entry. It does not cache calls. It will fail if offline or cannot connect.
+     *
+     * @param entryId Entry ID.
+     * @param siteId Site ID. If not defined, current site.
+     * @return Promise resolved when the action is done.
+     */
+    async deleteEntryOnline(entryId: number, siteId?: string): Promise<void> {
+        const site = await CoreSites.getSite(siteId);
+        const params: AddonModDataDeleteEntryWSParams = {
+            entryid: entryId,
+        };
+
+        await site.write('mod_data_delete_entry', params);
+    }
+
+    /**
+     * Delete entry offline action.
+     *
+     * @param dataId Database ID.
+     * @param entryId Entry ID.
+     * @param action Action name to delete.
+     * @param siteId Site ID.
+     * @return Resolved with true if the action has been found and deleted.
+     */
+    protected async deleteEntryOfflineAction(
+        dataId: number,
+        entryId: number,
+        action: AddonModDataAction,
+        siteId: string,
+    ): Promise<boolean> {
+        try {
+            // Get other not not synced actions.
+            await AddonModDataOffline.getEntry(dataId, entryId, action, siteId);
+            await AddonModDataOffline.deleteEntry(dataId, entryId, action, siteId);
+
+            return true;
+        } catch {
+            // Not found.
+            return false;
+        }
+    }
+
+    /**
+     * Updates an existing entry.
+     *
+     * @param dataId Database ID.
+     * @param entryId Entry ID.
+     * @param courseId Course ID.
+     * @param contents The contents data to be updated.
+     * @param fields The fields that define the contents.
+     * @param siteId Site ID. If not defined, current site.
+     * @param forceOffline Force editing entry in offline.
+     * @return Promise resolved when the action is done.
+     */
+    async editEntry(
+        dataId: number,
+        entryId: number,
+        courseId: number,
+        contents: AddonModDataEntryWSField[],
+        fields: AddonModDataField[],
+        siteId?: string,
+        forceOffline: boolean = false,
+    ): Promise<AddonModDataEditEntryResult> {
+        siteId = siteId || CoreSites.getCurrentSiteId();
+
+        // Convenience function to store a data to be synchronized later.
+        const storeOffline = async (): Promise<AddonModDataEditEntryResult> => {
+            await AddonModDataOffline.saveEntry(
+                dataId,
+                entryId,
+                AddonModDataAction.EDIT,
+                courseId,
+                undefined,
+                contents,
+                undefined,
+                siteId,
+            );
+
+            return {
+                updated: true,
+                sent: false,
+            };
+        };
+
+        if (!CoreApp.isOnline() || forceOffline) {
+            const notifications = this.checkFields(fields, contents);
+            if (notifications.length > 0) {
+                return { fieldnotifications: notifications };
+            }
+        }
+
+        // Remove unnecessary not synced actions.
+        await this.deleteEntryOfflineAction(dataId, entryId, AddonModDataAction.EDIT, siteId);
+
+        if (!CoreApp.isOnline() || forceOffline) {
+            // App is offline, store the action.
+            return storeOffline();
+        }
+
+        try {
+            const result: AddonModDataEditEntryResult = await this.editEntryOnline(entryId, contents, siteId);
+            result.sent = true;
+
+            return result;
+        } catch (error) {
+            if (CoreUtils.isWebServiceError(error)) {
+                // The WebService has thrown an error, this means that responses cannot be submitted.
+                throw error;
+            }
+
+            // Couldn't connect to server, store in offline.
+            return storeOffline();
+        }
+    }
+
+    /**
+     * Updates an existing entry. It does not cache calls. It will fail if offline or cannot connect.
+     *
+     * @param entryId Entry ID.
+     * @param data The fields data to be updated.
+     * @param siteId Site ID. If not defined, current site.
+     * @return Promise resolved when the action is done.
+     */
+    async editEntryOnline(
+        entryId: number,
+        data: AddonModDataEntryWSField[],
+        siteId?: string,
+    ): Promise<AddonModDataUpdateEntryWSResponse> {
+        const site = await CoreSites.getSite(siteId);
+        const params: AddonModDataUpdateEntryWSParams = {
+            entryid: entryId,
+            data,
+        };
+
+        return site.write<AddonModDataUpdateEntryWSResponse>('mod_data_update_entry', params);
+    }
+
+    /**
+     * Performs the whole fetch of the entries in the database.
+     *
+     * @param dataId Data ID.
+     * @param options Other options.
+     * @return Promise resolved when done.
+     */
+    fetchAllEntries(dataId: number, options: AddonModDataGetEntriesOptions = {}): Promise<AddonModDataEntry[]> {
+        options.siteId = options.siteId || CoreSites.getCurrentSiteId();
+        options = Object.assign({
+            page: 0,
+            perPage: AddonModDataProvider.PER_PAGE,
+        }, options);
+
+        return this.fetchEntriesRecursive(dataId, [], options);
+    }
+
+    /**
+     * Recursive call on fetch all entries.
+     *
+     * @param dataId Data ID.
+     * @param entries Entries already fetch (just to concatenate them).
+     * @param options Other options.
+     * @return Promise resolved when done.
+     */
+    protected async fetchEntriesRecursive(
+        dataId: number,
+        entries: AddonModDataEntry[],
+        options: AddonModDataGetEntriesOptions,
+    ): Promise<AddonModDataEntry[]> {
+        const result = await this.getEntries(dataId, options);
+        entries = entries.concat(result.entries);
+
+        const canLoadMore = options.perPage! > 0 && ((options.page! + 1) * options.perPage!) < result.totalcount;
+        if (canLoadMore) {
+            options.page!++;
+
+            return this.fetchEntriesRecursive(dataId, entries, options);
+        }
+
+        return entries;
+    }
+
+    /**
+     * Get cache key for data data WS calls.
+     *
+     * @param courseId Course ID.
+     * @return Cache key.
+     */
+    protected getDatabaseDataCacheKey(courseId: number): string {
+        return ROOT_CACHE_KEY + 'data:' + courseId;
+    }
+
+    /**
+     * Get prefix cache key for all database activity data WS calls.
+     *
+     * @param dataId Data ID.
+     * @return Cache key.
+     */
+    protected getDatabaseDataPrefixCacheKey(dataId: number): string {
+        return ROOT_CACHE_KEY + dataId;
+    }
+
+    /**
+     * Get a database data. If more than one is found, only the first will be returned.
+     *
+     * @param courseId Course ID.
+     * @param key Name of the property to check.
+     * @param value Value to search.
+     * @param options Other options.
+     * @return Promise resolved when the data is retrieved.
+     */
+    protected async getDatabaseByKey(
+        courseId: number,
+        key: string,
+        value: number,
+        options: CoreSitesCommonWSOptions = {},
+    ): Promise<AddonModDataData> {
+        const site = await CoreSites.getSite(options.siteId);
+
+        const params: AddonModDataGetDatabasesByCoursesWSParams = {
+            courseids: [courseId],
+        };
+        const preSets: CoreSiteWSPreSets = {
+            cacheKey: this.getDatabaseDataCacheKey(courseId),
+            updateFrequency: CoreSite.FREQUENCY_RARELY,
+            component: AddonModDataProvider.COMPONENT,
+            ...CoreSites.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets.
+        };
+        const response =
+            await site.read<AddonModDataGetDatabasesByCoursesWSResponse>('mod_data_get_databases_by_courses', params, preSets);
+
+        const currentData = response.databases.find((data) => data[key] == value);
+        if (currentData) {
+            return currentData;
+        }
+
+        throw new CoreError('Activity not found');
+    }
+
+    /**
+     * Get a data by course module ID.
+     *
+     * @param courseId Course ID.
+     * @param cmId Course module ID.
+     * @param options Other options.
+     * @return Promise resolved when the data is retrieved.
+     */
+    getDatabase(courseId: number, cmId: number, options: CoreSitesCommonWSOptions = {}): Promise<AddonModDataData> {
+        return this.getDatabaseByKey(courseId, 'coursemodule', cmId, options);
+    }
+
+    /**
+     * Get a data by ID.
+     *
+     * @param courseId Course ID.
+     * @param id Data ID.
+     * @param options Other options.
+     * @return Promise resolved when the data is retrieved.
+     */
+    getDatabaseById(courseId: number, id: number, options: CoreSitesCommonWSOptions = {}): Promise<AddonModDataData> {
+        return this.getDatabaseByKey(courseId, 'id', id, options);
+    }
+
+    /**
+     * Get prefix cache key for all database access information data WS calls.
+     *
+     * @param dataId Data ID.
+     * @return Cache key.
+     */
+    protected getDatabaseAccessInformationDataPrefixCacheKey(dataId: number): string {
+        return this.getDatabaseDataPrefixCacheKey(dataId) + ':access:';
+    }
+
+    /**
+     * Get cache key for database access information data WS calls.
+     *
+     * @param dataId Data ID.
+     * @param groupId Group ID.
+     * @return Cache key.
+     */
+    protected getDatabaseAccessInformationDataCacheKey(dataId: number, groupId: number = 0): string {
+        return this.getDatabaseAccessInformationDataPrefixCacheKey(dataId) + groupId;
+    }
+
+    /**
+     * Get  access information for a given database.
+     *
+     * @param dataId Data ID.
+     * @param options Other options.
+     * @return Promise resolved when the database is retrieved.
+     */
+    async getDatabaseAccessInformation(
+        dataId: number,
+        options: AddonModDataAccessInfoOptions = {},
+    ): Promise<AddonModDataGetDataAccessInformationWSResponse> {
+        const site = await CoreSites.getSite(options.siteId);
+
+        options.groupId = options.groupId || 0;
+
+        const params: AddonModDataGetDataAccessInformationWSParams = {
+            databaseid: dataId,
+            groupid: options.groupId,
+        };
+
+        const preSets: CoreSiteWSPreSets = {
+            cacheKey: this.getDatabaseAccessInformationDataCacheKey(dataId, options.groupId),
+            component: AddonModDataProvider.COMPONENT,
+            componentId: options.cmId,
+            ...CoreSites.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets.
+        };
+
+        return site.read<AddonModDataGetDataAccessInformationWSResponse>('mod_data_get_data_access_information', params, preSets);
+    }
+
+    /**
+     * Get entries for a specific database and group.
+     *
+     * @param dataId Data ID.
+     * @param options Other options.
+     * @return Promise resolved when the database is retrieved.
+     */
+    async getEntries(dataId: number, options: AddonModDataGetEntriesOptions = {}): Promise<AddonModDataEntries> {
+        options = Object.assign({
+            groupId: 0,
+            sort: 0,
+            order: 'DESC',
+            page: 0,
+            perPage: AddonModDataProvider.PER_PAGE,
+        }, options);
+
+        const site = await CoreSites.getSite(options.siteId);
+        // Always use sort and order params to improve cache usage (entries are identified by params).
+        const params: AddonModDataGetEntriesWSParams = {
+            databaseid: dataId,
+            returncontents: true,
+            page: options.page,
+            perpage: options.perPage,
+            groupid: options.groupId,
+            sort: options.sort,
+            order: options.order,
+        };
+
+        const preSets: CoreSiteWSPreSets = {
+            cacheKey: this.getEntriesCacheKey(dataId, options.groupId),
+            updateFrequency: CoreSite.FREQUENCY_SOMETIMES,
+            component: AddonModDataProvider.COMPONENT,
+            componentId: options.cmId,
+            ...CoreSites.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets.
+        };
+
+        const response = await site.read<AddonModDataGetEntriesWSResponse>('mod_data_get_entries', params, preSets);
+
+        const entriesFormatted = response.entries.map((entry) => this.formatEntryContents(entry));
+
+        return Object.assign(response, {
+            entries: entriesFormatted,
+        });
+    }
+
+    /**
+     * Get cache key for database entries data WS calls.
+     *
+     * @param dataId Data ID.
+     * @param groupId Group ID.
+     * @return Cache key.
+     */
+    protected getEntriesCacheKey(dataId: number, groupId: number = 0): string {
+        return this.getEntriesPrefixCacheKey(dataId) + groupId;
+    }
+
+    /**
+     * Get prefix cache key for database all entries data WS calls.
+     *
+     * @param dataId Data ID.
+     * @return Cache key.
+     */
+    protected getEntriesPrefixCacheKey(dataId: number): string {
+        return this.getDatabaseDataPrefixCacheKey(dataId) + ':entries:';
+    }
+
+    /**
+     * Get an entry of the database activity.
+     *
+     * @param dataId Data ID for caching purposes.
+     * @param entryId Entry ID.
+     * @param options Other options.
+     * @return Promise resolved when the entry is retrieved.
+     */
+    async getEntry(
+        dataId: number,
+        entryId: number,
+        options: CoreCourseCommonModWSOptions = {},
+    ): Promise<AddonModDataGetEntryFormatted> {
+        const site = await CoreSites.getSite(options.siteId);
+
+        const params: AddonModDataGetEntryWSParams = {
+            entryid: entryId,
+            returncontents: true,
+        };
+
+        const preSets: CoreSiteWSPreSets = {
+            cacheKey: this.getEntryCacheKey(dataId, entryId),
+            updateFrequency: CoreSite.FREQUENCY_SOMETIMES,
+            component: AddonModDataProvider.COMPONENT,
+            componentId: options.cmId,
+            ...CoreSites.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets.
+        };
+
+        const response = await site.read<AddonModDataGetEntryWSResponse>('mod_data_get_entry', params, preSets);
+
+        return Object.assign(response, {
+            entry: this.formatEntryContents(response.entry),
+        });
+    }
+
+    /**
+     * Formats the contents of an entry.
+     *
+     * @param entry Original WS entry.
+     * @returns Entry with contents formatted.
+     */
+    protected formatEntryContents(entry: AddonModDataEntryWS): AddonModDataEntry {
+        return Object.assign(entry, {
+            contents: CoreUtils.arrayToObject(entry.contents, 'fieldid'),
+        });
+    }
+
+    /**
+     * Get cache key for database entry data WS calls.
+     *
+     * @param dataId Data ID for caching purposes.
+     * @param entryId Entry ID.
+     * @return Cache key.
+     */
+    protected getEntryCacheKey(dataId: number, entryId: number): string {
+        return this.getDatabaseDataPrefixCacheKey(dataId) + ':entry:' + entryId;
+    }
+
+    /**
+     * Get the list of configured fields for the given database.
+     *
+     * @param dataId Data ID.
+     * @param options Other options.
+     * @return Promise resolved when the fields are retrieved.
+     */
+    async getFields(dataId: number, options: CoreCourseCommonModWSOptions = {}): Promise<AddonModDataField[]> {
+        const site = await CoreSites.getSite(options.siteId);
+
+        const params: AddonModDataGetFieldsWSParams = {
+            databaseid: dataId,
+        };
+
+        const preSets: CoreSiteWSPreSets = {
+            cacheKey: this.getFieldsCacheKey(dataId),
+            updateFrequency: CoreSite.FREQUENCY_RARELY,
+            component: AddonModDataProvider.COMPONENT,
+            componentId: options.cmId,
+            ...CoreSites.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets.
+        };
+
+        const response = await site.read<AddonModDataGetFieldsWSResponse>('mod_data_get_fields', params, preSets);
+        if (response.fields) {
+            return response.fields;
+        }
+
+        throw new CoreError('No fields were returned.');
+    }
+
+    /**
+     * Get cache key for database fields data WS calls.
+     *
+     * @param dataId Data ID.
+     * @return Cache key.
+     */
+    protected getFieldsCacheKey(dataId: number): string {
+        return this.getDatabaseDataPrefixCacheKey(dataId) + ':fields';
+    }
+
+    /**
+     * Invalidate the prefetched content.
+     * To invalidate files, use AddonModDataProvider#invalidateFiles.
+     *
+     * @param moduleId The module ID.
+     * @param courseId Course ID of the module.
+     * @param siteId Site ID. If not defined, current site.
+     * @return Promise resolved when the data is invalidated.
+     */
+    async invalidateContent(moduleId: number, courseId: number, siteId?: string): Promise<void> {
+        siteId = siteId || CoreSites.getCurrentSiteId();
+
+        const promises: Promise<void>[] = [];
+
+        promises.push(this.getDatabase(courseId, moduleId).then(async (database) => {
+            const ps: Promise<void>[] = [];
+
+            // Do not invalidate module data before getting module info, we need it!
+            ps.push(this.invalidateDatabaseData(courseId, siteId));
+            ps.push(this.invalidateDatabaseWSData(database.id, siteId));
+            ps.push(this.invalidateFieldsData(database.id, siteId));
+
+            await Promise.all(ps);
+
+            return;
+        }));
+
+        promises.push(this.invalidateFiles(moduleId, siteId));
+
+        await CoreUtils.allPromises(promises);
+    }
+
+    /**
+     * Invalidates database access information data.
+     *
+     * @param dataId Data ID.
+     * @param siteId Site ID. If not defined, current site.
+     * @return Promise resolved when the data is invalidated.
+     */
+    async invalidateDatabaseAccessInformationData(dataId: number, siteId?: string): Promise<void> {
+        const site = await CoreSites.getSite(siteId);
+
+        await site.invalidateWsCacheForKeyStartingWith(this.getDatabaseAccessInformationDataPrefixCacheKey(dataId));
+    }
+
+    /**
+     * Invalidates database entries data.
+     *
+     * @param dataId Data ID.
+     * @param siteId Site ID. If not defined, current site.
+     * @return Promise resolved when the data is invalidated.
+     */
+    async invalidateEntriesData(dataId: number, siteId?: string): Promise<void> {
+        const site = await CoreSites.getSite(siteId);
+
+        await site.invalidateWsCacheForKeyStartingWith(this.getEntriesPrefixCacheKey(dataId));
+    }
+
+    /**
+     * Invalidates database fields data.
+     *
+     * @param dataId Data ID.
+     * @param siteId Site ID. If not defined, current site.
+     * @return Promise resolved when the data is invalidated.
+     */
+    async invalidateFieldsData(dataId: number, siteId?: string): Promise<void> {
+        const site = await CoreSites.getSite(siteId);
+
+        await site.invalidateWsCacheForKey(this.getFieldsCacheKey(dataId));
+    }
+
+    /**
+     * Invalidate the prefetched files.
+     *
+     * @param moduleId The module ID.
+     * @param siteId Site ID. If not defined, current site.
+     * @return Promise resolved when the files are invalidated.
+     */
+    async invalidateFiles(moduleId: number, siteId?: string): Promise<void> {
+        await CoreFilepool.invalidateFilesByComponent(siteId, AddonModDataProvider.COMPONENT, moduleId);
+    }
+
+    /**
+     * Invalidates database data.
+     *
+     * @param courseId Course ID.
+     * @param siteId Site ID. If not defined, current site.
+     * @return Promise resolved when the data is invalidated.
+     */
+    async invalidateDatabaseData(courseId: number, siteId?: string): Promise<void> {
+        const site = await CoreSites.getSite(siteId);
+
+        await site.invalidateWsCacheForKey(this.getDatabaseDataCacheKey(courseId));
+    }
+
+    /**
+     * Invalidates database data except files and module info.
+     *
+     * @param databaseId Data ID.
+     * @param siteId Site ID. If not defined, current site.
+     * @return Promise resolved when the data is invalidated.
+     */
+    async invalidateDatabaseWSData(databaseId: number, siteId?: string): Promise<void> {
+        const site = await CoreSites.getSite(siteId);
+
+        await site.invalidateWsCacheForKeyStartingWith(this.getDatabaseDataPrefixCacheKey(databaseId));
+    }
+
+    /**
+     * Invalidates database entry data.
+     *
+     * @param dataId Data ID for caching purposes.
+     * @param entryId Entry ID.
+     * @param siteId Site ID. If not defined, current site.
+     * @return Promise resolved when the data is invalidated.
+     */
+    async invalidateEntryData(dataId: number, entryId: number, siteId?: string): Promise<void> {
+        const site = await CoreSites.getSite(siteId);
+
+        await site.invalidateWsCacheForKey(this.getEntryCacheKey(dataId, entryId));
+    }
+
+    /**
+     * Return whether or not the plugin is enabled in a certain site. Plugin is enabled if the database WS are available.
+     *
+     * @param siteId Site ID. If not defined, current site.
+     * @return Promise resolved with true if plugin is enabled, rejected or resolved with false otherwise.
+     * @since 3.3
+     */
+    async isPluginEnabled(siteId?: string): Promise<boolean> {
+        const site = await CoreSites.getSite(siteId);
+
+        return site.wsAvailable('mod_data_get_data_access_information');
+    }
+
+    /**
+     * Report the database as being viewed.
+     *
+     * @param id Module ID.
+     * @param name Name of the data.
+     * @param siteId Site ID. If not defined, current site.
+     * @return Promise resolved when the WS call is successful.
+     */
+    async logView(id: number, name?: string, siteId?: string): Promise<void> {
+        const params: AddonModDataViewDatabaseWSParams = {
+            databaseid: id,
+        };
+
+        await CoreCourseLogHelper.logSingle(
+            'mod_data_view_database',
+            params,
+            AddonModDataProvider.COMPONENT,
+            id,
+            name,
+            'data',
+            {},
+            siteId,
+        );
+    }
+
+    /**
+     * Performs search over a database.
+     *
+     * @param dataId The data instance id.
+     * @param options Other options.
+     * @return Promise resolved when the action is done.
+     */
+    async searchEntries(dataId: number, options: AddonModDataSearchEntriesOptions = {}): Promise<AddonModDataEntries> {
+        const site = await CoreSites.getSite(options.siteId);
+
+        options.groupId = options.groupId || 0;
+        options.sort = options.sort || 0;
+        options.order || options.order || 'DESC';
+        options.page = options.page || 0;
+        options.perPage = options.perPage || AddonModDataProvider.PER_PAGE;
+        options.readingStrategy = options.readingStrategy || CoreSitesReadingStrategy.PreferNetwork;
+
+        const params: AddonModDataSearchEntriesWSParams = {
+            databaseid: dataId,
+            groupid: options.groupId,
+            returncontents: true,
+            page: options.page,
+            perpage: options.perPage,
+        };
+        const preSets: CoreSiteWSPreSets = {
+            component: AddonModDataProvider.COMPONENT,
+            componentId: options.cmId,
+            ...CoreSites.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets.
+        };
+        if (typeof options.sort != 'undefined') {
+            params.sort = options.sort;
+        }
+        if (typeof options.order !== 'undefined') {
+            params.order = options.order;
+        }
+        if (typeof options.search !== 'undefined') {
+            params.search = options.search;
+        }
+        if (typeof options.advSearch !== 'undefined') {
+            params.advsearch = options.advSearch;
+        }
+        const response = await site.read<AddonModDataSearchEntriesWSResponse>('mod_data_search_entries', params, preSets);
+
+        const entriesFormatted = response.entries.map((entry) => this.formatEntryContents(entry));
+
+        return Object.assign(response, {
+            entries: entriesFormatted,
+        });
+    }
+
+}
+export const AddonModData = makeSingleton(AddonModDataProvider);
+
+/**
+ * Params of mod_data_view_database WS.
+ */
+type AddonModDataViewDatabaseWSParams = {
+    databaseid: number; // Data instance id.
+};
+
+/**
+ * Params of mod_data_search_entries WS.
+ */
+type AddonModDataSearchEntriesWSParams = {
+    databaseid: number; // Data instance id.
+    groupid?: number; // Group id, 0 means that the function will determine the user group.
+    returncontents?: boolean; // Whether to return contents or not.
+    search?: string; // Search string (empty when using advanced).
+    advsearch?: AddonModDataSearchEntriesAdvancedField[];
+    sort?: number; // Sort the records by this field id, reserved ids are:
+    // 0: timeadded
+    // -1: firstname
+    // -2: lastname
+    // -3: approved
+    // -4: timemodified.
+    // Empty for using the default database setting.
+    order?: string; // The direction of the sorting: 'ASC' or 'DESC'. Empty for using the default database setting.
+    page?: number; // The page of records to return.
+    perpage?: number; // The number of records to return per page.
+};
+
+/**
+ * Data returned by mod_data_search_entries WS.
+ */
+export type AddonModDataSearchEntriesWSResponse = {
+    entries: AddonModDataEntryWS[];
+    totalcount: number; // Total count of records returned by the search.
+    maxcount?: number; // Total count of records that the user could see in the database (if all the search criterias were removed).
+    listviewcontents?: string; // The list view contents as is rendered in the site.
+    warnings?: CoreWSExternalWarning[];
+};
+
+/**
+ * Options to pass to get access info.
+ */
+export type AddonModDataAccessInfoOptions = CoreCourseCommonModWSOptions & {
+    groupId?: number; // Group Id.
+};
+
+/**
+ * Options to pass to get entries.
+ */
+export type AddonModDataGetEntriesOptions = CoreCourseCommonModWSOptions & {
+    groupId?: number; // Group Id.
+    sort?: number; // Sort the records by this field id, defaults to 0. Reserved ids are:
+    // 0: timeadded
+    // -1: firstname
+    // -2: lastname
+    // -3: approved
+    // -4: timemodified
+    order?: string; // The direction of the sorting: 'ASC' or 'DESC'. Defaults to 'DESC'.
+    page?: number; // Page of records to return. Defaults to 0.
+    perPage?: number; // Records per page to return. Defaults to AddonModDataProvider.PER_PAGE.
+};
+
+/**
+ * Options to pass to search entries.
+ */
+export type AddonModDataSearchEntriesOptions = AddonModDataGetEntriesOptions & {
+    search?: string; // Search text. It will be used if advSearch is not defined.
+    advSearch?: AddonModDataSearchEntriesAdvancedField[];
+};
+
+/**
+ * Database entry (online or offline).
+ */
+export type AddonModDataEntry = Omit<AddonModDataEntryWS, 'contents'> & {
+    contents: AddonModDataEntryFields; // The record contents.
+    tags?: CoreTagItem[]; // Tags.
+    // Calculated data.
+    deleted?: boolean; // Entry is deleted offline.
+    hasOffline?: boolean; // Entry has offline actions.
+};
+
+/**
+ * Database entry data from WS.
+ */
+export type AddonModDataEntryWS = {
+    id: number; // Record id.
+    userid: number; // The id of the user who created the record.
+    groupid: number; // The group id this record belongs to (0 for no groups).
+    dataid: number; // The database id this record belongs to.
+    timecreated: number; // Time the record was created.
+    timemodified: number; // Last time the record was modified.
+    approved: boolean; // Whether the entry has been approved (if the database is configured in that way).
+    canmanageentry: boolean; // Whether the current user can manage this entry.
+    fullname?: string; // The user who created the entry fullname.
+    contents?: AddonModDataEntryField[];
+    tags?: CoreTagItem[]; // Tags.
+};
+
+/**
+ * Entry field content.
+ */
+export type AddonModDataEntryField = {
+    id: number; // Content id.
+    fieldid: number; // The field type of the content.
+    recordid: number; // The record this content belongs to.
+    content: string; // Contents.
+    content1: string; // Contents.
+    content2: string; // Contents.
+    content3: string; // Contents.
+    content4: string; // Contents.
+    files: (CoreWSExternalFile | FileEntry)[];
+};
+
+/**
+ * Entry contents indexed by field id.
+ */
+export type AddonModDataEntryFields = {
+    [fieldid: number]: AddonModDataEntryField;
+};
+
+/**
+ * List of entries returned by web service and helper functions.
+ */
+export type AddonModDataEntries = {
+    entries: AddonModDataEntry[]; // Online entries.
+    totalcount: number; // Total count of online entries or found entries.
+    maxcount?: number; // Total count of online entries. Only returned when searching.
+    offlineEntries?: AddonModDataEntry[]; // Offline entries.
+    hasOfflineActions?: boolean; // Whether the database has offline data.
+    hasOfflineRatings?: boolean; // Whether the database has offline ratings.
+};
+
+/**
+ * Subfield form data.
+ */
+export type AddonModDataSubfieldData = {
+    fieldid: number;
+    subfield?: string;
+    value?: unknown; // Value encoded in JSON.
+    files?: (CoreWSExternalFile | FileEntry)[];
+};
+
+/**
+ * Params of mod_data_get_data_access_information WS.
+ */
+type AddonModDataGetDataAccessInformationWSParams = {
+    databaseid: number; // Database instance id.
+    groupid?: number; // Group id, 0 means that the function will determine the user group.
+};
+
+/**
+ * Data returned by mod_data_get_data_access_information WS.
+ */
+export type AddonModDataGetDataAccessInformationWSResponse = {
+    groupid: number; // User current group id (calculated).
+    canaddentry: boolean; // Whether the user can add entries or not.
+    canmanageentries: boolean; // Whether the user can manage entries or not.
+    canapprove: boolean; // Whether the user can approve entries or not.
+    timeavailable: boolean; // Whether the database is available or not by time restrictions.
+    inreadonlyperiod: boolean; // Whether the database is in read mode only.
+    numentries: number; // The number of entries the current user added.
+    entrieslefttoadd: number; // The number of entries left to complete the activity.
+    entrieslefttoview: number; // The number of entries left to view other users entries.
+    warnings?: CoreWSExternalWarning[];
+};
+
+/**
+ * Params of mod_data_get_databases_by_courses WS.
+ */
+type AddonModDataGetDatabasesByCoursesWSParams = {
+    courseids?: number[]; // Array of course ids.
+};
+
+/**
+ * Data returned by mod_data_get_databases_by_courses WS.
+ */
+type AddonModDataGetDatabasesByCoursesWSResponse = {
+    databases: AddonModDataData[];
+    warnings?: CoreWSExternalWarning[];
+};
+
+/**
+ * Database data returned by mod_assign_get_assignments.
+ */
+export type AddonModDataData = {
+    id: number; // Database id.
+    course: number; // Course id.
+    name: string; // Database name.
+    intro: string; // The Database intro.
+    introformat?: number; // Intro format (1 = HTML, 0 = MOODLE, 2 = PLAIN or 4 = MARKDOWN).
+    comments: boolean; // Comments enabled.
+    timeavailablefrom: number; // Timeavailablefrom field.
+    timeavailableto: number; // Timeavailableto field.
+    timeviewfrom: number; // Timeviewfrom field.
+    timeviewto: number; // Timeviewto field.
+    requiredentries: number; // Requiredentries field.
+    requiredentriestoview: number; // Requiredentriestoview field.
+    maxentries: number; // Maxentries field.
+    rssarticles: number; // Rssarticles field.
+    singletemplate: string; // Singletemplate field.
+    listtemplate: string; // Listtemplate field.
+    listtemplateheader: string; // Listtemplateheader field.
+    listtemplatefooter: string; // Listtemplatefooter field.
+    addtemplate: string; // Addtemplate field.
+    rsstemplate: string; // Rsstemplate field.
+    rsstitletemplate: string; // Rsstitletemplate field.
+    csstemplate: string; // Csstemplate field.
+    jstemplate: string; // Jstemplate field.
+    asearchtemplate: string; // Asearchtemplate field.
+    approval: boolean; // Approval field.
+    manageapproved: boolean; // Manageapproved field.
+    scale?: number; // Scale field.
+    assessed?: number; // Assessed field.
+    assesstimestart?: number; // Assesstimestart field.
+    assesstimefinish?: number; // Assesstimefinish field.
+    defaultsort: number; // Defaultsort field.
+    defaultsortdir: number; // Defaultsortdir field.
+    editany?: boolean; // Editany field (not used any more).
+    notification?: number; // Notification field (not used any more).
+    timemodified?: number; // Time modified.
+    coursemodule: number; // Coursemodule.
+    introfiles?: CoreWSExternalFile[];
+};
+
+/**
+ * Params of mod_data_add_entry WS.
+ */
+type AddonModDataAddEntryWSParams = {
+    databaseid: number; // Data instance id.
+    groupid?: number; // Group id, 0 means that the function will determine the user group.
+    data: AddonModDataEntryWSField[]; // The fields data to be created.
+};
+
+/**
+ * Data returned by mod_data_add_entry WS.
+ */
+export type AddonModDataAddEntryWSResponse = {
+    newentryid: number; // True new created entry id. 0 if the entry was not created.
+    generalnotifications: string[];
+    fieldnotifications: AddonModDataFieldNotification[];
+    warnings?: CoreWSExternalWarning[];
+};
+
+/**
+ * Params of mod_data_approve_entry WS.
+ */
+type AddonModDataApproveEntryWSParams = {
+    entryid: number; // Record entry id.
+    approve?: boolean; // Whether to approve (true) or unapprove the entry.
+};
+
+/**
+ * Params of mod_data_delete_entry WS.
+ */
+type AddonModDataDeleteEntryWSParams = {
+    entryid: number; // Record entry id.
+};
+
+/**
+ * Params of mod_data_update_entry WS.
+ */
+type AddonModDataUpdateEntryWSParams = {
+    entryid: number; // The entry record id.
+    data: AddonModDataEntryWSField[];  // The fields data to be updated.
+};
+
+/**
+ * Data returned by mod_data_update_entry WS.
+ */
+export type AddonModDataUpdateEntryWSResponse = {
+    updated: boolean; // True if the entry was successfully updated, false other wise.
+    generalnotifications: string[];
+    fieldnotifications: AddonModDataFieldNotification[];
+    warnings?: CoreWSExternalWarning[];
+};
+
+// The fields data to be created or updated.
+export type AddonModDataEntryWSField = {
+    fieldid: number; // The field id. AddonModDataSubfieldData
+    subfield?: string; // The subfield name (if required).
+    value: string; // The contents for the field always JSON encoded.
+};
+
+/**
+ * Params of mod_data_get_entries WS.
+ */
+type AddonModDataGetEntriesWSParams = {
+    databaseid: number; // Data instance id.
+    groupid?: number; // Group id, 0 means that the function will determine the user group.
+    returncontents?: boolean; // Whether to return contents or not. This will return each entry raw contents and the complete list
+    // view(using the template).
+    sort?: number; // Sort the records by this field id, reserved ids are:
+    // 0: timeadded
+    // -1: firstname
+    // -2: lastname
+    // -3: approved
+    // -4: timemodified.
+    // Empty for using the default database setting.
+    order?: string; // The direction of the sorting: 'ASC' or 'DESC'. Empty for using the default database setting.
+    page?: number; // The page of records to return.
+    perpage?: number; // The number of records to return per page.
+};
+
+/**
+ * Data returned by mod_data_get_entries WS.
+ */
+export type AddonModDataGetEntriesWSResponse = {
+    entries: AddonModDataEntryWS[];
+    totalcount: number; // Total count of records.
+    totalfilesize: number; // Total size (bytes) of the files included in the records.
+    listviewcontents?: string; // The list view contents as is rendered in the site.
+    warnings?: CoreWSExternalWarning[];
+};
+
+/**
+ * Params of mod_data_get_entry WS.
+ */
+type AddonModDataGetEntryWSParams = {
+    entryid: number; // Record entry id.
+    returncontents?: boolean; // Whether to return contents or not.
+};
+
+/**
+ * Data returned by mod_data_get_entry WS.
+ */
+type AddonModDataGetEntryWSResponse = {
+    entry: AddonModDataEntryWS;
+    entryviewcontents?: string; // The entry as is rendered in the site.
+    ratinginfo?: CoreRatingInfo; // Rating information.
+    warnings?: CoreWSExternalWarning[];
+};
+
+/**
+ * Data returned by mod_data_get_entry WS.
+ */
+export type AddonModDataGetEntryFormatted = {
+    entry: AddonModDataEntry;
+    entryviewcontents?: string; // The entry as is rendered in the site.
+    ratinginfo?: CoreRatingInfo; // Rating information.
+    warnings?: CoreWSExternalWarning[];
+};
+
+export type AddonModDataFieldNotification = {
+    fieldname: string; // The field name.
+    notification: string; // The notification for the field.
+};
+
+/**
+ * Params of mod_data_get_fields WS.
+ */
+type AddonModDataGetFieldsWSParams = {
+    databaseid: number; // Database instance id.
+};
+
+/**
+ * Data returned by mod_data_get_fields WS.
+ */
+type AddonModDataGetFieldsWSResponse = {
+    fields: AddonModDataField[];
+    warnings?: CoreWSExternalWarning[];
+};
+
+/**
+ * Field data returned by mod_data_get_fields WS.
+ */
+export type AddonModDataField = {
+    id: number; // Field id.
+    dataid: number; // The field type of the content.
+    type: string; // The field type.
+    name: string; // The field name.
+    description: string; // The field description.
+    required: boolean; // Whether is a field required or not.
+    param1: string; // Field parameters.
+    param2: string; // Field parameters.
+    param3: string; // Field parameters.
+    param4: string; // Field parameters.
+    param5: string; // Field parameters.
+    param6: string; // Field parameters.
+    param7: string; // Field parameters.
+    param8: string; // Field parameters.
+    param9: string; // Field parameters.
+    param10: string; // Field parameters.
+};
+
+export type AddonModDataEntryChangedEventData = {
+    dataId: number;
+    entryId?: number;
+    deleted?: boolean;
+};
+
+/**
+ * Advanced search field.
+ */
+export type AddonModDataSearchEntriesAdvancedField = {
+    name: string; // Field key for search. Use fn or ln for first or last name.
+    value: string; // JSON encoded value for search.
+};
+
+/**
+ * Advanced search field.
+ */
+export type AddonModDataSearchEntriesAdvancedFieldFormatted = {
+    name: string; // Field key for search. Use fn or ln for first or last name.
+    value: unknown; // JSON encoded value for search.
+};
+
+export type AddonModDataAddEntryResult = Partial<AddonModDataAddEntryWSResponse> & {
+    sent?: boolean; // True if sent, false if stored offline.
+};
+
+export type AddonModDataApproveEntryResult = {
+    sent?: boolean; // True if sent, false if stored offline.
+};
+
+export type AddonModDataEditEntryResult = Partial<AddonModDataUpdateEntryWSResponse> & {
+    sent?: boolean; // True if sent, false if stored offline.
+};
diff --git a/src/addons/mod/data/services/database/data.ts b/src/addons/mod/data/services/database/data.ts
new file mode 100644
index 000000000..203274e78
--- /dev/null
+++ b/src/addons/mod/data/services/database/data.ts
@@ -0,0 +1,83 @@
+// (C) Copyright 2015 Moodle Pty Ltd.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import { SQLiteDB } from '@classes/sqlitedb';
+import { CoreSiteSchema } from '@services/sites';
+import { AddonModDataAction } from '../data';
+
+/**
+ * Database variables for AddonModDataOfflineProvider.
+ */
+export const DATA_ENTRY_TABLE = 'addon_mod_data_entry_1';
+export const ADDON_MOD_DATA_OFFLINE_SITE_SCHEMA: CoreSiteSchema = {
+    name: 'AddonModDataOfflineProvider',
+    version: 1,
+    tables: [
+        {
+            name: DATA_ENTRY_TABLE,
+            columns: [
+                {
+                    name: 'dataid',
+                    type: 'INTEGER',
+                },
+                {
+                    name: 'courseid',
+                    type: 'INTEGER',
+                },
+                {
+                    name: 'groupid',
+                    type: 'INTEGER',
+                },
+                {
+                    name: 'action',
+                    type: 'TEXT',
+                },
+                {
+                    name: 'entryid',
+                    type: 'INTEGER',
+                },
+                {
+                    name: 'fields',
+                    type: 'TEXT',
+                },
+                {
+                    name: 'timemodified',
+                    type: 'INTEGER',
+                },
+            ],
+            primaryKeys: ['dataid', 'entryid', 'action'],
+        },
+    ],
+    async migrate(db: SQLiteDB, oldVersion: number): Promise<void> {
+        if (oldVersion > 0) {
+            return;
+        }
+
+        // Move the records from the old table.
+        await db.migrateTable('addon_mod_data_entry', DATA_ENTRY_TABLE);
+    },
+};
+
+/**
+ * Data about data entries to sync.
+ */
+export type AddonModDataEntryDBRecord = {
+    dataid: number; // Primary key.
+    entryid: number; // Primary key. Negative for offline entries.
+    action: AddonModDataAction; // Primary key.
+    courseid: number;
+    groupid: number;
+    fields: string;
+    timemodified: number;
+};
diff --git a/src/addons/mod/data/services/handlers/approve-link.ts b/src/addons/mod/data/services/handlers/approve-link.ts
new file mode 100644
index 000000000..7dfdc3173
--- /dev/null
+++ b/src/addons/mod/data/services/handlers/approve-link.ts
@@ -0,0 +1,63 @@
+// (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 { makeSingleton } from '@singletons';
+import { AddonModData } from '../data';
+import { AddonModDataHelper } from '../data-helper';
+
+/**
+ * Content links handler for database approve/disapprove entry.
+ * Match mod/data/view.php?d=6&approve=5 with a valid data id and entryid.
+ */
+@Injectable({ providedIn: 'root' })
+export class AddonModDataApproveLinkHandlerService extends CoreContentLinksHandlerBase {
+
+    name = 'AddonModDataApproveLinkHandler';
+    featureName = 'CoreCourseModuleDelegate_AddonModData';
+    pattern = /\/mod\/data\/view\.php.*([?&](d|approve|disapprove)=\d+)/;
+    priority = 50; // Higher priority than the default link handler for view.php.
+
+    /**
+     * @inheritdoc
+     */
+    getActions(siteIds: string[], url: string, params: Params, courseId?: number): CoreContentLinksAction[] {
+        return [{
+            action: (siteId): void => {
+                const dataId = parseInt(params.d, 10);
+                const entryId = parseInt(params.approve, 10) || parseInt(params.disapprove, 10);
+                const approve = parseInt(params.approve, 10) ? true : false;
+
+                AddonModDataHelper.approveOrDisapproveEntry(dataId, entryId, approve, courseId, siteId);
+            },
+        }];
+    }
+
+    /**
+     * @inheritdoc
+     */
+    async isEnabled(siteId: string, url: string, params: Params): Promise<boolean> {
+        if (typeof params.d == 'undefined' || (typeof params.approve == 'undefined' && typeof params.disapprove == 'undefined')) {
+            // Required fields not defined. Cannot treat the URL.
+            return false;
+        }
+
+        return AddonModData.isPluginEnabled(siteId);
+    }
+
+}
+export const AddonModDataApproveLinkHandler = makeSingleton(AddonModDataApproveLinkHandlerService);
diff --git a/src/addons/mod/data/services/handlers/default-field.ts b/src/addons/mod/data/services/handlers/default-field.ts
new file mode 100644
index 000000000..3eec28e1a
--- /dev/null
+++ b/src/addons/mod/data/services/handlers/default-field.ts
@@ -0,0 +1,78 @@
+// (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 { FileEntry } from '@ionic-native/file';
+import { CoreWSExternalFile } from '@services/ws';
+import { AddonModDataEntryField, AddonModDataSearchEntriesAdvancedFieldFormatted, AddonModDataSubfieldData } from '../data';
+import { AddonModDataFieldHandler } from '../data-fields-delegate';
+
+/**
+ * Default handler used when a field plugin doesn't have a specific implementation.
+ */
+@Injectable({ providedIn: 'root' })
+export class AddonModDataDefaultFieldHandler implements AddonModDataFieldHandler {
+
+    name = 'AddonModDataDefaultFieldHandler';
+    type = 'default';
+
+    /**
+     * @inheritdoc
+     */
+    getFieldSearchData(): AddonModDataSearchEntriesAdvancedFieldFormatted[] {
+        return [];
+    }
+
+    /**
+     * @inheritdoc
+     */
+    getFieldEditData(): AddonModDataSubfieldData[] {
+        return [];
+    }
+
+    /**
+     * @inheritdoc
+     */
+    hasFieldDataChanged(): boolean {
+        return false;
+    }
+
+    /**
+     * @inheritdoc
+     */
+    getFieldEditFiles(): (CoreWSExternalFile | FileEntry)[] {
+        return [];
+    }
+
+    /**
+     * @inheritdoc
+     */
+    getFieldsNotifications(): undefined {
+        return;
+    }
+
+    /**
+     * @inheritdoc
+     */
+    overrideData(originalContent: AddonModDataEntryField): AddonModDataEntryField {
+        return originalContent;
+    }
+
+    /**
+     * @inheritdoc
+     */
+    async isEnabled(): Promise<boolean> {
+        return true;
+    }
+
+}
diff --git a/src/addons/mod/data/services/handlers/delete-link.ts b/src/addons/mod/data/services/handlers/delete-link.ts
new file mode 100644
index 000000000..6fd66bc4b
--- /dev/null
+++ b/src/addons/mod/data/services/handlers/delete-link.ts
@@ -0,0 +1,61 @@
+// (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 { makeSingleton } from '@singletons';
+import { AddonModData } from '../data';
+import { AddonModDataHelper } from '../data-helper';
+
+/**
+ * Content links handler for database delete entry.
+ * Match mod/data/view.php?d=6&delete=5 with a valid data id and entryid.
+ */
+@Injectable({ providedIn: 'root' })
+export class AddonModDataDeleteLinkHandlerService extends CoreContentLinksHandlerBase {
+
+    name = 'AddonModDataDeleteLinkHandler';
+    featureName = 'CoreCourseModuleDelegate_AddonModData';
+    pattern = /\/mod\/data\/view\.php.*([?&](d|delete)=\d+)/;
+
+    /**
+     * @inheritdoc
+     */
+    getActions(siteIds: string[], url: string, params: Params, courseId?: number): CoreContentLinksAction[] {
+        return [{
+            action: (siteId): void => {
+                const dataId = parseInt(params.d, 10);
+                const entryId = parseInt(params.delete, 10);
+
+                AddonModDataHelper.showDeleteEntryModal(dataId, entryId, courseId, siteId);
+            },
+        }];
+    }
+
+    /**
+     * @inheritdoc
+     */
+    async isEnabled(siteId: string, url: string, params: Params): Promise<boolean> {
+        if (typeof params.d == 'undefined' || typeof params.delete == 'undefined') {
+            // Required fields not defined. Cannot treat the URL.
+            return false;
+        }
+
+        return AddonModData.isPluginEnabled(siteId);
+    }
+
+}
+export const AddonModDataDeleteLinkHandler = makeSingleton(AddonModDataDeleteLinkHandlerService);
diff --git a/src/addons/mod/data/services/handlers/edit-link.ts b/src/addons/mod/data/services/handlers/edit-link.ts
new file mode 100644
index 000000000..36acd0cfa
--- /dev/null
+++ b/src/addons/mod/data/services/handlers/edit-link.ts
@@ -0,0 +1,79 @@
+// (C) Copyright 2015 Moodle Pty Ltd.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import { 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 { CoreCourse } from '@features/course/services/course';
+import { CoreNavigator } from '@services/navigator';
+import { CoreDomUtils } from '@services/utils/dom';
+import { makeSingleton } from '@singletons';
+import { AddonModData } from '../data';
+import { AddonModDataModuleHandlerService } from './module';
+
+/**
+ * Content links handler for database add or edit entry.
+ * Match mod/data/edit.php?d=6&rid=6 with a valid data and optional record id.
+ */
+@Injectable({ providedIn: 'root' })
+export class AddonModDataEditLinkHandlerService extends CoreContentLinksHandlerBase {
+
+    name = 'AddonModDataEditLinkHandler';
+    featureName = 'CoreCourseModuleDelegate_AddonModData';
+    pattern = /\/mod\/data\/edit\.php.*([?&](d|rid)=\d+)/;
+
+    /**
+     * @inheritdoc
+     */
+    getActions(siteIds: string[], url: string, params: Params): CoreContentLinksAction[] {
+        return [{
+            action: async (siteId): Promise<void> => {
+                const modal = await CoreDomUtils.showModalLoading();
+                const dataId = parseInt(params.d, 10);
+                const rId = params.rid || '';
+
+                try {
+                    const module = await CoreCourse.getModuleBasicInfoByInstance(dataId, 'data', siteId);
+                    const pageParams: Params = {
+                        module,
+                        courseId: module.course,
+                    };
+
+                    CoreNavigator.navigateToSitePath(
+                        `${AddonModDataModuleHandlerService.PAGE_NAME}/${module.course}/${module.id}/edit/${rId}`,
+                        { siteId, params: pageParams },
+                    );
+                } finally {
+                    // Just in case. In fact we need to dismiss the modal before showing a toast or error message.
+                    modal.dismiss();
+                }
+            },
+        }];
+    }
+
+    /**
+     * @inheritdoc
+     */
+    async isEnabled(siteId: string, url: string, params: Params): Promise<boolean> {
+        if (typeof params.d == 'undefined') {
+            // Id not defined. Cannot treat the URL.
+            return false;
+        }
+
+        return AddonModData.isPluginEnabled(siteId);
+    }
+
+}
+export const AddonModDataEditLinkHandler = makeSingleton(AddonModDataEditLinkHandlerService);
diff --git a/src/addons/mod/data/services/handlers/index-link.ts b/src/addons/mod/data/services/handlers/index-link.ts
new file mode 100644
index 000000000..a831add07
--- /dev/null
+++ b/src/addons/mod/data/services/handlers/index-link.ts
@@ -0,0 +1,40 @@
+// (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 { CoreContentLinksModuleIndexHandler } from '@features/contentlinks/classes/module-index-handler';
+import { makeSingleton } from '@singletons';
+import { AddonModData } from '../data';
+
+/**
+ * Handler to treat links to data.
+ */
+@Injectable({ providedIn: 'root' })
+export class AddonModDataIndexLinkHandlerService extends CoreContentLinksModuleIndexHandler {
+
+    name = 'AddonModDataLinkHandler';
+
+    constructor() {
+        super('AddonModData', 'data', 'd');
+    }
+
+    /**
+     * @inheritdoc
+     */
+    isEnabled(siteId: string): Promise<boolean> {
+        return AddonModData.isPluginEnabled(siteId);
+    }
+
+}
+export const AddonModDataIndexLinkHandler = makeSingleton(AddonModDataIndexLinkHandlerService);
diff --git a/src/addons/mod/data/services/handlers/list-link.ts b/src/addons/mod/data/services/handlers/list-link.ts
new file mode 100644
index 000000000..1bbd94082
--- /dev/null
+++ b/src/addons/mod/data/services/handlers/list-link.ts
@@ -0,0 +1,40 @@
+// (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 { CoreContentLinksModuleListHandler } from '@features/contentlinks/classes/module-list-handler';
+import { makeSingleton } from '@singletons';
+import { AddonModData } from '../data';
+
+/**
+ * Handler to treat links to data list page.
+ */
+@Injectable({ providedIn: 'root' })
+export class AddonModDataListLinkHandlerService extends CoreContentLinksModuleListHandler {
+
+    name = 'AddonModDataListLinkHandler';
+
+    constructor() {
+        super('AddonModData', 'data');
+    }
+
+    /**
+     * @inheritdoc
+     */
+    isEnabled(siteId?: string): Promise<boolean> {
+        return AddonModData.isPluginEnabled(siteId);
+    }
+
+}
+export const AddonModDataListLinkHandler = makeSingleton(AddonModDataListLinkHandlerService);
diff --git a/src/addons/mod/data/services/handlers/module.ts b/src/addons/mod/data/services/handlers/module.ts
new file mode 100644
index 000000000..c48f8bb97
--- /dev/null
+++ b/src/addons/mod/data/services/handlers/module.ts
@@ -0,0 +1,85 @@
+// (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 { CoreConstants } from '@/core/constants';
+import { Injectable, Type } from '@angular/core';
+import { CoreCourse, CoreCourseAnyModuleData } from '@features/course/services/course';
+import { CoreCourseModule } from '@features/course/services/course-helper';
+import { CoreCourseModuleHandler, CoreCourseModuleHandlerData } from '@features/course/services/module-delegate';
+import { CoreNavigationOptions, CoreNavigator } from '@services/navigator';
+import { makeSingleton } from '@singletons';
+import { AddonModDataIndexComponent } from '../../components/index';
+import { AddonModData } from '../data';
+
+/**
+ * Handler to support data modules.
+ */
+@Injectable({ providedIn: 'root' })
+export class AddonModDataModuleHandlerService implements CoreCourseModuleHandler {
+
+    static readonly PAGE_NAME = 'mod_data';
+
+    name = 'AddonModData';
+    modName = 'data';
+
+    supportedFeatures = {
+        [CoreConstants.FEATURE_GROUPS]: true,
+        [CoreConstants.FEATURE_GROUPINGS]: true,
+        [CoreConstants.FEATURE_MOD_INTRO]: true,
+        [CoreConstants.FEATURE_COMPLETION_TRACKS_VIEWS]: true,
+        [CoreConstants.FEATURE_COMPLETION_HAS_RULES]: true,
+        [CoreConstants.FEATURE_GRADE_HAS_GRADE]: true,
+        [CoreConstants.FEATURE_GRADE_OUTCOMES]: true,
+        [CoreConstants.FEATURE_BACKUP_MOODLE2]: true,
+        [CoreConstants.FEATURE_SHOW_DESCRIPTION]: true,
+        [CoreConstants.FEATURE_RATE]: true,
+        [CoreConstants.FEATURE_COMMENT]: true,
+    };
+
+    /**
+     * @inheritdoc
+     */
+    isEnabled(): Promise<boolean> {
+        return AddonModData.isPluginEnabled();
+    }
+
+    /**
+     * @inheritdoc
+     */
+    getData(module: CoreCourseAnyModuleData): CoreCourseModuleHandlerData {
+        return {
+            icon: CoreCourse.getModuleIconSrc(this.modName, 'modicon' in module ? module.modicon : undefined),
+            title: module.name,
+            class: 'addon-mod_data-handler',
+            showDownloadButton: true,
+            action(event: Event, module: CoreCourseModule, courseId: number, options?: CoreNavigationOptions): void {
+                options = options || {};
+                options.params = options.params || {};
+                Object.assign(options.params, { module });
+                const routeParams = '/' + courseId + '/' + module.id;
+
+                CoreNavigator.navigateToSitePath(AddonModDataModuleHandlerService.PAGE_NAME + routeParams, options);
+            },
+        };
+    }
+
+    /**
+     * @inheritdoc
+     */
+    async getMainComponent(): Promise<Type<unknown>> {
+        return AddonModDataIndexComponent;
+    }
+
+}
+export const AddonModDataModuleHandler = makeSingleton(AddonModDataModuleHandlerService);
diff --git a/src/addons/mod/data/services/handlers/prefetch.ts b/src/addons/mod/data/services/handlers/prefetch.ts
new file mode 100644
index 000000000..7a8106596
--- /dev/null
+++ b/src/addons/mod/data/services/handlers/prefetch.ts
@@ -0,0 +1,300 @@
+// (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 { CoreComments } from '@features/comments/services/comments';
+import { CoreCourseActivityPrefetchHandlerBase } from '@features/course/classes/activity-prefetch-handler';
+import { CoreCourseCommonModWSOptions, CoreCourse, CoreCourseAnyModuleData } from '@features/course/services/course';
+import { CoreFilepool } from '@services/filepool';
+import { CoreGroup, CoreGroups } from '@services/groups';
+import { CoreSitesCommonWSOptions, CoreSites, CoreSitesReadingStrategy } from '@services/sites';
+import { CoreTimeUtils } from '@services/utils/time';
+import { CoreUtils } from '@services/utils/utils';
+import { CoreWSExternalFile } from '@services/ws';
+import { makeSingleton } from '@singletons';
+import { AddonModDataProvider, AddonModDataEntry, AddonModData, AddonModDataData } from '../data';
+import { AddonModDataSync, AddonModDataSyncResult } from '../data-sync';
+
+/**
+ * Handler to prefetch databases.
+ */
+@Injectable({ providedIn: 'root' })
+export class AddonModDataPrefetchHandlerService extends CoreCourseActivityPrefetchHandlerBase {
+
+    name = 'AddonModData';
+    modName = 'data';
+    component = AddonModDataProvider.COMPONENT;
+    updatesNames = /^configuration$|^.*files$|^entries$|^gradeitems$|^outcomes$|^comments$|^ratings/;
+
+    /**
+     * Retrieves all the entries for all the groups and then returns only unique entries.
+     *
+     * @param dataId Database Id.
+     * @param groups Array of groups in the activity.
+     * @param options Other options.
+     * @return All unique entries.
+     */
+    protected async getAllUniqueEntries(
+        dataId: number,
+        groups: CoreGroup[],
+        options: CoreSitesCommonWSOptions = {},
+    ): Promise<AddonModDataEntry[]> {
+
+        const promises = groups.map((group) => AddonModData.fetchAllEntries(dataId, {
+            groupId: group.id,
+            ...options, // Include all options.
+        }));
+
+        const responses = await Promise.all(promises);
+
+        const uniqueEntries: Record<number, AddonModDataEntry> = {};
+        responses.forEach((groupEntries) => {
+            groupEntries.forEach((entry) => {
+                uniqueEntries[entry.id] = entry;
+            });
+        });
+
+        return CoreUtils.objectToArray(uniqueEntries);
+    }
+
+    /**
+     * Helper function to get all database info just once.
+     *
+     * @param module Module to get the files.
+     * @param courseId Course ID the module belongs to.
+     * @param omitFail True to always return even if fails. Default false.
+     * @param options Other options.
+     * @return Promise resolved with the info fetched.
+     */
+    protected async getDatabaseInfoHelper(
+        module: CoreCourseAnyModuleData,
+        courseId: number,
+        omitFail: boolean,
+        options: CoreCourseCommonModWSOptions = {},
+    ): Promise<{ database: AddonModDataData; groups: CoreGroup[]; entries: AddonModDataEntry[]; files: CoreWSExternalFile[]}> {
+        let groups: CoreGroup[] = [];
+        let entries: AddonModDataEntry[] = [];
+        let files: CoreWSExternalFile[] = [];
+
+        options.cmId = options.cmId || module.id;
+        options.siteId = options.siteId || CoreSites.getCurrentSiteId();
+
+        const database = await AddonModData.getDatabase(courseId, module.id, options);
+
+        try {
+            files = this.getIntroFilesFromInstance(module, database);
+
+            const groupInfo = await CoreGroups.getActivityGroupInfo(module.id, false, undefined, options.siteId);
+            if (!groupInfo.groups || groupInfo.groups.length == 0) {
+                groupInfo.groups = [{ id: 0, name: '' }];
+            }
+            groups = groupInfo.groups || [];
+
+            entries = await this.getAllUniqueEntries(database.id, groups, options);
+            files = files.concat(this.getEntriesFiles(entries));
+
+            return {
+                database,
+                groups,
+                entries,
+                files,
+            };
+        } catch (error) {
+            if (omitFail) {
+                // Any error, return the info we have.
+                return {
+                    database,
+                    groups,
+                    entries,
+                    files,
+                };
+            }
+
+            throw error;
+        }
+    }
+
+    /**
+     * Returns the file contained in the entries.
+     *
+     * @param entries List of entries to get files from.
+     * @return List of files.
+     */
+    protected getEntriesFiles(entries: AddonModDataEntry[]): CoreWSExternalFile[] {
+        let files: CoreWSExternalFile[] = [];
+
+        entries.forEach((entry) => {
+            CoreUtils.objectToArray(entry.contents).forEach((content) => {
+                files = files.concat(<CoreWSExternalFile[]>content.files);
+            });
+        });
+
+        return files;
+    }
+
+    /**
+     * @inheritdoc
+     */
+    async getFiles(module: CoreCourseAnyModuleData, courseId: number): Promise<CoreWSExternalFile[]> {
+        return this.getDatabaseInfoHelper(module, courseId, true).then((info) => info.files);
+    }
+
+    /**
+     * @inheritdoc
+     */
+    async getIntroFiles(module: CoreCourseAnyModuleData, courseId: number): Promise<CoreWSExternalFile[]> {
+        const data = await CoreUtils.ignoreErrors(AddonModData.getDatabase(courseId, module.id));
+
+        return this.getIntroFilesFromInstance(module, data);
+    }
+
+    /**
+     * @inheritdoc
+     */
+    async invalidateContent(moduleId: number, courseId: number): Promise<void> {
+        await AddonModData.invalidateContent(moduleId, courseId);
+    }
+
+    /**
+     * @inheritdoc
+     */
+    async invalidateModule(module: CoreCourseAnyModuleData, courseId: number): Promise<void> {
+        const promises: Promise<void>[] = [];
+        promises.push(AddonModData.invalidateDatabaseData(courseId));
+        promises.push(AddonModData.invalidateDatabaseAccessInformationData(module.instance!));
+
+        await Promise.all(promises);
+    }
+
+    /**
+     * @inheritdoc
+     */
+    async isDownloadable(module: CoreCourseAnyModuleData, courseId: number): Promise<boolean> {
+        const database = await AddonModData.getDatabase(courseId, module.id, {
+            readingStrategy: CoreSitesReadingStrategy.PreferCache,
+        });
+
+        const accessData = await AddonModData.getDatabaseAccessInformation(database.id, { cmId: module.id });
+        // Check if database is restricted by time.
+        if (!accessData.timeavailable) {
+            const time = CoreTimeUtils.timestamp();
+
+            // It is restricted, checking times.
+            if (database.timeavailablefrom && time < database.timeavailablefrom) {
+                return false;
+            }
+            if (database.timeavailableto && time > database.timeavailableto) {
+                return false;
+            }
+        }
+
+        return true;
+    }
+
+    /**
+     * @inheritdoc
+     */
+    async isEnabled(): Promise<boolean> {
+        return AddonModData.isPluginEnabled();
+    }
+
+    /**
+     * @inheritdoc
+     */
+    prefetch(module: CoreCourseAnyModuleData, courseId?: number): Promise<void> {
+        return this.prefetchPackage(module, courseId, this.prefetchDatabase.bind(this, module, courseId));
+    }
+
+    /**
+     * Prefetch a database.
+     *
+     * @param module Module.
+     * @param courseId Course ID the module belongs to.
+     * @return Promise resolved when done.
+     */
+    protected async prefetchDatabase(module: CoreCourseAnyModuleData, courseId?: number): Promise<void> {
+        const siteId = CoreSites.getCurrentSiteId();
+        courseId = courseId || module.course || CoreSites.getCurrentSiteHomeId();
+
+        const options = {
+            cmId: module.id,
+            readingStrategy: CoreSitesReadingStrategy.OnlyNetwork,
+            siteId,
+        };
+
+        const info = await this.getDatabaseInfoHelper(module, courseId, false, options);
+
+        // Prefetch the database data.
+        const database = info.database;
+
+        const commentsEnabled = !CoreComments.areCommentsDisabledInSite();
+
+        const promises: Promise<unknown>[] = [];
+
+        promises.push(AddonModData.getFields(database.id, options));
+        promises.push(CoreFilepool.addFilesToQueue(siteId, info.files, this.component, module.id));
+
+        info.groups.forEach((group) => {
+            promises.push(AddonModData.getDatabaseAccessInformation(database.id, {
+                groupId: group.id,
+                ...options, // Include all options.
+            }));
+        });
+
+        info.entries.forEach((entry) => {
+            promises.push(AddonModData.getEntry(database.id, entry.id, options));
+
+            if (commentsEnabled && database.comments) {
+                promises.push(CoreComments.getComments(
+                    'module',
+                    database.coursemodule,
+                    'mod_data',
+                    entry.id,
+                    'database_entry',
+                    0,
+                    siteId,
+                ));
+            }
+        });
+
+        // Add Basic Info to manage links.
+        promises.push(CoreCourse.getModuleBasicInfoByInstance(database.id, 'data', siteId));
+
+        await Promise.all(promises);
+    }
+
+    /**
+     * Sync a module.
+     *
+     * @param module Module.
+     * @param courseId Course ID the module belongs to
+     * @param siteId Site ID. If not defined, current site.
+     * @return Promise resolved when done.
+     */
+    async sync(module: CoreCourseAnyModuleData, courseId: number, siteId?: string): Promise<AddonModDataSyncResult> {
+        const promises = [
+            AddonModDataSync.syncDatabase(module.instance!, siteId),
+            AddonModDataSync.syncRatings(module.id, true, siteId),
+        ];
+
+        const results = await Promise.all(promises);
+
+        return results.reduce((a, b) => ({
+            updated: a.updated || b.updated,
+            warnings: (a.warnings || []).concat(b.warnings || []),
+        }), { updated: false , warnings: [] });
+    }
+
+}
+export const AddonModDataPrefetchHandler = makeSingleton(AddonModDataPrefetchHandlerService);
diff --git a/src/addons/mod/data/services/handlers/show-link.ts b/src/addons/mod/data/services/handlers/show-link.ts
new file mode 100644
index 000000000..63beb6200
--- /dev/null
+++ b/src/addons/mod/data/services/handlers/show-link.ts
@@ -0,0 +1,94 @@
+// (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 { CoreCourse } from '@features/course/services/course';
+import { CoreNavigator } from '@services/navigator';
+import { CoreDomUtils } from '@services/utils/dom';
+import { makeSingleton } from '@singletons';
+import { AddonModData } from '../data';
+import { AddonModDataModuleHandlerService } from './module';
+
+/**
+ * Content links handler for database show entry.
+ * Match mod/data/view.php?d=6&rid=5 with a valid data id and entryid.
+ */
+@Injectable({ providedIn: 'root' })
+export class AddonModDataShowLinkHandlerService extends CoreContentLinksHandlerBase {
+
+    name = 'AddonModDataShowLinkHandler';
+    featureName = 'CoreCourseModuleDelegate_AddonModData';
+    pattern = /\/mod\/data\/view\.php.*([?&](d|rid|page|group|mode)=\d+)/;
+    priority = 50; // Higher priority than the default link handler for view.php.
+
+    /**
+     * @inheritdoc
+     */
+    getActions(siteIds: string[], url: string, params: Params): CoreContentLinksAction[] {
+        return [{
+            action: async (siteId): Promise<void> => {
+                const modal = await CoreDomUtils.showModalLoading();
+                const dataId = parseInt(params.d, 10);
+                const rId = params.rid || '';
+                const group = parseInt(params.group, 10) || false;
+                const page = parseInt(params.page, 10) || false;
+
+                try {
+                    const module = await CoreCourse.getModuleBasicInfoByInstance(dataId, 'data', siteId);
+                    const pageParams: Params = {
+                        module: module,
+                        courseId: module.course,
+                    };
+
+                    if (group) {
+                        pageParams.group = group;
+                    }
+
+                    if (params.mode && params.mode == 'single') {
+                        pageParams.offset = page || 0;
+                    }
+
+                    CoreNavigator.navigateToSitePath(
+                        `${AddonModDataModuleHandlerService.PAGE_NAME}/${module.course}/${module.id}/${rId}`,
+                        { siteId, params: pageParams },
+                    );
+                } finally {
+                    // Just in case. In fact we need to dismiss the modal before showing a toast or error message.
+                    modal.dismiss();
+                }
+            },
+        }];
+    }
+
+    /**
+     * @inheritdoc
+     */
+    async isEnabled(siteId: string, url: string, params: Params): Promise<boolean> {
+        if (typeof params.d == 'undefined') {
+            // Id not defined. Cannot treat the URL.
+            return false;
+        }
+
+        if ((!params.mode || params.mode != 'single') && typeof params.rid == 'undefined') {
+            return false;
+        }
+
+        return AddonModData.isPluginEnabled(siteId);
+    }
+
+}
+export const AddonModDataShowLinkHandler = makeSingleton(AddonModDataShowLinkHandlerService);
diff --git a/src/addons/mod/data/services/handlers/sync-cron.ts b/src/addons/mod/data/services/handlers/sync-cron.ts
new file mode 100644
index 000000000..daab609e2
--- /dev/null
+++ b/src/addons/mod/data/services/handlers/sync-cron.ts
@@ -0,0 +1,43 @@
+// (C) Copyright 2015 Moodle Pty Ltd.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import { Injectable } from '@angular/core';
+import { CoreCronHandler } from '@services/cron';
+import { makeSingleton } from '@singletons';
+import { AddonModDataSync } from '../data-sync';
+
+/**
+ * Synchronization cron handler.
+ */
+@Injectable({ providedIn: 'root' })
+export class AddonModDataSyncCronHandlerService implements CoreCronHandler {
+
+    name = 'AddonModDataSyncCronHandler';
+
+    /**
+     * @inheritdoc
+     */
+    execute(siteId?: string, force?: boolean): Promise<void> {
+        return AddonModDataSync.syncAllDatabases(siteId, force);
+    }
+
+    /**
+     * @inheritdoc
+     */
+    getInterval(): number {
+        return AddonModDataSync.syncInterval;
+    }
+
+}
+export const AddonModDataSyncCronHandler = makeSingleton(AddonModDataSyncCronHandlerService);
diff --git a/src/addons/mod/data/services/handlers/tag-area.ts b/src/addons/mod/data/services/handlers/tag-area.ts
new file mode 100644
index 000000000..31341389e
--- /dev/null
+++ b/src/addons/mod/data/services/handlers/tag-area.ts
@@ -0,0 +1,53 @@
+// (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, Type } from '@angular/core';
+import { CoreTagFeedComponent } from '@features/tag/components/feed/feed';
+import { CoreTagAreaHandler } from '@features/tag/services/tag-area-delegate';
+import { CoreTagFeedElement, CoreTagHelper } from '@features/tag/services/tag-helper';
+import { makeSingleton } from '@singletons';
+import { AddonModData } from '../data';
+
+/**
+ * Handler to support tags.
+ */
+@Injectable({ providedIn: 'root' })
+export class AddonModDataTagAreaHandlerService implements CoreTagAreaHandler {
+
+    name = 'AddonModDataTagAreaHandler';
+    type = 'mod_data/data_records';
+
+    /**
+     * @inheritdoc
+     */
+    async isEnabled(): Promise<boolean> {
+        return AddonModData.isPluginEnabled();
+    }
+
+    /**
+     * @inheritdoc
+     */
+    parseContent(content: string): CoreTagFeedElement[] {
+        return CoreTagHelper.parseFeedContent(content);
+    }
+
+    /**
+     * @inheritdoc
+     */
+    getComponent(): Type<unknown> {
+        return CoreTagFeedComponent;
+    }
+
+}
+export const AddonModDataTagAreaHandler = makeSingleton(AddonModDataTagAreaHandlerService);
diff --git a/src/addons/mod/forum/services/forum-sync.ts b/src/addons/mod/forum/services/forum-sync.ts
index 2cec0080d..fae896b0c 100644
--- a/src/addons/mod/forum/services/forum-sync.ts
+++ b/src/addons/mod/forum/services/forum-sync.ts
@@ -330,12 +330,12 @@ export class AddonModForumSyncProvider extends CoreCourseActivitySyncBaseProvide
                 updated = true;
 
                 // Invalidate discussions of updated ratings.
-                promises.push(AddonModForum.invalidateDiscussionPosts(result.itemSet!.itemSetId, undefined, siteId));
+                promises.push(AddonModForum.invalidateDiscussionPosts(result.itemSet.itemSetId, undefined, siteId));
             }
 
             if (result.warnings.length) {
                 // Fetch forum to construct the warning message.
-                promises.push(AddonModForum.getForum(result.itemSet!.courseId!, result.itemSet!.instanceId, { siteId })
+                promises.push(AddonModForum.getForum(result.itemSet.courseId, result.itemSet.instanceId, { siteId })
                     .then((forum) => {
                         result.warnings.forEach((warning) => {
                             this.addOfflineDataDeletedWarning(warnings, forum.name, warning);
diff --git a/src/addons/mod/mod.module.ts b/src/addons/mod/mod.module.ts
index f16e25ea1..a3c6ba745 100644
--- a/src/addons/mod/mod.module.ts
+++ b/src/addons/mod/mod.module.ts
@@ -16,6 +16,7 @@ import { NgModule } from '@angular/core';
 
 import { AddonModAssignModule } from './assign/assign.module';
 import { AddonModBookModule } from './book/book.module';
+import { AddonModDataModule } from './data/data.module';
 import { AddonModFolderModule } from './folder/folder.module';
 import { AddonModForumModule } from './forum/forum.module';
 import { AddonModLabelModule } from './label/label.module';
@@ -32,10 +33,10 @@ import { AddonModScormModule } from './scorm/scorm.module';
 import { AddonModChoiceModule } from './choice/choice.module';
 
 @NgModule({
-    declarations: [],
     imports: [
         AddonModAssignModule,
         AddonModBookModule,
+        AddonModDataModule,
         AddonModForumModule,
         AddonModLessonModule,
         AddonModPageModule,
@@ -51,7 +52,5 @@ import { AddonModChoiceModule } from './choice/choice.module';
         AddonModScormModule,
         AddonModChoiceModule,
     ],
-    providers: [],
-    exports: [],
 })
 export class AddonModModule { }
diff --git a/src/core/features/compile/services/compile.ts b/src/core/features/compile/services/compile.ts
index e7738fca3..3ec49affb 100644
--- a/src/core/features/compile/services/compile.ts
+++ b/src/core/features/compile/services/compile.ts
@@ -126,6 +126,7 @@ import { ADDON_MOD_ASSIGN_SERVICES } from '@addons/mod/assign/assign.module';
 import { ADDON_MOD_BOOK_SERVICES } from '@addons/mod/book/book.module';
 // @todo import { ADDON_MOD_CHAT_SERVICES } from '@addons/mod/chat/chat.module';
 import { ADDON_MOD_CHOICE_SERVICES } from '@addons/mod/choice/choice.module';
+import { ADDON_MOD_DATA_SERVICES } from '@addons/mod/data/data.module';
 // @todo import { ADDON_MOD_FEEDBACK_SERVICES } from '@addons/mod/feedback/feedback.module';
 import { ADDON_MOD_FOLDER_SERVICES } from '@addons/mod/folder/folder.module';
 import { ADDON_MOD_FORUM_SERVICES } from '@addons/mod/forum/forum.module';
@@ -291,6 +292,7 @@ export class CoreCompileProvider {
             ...ADDON_MOD_BOOK_SERVICES,
             // @todo ...ADDON_MOD_CHAT_SERVICES,
             ...ADDON_MOD_CHOICE_SERVICES,
+            ...ADDON_MOD_DATA_SERVICES,
             // @todo ...ADDON_MOD_FEEDBACK_SERVICES,
             ...ADDON_MOD_FOLDER_SERVICES,
             ...ADDON_MOD_FORUM_SERVICES,
diff --git a/src/core/features/rating/services/database/rating.ts b/src/core/features/rating/services/database/rating.ts
index 9db9f7410..3f9359d94 100644
--- a/src/core/features/rating/services/database/rating.ts
+++ b/src/core/features/rating/services/database/rating.ts
@@ -92,7 +92,7 @@ export type CoreRatingDBPrimaryData = {
  */
 export type CoreRatingDBRecord = CoreRatingDBPrimaryData & {
     itemsetid: number;
-    courseid?: number;
+    courseid: number;
     scaleid: number;
     rating: number;
     rateduserid: number;
diff --git a/src/core/features/rating/services/rating-offline.ts b/src/core/features/rating/services/rating-offline.ts
index 88727b45b..0867e3294 100644
--- a/src/core/features/rating/services/rating-offline.ts
+++ b/src/core/features/rating/services/rating-offline.ts
@@ -28,7 +28,7 @@ export interface CoreRatingItemSet {
     contextLevel: ContextLevel;
     instanceId: number;
     itemSetId: number;
-    courseId?: number;
+    courseId: number;
 }
 
 /**
diff --git a/src/core/features/rating/services/rating-sync.ts b/src/core/features/rating/services/rating-sync.ts
index b0ff4b011..9e6027ca9 100644
--- a/src/core/features/rating/services/rating-sync.ts
+++ b/src/core/features/rating/services/rating-sync.ts
@@ -59,12 +59,12 @@ export class CoreRatingSyncProvider extends CoreSyncBaseProvider<CoreRatingSyncI
         itemSetId?: number,
         force?: boolean,
         siteId?: string,
-    ): Promise<CoreRatingSyncItem[]> {
+    ): Promise<CoreRatingSyncItemResult[]> {
         siteId = siteId || CoreSites.getCurrentSiteId();
 
         const itemSets = await CoreRatingOffline.getItemSets(component, ratingArea, contextLevel, instanceId, itemSetId, siteId);
 
-        const results: CoreRatingSyncItem[] = [];
+        const results: CoreRatingSyncItemResult[] = [];
         await Promise.all(itemSets.map(async (itemSet) => {
             const result = force
                 ? await this.syncItemSet(
@@ -301,11 +301,14 @@ declare module '@singletons/events' {
 }
 
 export type CoreRatingSyncItem = {
-    itemSet?: CoreRatingItemSet;
     warnings: string[];
     updated: number[];
 };
 
+export type CoreRatingSyncItemResult = CoreRatingSyncItem & {
+    itemSet: CoreRatingItemSet;
+};
+
 /**
  * Data passed to SYNCED_EVENT event.
  */
diff --git a/src/core/services/filepool.ts b/src/core/services/filepool.ts
index c81d56178..80739f24b 100644
--- a/src/core/services/filepool.ts
+++ b/src/core/services/filepool.ts
@@ -2181,7 +2181,7 @@ export class CoreFilepoolProvider {
      * @return Resolved when done.
      */
     async invalidateFilesByComponent(
-        siteId: string,
+        siteId: string | undefined,
         component: string,
         componentId?: string | number,
         onlyUnknown: boolean = true,
diff --git a/src/core/services/sites.ts b/src/core/services/sites.ts
index b1bfd4dd3..63c7e9014 100644
--- a/src/core/services/sites.ts
+++ b/src/core/services/sites.ts
@@ -1036,8 +1036,10 @@ export class CoreSitesProvider {
      * @param siteId The site ID. If not defined, current site (if available).
      * @return Promise resolved with the database.
      */
-    getSiteDb(siteId?: string): Promise<SQLiteDB> {
-        return this.getSite(siteId).then((site) => site.getDb());
+    async getSiteDb(siteId?: string): Promise<SQLiteDB> {
+        const site = await this.getSite(siteId);
+
+        return site.getDb();
     }
 
     /**
diff --git a/src/core/services/utils/utils.ts b/src/core/services/utils/utils.ts
index d043557d1..bf83806e2 100644
--- a/src/core/services/utils/utils.ts
+++ b/src/core/services/utils/utils.ts
@@ -117,7 +117,7 @@ export class CoreUtilsProvider {
      * @return The object.
      */
     arrayToObject<T>(
-        array: T[],
+        array: T[] = [],
         propertyName?: string,
         result: Record<string, T> = {},
     ): Record<string, T> {