Merge remote-tracking branch 'origin/support/3.2' into develop

This commit is contained in:
Molkobain
2024-02-19 10:00:21 +01:00
33 changed files with 1377 additions and 88 deletions

View File

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

View File

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

View File

@@ -1,6 +1,97 @@
<?xml version="1.0" encoding="UTF-8"?>
<itop_design xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="3.2">
<classes>
<class id="lnkActionNotificationToContact" _delta="define">
<parent>cmdbAbstractObject</parent>
<properties>
<category>core/cmdb,application</category>
<abstract>false</abstract>
<key_type>autoincrement</key_type>
<db_table>priv_lnk_action_notif_to_contact</db_table>
<db_key_field>id</db_key_field>
<db_final_class_field/>
<naming>
<attributes>
<attribute id="action_id"/>
<attribute id="contact_id"/>
</attributes>
</naming>
<uniqueness_rules>
<rule>
<attributes>
<attribute id="action_id"/>
<attribute id="contact_id"/>
<attribute id="trigger_id"/>
</attributes>
<filter/>
<disabled>false</disabled>
<is_blocking>true</is_blocking>
</rule>
</uniqueness_rules>
</properties>
<fields>
<field id="action_id" xsi:type="AttributeExternalKey">
<sql>action_id</sql>
<target_class>ActionNotification</target_class>
<default_value/>
<is_null_allowed>false</is_null_allowed>
</field>
<field id="contact_id" xsi:type="AttributeExternalKey">
<sql>contact_id</sql>
<target_class>Contact</target_class>
<default_value/>
<is_null_allowed>false</is_null_allowed>
</field>
<field id="trigger_id" xsi:type="AttributeExternalKey">
<sql>trigger_id</sql>
<target_class>Trigger</target_class>
<default_value/>
<is_null_allowed>false</is_null_allowed>
</field>
<field id="subscribed" xsi:type="AttributeBoolean">
<sql>subscribed</sql>
<default_value>true</default_value>
<is_null_allowed>false</is_null_allowed>
</field>
</fields>
<presentation>
<details>
<items>
<item id="col:col1">
<items>
<item id="fieldset:lnkActionNotificationToContact:content">
<items>
<item id="action_id">
<rank>10</rank>
</item>
<item id="contact_id">
<rank>20</rank>
</item>
<item id="title">
<rank>30</rank>
</item>
</items>
</item>
</items>
</item>
</items>
</details>
<list>
<items>
<item id="action_id">
<rank>10</rank>
</item>
<item id="contact_id">
<rank>20</rank>
</item>
<item id="title">
<rank>30</rank>
</item>
</items>
</list>
</presentation>
<methods/>
</class>
<class id="ActioniTopNotification" _delta="define">
<php_parent>
<name>ActionNotification</name>
@@ -18,6 +109,9 @@
<attribute id="title"/>
</attributes>
</naming>
<style>
<icon>../../images/icons/icons8-notification.svg</icon>
</style>
</properties>
<fields>
<field id="title" xsi:type="AttributeString">
@@ -94,6 +188,13 @@
</item>
</items>
</item>
<item id="fieldset:ActioniTopNotification:trigger">
<items>
<item id="trigger_list">
<rank>10</rank>
</item>
</items>
</item>
</items>
</item>
<item id="col:col2">
@@ -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);
}

View File

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

View File

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

View File

@@ -15,4 +15,5 @@
@import "global-search";
@import "run-query";
@import "welcome-popup";
@import "oauth.wizard";
@import "oauth.wizard";
@import "notifications-center";

View File

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

View File

@@ -1482,7 +1482,10 @@
</reconciliation>
</properties>
<fields>
<field id="file" xsi:type="AttributeBlob"/>
<field id="file" xsi:type="AttributeBlob">
<sql>file</sql>
<is_null_allowed>true</is_null_allowed>
</field>
</fields>
<methods>
<method id="DisplayBareRelations">

View File

@@ -140,6 +140,10 @@
<on_target_delete>DEL_AUTO</on_target_delete>
</field>
<field id="team_name" xsi:type="AttributeExternalField">
<extkey_attcode>team_id</extkey_attcode>
<target_attcode>name</target_attcode>
</field>
<field id="team_email" xsi:type="AttributeExternalField">
<extkey_attcode>team_id</extkey_attcode>
<target_attcode>email</target_attcode>
</field>

View File

@@ -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',
));
//

View File

@@ -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+' => '',

View File

@@ -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',
));

View File

@@ -0,0 +1,32 @@
<?php
/**
* Copyright (C) 2013-2024 Combodo SARL
*
* This file is part of iTop.
*
* iTop is free software; you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* iTop is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
*/
Dict::Add('EN US', 'English', 'English', array(
'UI:NotificationsCenter:Page:Title' => '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 <a href="%1$s">advanced mode</a>.',
'UI:NotificationsCenter:Panel:Advanced:SubTitle' => 'Manage your notifications subscriptions individually. To manage your subscriptions by type, use the <a href="%1$s">standard mode</a>.',
'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',
));

