diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 68eeeb4b6..e6a8dbc1f 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -64,7 +64,7 @@ Don't remove these lines, check them once done. --> - [ ] I have performed a self-review of my code - [ ] I have tested all changes I made on an iTop instance -- [ ] Would a unit test be relevant and have I added it? +- [ ] I have added a unit test, otherwise I have explained why I couldn't - [ ] Is the PR clear and detailled enough so anyone can understand digging in the code? ## Checklist of things to do before PR is ready to merge diff --git a/core/action.class.inc.php b/core/action.class.inc.php index 3589db91a..d1f5aa7b3 100644 --- a/core/action.class.inc.php +++ b/core/action.class.inc.php @@ -19,6 +19,8 @@ use Combodo\iTop\Application\TwigBase\Twig\TwigHelper; use Combodo\iTop\Application\UI\Base\Component\DataTable\DataTableUIBlockFactory; use Combodo\iTop\Application\WebPage\WebPage; +use Combodo\iTop\Service\Notification\NotificationsRepository; +use Combodo\iTop\Service\Notification\NotificationsService; use Combodo\iTop\Service\Router\Router; /** @@ -342,6 +344,7 @@ class ActionEmail extends ActionNotification "db_table" => "priv_action_email", "db_key_field" => "id", "db_finalclass_field" => "", + 'style' => new ormStyle(null, null, null, null, null, '../images/icons/icons8-mailing.svg'), ); MetaModel::Init_Params($aParams); MetaModel::Init_InheritAttributes(); @@ -412,18 +415,20 @@ class ActionEmail extends ActionNotification protected $m_aMailErrors; //array of strings explaining the issue /** - * Return a the list of emails as a string, or a detailed error description + * Return the list of emails as a string, or a detailed error description * * @param string $sRecipAttCode * @param array $aArgs + * @param \Trigger|null $oTrigger * * @return string + * @since 3.2.0 $oTrigger parameter added * @throws \ArchivedObjectException * @throws \CoreException * @throws \CoreUnexpectedValue * @throws \MySQLException */ - protected function FindRecipients($sRecipAttCode, $aArgs) + protected function FindRecipients($sRecipAttCode, $aArgs, ?Trigger $oTrigger = null) { $sOQL = $this->Get($sRecipAttCode); if (utils::IsNullOrEmptyString($sOQL)) return ''; @@ -432,7 +437,7 @@ class ActionEmail extends ActionNotification { $oSearch = DBObjectSearch::FromOQL($sOQL); if ($this->Get('ignore_notify') === 'no') { - // In theory it is possible to notify *any* kind of object, + // In theory, it is possible to notify *any* kind of object, // as long as there is an email attribute in the class // So let's not assume that the selected class is a Person $sFirstSelectedClass = $oSearch->GetClass(); @@ -465,6 +470,16 @@ class ActionEmail extends ActionNotification return "The objects of the class '$sClass' do not have any email attribute"; } + if(in_array('Contact', MetaModel::EnumParentClasses($sClass, ENUM_CHILD_CLASSES_ALL), true)) { + $aArgs['trigger_id'] = $oTrigger->GetKey(); + $aArgs['action_id'] = $this->GetKey(); + + $sSubscribedContactsOQL = NotificationsRepository::GetInstance()->GetSearchOQLContactUnsubscribedByTriggerAndAction(); + $sSubscribedContactsOQL->ApplyParameters($aArgs); + $sAlias = $oSearch->GetClassAlias(); + $oSearch->AddConditionExpression(Expression::FromOQL("`$sAlias`.id NOT IN ($sSubscribedContactsOQL)")); + } + $oSet = new DBObjectSet($oSearch, array() /* order */, $aArgs); $aRecipients = array(); while ($oObj = $oSet->Fetch()) @@ -475,6 +490,9 @@ class ActionEmail extends ActionNotification $aRecipients[] = $sAddress; $this->m_iRecipients++; } + if (in_array('Contact', MetaModel::EnumParentClasses($sClass, ENUM_CHILD_CLASSES_ALL), true)) { + NotificationsService::GetInstance()->RegisterSubscription($oTrigger, $this, $oObj); + } } return implode(', ', $aRecipients); } @@ -569,7 +587,7 @@ class ActionEmail extends ActionNotification $oEmail = new EMail(); - $aEmailContent = $this->PrepareMessageContent($aContextArgs, $oLog); + $aEmailContent = $this->PrepareMessageContent($aContextArgs, $oLog, $oTrigger); $oEmail->SetSubject($aEmailContent['subject']); $oEmail->SetBody($aEmailContent['body'], 'text/html', $sStyles); $oEmail->SetRecipientTO($aEmailContent['to']); @@ -621,13 +639,19 @@ class ActionEmail extends ActionNotification /** * @param array $aContextArgs * @param \EventNotification $oLog + * @param \Trigger|null $oTrigger * * @return array + * @throws \ArchivedObjectException * @throws \CoreException - * @throws \Exception + * @throws \CoreUnexpectedValue + * @throws \DictExceptionMissingString + * @throws \DictExceptionUnknownLanguage + * @throws \MySQLException * @since 3.1.0 N°918 + * @since 3.2.0 Added $oTrigger parameter */ - protected function PrepareMessageContent($aContextArgs, &$oLog): array + protected function PrepareMessageContent($aContextArgs, &$oLog, ?Trigger $oTrigger = null): array { $aMessageContent = [ 'to' => '', @@ -654,9 +678,9 @@ class ActionEmail extends ActionNotification // Determine recipients // - $aMessageContent['to'] = $this->FindRecipients('to', $aContextArgs); - $aMessageContent['cc'] = $this->FindRecipients('cc', $aContextArgs); - $aMessageContent['bcc'] = $this->FindRecipients('bcc', $aContextArgs); + $aMessageContent['to'] = $this->FindRecipients('to', $aContextArgs, $oTrigger); + $aMessageContent['cc'] = $this->FindRecipients('cc', $aContextArgs, $oTrigger); + $aMessageContent['bcc'] = $this->FindRecipients('bcc', $aContextArgs, $oTrigger); $aMessageContent['from'] = MetaModel::ApplyParams($this->Get('from'), $aContextArgs); $aMessageContent['from_label'] = MetaModel::ApplyParams($this->Get('from_label'), $aContextArgs); diff --git a/core/datamodel.core.xml b/core/datamodel.core.xml index 521d6928d..fab9c2572 100644 --- a/core/datamodel.core.xml +++ b/core/datamodel.core.xml @@ -1,6 +1,97 @@ + + cmdbAbstractObject + + core/cmdb,application + false + autoincrement + priv_lnk_action_notif_to_contact + id + + + + + + + + + + + + + + + + false + true + + + + + + action_id + ActionNotification + + false + + + contact_id + Contact + + false + + + trigger_id + Trigger + + false + + + subscribed + true + false + + + +
+ + + + + + + 10 + + + 20 + + + 30 + + + + + + +
+ + + + 10 + + + 20 + + + 30 + + + +
+ +
ActionNotification @@ -18,6 +109,9 @@ + @@ -94,6 +188,13 @@ + + + + 10 + + + @@ -159,6 +260,13 @@ $oRecipientsSet = new DBObjectSet($oRecipientsSearch); [$sPreviousLanguage, $aPreviousPluginProperties] = $this->SetNotificationLanguage(); while ($oRecipient = $oRecipientsSet->Fetch()) { + // Skip recipients that have no users + if (get_class($oRecipient) === Person::class && UserRights::GetUserFromPerson($oRecipient) === null) { + continue; + } + if (!\Combodo\iTop\Service\Notification\NotificationsService::GetInstance()->IsSubscribed($oTrigger, $this, $oRecipient)) { + continue; + } $oEvent = new EventiTopNotification(); $oEvent->Set('title', MetaModel::ApplyParams($this->Get('title'), $aContextArgs)); $oEvent->Set('message', MetaModel::ApplyParams($this->Get('message'), $aContextArgs)); @@ -172,6 +280,8 @@ $oEvent->Set('object_id', $iObjectId); $oEvent->Set('url', MetaModel::ApplyParams($this->Get('url'), $aContextArgs)); $oEvent->DBInsertNoReload(); + + \Combodo\iTop\Service\Notification\NotificationsService::GetInstance()->RegisterSubscription($oTrigger, $this, $oRecipient); } $this->SetNotificationLanguage($sPreviousLanguage, $aPreviousPluginProperties['language_code'] ?? null); } diff --git a/core/trigger.class.inc.php b/core/trigger.class.inc.php index 41f74f882..705c24a33 100644 --- a/core/trigger.class.inc.php +++ b/core/trigger.class.inc.php @@ -53,9 +53,10 @@ abstract class Trigger extends cmdbAbstractObject MetaModel::Init_AddAttribute(new AttributeEnumSet("context", array("allowed_values" => null, "possible_values" => new ValueSetEnumPadded($aTags, true), "sql" => "context", "depends_on" => array(), "is_null_allowed" => true, "max_items" => 12))); // "complement" is a computed field, fed by Trigger sub-classes, in general in ComputeValues method, for eg. the TriggerOnObject fed it with target_class info MetaModel::Init_AddAttribute(new AttributeString("complement", array("allowed_values" => null, "sql" => "complement", "default_value" => null, "is_null_allowed" => true, "depends_on" => array()))); + MetaModel::Init_AddAttribute(new AttributeEnum("subscription_policy", array("allowed_values" => new ValueSetEnum('allow_no_channel,force_at_least_one_channel,force_all_channels'), "sql" => "subscription_policy", "default_value" => 'allow_no_channel', "is_null_allowed" => false, "depends_on" => array()))); // Display lists - MetaModel::Init_SetZListItems('details', array('finalclass', 'description', 'context', 'action_list', 'complement')); // Attributes to be displayed for the complete details + MetaModel::Init_SetZListItems('details', array('finalclass', 'description', 'context', 'subscription_policy', 'action_list', 'complement')); // Attributes to be displayed for the complete details MetaModel::Init_SetZListItems('list', array('finalclass', 'complement')); // Attributes to be displayed for a list // Search criteria // MetaModel::Init_SetZListItems('standard_search', array('name')); // Criteria of the std search form @@ -438,7 +439,7 @@ class TriggerOnStateEnter extends TriggerOnStateChange MetaModel::Init_InheritAttributes(); // Display lists - MetaModel::Init_SetZListItems('details', array('description', 'context', 'target_class', 'filter', 'state', 'action_list')); // Attributes to be displayed for the complete details + MetaModel::Init_SetZListItems('details', array('description', 'context', 'target_class', 'filter', 'state', 'subscription_policy', 'action_list')); // Attributes to be displayed for the complete details MetaModel::Init_SetZListItems('list', array('target_class', 'state')); // Attributes to be displayed for a list // Search criteria MetaModel::Init_SetZListItems('standard_search', array('description', 'target_class', 'state')); // Criteria of the std search form @@ -471,7 +472,7 @@ class TriggerOnStateLeave extends TriggerOnStateChange MetaModel::Init_InheritAttributes(); // Display lists - MetaModel::Init_SetZListItems('details', array('description', 'context', 'target_class', 'filter', 'state', 'action_list')); // Attributes to be displayed for the complete details + MetaModel::Init_SetZListItems('details', array('description', 'context', 'target_class', 'filter', 'state', 'subscription_policy', 'action_list')); // Attributes to be displayed for the complete details MetaModel::Init_SetZListItems('list', array('target_class', 'state')); // Attributes to be displayed for a list // Search criteria MetaModel::Init_SetZListItems('standard_search', array('description', 'target_class', 'state')); // Criteria of the std search form @@ -504,7 +505,7 @@ class TriggerOnObjectCreate extends TriggerOnObject MetaModel::Init_InheritAttributes(); // Display lists - MetaModel::Init_SetZListItems('details', array('description', 'context', 'target_class', 'filter', 'action_list')); // Attributes to be displayed for the complete details + MetaModel::Init_SetZListItems('details', array('description', 'context', 'target_class', 'filter', 'subscription_policy', 'action_list')); // Attributes to be displayed for the complete details MetaModel::Init_SetZListItems('list', array('finalclass', 'target_class')); // Attributes to be displayed for a list // Search criteria MetaModel::Init_SetZListItems('standard_search', array('description', 'target_class')); // Criteria of the std search form @@ -537,7 +538,7 @@ class TriggerOnObjectDelete extends TriggerOnObject MetaModel::Init_InheritAttributes(); // Display lists - MetaModel::Init_SetZListItems('details', array('description', 'context', 'target_class', 'filter', 'action_list')); // Attributes to be displayed for the complete details + MetaModel::Init_SetZListItems('details', array('description', 'context', 'target_class', 'filter', 'subscription_policy', 'action_list')); // Attributes to be displayed for the complete details MetaModel::Init_SetZListItems('list', array('finalclass', 'target_class')); // Attributes to be displayed for a list // Search criteria MetaModel::Init_SetZListItems('standard_search', array('description', 'target_class')); // Criteria of the std search form @@ -572,7 +573,7 @@ class TriggerOnObjectUpdate extends TriggerOnObject MetaModel::Init_AddAttribute(new AttributeClassAttCodeSet('target_attcodes', array("allowed_values" => null, "class_field" => "target_class", "sql" => "target_attcodes", "default_value" => null, "is_null_allowed" => true, "max_items" => 20, "min_items" => 0, "attribute_definition_exclusion_list" => "AttributeDashboard,AttributeExternalField,AttributeFinalClass,AttributeFriendlyName,AttributeObsolescenceDate,AttributeObsolescenceFlag,AttributeSubItem", "attribute_definition_list" => null, "depends_on" => array('target_class')))); // Display lists - MetaModel::Init_SetZListItems('details', array('description', 'context', 'target_class', 'filter', 'target_attcodes', 'action_list')); // Attributes to be displayed for the complete details + MetaModel::Init_SetZListItems('details', array('description', 'context', 'target_class', 'filter', 'target_attcodes', 'subscription_policy', 'action_list')); // Attributes to be displayed for the complete details MetaModel::Init_SetZListItems('list', array('finalclass', 'target_class')); // Attributes to be displayed for a list // Search criteria MetaModel::Init_SetZListItems('standard_search', array('description', 'target_class')); // Criteria of the std search form @@ -668,7 +669,7 @@ class TriggerOnObjectMention extends TriggerOnObject MetaModel::Init_AddAttribute(new AttributeOQL("mentioned_filter", array("allowed_values" => null, "sql" => "mentioned_filter", "default_value" => null, "is_null_allowed" => true, "depends_on" => array()))); // Display lists - MetaModel::Init_SetZListItems('details', array('description', 'context', 'target_class', 'filter', 'mentioned_filter', 'action_list')); // Attributes to be displayed for the complete details + MetaModel::Init_SetZListItems('details', array('description', 'context', 'target_class', 'filter', 'mentioned_filter', 'subscription_policy', 'action_list')); // Attributes to be displayed for the complete details MetaModel::Init_SetZListItems('list', array('finalclass', 'target_class')); // Attributes to be displayed for a list // Search criteria MetaModel::Init_SetZListItems('standard_search', array('description', 'target_class')); // Criteria of the std search form diff --git a/css/backoffice/components/input/_input-select.scss b/css/backoffice/components/input/_input-select.scss index 8b6df6126..71c727eda 100644 --- a/css/backoffice/components/input/_input-select.scss +++ b/css/backoffice/components/input/_input-select.scss @@ -224,7 +224,11 @@ $ibo-input-select--autocomplete-item-image--border: 1px solid $ibo-color-grey-60 margin-right: $ibo-input-select--autocomplete-item-image--margin-right; background-color: $ibo-input-select--autocomplete-item-image--background-color; border: $ibo-input-select--autocomplete-item-image--border; - + &.ibo-is-not-medallion{ + border: unset; + border-radius: 0; + background-color: unset; + } @extend %ibo-fully-centered-content; } diff --git a/css/backoffice/pages/_all.scss b/css/backoffice/pages/_all.scss index 0f44891a7..c6bb9b859 100644 --- a/css/backoffice/pages/_all.scss +++ b/css/backoffice/pages/_all.scss @@ -15,4 +15,5 @@ @import "global-search"; @import "run-query"; @import "welcome-popup"; -@import "oauth.wizard"; \ No newline at end of file +@import "oauth.wizard"; +@import "notifications-center"; \ No newline at end of file diff --git a/css/backoffice/pages/_notifications-center.scss b/css/backoffice/pages/_notifications-center.scss new file mode 100644 index 000000000..6fe771fb9 --- /dev/null +++ b/css/backoffice/pages/_notifications-center.scss @@ -0,0 +1,18 @@ +/* + * @copyright Copyright (C) 2010-2024 Combodo SARL + * @license http://opensource.org/licenses/AGPL-3.0 + */ +$ibo-input-select--notification-item--mixed-value--color: $ibo-color-primary-800 !default; +$ibo-input-select--notification-item--mixed-value--margin-left: 4px !default; + +.ibo-input-select--notification-item { + display: flex !important; // override selectize default display with a stronger rule + flex-direction: row; + @extend .ibo-input-select--autocomplete-item +} + +.ibo-input-select--notification-item--mixed-value{ + font-size: $ibo-font-size-100; + color: $ibo-input-select--notification-item--mixed-value--color; + margin-left: $ibo-input-select--notification-item--mixed-value--margin-left; +} \ No newline at end of file diff --git a/datamodels/2.x/itop-structure/datamodel.itop-structure.xml b/datamodels/2.x/itop-structure/datamodel.itop-structure.xml index f2b969da7..a462bcf0e 100644 --- a/datamodels/2.x/itop-structure/datamodel.itop-structure.xml +++ b/datamodels/2.x/itop-structure/datamodel.itop-structure.xml @@ -1482,7 +1482,10 @@ - + + file + true + diff --git a/datamodels/2.x/itop-tickets/datamodel.itop-tickets.xml b/datamodels/2.x/itop-tickets/datamodel.itop-tickets.xml index fd8f27ab3..fe38522e8 100755 --- a/datamodels/2.x/itop-tickets/datamodel.itop-tickets.xml +++ b/datamodels/2.x/itop-tickets/datamodel.itop-tickets.xml @@ -140,6 +140,10 @@ DEL_AUTO + team_id + name + + team_id email diff --git a/dictionaries/en.dictionary.itop.core.php b/dictionaries/en.dictionary.itop.core.php index 6026e9c91..241adbfa1 100644 --- a/dictionaries/en.dictionary.itop.core.php +++ b/dictionaries/en.dictionary.itop.core.php @@ -633,9 +633,10 @@ While editing, click on the magnifier to get pertinent examples', // Dict::Add('EN US', 'English', 'English', array( - 'ActioniTopNotification:content' => 'Content', + 'ActioniTopNotification:trigger' => 'Trigger', + 'ActioniTopNotification:content' => 'Message', 'ActioniTopNotification:settings' => 'Settings', - 'Class:ActioniTopNotification' => 'ActioniTopNotification', + 'Class:ActioniTopNotification' => ITOP_APPLICATION_SHORT.' Notification', 'Class:ActioniTopNotification+' => '', 'Class:ActioniTopNotification/Attribute:language' => 'Language', 'Class:ActioniTopNotification/Attribute:language+' => '', @@ -679,6 +680,11 @@ Dict::Add('EN US', 'English', 'English', array( 'Class:Trigger/Attribute:context+' => 'Context to allow the trigger to start', 'Class:Trigger/Attribute:complement' => 'Additional information', 'Class:Trigger/Attribute:complement+' => 'Further information as provided in english, by this trigger', + 'Class:Trigger/Attribute:subscription_policy' => 'Subscription policy', + 'Class:Trigger/Attribute:subscription_policy+' => 'Allows users to unsubscribe from the trigger', + 'Class:Trigger/Attribute:subscription_policy/Value:allow_no_channel' => 'Allow no channel', + 'Class:Trigger/Attribute:subscription_policy/Value:force_at_least_one_channel' => 'Force at least one channel', + 'Class:Trigger/Attribute:subscription_policy/Value:force_all_channels' => 'Force all channels', )); // diff --git a/dictionaries/fr.dictionary.itop.core.php b/dictionaries/fr.dictionary.itop.core.php index d55717217..71ac7bd4e 100644 --- a/dictionaries/fr.dictionary.itop.core.php +++ b/dictionaries/fr.dictionary.itop.core.php @@ -584,7 +584,10 @@ En édition, cliquez sur la loupe pour obtenir des exemples pertinents.', // Dict::Add('FR FR', 'French', 'Français', array( - 'Class:ActioniTopNotification' => 'ActioniTopNotification', + 'ActioniTopNotification:trigger' => 'Conditions de déclenchement', + 'ActioniTopNotification:content' => 'Message', + 'ActioniTopNotification:settings' => 'Paramètres', + 'Class:ActioniTopNotification' => ITOP_APPLICATION_SHORT.' Notification', 'Class:ActioniTopNotification+' => '', 'Class:ActioniTopNotification/Attribute:title' => 'Titre', 'Class:ActioniTopNotification/Attribute:title+' => '', diff --git a/dictionaries/ui/components/input/en.dictionary.itop.input.php b/dictionaries/ui/components/input/en.dictionary.itop.input.php index e1905aff0..64b558258 100644 --- a/dictionaries/ui/components/input/en.dictionary.itop.input.php +++ b/dictionaries/ui/components/input/en.dictionary.itop.input.php @@ -20,4 +20,5 @@ // Input Dict::Add('EN US', 'English', 'English', array( 'UI:Component:Input:Password:DoesNotMatch' => 'Passwords do not match', + 'UI:Component:Input:Set:MinimumItems' => 'Minimum %1$s items required', )); \ No newline at end of file diff --git a/dictionaries/ui/pages/notifications-center/en.dictionary.itop.notifications-center.php b/dictionaries/ui/pages/notifications-center/en.dictionary.itop.notifications-center.php new file mode 100644 index 000000000..07e006837 --- /dev/null +++ b/dictionaries/ui/pages/notifications-center/en.dictionary.itop.notifications-center.php @@ -0,0 +1,32 @@ + 'Notifications center', + 'UI:NotificationsCenter:Panel:Title' => 'Notifications center', + 'UI:NotificationsCenter:Panel:SubTitle' => 'Manage your notifications subscriptions. For more granularity, you can also manage individual subscriptions using the advanced mode.', + 'UI:NotificationsCenter:Panel:Advanced:SubTitle' => 'Manage your notifications subscriptions individually. To manage your subscriptions by type, use the standard mode.', + 'UI:NotificationsCenter:Panel:Table:Channels' => 'Channels.', + 'UI:NotificationsCenter:Unsubscribe:Success' => 'You have been successfully unsubscribed from the selected notifications.', + 'UI:NotificationsCenter:Unsubscribe:Error' => 'An error occurred while unsubscribing from the selected notifications.', + 'UI:NotificationsCenter:Subscribe:Success' => 'You have been successfully subscribed to the selected notifications.', + 'UI:NotificationsCenter:Subscribe:Error' => 'An error occurred while subscribing to the selected notifications.', + 'UI:NotificationsCenter:Channel:OutOf:Text' => '%1$s out of %2$s', + 'UI:NotificationsCenter:Advanced:Input:Label' => '%1$s: %2$s', +)); \ No newline at end of file diff --git a/dictionaries/ui/pages/preferences/en.dictionary.itop.preferences.php b/dictionaries/ui/pages/preferences/en.dictionary.itop.preferences.php index 6c24e4672..5fb768156 100644 --- a/dictionaries/ui/pages/preferences/en.dictionary.itop.preferences.php +++ b/dictionaries/ui/pages/preferences/en.dictionary.itop.preferences.php @@ -51,4 +51,7 @@ Dict::Add('EN US', 'English', 'English', array( 'UI:Preferences:General:Toasts:Top' => 'Top', 'UI:Preferences:ChooseAPlaceholder' => 'User placeholder image', 'UI:Preferences:ChooseAPlaceholder+' => 'Choose a placeholder image that will be displayed if the contact linked to your user doesn\'t have one', + 'UI:Preferences:Notifications' => 'Notifications', + 'UI:Preferences:Notifications+' => 'Configure the notifications you want to receive on this page.', + )); diff --git a/images/icons/icons8-mailing.svg b/images/icons/icons8-mailing.svg new file mode 100644 index 000000000..4adfa4f49 --- /dev/null +++ b/images/icons/icons8-mailing.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/images/icons/icons8-notification.svg b/images/icons/icons8-notification.svg new file mode 100644 index 000000000..595d505b6 --- /dev/null +++ b/images/icons/icons8-notification.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/js/selectize/plugin_combodo_min_items.js b/js/selectize/plugin_combodo_min_items.js new file mode 100644 index 000000000..c3243b71d --- /dev/null +++ b/js/selectize/plugin_combodo_min_items.js @@ -0,0 +1,26 @@ +Selectize.define("combodo_min_items", function (aOptions) { + + // Selectize instance + let oSelf = this; + + // Plugin options + aOptions = $.extend({ + minItems: 0, + errorMessage: 'Minimum ' + aOptions.minItems + ' item(s) required.' + }, + aOptions + ); + + // Override removeItem function + oSelf.removeItem = (function () { + let oOriginal = oSelf.removeItem; + return function () { + if(oSelf.items.length <= aOptions.minItems) { + CombodoModal.OpenErrorModal(aOptions.errorMessage, []); + return; + } + return oOriginal.apply(this, arguments); + } + })(); + +}); \ No newline at end of file diff --git a/js/selectize/plugin_combodo_update_operations.js b/js/selectize/plugin_combodo_update_operations.js index e670a0207..12750bd63 100644 --- a/js/selectize/plugin_combodo_update_operations.js +++ b/js/selectize/plugin_combodo_update_operations.js @@ -57,7 +57,8 @@ Selectize.define("combodo_update_operations", function (aOptions) { let oOriginal = oSelf.disable; return function () { oOriginal.apply(oSelf, arguments); - oSelf.$operationsInput.prop('disabled', true); + if(oSelf.$operationsInput !== undefined) + oSelf.$operationsInput.prop('disabled', true); } })(); diff --git a/js/utils.js b/js/utils.js index 9e8feeed2..7cdbb307a 100644 --- a/js/utils.js +++ b/js/utils.js @@ -797,7 +797,7 @@ const CombodoGlobalToolbox = { } // Attribute replacement - let aAttrElements = ['title', 'name', 'for']; + let aAttrElements = ['title', 'name', 'for', 'src']; aAttrElements.forEach(function(e){ $(`[data-template-attr-${e}]`, oElement).each(function(){ $(this).attr(e, aData[$(this).attr(`data-template-attr-${e}`)]); diff --git a/lib/composer/autoload_classmap.php b/lib/composer/autoload_classmap.php index 12549749d..301857879 100644 --- a/lib/composer/autoload_classmap.php +++ b/lib/composer/autoload_classmap.php @@ -395,6 +395,7 @@ return array( 'Combodo\\iTop\\Controller\\Links\\LinkSetController' => $baseDir . '/sources/Controller/Links/LinkSetController.php', 'Combodo\\iTop\\Controller\\Newsroom\\iTopNewsroomController' => $baseDir . '/sources/Controller/Newsroom/iTopNewsroomController.php', 'Combodo\\iTop\\Controller\\Notifications\\ActionController' => $baseDir . '/sources/Controller/Notifications/ActionController.php', + 'Combodo\\iTop\\Controller\\Notifications\\NotificationsCenterController' => $baseDir . '/sources/Controller/Notifications/NotificationsCenterController.php', 'Combodo\\iTop\\Controller\\OAuth\\OAuthLandingController' => $baseDir . '/sources/Controller/OAuth/OAuthLandingController.php', 'Combodo\\iTop\\Controller\\PreferencesController' => $baseDir . '/sources/Controller/PreferencesController.php', 'Combodo\\iTop\\Controller\\TemporaryObjects\\TemporaryObjectController' => $baseDir . '/sources/Controller/TemporaryObjects/TemporaryObjectController.php', @@ -493,6 +494,8 @@ return array( 'Combodo\\iTop\\Service\\Links\\LinksBulkDataPostProcessor' => $baseDir . '/sources/Service/Links/LinksBulkDataPostProcessor.php', 'Combodo\\iTop\\Service\\Module\\ModuleService' => $baseDir . '/sources/Service/Module/ModuleService.php', 'Combodo\\iTop\\Service\\Notification\\Event\\EventiTopNotificationGC' => $baseDir . '/sources/Service/Notification/Event/EventiTopNotificationGC.php', + 'Combodo\\iTop\\Service\\Notification\\NotificationsRepository' => $baseDir . '/sources/Service/Notification/NotificationsRepository.php', + 'Combodo\\iTop\\Service\\Notification\\NotificationsService' => $baseDir . '/sources/Service/Notification/NotificationsService.php', 'Combodo\\iTop\\Service\\Router\\Exception\\RouteNotFoundException' => $baseDir . '/sources/Service/Router/Exception/RouteNotFoundException.php', 'Combodo\\iTop\\Service\\Router\\Exception\\RouterException' => $baseDir . '/sources/Service/Router/Exception/RouterException.php', 'Combodo\\iTop\\Service\\Router\\Router' => $baseDir . '/sources/Service/Router/Router.php', diff --git a/lib/composer/autoload_static.php b/lib/composer/autoload_static.php index fc5240244..33080132f 100644 --- a/lib/composer/autoload_static.php +++ b/lib/composer/autoload_static.php @@ -770,6 +770,7 @@ class ComposerStaticInit7f81b4a2a468a061c306af5e447a9a9f 'Combodo\\iTop\\Controller\\Links\\LinkSetController' => __DIR__ . '/../..' . '/sources/Controller/Links/LinkSetController.php', 'Combodo\\iTop\\Controller\\Newsroom\\iTopNewsroomController' => __DIR__ . '/../..' . '/sources/Controller/Newsroom/iTopNewsroomController.php', 'Combodo\\iTop\\Controller\\Notifications\\ActionController' => __DIR__ . '/../..' . '/sources/Controller/Notifications/ActionController.php', + 'Combodo\\iTop\\Controller\\Notifications\\NotificationsCenterController' => __DIR__ . '/../..' . '/sources/Controller/Notifications/NotificationsCenterController.php', 'Combodo\\iTop\\Controller\\OAuth\\OAuthLandingController' => __DIR__ . '/../..' . '/sources/Controller/OAuth/OAuthLandingController.php', 'Combodo\\iTop\\Controller\\PreferencesController' => __DIR__ . '/../..' . '/sources/Controller/PreferencesController.php', 'Combodo\\iTop\\Controller\\TemporaryObjects\\TemporaryObjectController' => __DIR__ . '/../..' . '/sources/Controller/TemporaryObjects/TemporaryObjectController.php', @@ -868,6 +869,8 @@ class ComposerStaticInit7f81b4a2a468a061c306af5e447a9a9f 'Combodo\\iTop\\Service\\Links\\LinksBulkDataPostProcessor' => __DIR__ . '/../..' . '/sources/Service/Links/LinksBulkDataPostProcessor.php', 'Combodo\\iTop\\Service\\Module\\ModuleService' => __DIR__ . '/../..' . '/sources/Service/Module/ModuleService.php', 'Combodo\\iTop\\Service\\Notification\\Event\\EventiTopNotificationGC' => __DIR__ . '/../..' . '/sources/Service/Notification/Event/EventiTopNotificationGC.php', + 'Combodo\\iTop\\Service\\Notification\\NotificationsRepository' => __DIR__ . '/../..' . '/sources/Service/Notification/NotificationsRepository.php', + 'Combodo\\iTop\\Service\\Notification\\NotificationsService' => __DIR__ . '/../..' . '/sources/Service/Notification/NotificationsService.php', 'Combodo\\iTop\\Service\\Router\\Exception\\RouteNotFoundException' => __DIR__ . '/../..' . '/sources/Service/Router/Exception/RouteNotFoundException.php', 'Combodo\\iTop\\Service\\Router\\Exception\\RouterException' => __DIR__ . '/../..' . '/sources/Service/Router/Exception/RouterException.php', 'Combodo\\iTop\\Service\\Router\\Router' => __DIR__ . '/../..' . '/sources/Service/Router/Router.php', diff --git a/pages/preferences.php b/pages/preferences.php index 5a24a2bb5..d710ba737 100644 --- a/pages/preferences.php +++ b/pages/preferences.php @@ -23,6 +23,8 @@ use Combodo\iTop\Application\UI\Preferences\BlockShortcuts\BlockShortcuts; use Combodo\iTop\Application\WebPage\ErrorPage; use Combodo\iTop\Application\WebPage\iTopWebPage; use Combodo\iTop\Application\WebPage\WebPage; +use Combodo\iTop\Controller\Notifications\NotificationsCenterController; +use Combodo\iTop\Service\Router\Router; require_once('../approot.inc.php'); require_once(APPROOT.'/application/application.inc.php'); @@ -139,6 +141,17 @@ function ValidateOtherSettings() JS ); + ////////////////////////////////////////////////////////////////////////// + // + // Notifications + // + ////////////////////////////////////////////////////////////////////////// + $oNotificationsBlock = new Panel(Dict::S('UI:Preferences:Notifications'), array(), Panel::ENUM_COLOR_SCHEME_GREY, 'ibo-notifications'); + $sNotificationsCenterUrl = Router::GetInstance()->GenerateUrl(NotificationsCenterController::ROUTE_NAMESPACE.'.display_page', [], true); + $oNotificationsBlock->AddSubBlock(new Html('

'.Dict::Format('UI:Preferences:Notifications+', $sNotificationsCenterUrl).'

')); + $oContentLayout->AddMainBlock($oNotificationsBlock); + + ////////////////////////////////////////////////////////////////////////// // // Favorite Organizations diff --git a/sources/Application/UI/Base/Component/Input/Set/Set.php b/sources/Application/UI/Base/Component/Input/Set/Set.php index 804444986..9fc16c038 100644 --- a/sources/Application/UI/Base/Component/Input/Set/Set.php +++ b/sources/Application/UI/Base/Component/Input/Set/Set.php @@ -43,10 +43,17 @@ class Set extends AbstractInput 'js/selectize/plugin_combodo_auto_position.js', 'js/selectize/plugin_combodo_update_operations.js', 'js/selectize/plugin_combodo_multi_values_synthesis.js', + 'js/selectize/plugin_combodo_min_items.js', ]; + protected $bIsDisabled = false; + /** @var int|null $iMaxItems Maximum number of items selectable */ private ?int $iMaxItems; + /** @var int|null $iMinItems Minimum number of items selectable */ + + private ?int $iMinItems; + /** @var int|null $iMaxItem Maximum number of displayed options */ private ?int $iMaxOptions; @@ -63,6 +70,18 @@ class Set extends AbstractInput /** @var string $sAddButtonTitle Add button title */ private string $sAddButtonTitle; + /** @var string|null $sOnOptionRemoveJs JS code to execute when an option is no longer among available options */ + private ?string $sOnOptionRemoveJs; + + /** @var string|null $sOnOptionAddJs JS code to execute when an option is added to the available options */ + private ?string $sOnOptionAddJs; + + /** @var string|null $sOnItemRemoveJs JS code to execute when a selected item is removed */ + private ?string $sOnItemRemoveJs; + + /** @var string|null $sOnItemAddJs JS code to execute when a new item is selected */ + private ?string $sOnItemAddJs; + /** @var bool $bIsPreloadEnabled Load data at initialization (ajax data provider only) */ private bool $bIsPreloadEnabled; @@ -107,16 +126,22 @@ class Set extends AbstractInput // @todo BDA placeholder depending on autocomplete activation (search...., click to add...) $this->SetPlaceholder(Dict::S('Core:AttributeSet:placeholder')); $this->iMaxItems = null; + $this->iMinItems = null; $this->iMaxOptions = null; $this->bHasRemoveItemButton = true; $this->bHasAddOptionButton = false; $this->sAddOptionButtonJsOnClick = null; $this->sAddButtonTitle = Dict::S('UI:Button:Create'); + $this->sOnItemAddJs = null; + $this->sOnItemRemoveJs = null; + $this->sOnOptionAddJs = null; + $this->sOnOptionRemoveJs = null; $this->bIsPreloadEnabled = false; $this->sTemplateOptions = null; $this->sTemplateItems = null; $this->bIsMultiValuesSynthesis = false; $this->bHasError = false; + $this->bIsDisabled = false; } /** @@ -143,6 +168,32 @@ class Set extends AbstractInput return $this->iMaxItems; } + /** + * SetMinItems. + * + * @param int|null $iMinItems + * + * @return $this + * @since 3.2.0 + */ + public function SetMinItems(?int $iMinItems) + { + $this->iMinItems = $iMinItems; + + return $this; + } + + /** + * GetMinItems. + * + * @return int|null + * @since 3.2.0 + */ + public function GetMinItems(): ?int + { + return $this->iMinItems; + } + /** * SetMaxOptions. * @@ -458,4 +509,103 @@ class Set extends AbstractInput return $this; } + + /** + * @param string|null $sOnOptionRemoveJs + * + * @return $this + */ + public function SetOnOptionRemoveJs(?string $sOnOptionRemoveJs) + { + $this->sOnOptionRemoveJs = $sOnOptionRemoveJs; + + return $this; + } + + /** + * @return string|null + */ + public function GetOnOptionRemoveJs(): ?string + { + return $this->sOnOptionRemoveJs; + } + + /** + * @param string|null $sOnOptionAddJs + * + * @return $this + */ + public function SetOnOptionAddJs(?string $sOnOptionAddJs) + { + $this->sOnOptionAddJs = $sOnOptionAddJs; + + return $this; + } + + /** + * @return string|null + */ + public function GetOnOptionAddJs(): ?string + { + return $this->sOnOptionAddJs; + } + + /** + * @param string|null $sOnItemRemoveJs + * + * @return $this + */ + public function SetOnItemRemoveJs(?string $sOnItemRemoveJs) + { + $this->sOnItemRemoveJs = $sOnItemRemoveJs; + + return $this; + } + + /** + * @return string|null + */ + public function GetOnItemRemoveJs(): ?string + { + return $this->sOnItemRemoveJs; + } + + /** + * @param string|null $sOnItemAddJs + * + * @return $this + */ + public function SetOnItemAddJs(?string $sOnItemAddJs) + { + $this->sOnItemAddJs = $sOnItemAddJs; + + return $this; + } + + /** + * @return string|null + */ + public function GetOnItemAddJs(): ?string + { + return $this->sOnItemAddJs; + } + + /** + * @return bool + */ + public function IsDisabled(): bool + { + return $this->bIsDisabled; + } + + /** + * @param bool $bIsDisabled + * + * @return $this + */ + public function SetIsDisabled(bool $bIsDisabled) + { + $this->bIsDisabled = $bIsDisabled; + return $this; + } } \ No newline at end of file diff --git a/sources/Application/UI/Base/Component/Input/Set/SetUIBlockFactory.php b/sources/Application/UI/Base/Component/Input/Set/SetUIBlockFactory.php index 7e77cff1d..6631266d8 100644 --- a/sources/Application/UI/Base/Component/Input/Set/SetUIBlockFactory.php +++ b/sources/Application/UI/Base/Component/Input/Set/SetUIBlockFactory.php @@ -56,7 +56,7 @@ class SetUIBlockFactory extends AbstractUIBlockFactory * * @return \Combodo\iTop\Application\UI\Base\Component\Input\Set\Set */ - public static function MakeForSimple(string $sId, array $aOptions, string $sLabelFields, string $sValueField, array $aSearchFields, ?string $sGroupField = null): Set + public static function MakeForSimple(string $sId, array $aOptions, string $sLabelFields, string $sValueField, array $aSearchFields, ?string $sGroupField = null, ?string $sTooltipField = null): Set { // Create set ui block $oSetUIBlock = new Set($sId); @@ -67,7 +67,7 @@ class SetUIBlockFactory extends AbstractUIBlockFactory ->SetDataLabelField($sLabelFields) ->SetDataValueField($sValueField) ->SetDataSearchFields($aSearchFields) - ->SetTooltipField($sLabelFields); + ->SetTooltipField($sTooltipField ?? $sLabelFields); if ($sGroupField != null) { $oDataProvider->SetGroupField($sGroupField); } diff --git a/sources/Controller/Notifications/NotificationsCenterController.php b/sources/Controller/Notifications/NotificationsCenterController.php new file mode 100644 index 000000000..aaa129ea2 --- /dev/null +++ b/sources/Controller/Notifications/NotificationsCenterController.php @@ -0,0 +1,585 @@ + +* @package Combodo\iTop\Controller\Notifications +* @since 3.2.0 +*/ +class NotificationsCenterController extends Controller +{ + public const ROUTE_NAMESPACE = 'notificationscenter'; + + public function CheckPostedCSRF(){ + $sToken = utils::ReadParam('token', '', true, 'raw_data'); + return utils::IsTransactionValid($sToken, false); + } + + /** + * Displays a table containing all ActionNotifications that current user is likely to receive and allows to unsubscribe from them. + * + * @return iTopWebPage + * @throws \ArchivedObjectException + * @throws \ConfigException + * @throws \CoreException + * @throws \CoreUnexpectedValue + * @throws \DictExceptionMissingString + * @throws \MySQLException + * @throws \ReflectionException + * @throws \Twig\Error\LoaderError + * @throws \Twig\Error\RuntimeError + * @throws \Twig\Error\SyntaxError + */ + public function OperationDisplayPage() + { + $oPage = new iTopWebPage(Dict::S('UI:NotificationsCenter:Page:Title')); + // Create a panel that will contain the table + $oNotificationsPanel = new Panel(Dict::S('UI:NotificationsCenter:Panel:Title'), array(), 'grey', 'ibo-notifications-center'); + $oNotificationsPanel->AddCSSClass('ibo-datatable-panel'); + $oSubtitleBlock = new UIContentBlock(null, ['ibo-notifications-center--sub-title']); + $sDisplayAdvancedPageUrl = Router::GetInstance()->GenerateUrl(self::ROUTE_NAMESPACE.'.display_advanced_page', [], true); + $oSubtitleBlock->AddSubBlock(new Html(Dict::Format('UI:NotificationsCenter:Panel:SubTitle', $sDisplayAdvancedPageUrl))); + $oNotificationsPanel->SetSubTitleBlock($oSubtitleBlock); + $oNotificationsCenterTableColumns = [ + 'trigger' => array('label' => MetaModel::GetName('Trigger')), + 'channels' => array('label' => Dict::S('UI:NotificationsCenter:Panel:Table:Channels')), + ]; + + // Get all subscribed/unsubscribed actions notifications for the current user + $oLnkNotificationsSet = NotificationsRepository::GetInstance()->SearchSubscriptionByContact(\UserRights::GetContactId()); + $oActionsNotificationsByTrigger = []; + $aSubscribedActionsNotificationsByTrigger = []; + while ($oLnkActionsNotifications = $oLnkNotificationsSet->Fetch()) { + $oSubscribedActionNotification = MetaModel::GetObject(ActionNotification::class, $oLnkActionsNotifications->Get('action_id')); + $oTrigger = MetaModel::GetObject('Trigger', $oLnkActionsNotifications->Get('trigger_id')); + $iTriggerId = $oTrigger->GetKey(); + // Create an new array for the trigger if it doesn't exist + if (!isset($oActionsNotificationsByTrigger[$iTriggerId])) { + $oActionsNotificationsByTrigger[$iTriggerId] = []; + $aSubscribedActionsNotificationsByTrigger[$iTriggerId] = []; + } + // Add the action notification to the list of actions notifications for the trigger + $oActionsNotificationsByTrigger[$iTriggerId][] = $oSubscribedActionNotification; + // Add the subscribed status to the list of subscribed actions notifications for the trigger + $aSubscribedActionsNotificationsByTrigger[$iTriggerId][$oSubscribedActionNotification->GetKey()] = $oLnkActionsNotifications->Get('subscribed') || $oTrigger->Get('subscription_policy') === 'force_all_channels'; + } + + // Build table rows + $sSubscribeUrl = Router::GetInstance()->GenerateUrl(self::ROUTE_NAMESPACE.'.subscribe_to_channel', [], true); + $sUnsubscribeUrl = Router::GetInstance()->GenerateUrl(self::ROUTE_NAMESPACE.'.unsubscribe_from_channel', [], true); + $aInputSetOptions = []; + $aTableRows = []; + foreach ($oActionsNotificationsByTrigger as $iTriggerId => $aActionsNotifications) { + $oTrigger = MetaModel::GetObject('Trigger', $iTriggerId, false); + if ($oTrigger === null) { + continue; + } + $sTriggerSubscriptionPolicy = $oTrigger->Get('subscription_policy'); + $aChannels = []; + $aSetValues = []; + // Create a channel for each action notification class and add it to the channels array + foreach ($aActionsNotifications as $oActionNotification) { + $sNotificationClass = get_class($oActionNotification); + // Create a new channel if it doesn't exist for the current action notification class + if (!array_key_exists($sNotificationClass, $aChannels)) { + $aChannels[$sNotificationClass] = [ + 'friendlyname' => MetaModel::GetName($sNotificationClass), + 'has_additional_field' => true, + 'additional_field' => '', + 'value' => $oTrigger->GetKey().'|'.$sNotificationClass, + 'has_image' => true, + 'picture_url' => 'url("'.MetaModel::GetClassIcon($sNotificationClass, false).'")', + 'subscribed' => $aSubscribedActionsNotificationsByTrigger[$iTriggerId][$oActionNotification->GetKey()] === true, + 'status' => [$oActionNotification->Get('status') => 1], + 'class' => 'ibo-is-not-medallion', + 'total' => 1, + 'total_subscribed' => $aSubscribedActionsNotificationsByTrigger[$iTriggerId][$oActionNotification->GetKey()] === true ? 1 : 0, + 'mixed' => false, + ]; + } else { + // Check if all actions from the same type are subscribed or not + if (($aSubscribedActionsNotificationsByTrigger[$iTriggerId][$oActionNotification->GetKey()] === true) !== $aChannels[$sNotificationClass]['subscribed']) { + $aChannels[$sNotificationClass]['subscribed'] = 'mixed'; + } + $aChannels[$sNotificationClass]['total']++; + $aChannels[$sNotificationClass]['total_subscribed'] += $aSubscribedActionsNotificationsByTrigger[$iTriggerId][$oActionNotification->GetKey()] === true ? 1 : 0; + // Count the number of actions with the same status + if (isset($aChannels[$sNotificationClass]['status'][$oActionNotification->Get('status')])) { + $aChannels[$sNotificationClass]['status'][$oActionNotification->Get('status')]++; + } else { + $aChannels[$sNotificationClass]['status'][$oActionNotification->Get('status')] = 1; + } + } + } + foreach ($aChannels as $sNotificationClass => $aChannel) { + // Define if all actions from the same type are subscribed or not + if ($aChannel['subscribed'] === 'mixed') { + $aSetValues[] = $aChannel['value']; + $aChannels[$sNotificationClass]['mixed'] = true; + $aChannels[$sNotificationClass]['mixed_value'] = Dict::Format('UI:NotificationsCenter:Channel:OutOf:Text', $aChannel['total_subscribed'], $aChannel['total']); + } else if ($aChannel['subscribed'] === true) { + $aSetValues[] = $aChannel['value']; + } + + // Explode status array into a readable string + $aChannels[$sNotificationClass]['additional_field'] = implode(', ', array_map(function($iCount, $sStatus) use($sNotificationClass) { + return $iCount.' '. MetaModel::GetStateLabel($sNotificationClass, $sStatus); + }, $aChannel['status'], array_keys($aChannel['status']))); + } + // Create a input set for the channels + $oChannelSet = SetUIBlockFactory::MakeForSimple('ibochannel'.$oTrigger->GetKey(), array_values($aChannels), 'friendlyname', 'value', ['friendlyname', 'additional_field'], null, 'additional_field'); + $oChannelSet->SetName('channel-'.$oTrigger->GetKey()); + $oChannelSet->SetInitialValue(json_encode($aSetValues)); + $oChannelSet->SetValue(json_encode($aSetValues)); + $oChannelSet->SetOptionsTemplate('application/object/set/option_renderer.html.twig'); + $oChannelSet->SetItemsTemplate('application/preferences/notification-center/item_renderer.html.twig'); + // Disable the input set if the subscription policy is 'force_all_channels' + if($sTriggerSubscriptionPolicy === 'force_all_channels'){ + $oChannelSet->SetIsDisabled(true); + } + // Add a CSRF Token + $sCSRFToken = utils::GetNewTransactionId(); + $oChannelSet->SetOnItemAddJs( + <<SetMinItems(1); + } + $oChannelSet->SetOnItemRemoveJs( + << $oTrigger->Get('description'), + 'channels' => $oBlockRenderer->RenderHtml(), + 'js' => $oBlockRenderer->RenderJsInline($oChannelSet::ENUM_JS_TYPE_ON_READY), + ]; + } + $oNotificationsCenterTable = DataTableUIBlockFactory::MakeForStaticData('', $oNotificationsCenterTableColumns, $aTableRows, 'ibo-notifications-center--datatable', ['surround_with_panel' => true]); + $oNotificationsPanel->AddSubBlock($oNotificationsCenterTable); + + // Add input js on each page draw so when it's refreshed we keep js interactivity + foreach ($aTableRows as $aAtt) { + $sJS = $aAtt['js']; + $oPage->add_ready_script( + <<add_linked_script(utils::GetAbsoluteUrlAppRoot().$sJsFile); + } + + $oPage->AddSubBlock($oNotificationsPanel); + + return $oPage; + } + + public function OperationDisplayAdvancedPage(){ + $oPage = new iTopWebPage(Dict::S('UI:NotificationsCenter:Page:Title')); + // Create a panel that will contain the table + $oNotificationsPanel = new Panel(Dict::S('UI:NotificationsCenter:Panel:Title'), array(), 'grey', 'ibo-notifications-center'); + $oSubtitleBlock = new UIContentBlock(null, ['ibo-notifications-center--sub-title']); + $sDisplayAdvancedPageUrl = Router::GetInstance()->GenerateUrl(self::ROUTE_NAMESPACE.'.display_page', [], true); + $oSubtitleBlock->AddSubBlock(new Html(Dict::Format('UI:NotificationsCenter:Panel:Advanced:SubTitle', $sDisplayAdvancedPageUrl))); + $oNotificationsPanel->SetSubTitleBlock($oSubtitleBlock); + $oPage->AddUiBlock($oNotificationsPanel); + + // Get all subscribed/unsubscribed actions notifications for the current user + $oLnkNotificationsSet = NotificationsRepository::GetInstance()->SearchSubscriptionByContact(\UserRights::GetContactId()); + $oActionsNotificationsByTrigger = []; + $aSubscribedActionsNotificationsByTrigger = []; + while ($oLnkActionsNotifications = $oLnkNotificationsSet->Fetch()) { + $oSubscribedActionNotification = MetaModel::GetObject(ActionNotification::class, $oLnkActionsNotifications->Get('action_id')); + $oTrigger = MetaModel::GetObject('Trigger', $oLnkActionsNotifications->Get('trigger_id')); + $iTriggerId = $oTrigger->GetKey(); + // Create a new array for the trigger if it doesn't exist + if (!isset($oActionsNotificationsByTrigger[$iTriggerId])) { + $oActionsNotificationsByTrigger[$iTriggerId] = []; + $aSubscribedActionsNotificationsByTrigger[$iTriggerId] = []; + } + // Add the action notification to the list of actions notifications for the trigger + $oActionsNotificationsByTrigger[$iTriggerId][] = $oSubscribedActionNotification; + // Add the subscribed status to the list of subscribed actions notifications for the trigger + $aSubscribedActionsNotificationsByTrigger[$iTriggerId][$oSubscribedActionNotification->GetKey()] = $oLnkActionsNotifications->Get('subscribed') || $oTrigger->Get('subscription_policy') === 'force_all_channels'; + } + + $oPage->AddTabContainer('NotificationsCenter', '', $oNotificationsPanel); + $oPage->SetCurrentTabContainer('NotificationsCenter'); + // Create a new tab for each trigger + foreach ($oActionsNotificationsByTrigger as $iTriggerId => $aActionsNotifications) { + $oTrigger = MetaModel::GetObject('Trigger', $iTriggerId, false); + if ($oTrigger === null) { + continue; + } + foreach ($aActionsNotifications as $oActionNotification) { + $oPage->SetCurrentTab(MetaModel::GetName(get_class($oActionNotification))); + $oCheckBox = InputUIBlockFactory::MakeForInputWithLabel( + Dict::Format('UI:NotificationsCenter:Advanced:Input:Label', $oTrigger->Get('description'), $oActionNotification->Get('name')), + $oTrigger->GetKey().'|'.$oActionNotification->GetKey(), + "", + $oTrigger->GetKey().'|'.$oActionNotification->GetKey(), + "checkbox" + ); + $oCheckBox->GetInput()->SetIsChecked($aSubscribedActionsNotificationsByTrigger[$iTriggerId][$oActionNotification->GetKey()] === true); + $oCheckBox->SetBeforeInput(false); + $oCheckBox->GetInput()->AddCSSClass('ibo-input--label-right'); + $oCheckBox->GetInput()->AddCSSClass('ibo-input-checkbox'); + $oContainer = new UIContentBlock(null, ['ibo-notifications-center-advanced--input--container']); + $oContainer->AddSubBlock($oCheckBox); + $oPage->AddUiBlock($oContainer); + } + } + $sSubscribeUrl = Router::GetInstance()->GenerateUrl(self::ROUTE_NAMESPACE.'.subscribe', [], true); + $sUnsubscribeUrl = Router::GetInstance()->GenerateUrl(self::ROUTE_NAMESPACE.'.unsubscribe', [], true); + $sCSRFToken = utils::GetNewTransactionId(); + $oPage->add_ready_script( +<<CheckPostedCSRF()) { + throw new \Exception('Invalid token'); + } + + $sChannel = utils::ReadParam('channel', '', true, 'raw_data'); + $aChannel = explode('|', $sChannel); + $oPage = new \JsonPage(); + $aReturnData = []; + try { + if (count($aChannel) !== 2) { + throw new \Exception('Invalid channel'); + } + $iTriggerKey = $aChannel[0]; + $iActionNotificationKey = $aChannel[1]; + $oTrigger = MetaModel::GetObject('Trigger', $iTriggerKey, false); + if ($oTrigger === null) { + throw new \Exception('Invalid trigger'); + } + $oActionNotification = MetaModel::GetObject('ActionNotification', $iActionNotificationKey, false); + if ($oActionNotification === null) { + throw new \Exception('Invalid action notification'); + } + $oSubscribedActionsNotificationsSearch = \DBObjectSearch::FromOQL("SELECT lnkActionNotificationToContact AS lnk WHERE lnk.action_id = :actionnotification_id AND lnk.contact_id = :contact_id AND lnk.trigger_id = :trigger_id AND lnk.subscribed = '1'"); + $oSubscribedActionsNotificationsSet = new \DBObjectSet($oSubscribedActionsNotificationsSearch, array(), array('actionnotification_id' => $iActionNotificationKey, 'contact_id' => \UserRights::GetContactId(), 'trigger_id' => $iTriggerKey)); + if ($oSubscribedActionsNotificationsSet->Count() === 0) { + throw new \Exception('You are not subscribed to this channel'); + } + while ($oSubscribedActionsNotifications = $oSubscribedActionsNotificationsSet->Fetch()) { + $oSubscribedActionsNotifications->Set('subscribed', false); + $oSubscribedActionsNotifications->DBUpdate(); + } + $aReturnData = [ + 'status' => 'success', + 'message' => Dict::S('UI:NotificationsCenter:Unsubscribe:Success'), + ]; + } + catch (Exception $e) { + $aReturnData = [ + 'status' => 'error', + 'message' => $e->getMessage(), + ]; + } + $oPage->SetData($aReturnData); + $oPage->SetOutputDataOnly(true); + + return $oPage; + } + + function OperationSubscribe() + { + + // Get the CSRF token from the request and check if it's valid + if (!$this->CheckPostedCSRF()) { + throw new \Exception('Invalid token'); + } + + $sChannel = utils::ReadParam('channel', '', true, 'raw_data'); + $aChannel = explode('|', $sChannel); + + $oPage = new \JsonPage(); + $aReturnData = []; + try { + if (count($aChannel) !== 2) { + throw new \Exception('Invalid channel'); + } + $iTriggerKey = $aChannel[0]; + $iActionNotificationKey = $aChannel[1]; + $oTrigger = MetaModel::GetObject('Trigger', $iTriggerKey, false); + if ($oTrigger === null) { + throw new \Exception('Invalid trigger'); + } + $oActionNotification = MetaModel::GetObject('ActionNotification', $iActionNotificationKey, false); + if ($oActionNotification === null) { + throw new \Exception('Invalid action notification'); + } + $oSubscribedActionsNotificationsSearch = \DBObjectSearch::FromOQL("SELECT lnkActionNotificationToContact AS lnk WHERE lnk.action_id = :actionnotification_id AND lnk.contact_id = :contact_id AND lnk.trigger_id = :trigger_id AND lnk.subscribed = '0'"); + $oSubscribedActionsNotificationsSet = new \DBObjectSet($oSubscribedActionsNotificationsSearch, array(), array('actionnotification_id' => $iActionNotificationKey, 'contact_id' => \UserRights::GetContactId(), 'trigger_id' => $iTriggerKey)); + if ($oSubscribedActionsNotificationsSet->Count() === 0) { + throw new \Exception('You are not allow to subscribe to this channel'); + } + while ($oSubscribedActionsNotifications = $oSubscribedActionsNotificationsSet->Fetch()) { + $oSubscribedActionsNotifications->Set('subscribed', true); + $oSubscribedActionsNotifications->DBUpdate(); + } + $aReturnData = [ + 'status' => 'success', + 'message' => Dict::S('UI:NotificationsCenter:Subscribe:Success'), + ]; + } + catch (Exception $e) { + $aReturnData = [ + 'status' => 'error', + 'message' => $e->getMessage(), + ]; + } + $oPage->SetData($aReturnData); + $oPage->SetOutputDataOnly(true); + + return $oPage; + } + + /** + * @return \JsonPage + * @throws \Exception + */ + function OperationUnsubscribeFromChannel(): \JsonPage + { + // Get the CSRF token from the request and check if it's valid + if (!$this->CheckPostedCSRF()) { + throw new \Exception('Invalid token'); + } + + // Get the channel from the request + $sChannel = utils::ReadParam('channel', '', true, 'raw_data'); + $aChannel = explode('|', $sChannel); + + $oPage = new \JsonPage(); + $aReturnData = []; + try { + if (count($aChannel) !== 2) { + throw new \Exception('Invalid channel'); + } + [$iTriggerId, $sFinalclass] = $aChannel; + $oTrigger = MetaModel::GetObject('Trigger', $iTriggerId, false); + if ($oTrigger === null) { + throw new \Exception('Invalid trigger'); + } + // Check the trigger subscription policy + if($oTrigger->Get('subscription_policy') === 'force_all_channels'){ + throw new \Exception('You are not allowed to unsubscribe from this channel'); + } + + // Check if we are subscribed to at least 1 channel + $oSubscribedActionsNotificationsSet = NotificationsRepository::GetInstance()->SearchSubscriptionByTriggerContactSubscriptionAndFinalclass($iTriggerId, \UserRights::GetContactId(), '1', $sFinalclass); + if ($oSubscribedActionsNotificationsSet->Count() === 0) { + throw new \Exception('You are not subscribed to any channel'); + } + // Check the trigger subscription policy and if we are subscribed to at least 1 channel if necessary + if($oTrigger->Get('subscription_policy') === 'force_at_least_one_channel') { + $oTotalSubscribedActionsNotificationsSet = NotificationsRepository::GetInstance()->SearchSubscriptionByTriggerContactAndSubscription($iTriggerId, \UserRights::GetContactId(), '1'); + if (($oTotalSubscribedActionsNotificationsSet->Count() - $oSubscribedActionsNotificationsSet->Count()) === 0) { + throw new \Exception('You can\'t unsubscribe from this channel, you must be subscribed to at least one channel'); + } + } + // Unsubscribe from all channels + while ($oSubscribedActionsNotifications = $oSubscribedActionsNotificationsSet->Fetch()) { + $oSubscribedActionsNotifications->Set('subscribed', false); + $oSubscribedActionsNotifications->DBUpdate(); + } + $aReturnData = [ + 'status' => 'success', + 'message' => Dict::S('UI:NotificationsCenter:Unsubscribe:Success'), + ]; + } + catch (Exception $e) { + // Return an error message if an exception is thrown + $aReturnData = [ + 'status' => 'error', + 'message' => $e->getMessage(), + ]; + } + $oPage->SetData($aReturnData); + $oPage->SetOutputDataOnly(true); + + return $oPage; + } + + /** + * @return \JsonPage + * @throws \Exception + */ + function OperationSubscribeToChannel(): \JsonPage + { + // Get the CSRF token from the request and check if it's valid + if (!$this->CheckPostedCSRF()) { + throw new \Exception('Invalid token'); + } + + // Get the channel from the request + $sChannel = utils::ReadParam('channel', '', true, 'raw_data'); + $aChannel = explode('|', $sChannel); + + $oPage = new \JsonPage(); + $aReturnData = []; + try { + if (count($aChannel) !== 2) { + throw new \Exception('Invalid channel'); + } + [$iTriggerId, $sFinalclass] = $aChannel; + $oTrigger = MetaModel::GetObject('Trigger', $iTriggerId, false); + if ($oTrigger === null) { + throw new \Exception('Invalid trigger'); + } + $oSubscribedActionsNotificationsSet = NotificationsRepository::GetInstance()->SearchSubscriptionByTriggerContactSubscriptionAndFinalclass($iTriggerId, \UserRights::GetContactId(), '0', $sFinalclass); + if ($oSubscribedActionsNotificationsSet->Count() === 0) { + throw new \Exception('You are not subscribed to any channel'); + } + // Subscribe to all channels + while ($oSubscribedActionsNotifications = $oSubscribedActionsNotificationsSet->Fetch()) { + $oSubscribedActionsNotifications->Set('subscribed', true); + $oSubscribedActionsNotifications->DBUpdate(); + } + $aReturnData = [ + 'status' => 'success', + 'message' => Dict::S('UI:NotificationsCenter:Subscribe:Success'), + ]; + } + catch (Exception $e) { + // Return an error message if an exception is thrown + $aReturnData = [ + 'status' => 'error', + 'message' => $e->getMessage(), + ]; + } + $oPage->SetData($aReturnData); + $oPage->SetOutputDataOnly(true); + + return $oPage; + } +} \ No newline at end of file diff --git a/sources/Service/Notification/NotificationsRepository.php b/sources/Service/Notification/NotificationsRepository.php new file mode 100644 index 000000000..fed5a8eee --- /dev/null +++ b/sources/Service/Notification/NotificationsRepository.php @@ -0,0 +1,120 @@ + + * @package Combodo\iTop\Service\Notification + * @since 3.2.0 + */ +class NotificationsRepository +{ + /** @var NotificationsRepository|null Singleton */ + protected static ?NotificationsRepository $oSingleton = null; + + /** + * @api + * @return static The singleton instance of the notifications repository + */ + public static function GetInstance(): static + { + if (is_null(self::$oSingleton)) { + self::$oSingleton = new static(); + } + + return self::$oSingleton; + } + + /**********************/ + /* Non-static methods */ + /**********************/ + + /** + * Singleton pattern, can't use the constructor. Use {@see \Combodo\iTop\Service\Notification\NotificationsRepository::GetInstance()} instead. + * + * @return void + */ + protected function __construct() + { + // Don't do anything, we don't want to be initialized + } + + /** + * Search for subscriptions by contact ID. + * + * @param int $iContactId The ID of the contact. + * + * @return DBObjectSet The result set of subscriptions associated with the contact. + */ + public function SearchSubscriptionByContact(int $iContactId): DBObjectSet + { + $oSearch = DBObjectSearch::FromOQL("SELECT lnkActionNotificationToContact AS lnk WHERE lnk.contact_id = :contact_id"); + $oSet = new DBObjectSet($oSearch, array(), array('contact_id' => $iContactId)); + + return $oSet; + } + + /** + * Searches for a subscription by trigger, contact, and action. + * + * @param int $iTriggerId The ID of the trigger. + * @param int $iContactId The ID of the contact. + * @param int $iActionID The ID of the action. + * + * @return DBObjectSet The set of subscriptions matching the given trigger, contact, and action. + */ + public function SearchSubscriptionByTriggerContactAndAction(int $iTriggerId, int $iContactId, int $iActionID): DBObjectSet + { + $oSearch = DBObjectSearch::FromOQL("SELECT lnkActionNotificationToContact AS lnk WHERE lnk.contact_id = :contact_id AND lnk.trigger_id = :trigger_id AND lnk.action_id = :action_id"); + $oSet = new DBObjectSet($oSearch, array(), array('trigger_id' => $iTriggerId, 'contact_id' => $iContactId, 'action_id' => $iActionID)); + + return $oSet; + } + + /** + * Search for subscriptions based on trigger, contact, and subscription type. + * + * @param int $iTriggerId The ID of the trigger. + * @param int $iContactId The ID of the contact. + * @param string $sSubscription The subscription type. + * + * @return DBObjectSet A set of subscription objects matching the given parameters. + */ + public function SearchSubscriptionByTriggerContactAndSubscription(int $iTriggerId, int $iContactId, string $sSubscription): DBObjectSet + { + $oSearch = DBObjectSearch::FromOQL("SELECT lnkActionNotificationToContact AS lnk WHERE lnk.contact_id = :contact_id AND lnk.trigger_id = :trigger_id AND lnk.subscribed = :subscription"); + $oSet = new DBObjectSet($oSearch, array(), array('trigger_id' => $iTriggerId, 'contact_id' => $iContactId, 'subscription' => $sSubscription)); + + return $oSet; + } + + + /** + * Search for subscriptions based on trigger, contact, subscription type, and final class. + * + * @param int $iTriggerId The ID of the trigger. + * @param int $iContactId The ID of the contact. + * @param int $sSubscription The subscription type. + * @param string $sFinalclass The final class of the action notification. + * + * @return DBObjectSet A set of subscription objects matching the given parameters. + */ + public function SearchSubscriptionByTriggerContactSubscriptionAndFinalclass(int $iTriggerId, int $iContactId, int $sSubscription, string $sFinalclass): DBObjectSet + { + $oSearch = DBObjectSearch::FromOQL("SELECT lnkActionNotificationToContact AS lnk JOIN ActionNotification AS an ON lnk.action_id = an.id WHERE lnk.contact_id = :contact_id AND lnk.trigger_id = :trigger_id AND lnk.subscribed = :subscription AND an.finalclass = :finalclass"); + $oSet = new DBObjectSet($oSearch, array(), array('trigger_id' => $iTriggerId, 'contact_id' => $iContactId, 'subscription' => $sSubscription, 'finalclass' => $sFinalclass)); + + return $oSet; + } + + public function GetSearchOQLContactUnsubscribedByTriggerAndAction(): DBObjectSearch + { + $oSearch = DBObjectSearch::FromOQL("SELECT Contact AS c JOIN lnkActionNotificationToContact AS lnk ON lnk.contact_id = c.id WHERE lnk.trigger_id = :trigger_id AND lnk.action_id = :action_id AND lnk.subscribed = '0'"); + return $oSearch; + } +} \ No newline at end of file diff --git a/sources/Service/Notification/NotificationsService.php b/sources/Service/Notification/NotificationsService.php new file mode 100644 index 000000000..d0caf209f --- /dev/null +++ b/sources/Service/Notification/NotificationsService.php @@ -0,0 +1,116 @@ + + * @package Combodo\iTop\Service\Notification + * @since 3.2.0 + */ +class NotificationsService { + protected static ?NotificationsService $oSingleton = null; + + /** + * @api + * @return static The singleton instance of the notifications service + */ + public static function GetInstance(): static + { + if (null === static::$oSingleton) { + static::$oSingleton = new static(); + } + + return static::$oSingleton; + } + + /**********************/ + /* Non-static methods */ + /**********************/ + + /** + * Singleton pattern, can't use the constructor. Use {@see \Combodo\iTop\Service\Notification\NotificationsService::GetInstance()} instead. + * + * @return void + */ + protected function __construct() { + // Don't do anything, we don't want to be initialized + } + + /** + * Register that $oRecipient was a recipient for the $oTrigger / $oActionNotification tuple at least one time + * + * @param \Trigger $oTrigger + * @param \ActionNotification $oActionNotification + * @param \Contact $oRecipient + * + * @return void + * @throws \ArchivedObjectException + * @throws \CoreCannotSaveObjectException + * @throws \CoreException + * @throws \CoreUnexpectedValue + * @throws \CoreWarning + * @throws \MissingQueryArgument + * @throws \MySQLException + * @throws \MySQLHasGoneAwayException + * @throws \OQLException + */ + public function RegisterSubscription(Trigger $oTrigger, ActionNotification $oActionNotification, Contact $oRecipient): void + { + // Check if the user is already subscribed to the action notification + $oSubscribedActionsNotificationsSet = NotificationsRepository::GetInstance()->SearchSubscriptionByTriggerContactAndAction($oTrigger->GetKey(), $oRecipient->GetKey(), $oActionNotification->GetKey()); + if ($oSubscribedActionsNotificationsSet->Count() === 0) { + // Create a new subscription + $oSubscribedActionsNotifications = new lnkActionNotificationToContact(); + $oSubscribedActionsNotifications->Set('action_id', $oActionNotification->GetKey()); + $oSubscribedActionsNotifications->Set('contact_id', $oRecipient->GetKey()); + $oSubscribedActionsNotifications->Set('trigger_id', $oTrigger->GetKey()); + $oSubscribedActionsNotifications->Set('subscribed', true); + $oSubscribedActionsNotifications->DBInsertNoReload(); + } + else { + while ($oSubscribedActionsNotifications = $oSubscribedActionsNotificationsSet->Fetch()) { + // Update the subscription + $oSubscribedActionsNotifications->Set('subscribed', true); + $oSubscribedActionsNotifications->DBUpdate(); + } + } + } + + /** + * @param \Trigger $oTrigger + * @param \ActionNotification $oActionNotification + * @param \Contact $oRecipient + * + * @return bool + * @throws \ArchivedObjectException + * @throws \CoreException + * @throws \CoreUnexpectedValue + * @throws \MissingQueryArgument + * @throws \MySQLException + * @throws \MySQLHasGoneAwayException + */ + public function IsSubscribed(Trigger $oTrigger, ActionNotification $oActionNotification, Contact $oRecipient): bool + { + // Check if the trigger subscription policy is 'force_all_channels' + if ($oTrigger->Get('subscription_policy') === 'force_all_channels') { + return true; + } + // Check if the user is already subscribed to the action notification + $oSubscribedActionsNotificationsSet = NotificationsRepository::GetInstance()->SearchSubscriptionByTriggerContactAndAction($oTrigger->GetKey(), $oRecipient->GetKey(), $oActionNotification->GetKey()); + if ($oSubscribedActionsNotificationsSet->Count() === 0) { + return false; + } + + // Return the subscribed status + $oSubscribedActionsNotifications = $oSubscribedActionsNotificationsSet->Fetch(); + return $oSubscribedActionsNotifications->Get('subscribed'); + } + +} \ No newline at end of file diff --git a/templates/application/object/set/option_renderer.html.twig b/templates/application/object/set/option_renderer.html.twig index 3b873c7c9..7d244ffa6 100644 --- a/templates/application/object/set/option_renderer.html.twig +++ b/templates/application/object/set/option_renderer.html.twig @@ -1,7 +1,7 @@
{# Image #} - + {# Desc #} diff --git a/templates/application/preferences/notification-center/item_renderer.html.twig b/templates/application/preferences/notification-center/item_renderer.html.twig new file mode 100644 index 000000000..13c45ff5b --- /dev/null +++ b/templates/application/preferences/notification-center/item_renderer.html.twig @@ -0,0 +1,21 @@ +
+ + {# Image #} + + + {# Desc #} + + + {# Friendly name #} + + + {# Additional content #} + {% block additional_content %} + {% endblock %} + + + + + + +
\ No newline at end of file diff --git a/templates/base/components/input/set/layout.html.twig b/templates/base/components/input/set/layout.html.twig index 9d658c23e..9e8eb2690 100644 --- a/templates/base/components/input/set/layout.html.twig +++ b/templates/base/components/input/set/layout.html.twig @@ -7,6 +7,9 @@ name="{{ oUIBlock.GetName() }}" multiple style="display: none;" + {% if oUIBlock.IsDisabled() == true %} + disabled + {% endif %} > diff --git a/templates/base/components/input/set/layout.ready.js.twig b/templates/base/components/input/set/layout.ready.js.twig index 530bcc7c2..b93829107 100644 --- a/templates/base/components/input/set/layout.ready.js.twig +++ b/templates/base/components/input/set/layout.ready.js.twig @@ -49,6 +49,14 @@ let oWidget{{ oUIBlock.GetId() }} = $('#{{ oUIBlock.GetId() }}').selectize({ {% if oUIBlock.HasRemoveItemButton() %} 'remove_button' : {}, {% endif %} + + {# PLUGIN min items #} + {% if oUIBlock.GetMinItems() is not empty %} + 'combodo_min_items' : { + minItems: {{ oUIBlock.GetMinItems() }}, + errorMessage: '{{ 'UI:Component:Input:Set:MinimumItems'|dict_format(oUIBlock.GetMinItems()) }}' + }, + {% endif %} }, {# Max items you can select #} @@ -197,6 +205,31 @@ let oWidget{{ oUIBlock.GetId() }} = $('#{{ oUIBlock.GetId() }}').selectize({ 'data-tooltip-html-enabled': true }); CombodoTooltip.InitTooltipFromMarkup($item); + + {% if oUIBlock.GetOnItemAddJs() is not null %} + {{ oUIBlock.GetOnItemAddJs()|raw }} + {% endif %} + }, + + {# On item remove #} + onItemRemove: function(value, $item){ + {% if oUIBlock.GetOnItemRemoveJs() is not null %} + {{ oUIBlock.GetOnItemRemoveJs()|raw }} + {% endif %} + }, + + {# On option remove #} + onOptionRemove: function(value, $item){ + {% if oUIBlock.GetOnOptionRemoveJs() is not null %} + {{ oUIBlock.GetOnOptionRemoveJs()|raw }} + {% endif %} + }, + + {# On option add #} + onOptionAdd: function(value, $item){ + {% if oUIBlock.GetOnOptionAddJs() is not null %} + {{ oUIBlock.GetOnOptionAddJs()|raw }} + {% endif %} }, {# plugin combodo_add_button #} diff --git a/templates/base/components/input/set/regular_option_renderer.html.twig b/templates/base/components/input/set/regular_option_renderer.html.twig new file mode 100644 index 000000000..64d2e7754 --- /dev/null +++ b/templates/base/components/input/set/regular_option_renderer.html.twig @@ -0,0 +1,17 @@ +{# @copyright Copyright (C) 2010-2023 Combodo SARL #} +{# @license http://opensource.org/licenses/AGPL-3.0 #} + +
+ +
+ + + + + + + + +
+ +
\ No newline at end of file diff --git a/webservices/cron.php b/webservices/cron.php index 7c0b98014..b27f80912 100644 --- a/webservices/cron.php +++ b/webservices/cron.php @@ -44,12 +44,6 @@ if (!file_exists($sConfigFile)) require_once(APPROOT.'/application/startup.inc.php'); -// Temporary fix until below bug is resolved properly: -// N°7008 - Fix missing background tasks in CRON when NOT in "developer_mode" -require_once(APPROOT.'/sources/SessionTracker/SessionGC.php'); -require_once(APPROOT.'/sources/Service/TemporaryObjects/TemporaryObjectGC.php'); -require_once(APPROOT.'/sources/Service/Notification/Event/EventiTopNotificationGC.php'); - $oCtx = new ContextTag(ContextTag::TAG_CRON); function ReadMandatoryParam($oP, $sParam, $sSanitizationFilter = 'parameter') @@ -414,71 +408,63 @@ function ReSyncProcesses($oP, $bVerbose, $bDebug) $oNow = new DateTime(); $aProcesses = array(); - foreach (get_declared_classes() as $sTaskClass) + foreach (utils::GetClassesForInterface('iProcess', '', ['[\\\\/]lib[\\\\/]', '[\\\\/]node_modules[\\\\/]', '[\\\\/]test[\\\\/]', '[\\\\/]tests[\\\\/]']) as $sTaskClass) { - $oRefClass = new ReflectionClass($sTaskClass); - if ($oRefClass->isAbstract()) - { - continue; - } - if ($oRefClass->implementsInterface('iProcess')) - { - $oProcess = new $sTaskClass; - $aProcesses[$sTaskClass] = $oProcess; + $oProcess = new $sTaskClass; + $aProcesses[$sTaskClass] = $oProcess; - // Create missing entry if needed - if (!array_key_exists($sTaskClass, $aTasks)) + // Create missing entry if needed + if (!array_key_exists($sTaskClass, $aTasks)) + { + // New entry, let's create a new BackgroundTask record, and plan the first execution + $oTask = new BackgroundTask(); + $oTask->SetDebug($bDebug); + $oTask->Set('class_name', $sTaskClass); + $oTask->Set('total_exec_count', 0); + $oTask->Set('min_run_duration', 99999.999); + $oTask->Set('max_run_duration', 0); + $oTask->Set('average_run_duration', 0); + $oRefClass = new ReflectionClass($sTaskClass); + if ($oRefClass->implementsInterface('iScheduledProcess')) { - // New entry, let's create a new BackgroundTask record, and plan the first execution - $oTask = new BackgroundTask(); - $oTask->SetDebug($bDebug); - $oTask->Set('class_name', $sTaskClass); - $oTask->Set('total_exec_count', 0); - $oTask->Set('min_run_duration', 99999.999); - $oTask->Set('max_run_duration', 0); - $oTask->Set('average_run_duration', 0); + $oNextOcc = $oProcess->GetNextOccurrence(); + $oTask->Set('next_run_date', $oNextOcc->format('Y-m-d H:i:s')); + } + else + { + // Background processes do start asap, i.e. "now" + $oTask->Set('next_run_date', $oNow->format('Y-m-d H:i:s')); + } + if ($bVerbose) + { + $oP->p('Creating record for: '.$sTaskClass); + $oP->p('First execution planned at: '.$oTask->Get('next_run_date')); + } + $oTask->DBInsert(); + } + else + { + /** @var \BackgroundTask $oTask */ + $oTask = $aTasks[$sTaskClass]; + if ($oTask->Get('next_run_date') == '3000-01-01 00:00:00') + { + // check for rescheduled tasks $oRefClass = new ReflectionClass($sTaskClass); if ($oRefClass->implementsInterface('iScheduledProcess')) { $oNextOcc = $oProcess->GetNextOccurrence(); $oTask->Set('next_run_date', $oNextOcc->format('Y-m-d H:i:s')); - } - else - { - // Background processes do start asap, i.e. "now" - $oTask->Set('next_run_date', $oNow->format('Y-m-d H:i:s')); - } - if ($bVerbose) - { - $oP->p('Creating record for: '.$sTaskClass); - $oP->p('First execution planned at: '.$oTask->Get('next_run_date')); - } - $oTask->DBInsert(); - } - else - { - /** @var \BackgroundTask $oTask */ - $oTask = $aTasks[$sTaskClass]; - if ($oTask->Get('next_run_date') == '3000-01-01 00:00:00') - { - // check for rescheduled tasks - $oRefClass = new ReflectionClass($sTaskClass); - if ($oRefClass->implementsInterface('iScheduledProcess')) - { - $oNextOcc = $oProcess->GetNextOccurrence(); - $oTask->Set('next_run_date', $oNextOcc->format('Y-m-d H:i:s')); - $oTask->DBUpdate(); - } - } - // Reactivate task if necessary - if ($oTask->Get('status') == 'removed') - { - $oTask->Set('status', 'active'); $oTask->DBUpdate(); } - // task having a real class to execute - unset($aTasks[$sTaskClass]); } + // Reactivate task if necessary + if ($oTask->Get('status') == 'removed') + { + $oTask->Set('status', 'active'); + $oTask->DBUpdate(); + } + // task having a real class to execute + unset($aTasks[$sTaskClass]); } }