N°3531 - Activity panel: Restore possibility to load extra history entries asynchroniously

This commit is contained in:
Molkobain
2021-03-16 22:29:36 +01:00
parent 190ac1a65a
commit b97e2839c5
10 changed files with 394 additions and 63 deletions

View File

@@ -58,6 +58,11 @@ $ibo-activity-entry--sub-information--text-color: $ibo-color-grey-700 !default;
$ibo-activity-entry--author-name--sibling-spacing: 0.2rem !default;
$ibo-activity-panel--load-more-entries--size: 32px !default;
$ibo-activity-panel--load-more-entries--border-radius: $ibo-border-radius-full !default;
$ibo-activity-panel--load-more-entries--background-color: $ibo-activity-entry--main-information--background-color !default;
$ibo-activity-panel--load-more-entries--border: $ibo-content-block--border !default;
/* Entry group */
.ibo-activity-panel--entry-group{
&:not(:last-child){
@@ -228,4 +233,18 @@ $ibo-activity-entry--author-name--sibling-spacing: 0.2rem !default;
margin-left: $ibo-activity-entry--author-name--sibling-spacing;
margin-right: $ibo-activity-entry--author-name--sibling-spacing;
}
}
.ibo-activity-panel--load-more-entries-container {
@extend %ibo-fully-centered-content;
}
.ibo-activity-panel--load-more-entries {
width: $ibo-activity-panel--load-more-entries--size;
height: $ibo-activity-panel--load-more-entries--size;
border-radius: $ibo-activity-panel--load-more-entries--border-radius;
background-color: $ibo-activity-panel--load-more-entries--background-color;
border: $ibo-activity-panel--load-more-entries--border;
@extend %ibo-fully-centered-content;
@extend %ibo-hyperlink-inherited-colors;
}

View File