View File

@@ -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 <a href="%1$s">on this page</a>.',
));

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="48px" height="48px"><linearGradient id="AY6Uvh9Cgiro1p2xH7xe2a" x1="25.633" x2="48.431" y1="17.912" y2="52.033" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#28afea"/><stop offset="1" stop-color="#0b88da"/></linearGradient><path fill="url(#AY6Uvh9Cgiro1p2xH7xe2a)" d="M13.714,25.875L48,12.461v24.664C48,38.161,47.148,39,46.095,39H13.714V25.875z"/><linearGradient id="AY6Uvh9Cgiro1p2xH7xe2b" x1="6.557" x2="39.664" y1="24.411" y2="45.033" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#28afea"/><stop offset="1" stop-color="#0b88da"/></linearGradient><path fill="url(#AY6Uvh9Cgiro1p2xH7xe2b)" d="M8,12.461v24.664C8,38.161,8.852,39,9.905,39h36.19c0.468,0,0.89-0.172,1.222-0.448L8,12.461 z"/><path d="M8,11.813h40v1.586L31.505,26.008c-2.062,1.576-4.948,1.576-7.01,0 L8,13.399V11.813z" opacity=".05"/><path d="M8,11.344h40v1.586L30.825,25.222c-1.678,1.223-3.971,1.223-5.65,0 L8,12.93V11.344z" opacity=".07"/><path fill="#50e6ff" d="M9.905,9h36.19C47.148,9,48,9.839,48,10.875v1.586L30.145,24.437c-1.294,0.868-2.996,0.868-4.29,0 L8,12.461v-1.586C8,9.839,8.852,9,9.905,9z"/><path d="M15.5,26H8v5h7.5c0.827,0,1.5-0.673,1.5-1.5v-2C17,26.673,16.327,26,15.5,26z" opacity=".05"/><path d="M15.5,31H8v5h7.5c0.827,0,1.5-0.673,1.5-1.5v-2C17,31.673,16.327,31,15.5,31z" opacity=".05"/><path d="M15.5,31.5H8v4h7.5c0.552,0,1-0.449,1-1v-2C16.5,31.949,16.052,31.5,15.5,31.5z" opacity=".07"/><path d="M15.5,26.5H8v4h7.5c0.552,0,1-0.449,1-1v-2C16.5,26.949,16.052,26.5,15.5,26.5z" opacity=".07"/><path d="M15.5,21H8v5h7.5c0.827,0,1.5-0.673,1.5-1.5v-2C17,21.673,16.327,21,15.5,21z" opacity=".05"/><linearGradient id="AY6Uvh9Cgiro1p2xH7xe2c" x1="0" x2="16" y1="33.5" y2="33.5" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#5961c3"/><stop offset="1" stop-color="#3a41ac"/></linearGradient><path fill="url(#AY6Uvh9Cgiro1p2xH7xe2c)" d="M15.5,35h-15C0.224,35,0,34.776,0,34.5v-2C0,32.224,0.224,32,0.5,32h15 c0.276,0,0.5,0.224,0.5,0.5v2C16,34.776,15.776,35,15.5,35z"/><path d="M15.5,21.5H8v4h7.5c0.552,0,1-0.449,1-1v-2C16.5,21.949,16.052,21.5,15.5,21.5z" opacity=".07"/><linearGradient id="AY6Uvh9Cgiro1p2xH7xe2d" x1="2" x2="16" y1="28.5" y2="28.5" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#5961c3"/><stop offset="1" stop-color="#3a41ac"/></linearGradient><path fill="url(#AY6Uvh9Cgiro1p2xH7xe2d)" d="M15.5,30h-13C2.224,30,2,29.776,2,29.5v-2C2,27.224,2.224,27,2.5,27h13 c0.276,0,0.5,0.224,0.5,0.5v2C16,29.776,15.776,30,15.5,30z"/><linearGradient id="AY6Uvh9Cgiro1p2xH7xe2e" x1="5" x2="16" y1="23.5" y2="23.5" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#5961c3"/><stop offset="1" stop-color="#3a41ac"/></linearGradient><path fill="url(#AY6Uvh9Cgiro1p2xH7xe2e)" d="M15.5,25h-10C5.224,25,5,24.776,5,24.5v-2C5,22.224,5.224,22,5.5,22h10 c0.276,0,0.5,0.224,0.5,0.5v2C16,24.776,15.776,25,15.5,25z"/></svg>

After

