From e9c4d3f07e0d8400134789ffe5a274c5604322b5 Mon Sep 17 00:00:00 2001 From: Ralf Becker Date: Fri, 24 Jan 2020 13:31:56 +0100 Subject: [PATCH] complete push implementation for timesheet incl. ACL check --- api/js/jsapi/egw.js | 3 +- api/js/jsapi/egw_app.js | 37 ++++++++++++++- api/js/jsapi/egw_app.ts | 57 +++++++++++++++++++++++- api/js/jsapi/egw_preferences.js | 45 +++++++++++++++++++ api/src/Acl.php | 32 +++++++++++++ api/src/Link.php | 6 ++- doc/js2ts.php | 2 +- timesheet/inc/class.timesheet_bo.inc.php | 6 ++- timesheet/js/app.js | 44 +++++++++++++++++- timesheet/js/app.ts | 49 +++++++++++++++++++- 10 files changed, 271 insertions(+), 10 deletions(-) diff --git a/api/js/jsapi/egw.js b/api/js/jsapi/egw.js index f8bb84c042..dab9d0518d 100644 --- a/api/js/jsapi/egw.js +++ b/api/js/jsapi/egw.js @@ -417,6 +417,7 @@ egw_script.getAttribute('data-websocket-url'), JSON.parse(egw_script.getAttribute('data-websocket-tokens')) ); + egw.set_grants(JSON.parse(egw_script.getAttribute('data-grants') || "{}")); } } catch(e) { @@ -440,7 +441,7 @@ // get TypeScript modules working with our loader function require(_file) { - return { EgwApp: window.EgwApp}; + return window.exports; } var exports = {}; diff --git a/api/js/jsapi/egw_app.js b/api/js/jsapi/egw_app.js index 97ecb4809f..f62a6b8141 100644 --- a/api/js/jsapi/egw_app.js +++ b/api/js/jsapi/egw_app.js @@ -135,6 +135,9 @@ var EgwApp = /** @class */ (function () { /** * Handle a push notification about entry changes from the websocket * + * Get's called for data of all apps, but should only handle data of apps it displays, + * which is by default only it's own, but can be for multiple apps eg. for calendar. + * * @param pushData * @param {string} pushData.app application name * @param {(string|number)} pushData.id id of entry to refresh or null @@ -148,9 +151,40 @@ var EgwApp = /** @class */ (function () { * @param {number} pushData.account_id User that caused the notification */ EgwApp.prototype.push = function (pushData) { + // don't care about other apps data, reimplement if your app does care eg. calendar + if (pushData.app !== this.appname) + return; // only handle delete by default, for simple case of uid === "$app::$id" if (pushData.type === 'delete') { - egw.dataStoreUID(pushData.app + '::' + pushData.id, null); + egw.dataStoreUID(this.uid(pushData), null); + } + }; + /** + * Get (possible) app-specific uid + * + * @param {object} pushData see push method for individual attributes + */ + EgwApp.prototype.uid = function (pushData) { + return pushData.app + '::' + pushData.id; + }; + /** + * Method called after apps push implementation checked visibility + * + * @param {et2_nextmatch} nm + * @param pushData see push method for individual attributes + * @todo implement better way to update nextmatch widget without disturbing the user / state + * @todo show indicator that an update has happend + * @todo rate-limit update frequency + */ + EgwApp.prototype.updateList = function (nm, pushData) { + switch (pushData.type) { + case 'add': + case 'unknown': + nm.applyFilters(); + break; + default: + egw.dataRefreshUID(this.uid(pushData)); + break; } }; /** @@ -1634,3 +1668,4 @@ var EgwApp = /** @class */ (function () { return EgwApp; }()); exports.EgwApp = EgwApp; +//# sourceMappingURL=egw_app.js.map \ No newline at end of file diff --git a/api/js/jsapi/egw_app.ts b/api/js/jsapi/egw_app.ts index e64e4997dc..4674e2dc9b 100644 --- a/api/js/jsapi/egw_app.ts +++ b/api/js/jsapi/egw_app.ts @@ -15,6 +15,19 @@ import 'jqueryui'; import '../jsapi/egw_global'; import '../etemplate/et2_types'; +/** + * Type for push-message + */ +export interface PushData +{ + type: "add"|"edit"|"update"|"delete"|"unknown"; + app: string; // app-name, can include a subtype eg. "projectmanager-element" + id: string | number; + acl?: any; // app-specific acl data, eg. the owner, or array of participants + account_id: number; // user that caused the change + [propName:string]: any; // arbitrary more parameters +} + /** * Common base class for application javascript * Each app should extend as needed. @@ -206,6 +219,9 @@ export abstract class EgwApp /** * Handle a push notification about entry changes from the websocket * + * Get's called for data of all apps, but should only handle data of apps it displays, + * which is by default only it's own, but can be for multiple apps eg. for calendar. + * * @param pushData * @param {string} pushData.app application name * @param {(string|number)} pushData.id id of entry to refresh or null @@ -218,12 +234,49 @@ export abstract class EgwApp * @param {object|null} pushData.acl Extra data for determining relevance. eg: owner or responsible to decide if update is necessary * @param {number} pushData.account_id User that caused the notification */ - push(pushData) + push(pushData : PushData) { + // don't care about other apps data, reimplement if your app does care eg. calendar + if (pushData.app !== this.appname) return; + // only handle delete by default, for simple case of uid === "$app::$id" if (pushData.type === 'delete') { - egw.dataStoreUID(pushData.app + '::' + pushData.id, null); + egw.dataStoreUID(this.uid(pushData), null); + } + } + + /** + * Get (possible) app-specific uid + * + * @param {object} pushData see push method for individual attributes + */ + uid(pushData) + { + return pushData.app + '::' + pushData.id; + } + + /** + * Method called after apps push implementation checked visibility + * + * @param {et2_nextmatch} nm + * @param pushData see push method for individual attributes + * @todo implement better way to update nextmatch widget without disturbing the user / state + * @todo show indicator that an update has happend + * @todo rate-limit update frequency + */ + updateList(nm, pushData : PushData) + { + switch (pushData.type) + { + case 'add': + case 'unknown': + nm.applyFilters(); + break; + + default: + egw.dataRefreshUID(this.uid(pushData)); + break; } } diff --git a/api/js/jsapi/egw_preferences.js b/api/js/jsapi/egw_preferences.js index bccc8e779a..aeaab16f78 100644 --- a/api/js/jsapi/egw_preferences.js +++ b/api/js/jsapi/egw_preferences.js @@ -25,6 +25,7 @@ egw.extend('preferences', egw.MODULE_GLOBAL, function() * @access: private, use egw.preferences() or egw.set_perferences() */ var prefs = {}; + var grants = {}; // Return the actual extension return { @@ -171,6 +172,50 @@ egw.extend('preferences', egw.MODULE_GLOBAL, function() break; } } + }, + + /** + * Setting prefs for an app or 'common' + * + * @param {object} _data + * @param {string} _app application name or undefined to set grants of all apps at once + * and therefore will be inaccessible in IE, after that window is closed + */ + set_grants: function(_data, _app) + { + if (_app) + { + grants[_app] = jQuery.extend(true, {}, _data); + } + else + { + grants = jQuery.extend(true, {}, _data); + } + }, + + /** + * Query an EGroupware user preference + * + * We currently load grants from all apps in egw.js, so no need for a callback or promise. + * + * @param {string} _app app-name + * @param {function|false|undefined} _callback optional callback, if preference needs loading first + * if false given and preference is not loaded, undefined is return and no (synchronious) request is send to server + * @param {object} _context context for callback + * @return {object|undefined|false} grant object, false if not (yet) loaded and no callback or undefined + */ + grants: function( _app) //, _callback, _context) + { + /* we currently load grants from all apps in egw.js, so no need for a callback or promise + if (typeof grants[_app] == 'undefined') + { + if (_callback === false) return undefined; + var request = this.json('EGroupware\\Api\\Framework::ajax_get_preference', [_app], _callback, _context); + request.sendRequest(typeof _callback == 'function', 'GET'); // use synchronous (cachable) GET request + if (typeof grants[_app] == 'undefined') grants[_app] = {}; + if (typeof _callback == 'function') return false; + }*/ + return typeof grants[_app] === 'object' ? jQuery.extend({}, grants[_app]) : grants[_app]; } }; }); diff --git a/api/src/Acl.php b/api/src/Acl.php index ecf5f0dfc3..31fe1af426 100644 --- a/api/src/Acl.php +++ b/api/src/Acl.php @@ -718,6 +718,38 @@ class Acl return $grants; } + /** + * Get grants for a single app + * + * We use a "get-grants" hook, in case an app need more then what Acl::get_grants returns (with default parameters!). + * + * @param string $_app app-name + * @return array + */ + function ajax_get_grants($_app=null) + { + if (!($grants = Hooks::single('get-grants', $_app))) + { + $grants = $this->get_grants($_app); + } + return $grants; + } + + /** + * Get grants for all apps + * + * @return array with app => array of grants pairs + */ + function ajax_get_all_grants() + { + $app_grants = []; + foreach(array_keys($GLOBALS['egw_info']['user']['apps']) as $app) + { + $app_grants[$app] = $this->get_grants($app); + } + return $app_grants; + } + /** * Deletes all ACL entries for an account (user or group) * diff --git a/api/src/Link.php b/api/src/Link.php index ae28ebde5f..5953f3d463 100644 --- a/api/src/Link.php +++ b/api/src/Link.php @@ -677,6 +677,7 @@ class Link extends Link\Storage */ static function unlink2($link_id,$app,&$id,$owner=0,$app2='',$id2='',$hold_for_purge=false) { + error_log(__METHOD__."($link_id, '$app', $id, ...)"); if (self::DEBUG) { echo "

Link::unlink('$link_id','$app',".array2string($id).",'$owner','$app2','$id2', $hold_for_purge)

\n"; @@ -1479,8 +1480,9 @@ class Link extends Link\Storage * @param string $app name of app in which the updated happend * @param string $id id in $app of the updated entry * @param array $data =null updated data of changed entry, as the read-method of the BO-layer would supply it + * @param string $type ="unknown" type of update: "add", "edit", "update" or default "unknown" */ - static function notify_update($app,$id,$data=null) + static function notify_update($app,$id,$data=null,$type='unknown') { self::delete_cache($app,$id); //error_log(__METHOD__."('$app', $id, $data)"); @@ -1497,7 +1499,7 @@ class Link extends Link\Storage // in case "someone" interested in all changes (used eg. for push) Hooks::process([ 'location' => 'notify-all', - 'type' => 'edit', + 'type' => !empty($data[Link::OLD_LINK_TITLE]) ? 'update' : $type, 'app' => $app, 'id' => $id, 'data' => $data, diff --git a/doc/js2ts.php b/doc/js2ts.php index 41c75730bf..1c6de7e3b3 100755 --- a/doc/js2ts.php +++ b/doc/js2ts.php @@ -23,7 +23,7 @@ $replace = array( "\n});" => "\n}\n\napp.classes.$matches[1] = ".ucfirst($matches[1])."App;" ]); }, - "/^\tappname:\s*'([^']+)',/m" => "\treadonly appname: '$1';", + "/^\tappname:\s*'([^']+)',/m" => "\treadonly appname = '$1';", "/^\t([^: ,;(]+):\s*([^()]+),/m" => "\t\$1: $2;", "/^\t([^:\n]+):\s*function\s*\(.*this._super.(apply|call)\(/msU" => function($matches) { diff --git a/timesheet/inc/class.timesheet_bo.inc.php b/timesheet/inc/class.timesheet_bo.inc.php index 9e6e1eb38a..8fbb3a7864 100644 --- a/timesheet/inc/class.timesheet_bo.inc.php +++ b/timesheet/inc/class.timesheet_bo.inc.php @@ -640,10 +640,14 @@ class timesheet_bo extends Api\Storage } } + $type = !isset($old) ? 'add' : + ($new['ts_status'] == self::DELETED_STATUS ? 'delete' : 'update'); + // Check for restore of deleted contact, restore held links if($old && $old['ts_status'] == self::DELETED_STATUS && $new['ts_status'] != self::DELETED_STATUS) { Link::restore(TIMESHEET_APP, $new['ts_id']); + $type = 'add'; } if (!($err = parent::save())) @@ -659,7 +663,7 @@ class timesheet_bo extends Api\Storage return implode(', ',$this->tracking->errors); } // notify the link-class about the update, as other apps may be subscribt to it - Link::notify_update(TIMESHEET_APP,$this->data['ts_id'],$this->data); + Link::notify_update(TIMESHEET_APP, $this->data['ts_id'], $this->data, $type); } return $err; diff --git a/timesheet/js/app.js b/timesheet/js/app.js index 63bf2661ce..06ac07b75b 100644 --- a/timesheet/js/app.js +++ b/timesheet/js/app.js @@ -38,7 +38,9 @@ var egw_app_1 = require("../../api/js/jsapi/egw_app"); var TimesheetApp = /** @class */ (function (_super) { __extends(TimesheetApp, _super); function TimesheetApp() { - return _super !== null && _super.apply(this, arguments) || this; + var _this = _super !== null && _super.apply(this, arguments) || this; + _this.appname = 'timesheet'; + return _this; } /** * This function is called when the etemplate2 object is loaded @@ -170,6 +172,46 @@ var TimesheetApp = /** @class */ (function (_super) { if (widget) return widget.options.value; }; + /** + * Handle a push notification about entry changes from the websocket + * + * @param pushData + * @param {string} pushData.app application name + * @param {(string|number)} pushData.id id of entry to refresh or null + * @param {string} pushData.type either 'update', 'edit', 'delete', 'add' or null + * - update: request just modified data from given rows. Sorting is not considered, + * so if the sort field is changed, the row will not be moved. + * - edit: rows changed, but sorting may be affected. Requires full reload. + * - delete: just delete the given rows clientside (no server interaction neccessary) + * - add: requires full reload for proper sorting + * @param {object|null} pushData.acl Extra data for determining relevance. eg: owner or responsible to decide if update is necessary + * @param {number} pushData.account_id User that caused the notification + */ + TimesheetApp.prototype.push = function (pushData) { + var _a, _b, _c; + // timesheed does NOT care about other apps data + if (pushData.app !== this.appname) + return; + if (pushData.type === 'delete') { + return _super.prototype.push.call(this, pushData); + } + // all other cases (add, edit, update) are handled identical + // check visibility + if (typeof this._grants === 'undefined') { + this._grants = egw.grants(this.appname); + } + if (typeof this._grants[pushData.acl] === 'undefined') + return; + // check if we might not see it because of an owner filter + var nm = (_a = this.et2) === null || _a === void 0 ? void 0 : _a.getWidgetById('nm'); + var nm_value = (_b = nm) === null || _b === void 0 ? void 0 : _b.getValue(); + if (nm && nm_value && typeof ((_c = nm_value.col_filter) === null || _c === void 0 ? void 0 : _c.ts_owner) !== 'undefined') { + if (!nm_value.col_filter.ts_owner || nm_value.col_filter.ts_owner == pushData.acl) { + this.updateList(nm, pushData); + } + } + }; return TimesheetApp; }(egw_app_1.EgwApp)); app.classes.timesheet = TimesheetApp; +//# sourceMappingURL=app.js.map \ No newline at end of file diff --git a/timesheet/js/app.ts b/timesheet/js/app.ts index 17da1918c0..2b8ebc6bdd 100644 --- a/timesheet/js/app.ts +++ b/timesheet/js/app.ts @@ -26,7 +26,7 @@ import { EgwApp } from '../../api/js/jsapi/egw_app'; */ class TimesheetApp extends EgwApp { - readonly appname: 'timesheet'; + readonly appname = 'timesheet'; /** * This function is called when the etemplate2 object is loaded @@ -191,6 +191,53 @@ class TimesheetApp extends EgwApp var widget = this.et2.getWidgetById('ts_title'); if(widget) return widget.options.value; } + + private _grants : any; + + /** + * Handle a push notification about entry changes from the websocket + * + * @param pushData + * @param {string} pushData.app application name + * @param {(string|number)} pushData.id id of entry to refresh or null + * @param {string} pushData.type either 'update', 'edit', 'delete', 'add' or null + * - update: request just modified data from given rows. Sorting is not considered, + * so if the sort field is changed, the row will not be moved. + * - edit: rows changed, but sorting may be affected. Requires full reload. + * - delete: just delete the given rows clientside (no server interaction neccessary) + * - add: requires full reload for proper sorting + * @param {object|null} pushData.acl Extra data for determining relevance. eg: owner or responsible to decide if update is necessary + * @param {number} pushData.account_id User that caused the notification + */ + push(pushData) + { + // timesheed does NOT care about other apps data + if (pushData.app !== this.appname) return; + + if (pushData.type === 'delete') + { + return super.push(pushData); + } + + // all other cases (add, edit, update) are handled identical + // check visibility + if (typeof this._grants === 'undefined') + { + this._grants = egw.grants(this.appname); + } + if (typeof this._grants[pushData.acl] === 'undefined') return; + + // check if we might not see it because of an owner filter + let nm = this.et2?.getWidgetById('nm'); + let nm_value = nm?.getValue(); + if (nm && nm_value && typeof nm_value.col_filter?.ts_owner !== 'undefined') + { + if (!nm_value.col_filter.ts_owner || nm_value.col_filter.ts_owner == pushData.acl) + { + this.updateList(nm, pushData); + } + } + } } app.classes.timesheet = TimesheetApp;