@@ -22,6 +22,7 @@ Dict::Add('EN US', 'English', 'English', array(
'UI:Layout:ActivityPanel:SizeToggler:Expand:Tooltip' => 'Expand',
'UI:Layout:ActivityPanel:SizeToggler:Reduce:Tooltip' => 'Reduce',
'UI:Layout:ActivityPanel:DisplayToggler:Close:Tooltip' => 'Close',
'UI:Layout:ActivityPanel:LoadMoreEntries:Tooltip' => 'Load previous entries',
// Tabs
'UI:Layout:ActivityPanel:Tab:Activity:Title' => 'Activity',

View File

@@ -34,6 +34,8 @@ $(function()
lock_endpoint: null,
show_multiple_entries_submit_confirmation: true,
save_state_endpoint: null,
last_loaded_entries_ids: {},
load_more_entries_endpoint: null,
},
css_classes:
{
@@ -84,6 +86,9 @@ $(function()
edits_entry_long_description_toggler: '[data-role="ibo-edits-entry--long-description-toggler"]',
notification_entry_long_description: '[data-role="ibo-notification-entry--long-description"]',
notification_entry_long_description_toggler: '[data-role="ibo-notification-entry--long-description-toggler"]',
load_more_entries_container: '[data-role="ibo-activity-panel--load-more-entries-container"]',
load_more_entries: '[data-role="ibo-activity-panel--load-more-entries"]',
load_more_entries_icon: '[data-role="ibo-activity-panel--load-more-entries-icon"]',
},
enums: {
tab_types: {
@@ -203,17 +208,21 @@ $(function()
// Entries
// - Click on a closed case log message
this.element.find(this.js_selectors.entry_group).on('click', '.'+this.css_classes.is_closed+' '+this.js_selectors.entry_main_information, function (oEvent) {
this.element.on('click', this.js_selectors.entry+'.'+this.css_classes.is_closed+' '+this.js_selectors.entry_main_information, function (oEvent) {
me._onCaseLogClosedMessageClick($(this).closest(me.js_selectors.entry));
});
// - Click on an edits entry's long description toggler
this.element.find(this.js_selectors.edits_entry_long_description_toggler).on('click', function (oEvent) {
this.element.on('click', this.js_selectors.edits_entry_long_description_toggler, function (oEvent) {
me._onEntryLongDescriptionTogglerClick(oEvent, $(this).closest(me.js_selectors.entry));
});
// - Click on an notification entry's long description toggler
this.element.find(this.js_selectors.notification_entry_long_description_toggler).on('click', function (oEvent) {
this.element.on('click', this.js_selectors.notification_entry_long_description_toggler, function (oEvent) {
me._onEntryLongDescriptionTogglerClick(oEvent, $(this).closest(me.js_selectors.entry));
});
// - Click on load more entries button
this.element.find(this.js_selectors.load_more_entries).on('click', function (oEvent) {
me._onLoadMoreEntriesButtonClick(oEvent);
});
// Page exit
// - Show confirm dialog if draft entries (IMPORTANT: Lock is NOT released, see N°3786)
@@ -345,6 +354,16 @@ $(function()
// Else, open a popover menu to choose one
}
},
/**
* @param oEvent {Object}
* @return {void}
* @private
*/
_onLoadMoreEntriesButtonClick: function (oEvent) {
oEvent.preventDefault();
this._LoadMoreEntries();
},
/**
* Indicate that there is a draft entry and will request lock on the object
*
@@ -820,7 +839,7 @@ $(function()
// Update the feed
for (let sCaseLogAttCode in oData.data.entries) {
me._AddEntry(sCaseLogAttCode, oData.data.entries[sCaseLogAttCode]);
me._AddEntry(oData.data.entries[sCaseLogAttCode], 'start');
}
me._ApplyEntriesFilters();
@@ -1168,20 +1187,88 @@ $(function()
});
},
/**
* Add an entry represented by its oData to the case log with the sCaseLogAttCode
* Load the next entries and append them to the current ones
*
* IMPORTANT: For now the logic is naive, the entries come from 3 different sources : case logs, CMDB change ops and notifications.
* We load all the case logs and notifications entries, but only the 'max_history_length' first from the CMDB change ops.
*
* When we load the remaining history entries (CMDB change ops) and append them to the activity panel, some of them should actually
* be placed between already present entries (case logs, notifications) to keep the chronological order. This is a known limitation
* and might be worked on in a future version.
*
* @private
* @return {void}
*/
_LoadMoreEntries: function () {
const me = this;
// Change icon to spinning
this.element.find(this.js_selectors.load_more_entries_icon)
.removeClass('fas fa-angle-double-down')
.addClass('fas fa-sync-alt fa-spin');
// Send XHR request
let oParams = {
operation: 'activity_panel_load_more_entries',
object_class: this._GetHostObjectClass(),
object_id: this._GetHostObjectID(),
last_loaded_entries_ids: this.options.last_loaded_entries_ids,
};
$.post(
this.options.load_more_entries_endpoint,
oParams,
'json'
)
.fail(function (oXHR, sStatus, sErroThrown) {
// TODO 3.0.0: Maybe we could have a centralized dialog to display error messages?
alert(sErrorThrown);
})
.done(function (oData) {
if (false === oData.data.success) {
// TODO 3.0.0: Same comment as the fail() callback
alert(oData.data.error_message);
return false;
}
// Update the feed
for (let oEntry of oData.data.entries) {
me._AddEntry(oEntry, 'end');
}
me._ApplyEntriesFilters();
// Check if more entries to load
// - Update metadata
me.options.last_loaded_entries_ids = oData.data.last_loaded_entries_ids;
// - Update button state
if (Object.keys(me.options.last_loaded_entries_ids).length === 0) {
me.element.find(me.js_selectors.load_more_entries).addClass(me.css_classes.is_hidden);
}
})
.always(function () {
// Change icon back to original (whether it should be displayed or not will be handle by thes other callbacks)
// - fail => keep displayed for retry
// - done => display only if more entries to load
me.element.find(me.js_selectors.load_more_entries_icon)
.removeClass('fas fa-sync-alt fa-spin')
.addClass('fas fa-angle-double-down');
});
},
/**
* Add an entry represented by its oData to the feed
*
* @param sCaseLogAttCode {string}
* @param oData {Object} Structured data of the entry: {html_rendering: <HTML_DATA>}
* @param sPosition {string} Whether the entry should be added at the 'start' or 'end' of the feed
* @private
*/
_AddEntry: function (sCaseLogAttCode, oData) {
_AddEntry: function (oData, sPosition = 'start') {
// Info about the new entry
const oNewEntryElem = $(oData.html_rendering);
const sNewEntryAuthorLogin = oNewEntryElem.attr('data-entry-author-login');
const sNewEntryOrigin = oNewEntryElem.attr('data-entry-group-origin');
// Info about the last entry group to see the entry to add should be in this one or a new one
const oLastEntryGroupElem = this.element.find(this.js_selectors.entry_group+':first');
const sEntryGroupPosition = (sPosition === 'start') ? 'first' : 'last';
const oLastEntryGroupElem = this.element.find(this.js_selectors.entry_group+':'+sEntryGroupPosition);
const sLastEntryAuthorLogin = oLastEntryGroupElem.length > 0 ? oLastEntryGroupElem.attr('data-entry-author-login') : null;
const sLastEntryOrigin = oLastEntryGroupElem.length > 0 ? oLastEntryGroupElem.attr('data-entry-group-origin') : null;
@@ -1189,9 +1276,12 @@ $(function()
if ((sLastEntryAuthorLogin === sNewEntryAuthorLogin) && (sLastEntryOrigin && sNewEntryOrigin)) {
oTargetEntryGroup = oLastEntryGroupElem;
} else {
oTargetEntryGroup = this._CreateEntryGroup(sNewEntryAuthorLogin, sNewEntryOrigin);
oTargetEntryGroup = this._CreateEntryGroup(sNewEntryAuthorLogin, sNewEntryOrigin, sPosition);
}
const sInsertFunction = (sPosition === 'start') ? 'prepend' : 'append';
oTargetEntryGroup.prepend(oNewEntryElem);
this._ReformatDateTimes();
},
/**
@@ -1199,20 +1289,26 @@ $(function()
*
* @param sAuthorLogin {string}
* @param sOrigin {string}
* @param sPosition {string} Whether the entry group should be added at the start or the end of the feed
* @returns {Object} jQuery object representing the created entry group
* @private
*/
_CreateEntryGroup: function (sAuthorLogin, sOrigin) {
_CreateEntryGroup: function (sAuthorLogin, sOrigin, sPosition = 'start') {
// Note: When using the ActivityPanel, there should always be at least one entry group already, the one from the object creation
let oEntryGroupElem = this.element.find(this.js_selectors.entry_group+':first')
.clone()
.attr('data-entry-author-login', sAuthorLogin)
.attr('data-entry-group-origin', sOrigin)
.addClass(this.css_classes.is_current_user)
.html('')
.prependTo(this.element.find(this.js_selectors.body));
.html('');
if ('start' === sPosition) {
oEntryGroupElem.prependTo(this.element.find(this.js_selectors.body));
} else {
oEntryGroupElem.insertBefore(this.element.find(this.js_selectors.load_more_entries_container));
}
return oEntryGroupElem;
},
}
});
});

View File

@@ -2767,6 +2767,24 @@ EOF
$oPage->SetData($aResult);
break;
/** @internal */
case 'activity_panel_load_more_entries':
$oPage = new JsonPage();
try {
$aResult = ActivityPanelController::LoadMoreEntries();
}
catch (Exception $oException) {
$aResult = [
'success' => false,
'error_message' => $oException->getMessage(),
];
}
$oPage->SetData($aResult);
break;
//--------------------------------
// Navigation menu
//--------------------------------
case 'get_menus_count':
$oAjaxRenderController->GetMenusCount($oPage);

View File

@@ -46,10 +46,10 @@ class ActivityPanelController
* 'success' => true,
* 'entries' => [
* '<ATT_CODE_1>' => [
* html_rendering => '<HTML_RENDERING_TO_BE_APPEND_IN_FRONT_END>',
* 'html_rendering' => '<HTML_RENDERING_TO_BE_APPEND_IN_FRONT_END>',
* ],
* '<ATT_CODE_2>' => [
* html_rendering => '<HTML_RENDERING_TO_BE_APPEND_IN_FRONT_END>',
* 'html_rendering' => '<HTML_RENDERING_TO_BE_APPEND_IN_FRONT_END>',
* ],
* ...
* ],
@@ -115,4 +115,59 @@ class ActivityPanelController
return $aResults;
}
/**
* Load next entries chunk
*
* @return array Entries already rendered and metadata for pagination
* [
* 'success' => true,
* 'entries' => [
* ['html_rendering' => '<HTML_RENDERING>'],
* ['html_rendering' => '<HTML_RENDERING>'],
* ...
* ],
* 'last_loaded_entries_ids' => [
* 'cmdbchangeop' => <LAST_ENTRY_ID>,
* ],
* ]
*
* @throws \ArchivedObjectException
* @throws \CoreException
* @throws \ReflectionException
* @throws \Twig\Error\LoaderError
* @throws \Twig\Error\RuntimeError
* @throws \Twig\Error\SyntaxError
*/
public static function LoadMoreEntries(): array
{
$sObjectClass = utils::ReadPostedParam('object_class', null, utils::ENUM_SANITIZATION_FILTER_CLASS);
$sObjectId = utils::ReadPostedParam('object_id', 0);
$aLastLoadedEntriesIds = utils::ReadPostedParam('last_loaded_entries_ids', [], utils::ENUM_SANITIZATION_FILTER_RAW_DATA);
$aResults = [
'success' => true,
'entries' => [],
'last_loaded_entries_ids' => [],
];
// CMDBChangeOp entries
if (array_key_exists('cmdbchangeop', $aLastLoadedEntriesIds)) {
$aChangesData = ActivityPanelHelper::GetCMDBChangeOpEditsEntriesForObject($sObjectClass, $sObjectId, $aLastLoadedEntriesIds['cmdbchangeop']);
if (true === $aChangesData['more_entries_to_load']) {
$aResults['last_loaded_entries_ids']['cmdbchangeop'] = $aChangesData['last_loaded_entry_id'];
}
/** @var \Combodo\iTop\Application\UI\Base\Layout\ActivityPanel\ActivityEntry\EditsEntry $oEntryBlock */
foreach ($aChangesData['entries'] as $oEntryBlock) {
$sEntryAsHtml = BlockRenderer::RenderBlockTemplates($oEntryBlock);
$aResults['entries'][] = [
'html_rendering' => $sEntryAsHtml,
];
}
}
return $aResults;
}
}

View File

@@ -71,9 +71,13 @@ class ActivityPanel extends UIBlock
protected $aEntries;
/** @var bool $bAreEntriesSorted True if the entries have been sorted by date */
protected $bAreEntriesSorted;
/** @var bool True if there are more entries to load asynchroniously */
protected $bHasMoreEntriesToLoad;
/** @var array IDs of the last loaded entries of each type, makes it easier to load the next entries asynchronioulsy */
protected $aLastLoadedEntriesIds;
/**
* @var bool True if the host object has states (but not necessary a lifecycle)
* @see MetaModel::HasStateAttributeCode()
* @var bool True if the host object has states (but not necessary a lifecycle)
*/
protected $bHasStates;
/** @var \Combodo\iTop\Application\UI\Base\Layout\ActivityPanel\CaseLogEntryForm\CaseLogEntryForm[] $aCaseLogTabsEntryForms */
@@ -101,6 +105,8 @@ class ActivityPanel extends UIBlock
$this->SetObject($oObject);
$this->SetEntries($aEntries);
$this->bAreEntriesSorted = false;
$this->bHasMoreEntriesToLoad = false;
$this->aLastLoadedEntriesIds = [];
$this->ComputedShowMultipleEntriesSubmitConfirmation();
}
@@ -453,6 +459,52 @@ class ActivityPanel extends UIBlock
return !empty($this->aEntries);
}
/**
* @see static::$bHasMoreEntriesToLoad
*
* @param bool $bHasMoreEntriesToLoad
*
* @return $this
*/
public function SetHasMoreEntriesToLoad(bool $bHasMoreEntriesToLoad)
{
$this->bHasMoreEntriesToLoad = $bHasMoreEntriesToLoad;
return $this;
}
/**
* @see static::$bHasMoreEntriesToLoad
* @return bool
*/
public function HasMoreEntriesToLoad(): bool
{
return $this->bHasMoreEntriesToLoad;
}
/**
* @param string $sEntryType Type of entry (eg. cmdbchangeop, caselog, notification)
* @param string $sEntryId ID of the last loaded entry
*
* @return $this
* @uses static::$aLastLoadedEntriesIds
*/
public function SetLastEntryId(string $sEntryType, string $sEntryId)
{
$this->aLastLoadedEntriesIds[$sEntryType] = $sEntryId;
return $this;
}
/**
* @return array Hash array of the last loaded entries
* @uses static::$aLastLoadedEntriesIds
*/
public function GetLastEntryIds(): array
{
return $this->aLastLoadedEntriesIds;
}
/**
* Return all the case log tabs metadata, not their entries
*
@@ -710,7 +762,7 @@ class ActivityPanel extends UIBlock
* @return string The endpoint for all "lock" related operations
* @throws \Exception
*/
public function GetLockEndpointForJSWidget(): string
public function GetLockEndpoint(): string
{
return utils::GetAbsoluteUrlAppRoot().'pages/ajax.render.php';
}
@@ -724,6 +776,15 @@ class ActivityPanel extends UIBlock
return utils::GetAbsoluteUrlAppRoot().'pages/ajax.render.php';
}
/**
* @return string The endpoint to load the remaining entries
* @throws \Exception
*/
public function GetLoadMoreEntriesEndpoint(): string
{
return utils::GetAbsoluteUrlAppRoot().'pages/ajax.render.php';
}
/**
* @inheritdoc
*/

View File

@@ -21,9 +21,7 @@ namespace Combodo\iTop\Application\UI\Base\Layout\ActivityPanel;
use cmdbAbstractObject;
use CMDBChangeOpSetAttributeCaseLog;
use Combodo\iTop\Application\UI\Base\Layout\ActivityPanel\ActivityEntry\ActivityEntryFactory;
use Combodo\iTop\Application\UI\Base\Layout\ActivityPanel\ActivityEntry\EditsEntry;
use Combodo\iTop\Application\UI\Base\Layout\ActivityPanel\CaseLogEntryFormFactory\CaseLogEntryFormFactory;
use DBObject;
use DBObjectSearch;
@@ -61,7 +59,7 @@ class ActivityPanelFactory
public static function MakeForObjectDetails(DBObject $oObject, string $sMode = cmdbAbstractObject::DEFAULT_OBJECT_MODE)
{
$sObjClass = get_class($oObject);
$iObjId = $oObject->GetKey();
$sObjId = $oObject->GetKey();
if ($sMode == cmdbAbstractObject::ENUM_OBJECT_MODE_PRINT) {
$oActivityPanel = new ActivityPanelPrint($oObject, [], ActivityPanel::BLOCK_CODE);
@@ -90,48 +88,19 @@ class ActivityPanelFactory
}
// Retrieve history changes (excluding case logs entries)
// - Prepare query to retrieve changes
$oChangesSearch = DBObjectSearch::FromOQL('SELECT CMDBChangeOp WHERE objclass = :obj_class AND objkey = :obj_key AND finalclass NOT IN (:excluded_optypes)');
// Note: We can't order by date (only) as something multiple CMDBChangeOp rows are inserted at the same time (eg. Delivery model of the "Demo" Organization in the sample data).
// As the DB returns rows "chronologically", we get the older first and it messes with the processing. Ordering by the ID is way much simpler and less DB CPU consuming.
$oChangesSet = new DBObjectSet($oChangesSearch, ['id' => false], ['obj_class' => $sObjClass, 'obj_key' => $iObjId, 'excluded_optypes' => ['CMDBChangeOpSetAttributeCaseLog']]);
$oChangesSet->SetLimit(MetaModel::GetConfig()->Get('max_history_length'));
$aChangesData = ActivityPanelHelper::GetCMDBChangeOpEditsEntriesForObject($sObjClass, $sObjId);
// Prepare previous values to group edits within a same CMDBChange
$iPreviousChangeId = 0;
$oPreviousEditsEntry = null;
/** @var \CMDBChangeOp $oChangeOp */
while ($oChangeOp = $oChangesSet->Fetch()) {
// Skip case log changes as they are handled directly from the attributes themselves (most of them should have been excluded by the OQL above, but some derivated classes could still be retrieved)
if ($oChangeOp instanceof CMDBChangeOpSetAttributeCaseLog) {
continue;
}
// Make entry from CMDBChangeOp
$iChangeId = $oChangeOp->Get('change');
try {
$oEntry = ActivityEntryFactory::MakeFromCmdbChangeOp($oChangeOp);
}
catch (Exception $oException) {
IssueLog::Debug(static::class.': Could not create entry from CMDBChangeOp #'.$oChangeOp->GetKey().' related to '.$oChangeOp->Get('objclass').'::'.$oChangeOp->Get('objkey').': '.$oException->getMessage());
continue;
}
// If same CMDBChange and mergeable edits entry from the same author, we merge them
if (($iChangeId == $iPreviousChangeId) && ($oPreviousEditsEntry instanceof EditsEntry) && ($oEntry instanceof EditsEntry) && ($oPreviousEditsEntry->GetAuthorLogin() === $oEntry->GetAuthorLogin())) {
$oPreviousEditsEntry->Merge($oEntry);
} else {
$oActivityPanel->AddEntry($oEntry);
// Set previous edits entry
if ($oEntry instanceof EditsEntry) {
$oPreviousEditsEntry = $oEntry;
}
}
$iPreviousChangeId = $iChangeId;
// - Set metadata for pagination
if (true === $aChangesData['more_entries_to_load']) {
$oActivityPanel->SetHasMoreEntriesToLoad(true);
$oActivityPanel->SetLastEntryId('cmdbchangeop', $aChangesData['last_loaded_entry_id']);
}
// - Add history entries
/** @var \Combodo\iTop\Application\UI\Base\Layout\ActivityPanel\ActivityEntry\EditsEntry $oEntry */
foreach ($aChangesData['entries'] as $oEntry) {
$oActivityPanel->AddEntry($oEntry);
}
unset($oChangesSet);
// Retrieving notification events for cmdbAbstractObject only
if ($oObject instanceof cmdbAbstractObject) {
@@ -141,7 +110,7 @@ class ActivityPanelFactory
if (false === empty($aRelatedTriggersIDs)) {
// - Prepare query to retrieve events
$oNotifEventsSearch = DBObjectSearch::FromOQL('SELECT EN FROM EventNotification AS EN JOIN Action AS A ON EN.action_id = A.id WHERE EN.trigger_id IN (:triggers_ids) AND EN.object_id = :object_id');
$oNotifEventsSet = new DBObjectSet($oNotifEventsSearch, ['id' => false], ['triggers_ids' => $aRelatedTriggersIDs, 'object_id' => $iObjId]);
$oNotifEventsSet = new DBObjectSet($oNotifEventsSearch, ['id' => false], ['triggers_ids' => $aRelatedTriggersIDs, 'object_id' => $sObjId]);
$oNotifEventsSet->SetLimit(MetaModel::GetConfig()->Get('max_history_length'));
/** @var \EventNotification $oNotifEvent */

View File

@@ -8,9 +8,17 @@ namespace Combodo\iTop\Application\UI\Base\Layout\ActivityPanel;
use appUserPreferences;
use BinaryExpression;
use cmdbAbstractObject;
use Combodo\iTop\Application\UI\Base\Layout\ActivityPanel\ActivityEntry\ActivityEntryFactory;
use Combodo\iTop\Application\UI\Base\Layout\ActivityPanel\ActivityEntry\EditsEntry;
use DBObjectSearch;
use DBObjectSet;
use Exception;
use FieldExpression;
use IssueLog;
use MetaModel;
use VariableExpression;
/**
* Class ActivityPanelHelper
@@ -75,4 +83,99 @@ class ActivityPanelHelper
$aStates[$sObjectClass.'::'.$sObjectMode] = $bIsClosed;
appUserPreferences::SetPref('activity_panel.is_closed', $aStates);
}
/**
* @param string $sObjectClass
* @param string $sObjectId
* @param string|null $sChangeOpIdToOffsetFrom Entries will be retrieved after this CMDBChangeOp ID. Typically used for pagination.
*
* @return array The 'max_history_length' edits entries from the CMDBChangeOp of the object, starting from $sChangeOpIdToOffsetFrom. Flag to know if more entries are available and the ID of the last returned entry are also provided.
*
* [
* 'entries' => EditsEntry[],
* 'last_loaded_entry_id' => null|int,
* 'more_entries_to_load' => bool,
* ]
*
* @throws \ArchivedObjectException
* @throws \CoreException
*/
public static function GetCMDBChangeOpEditsEntriesForObject(string $sObjectClass, string $sObjectId, ?string $sChangeOpIdToOffsetFrom = null): array
{
$iMaxHistoryLength = MetaModel::GetConfig()->Get('max_history_length');
$aResults = [
'entries' => [],
'last_loaded_entry_id' => null,
'more_entries_to_load' => false,
];
// - Prepare query to retrieve changes
$oSearch = DBObjectSearch::FromOQL('SELECT CO FROM CMDBChangeOp AS CO WHERE CO.objclass = :obj_class AND CO.objkey = :obj_key AND CO.finalclass NOT IN (:excluded_optypes)');
$aArgs = ['obj_class' => $sObjectClass, 'obj_key' => $sObjectId, 'excluded_optypes' => ['CMDBChangeOpSetAttributeCaseLog']];
// - Optional offset condition
if (null !== $sChangeOpIdToOffsetFrom) {
$oSearch->AddConditionExpression(
new BinaryExpression(
new FieldExpression('id', 'CO'), '<', new VariableExpression('id')
)
);
$aArgs['id'] = $sChangeOpIdToOffsetFrom;
}
// Note: We can't order by date (only) as something multiple CMDBChangeOp rows are inserted at the same time (eg. Delivery model of the "Demo" Organization in the sample data).
// As the DB returns rows "chronologically", we get the older first and it messes with the processing. Ordering by the ID is way much simpler and less DB CPU consuming.
$oSet = new DBObjectSet($oSearch, ['id' => false], $aArgs);
// - Limit history entries to display
$bMoreEntriesToLoad = $oSet->CountExceeds($iMaxHistoryLength);
$oSet->SetLimit($iMaxHistoryLength);
// Prepare previous values to group edits within a same CMDBChange
$iPreviousChangeId = 0;
/** @var string|int $iPreviousChangeOpId Only used for pagination */
$iPreviousChangeOpId = 0;
$oPreviousEditsEntry = null;
/** @var \CMDBChangeOp $oChangeOp */
while ($oChangeOp = $oSet->Fetch()) {
// Skip case log changes as they are handled directly from the attributes themselves (most of them should have been excluded by the OQL above, but some derivated classes could still be retrieved)
if ($oChangeOp instanceof CMDBChangeOpSetAttributeCaseLog) {
continue;
}
// Make entry from CMDBChangeOp
$iChangeId = $oChangeOp->Get('change');
try {
$oEntry = ActivityEntryFactory::MakeFromCmdbChangeOp($oChangeOp);
}
catch (Exception $oException) {
IssueLog::Debug(static::class.': Could not create entry from CMDBChangeOp #'.$oChangeOp->GetKey().' related to '.$oChangeOp->Get('objclass').'::'.$oChangeOp->Get('objkey').': '.$oException->getMessage());
continue;
}
// If same CMDBChange and mergeable edits entry from the same author, we merge them
if (($iChangeId == $iPreviousChangeId) && ($oPreviousEditsEntry instanceof EditsEntry) && ($oEntry instanceof EditsEntry) && ($oPreviousEditsEntry->GetAuthorLogin() === $oEntry->GetAuthorLogin())) {
$oPreviousEditsEntry->Merge($oEntry);
} else {
$aResults['entries'][] = $oEntry;
// Set previous edits entry
if ($oEntry instanceof EditsEntry) {
$oPreviousEditsEntry = $oEntry;
}
}
$iPreviousChangeId = $iChangeId;
$iPreviousChangeOpId = $oChangeOp->GetKey();
}
unset($oSet);
// - Set last entry ID so the other can be loaded later
if ((true === $bMoreEntriesToLoad) && (0 !== $iPreviousChangeOpId)) {
$aResults['last_loaded_entry_id'] = $iPreviousChangeOpId;
$aResults['more_entries_to_load'] = true;
}
return $aResults;
}
}

View File

@@ -77,6 +77,13 @@
{% for aEntryGroup in oUIBlock.GetGroupedEntries() %}
{{ include('base/layouts/activity-panel/entry-group.html.twig', {aEntryGroup: aEntryGroup}) }}
{% endfor %}
{% if oUIBlock.HasMoreEntriesToLoad() %}
<div class="ibo-activity-panel--load-more-entries-container" data-role="ibo-activity-panel--load-more-entries-container">
<a href="#" class="ibo-activity-panel--load-more-entries" data-role="ibo-activity-panel--load-more-entries" data-tooltip-content="{{ 'UI:Layout:ActivityPanel:LoadMoreEntries:Tooltip'|dict_s }}">
<span class="ibo-activity-panel--load-more-entries-icon fas fa-fw fa-angle-double-down" data-role="ibo-activity-panel--load-more-entries-icon"></span>
</a>
</div>
{% endif %}
{% else %}
<div class="ibo-activity-panel--body--placeholder">
<div class="ibo-activity-panel--body--placeholder-image ibo-svg-illustration--container">

View File

@@ -4,7 +4,9 @@ $('#{{ oUIBlock.GetId() }}').activity_panel({
{% if oUIBlock.HasTransactionId() %}transaction_id: {{ oUIBlock.GetTransactionId()|var_export }},{% endif %}
lock_enabled: {{ oUIBlock.IsLockEnabled()|var_export }},
lock_watcher_period: {{ get_config_parameter('activity_panel.lock_watcher_period') }},
lock_endpoint: {{ oUIBlock.GetLockEndpointForJSWidget()|var_export|raw }},
lock_endpoint: {{ oUIBlock.GetLockEndpoint()|var_export|raw }},
show_multiple_entries_submit_confirmation: {{ oUIBlock.GetShowMultipleEntriesSubmitConfirmation()|var_export }},
save_state_endpoint: {{ oUIBlock.GetSaveStateEndpoint()|var_export|raw }}
save_state_endpoint: {{ oUIBlock.GetSaveStateEndpoint()|var_export|raw }},
last_loaded_entries_ids: {{ oUIBlock.GetLastEntryIds()|json_encode|raw }},
load_more_entries_endpoint: {{ oUIBlock.GetLoadMoreEntriesEndpoint()|var_export|raw }},
});