Width:  |  Height:  |  Size: 2.8 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0,0,256,256" width="48px" height="48px"><defs><linearGradient x1="24" y1="1.993" x2="24" y2="7.005" gradientUnits="userSpaceOnUse" id="color-1"><stop offset="0" stop-color="#fede00"></stop><stop offset="1" stop-color="#ffd000"></stop></linearGradient><linearGradient x1="24" y1="33.993" x2="24" y2="39.005" gradientUnits="userSpaceOnUse" id="color-2"><stop offset="0" stop-color="#fede00"></stop><stop offset="1" stop-color="#ffd000"></stop></linearGradient><linearGradient x1="24" y1="42.919" x2="24" y2="38.859" gradientUnits="userSpaceOnUse" id="color-3"><stop offset="0.486" stop-color="#fbc300"></stop><stop offset="1" stop-color="#dbaa00"></stop></linearGradient></defs><g fill="none" fill-rule="nonzero" stroke="none" stroke-width="1" stroke-linecap="butt" stroke-linejoin="miter" stroke-miterlimit="10" stroke-dasharray="" stroke-dashoffset="0" font-family="none" font-weight="none" font-size="none" text-anchor="none" style="mix-blend-mode: normal"><g transform="scale(5.33333,5.33333)"><path d="M27,7h-6v-2c0,-1.657 1.343,-3 3,-3v0c1.657,0 3,1.343 3,3z" fill="url(#color-1)"></path><path d="M39,21c0,-8.284 -6.716,-15 -15,-15c-8.284,0 -15,6.716 -15,15c0,0.39 0,13 0,13h30c0,0 0,-12.61 0,-13z" fill="#f5be00"></path><path d="M39,34h-30l-3.875,1.55c-0.68,0.272 -1.125,0.93 -1.125,1.661v0c0,0.988 0.801,1.789 1.789,1.789h36.422c0.988,0 1.789,-0.801 1.789,-1.789v0c0,-0.731 -0.445,-1.389 -1.125,-1.661z" fill="url(#color-2)"></path><path d="M28,39c0,2.209 -1.791,4 -4,4c-2.209,0 -4,-1.791 -4,-4z" fill="url(#color-3)"></path></g></g></svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@@ -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);
}
})();
});

View File

@@ -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);
}
})();

View File

@@ -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}`)]);

View File

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

View File

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

View File

@@ -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('<p>'.Dict::Format('UI:Preferences:Notifications+', $sNotificationsCenterUrl).'</p>'));
$oContentLayout->AddMainBlock($oNotificationsBlock);
//////////////////////////////////////////////////////////////////////////
//
// Favorite Organizations

View File

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

View File

@@ -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);
}

View File

@@ -0,0 +1,585 @@
<?php
namespace Combodo\iTop\Controller\Notifications;
use ActionNotification;
use Combodo\iTop\Application\TwigBase\Controller\Controller;
use Combodo\iTop\Application\UI\Base\Component\DataTable\DataTableUIBlockFactory;
use Combodo\iTop\Application\UI\Base\Component\Html\Html;
use Combodo\iTop\Application\UI\Base\Component\Input\InputUIBlockFactory;
use Combodo\iTop\Application\UI\Base\Component\Input\Set\Set;
use Combodo\iTop\Application\UI\Base\Component\Input\Set\SetUIBlockFactory;
use Combodo\iTop\Application\UI\Base\Component\Panel\Panel;
use Combodo\iTop\Application\UI\Base\Layout\UIContentBlock;
use Combodo\iTop\Application\WebPage\iTopWebPage;
use Combodo\iTop\Renderer\BlockRenderer;
use Combodo\iTop\Service\Notification\NotificationsRepository;
use Combodo\iTop\Service\Router\Router;
use Dict;
use Exception;
use MetaModel;
use utils;
/**
* Class NotificationsCenterController
*
* @author Stephen Abello <stephen.abello@combodo.com>
* @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(
<<<JS
let oSelf = this;
// Send subscribe request
$.ajax({
url: '{$sSubscribeUrl}',
type: 'POST',
data: {
channel: value,
token: '{$sCSRFToken}',
},
dataType: 'json',
success: function (data) {
if (data.status === 'success') {
// Display success message
oSelf.refreshItems();
CombodoToast.OpenSuccessToast(data.message);
}
else {
CombodoToast.OpenErrorToast(data.message);
}
},
error: function (jqXHR, textStatus, errorThrown) {
CombodoToast.OpenErrorToast(data.message);
}
});
JS
);
// Set the minimum number of channels to 1 if the subscription policy is 'force_at_least_one_channel'
if($sTriggerSubscriptionPolicy === 'force_at_least_one_channel')
{
$oChannelSet->SetMinItems(1);
}
$oChannelSet->SetOnItemRemoveJs(
<<<JS
let oSelf = this;
// Send unsubscribe request
$.ajax({
url: '{$sUnsubscribeUrl}',
type: 'POST',
data: {
channel: value,
token: '{$sCSRFToken}',
},
dataType: 'json',
success: function (data) {
if (data.status === 'success') {
// Display success message
CombodoToast.OpenSuccessToast(data.message);
// Remove item from set
oSelf.options[value]['mixed'] = false;
oSelf.clearCache();
$('#channel$iTriggerId').find('option[value="' + value + '"]').remove();
$('#channel$iTriggerId').trigger('change');
}
else {
CombodoToast.OpenErrorToast(data.message);
}
},
error: function (jqXHR, textStatus, errorThrown) {
CombodoToast.OpenErrorToast(data.message);
}
});
JS
);
// Use a renderer to display the input set in a table row
$oBlockRenderer = new BlockRenderer($oChannelSet);
$aTableRows[] = [
'trigger' => $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(
<<<JS
$('#ibo-notifications-center--datatable').on('init.dt draw.dt', function(){
$sJS
CombodoTooltip.InitAllNonInstantiatedTooltips($(this));
});
JS
);
}
// Add Set JS files to the page as we used a renderer ourselves, they are not added automatically by the page
foreach (Set::DEFAULT_JS_FILES_REL_PATH as $sJsFile) {
$oPage->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(
<<<JS
$('.ibo-notifications-center-advanced--input--container .ibo-input-checkbox').on('change', function(){
let sUrl = '{$sUnsubscribeUrl}';
if ($(this).prop("checked")) {
sUrl = '{$sSubscribeUrl}'
}
$.ajax({
url: sUrl,
type: 'POST',
data: {
channel: $(this).attr('name'),
token: '{$sCSRFToken}',
},
dataType: 'json',
success: function (data) {
if (data.status === 'success') {
// Display success message
CombodoToast.OpenSuccessToast(data.message);
}
else {
CombodoToast.OpenErrorToast(data.message);
}
},
error: function (jqXHR, textStatus, errorThrown) {
CombodoToast.OpenErrorToast(data.message);
}
});
});
JS
);
return $oPage;
}
/**
* @return \JsonPage
*/
function OperationUnsubscribe()
{
// 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 = '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;
}
}

View File

@@ -0,0 +1,120 @@
<?php
namespace Combodo\iTop\Service\Notification;
use DBObjectSearch;
use DBObjectSet;
/**
* Class NotificationsRepository
*
* @author Stephen Abello <stephen.abello@combodo.com>
* @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;
}
}

View File

@@ -0,0 +1,116 @@
<?php
namespace Combodo\iTop\Service\Notification;
use ActionNotification;
use Contact;
use lnkActionNotificationToContact;
use Trigger;
/**
* Class NotificationsService
*
* @author Stephen Abello <stephen.abello@combodo.com>
* @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');
}
}

View File

@@ -1,7 +1,7 @@
<div class="option ibo-input-select--autocomplete-item" role="option" >
{# Image #}
<span class="ibo-input-select--autocomplete-item-image" data-template-condition="has_image" data-template-css-background-image="picture_url" data-template-text="initials"></span>
<span class="ibo-input-select--autocomplete-item-image" data-template-add-class="class" data-template-condition="has_image" data-template-css-background-image="picture_url" data-template-text="initials"></span>
{# Desc #}
<span class="ibo-input-select--autocomplete-item-txt">

View File

@@ -0,0 +1,21 @@
<div class="item ibo-input-select--notification-item" role="item" >
{# Image #}
<span class="ibo-input-select--autocomplete-item-image" data-template-add-class="class" data-template-condition="has_image" data-template-css-background-image="picture_url" data-template-text="initials"></span>
{# Desc #}
<span class="ibo-input-select--autocomplete-item-txt">
{# Friendly name #}
<span data-template-text="friendlyname"></span>
{# Additional content #}
{% block additional_content %}
{% endblock %}
<span class="ibo-input-select--notification-item--mixed-value" data-template-condition="mixed">
<span data-template-text="mixed_value"></span>
</span>
</span>
</div>

View File

@@ -7,6 +7,9 @@
name="{{ oUIBlock.GetName() }}"
multiple
style="display: none;"
{% if oUIBlock.IsDisabled() == true %}
disabled
{% endif %}
>
</select>

View File

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

View File

@@ -0,0 +1,17 @@
{# @copyright Copyright (C) 2010-2023 Combodo SARL #}
{# @license http://opensource.org/licenses/AGPL-3.0 #}
<div class="option simple-option-renderer" role="option">
<div class="simple-option-renderer--container" data-template-add-class="class">
<img class="simple-option-renderer--container--icon" src="" data-template-attr-src="img">
<span class="simple-option-renderer--container--label" data-template-text="label">
</span>
<span class="simple-option-renderer--container--complementary" data-template-text="complementary-text">
</span>
</div>
</div>

View File

@@ -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]);
}
}