diff --git a/administrator/components/com_admin/script.php b/administrator/components/com_admin/script.php index eb77c91dab6b1..4b66adbc5b866 100644 --- a/administrator/components/com_admin/script.php +++ b/administrator/components/com_admin/script.php @@ -16,6 +16,7 @@ use Joomla\CMS\Language\Text; use Joomla\CMS\Log\Log; use Joomla\CMS\Table\Asset; +use Joomla\Database\DatabaseInterface; use Joomla\Database\ParameterType; use Joomla\Filesystem\File; use Joomla\Filesystem\Folder; @@ -148,6 +149,13 @@ public function update($installer) $this->collectError('Further update', $e); } + // Separate try/catch so a missing task cannot stop the cache being cleaned below. + try { + $this->createWorkflowAutomationTask(); + } catch (\Throwable $e) { + $this->collectError('createWorkflowAutomationTask', $e); + } + // Clean cache try { $this->cleanJoomlaCache(); @@ -208,6 +216,72 @@ protected function clearStatsCache() } } + /** + * Creates the workflow automation scheduler task on a site that does not have one. + * + * @return void + * + * @since __DEPLOY_VERSION__ + */ + protected function createWorkflowAutomationTask() + { + $db = Factory::getContainer()->get(DatabaseInterface::class); + + // No state filter, so a task the administrator disabled is not recreated. + $existing = $db->setQuery( + $db->createQuery() + ->select($db->quoteName('id')) + ->from($db->quoteName('#__scheduler_tasks')) + ->where($db->quoteName('type') . ' = ' . $db->quote('workflow.automation')) + )->loadResult(); + + if ($existing) { + return; + } + + // Booted rather than imported directly: during an update the autoloader map can still + // predate the changed files. + /** @var \Joomla\Component\Scheduler\Administrator\Table\TaskTable $task */ + $task = Factory::getApplication()->bootComponent('com_scheduler') + ->getMVCFactory() + ->createTable('Task', 'Administrator'); + + $now = Factory::getDate(); + + $task->bind([ + 'title' => 'Workflow Automation', + 'type' => 'workflow.automation', + + // Every 15 minutes, because this ships to every site, including those with no workflows. + // The first run waits a day so a brand new site is not doing this before anyone has + // built a workflow. Saving or test running the task brings it forward. + 'execution_rules' => json_encode([ + 'rule-type' => 'interval-minutes', + 'interval-minutes' => 15, + 'exec-day' => $now->format('d'), + 'exec-time' => $now->format('H:i'), + ]), + 'cron_rules' => json_encode(['type' => 'interval', 'exp' => 'PT15M']), + 'state' => 1, + 'next_execution' => Factory::getDate('+24 hours')->toSql(), + + 'params' => json_encode([ + 'individual_log' => false, + 'log_file' => '', + 'notifications' => [ + 'success_mail' => '0', + 'failure_mail' => '1', + 'fatal_failure_mail' => '1', + 'orphan_mail' => '1', + ], + ]), + ]); + + if (!$task->check() || !$task->store()) { + $this->collectError(__METHOD__, new \RuntimeException('The workflow automation task could not be stored.')); + } + } + /** * Method to update Database * diff --git a/administrator/components/com_admin/sql/updates/mysql/6.3.0-2026-09-20.sql b/administrator/components/com_admin/sql/updates/mysql/6.3.0-2026-09-20.sql new file mode 100644 index 0000000000000..5ef266cb76f78 --- /dev/null +++ b/administrator/components/com_admin/sql/updates/mysql/6.3.0-2026-09-20.sql @@ -0,0 +1,77 @@ +CREATE TABLE IF NOT EXISTS `#__workflow_automation_rules` ( + `id` int NOT NULL AUTO_INCREMENT, + `transition_id` int NOT NULL COMMENT 'Foreign Key to #__workflow_transitions.id', + `published` tinyint NOT NULL DEFAULT 0, + `ordering` int NOT NULL DEFAULT 0, + `rule_type` varchar(20) NOT NULL DEFAULT 'delay' COMMENT 'delay or cron', + `delay_value` int DEFAULT NULL, + `delay_unit` varchar(10) DEFAULT NULL COMMENT 'minutes, hours, days, months', + `cron_expression` varchar(100) DEFAULT NULL, + `item_filter` text COMMENT 'JSON filter tree: which items this rule applies to (evaluated at selection)', + `fire_condition` text COMMENT 'JSON expression tree: gate evaluated live at fire time', + `run_as_user_id` int NOT NULL DEFAULT 0 COMMENT 'User identity used to execute the transition', + `created` datetime NOT NULL, + `created_by` int NOT NULL DEFAULT 0, + `modified` datetime NOT NULL, + `modified_by` int NOT NULL DEFAULT 0, + PRIMARY KEY (`id`), + UNIQUE KEY `idx_transition` (`transition_id`), + KEY `idx_published` (`published`) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 DEFAULT COLLATE = utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `#__workflow_item_state` ( + `id` int NOT NULL AUTO_INCREMENT, + `item_id` int NOT NULL DEFAULT 0 COMMENT 'Extension table id value', + `extension` varchar(50) NOT NULL, + `stage_id` int NOT NULL COMMENT 'Foreign Key to #__workflow_stages.id', + `entered_at` datetime NOT NULL COMMENT 'When the item arrived in stage_id', + `triggered_by` varchar(20) NOT NULL DEFAULT 'manual' COMMENT 'Determine if a transition was triggered manually or by the automation', + `requires_intervention` tinyint NOT NULL DEFAULT 0 COMMENT 'Set when an automated transition failed; excluded from the scheduler until an admin clears it', + `last_checked_at` datetime DEFAULT NULL COMMENT 'When the scheduler last considered this item; null means never', + `last_failure_at` datetime DEFAULT NULL COMMENT 'When this item last could not be evaluated; null means it evaluated cleanly', + `last_failure_reason` varchar(500) DEFAULT NULL COMMENT 'Why it could not be evaluated, so the same failure is only reported once', + PRIMARY KEY (`id`), + UNIQUE KEY `idx_item_extension` (`item_id`, `extension`), + KEY `idx_stage_entered` (`stage_id`, `entered_at`), + KEY `idx_requires_intervention` (`requires_intervention`) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 DEFAULT COLLATE = utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `#__workflow_automation_log` ( + `id` int NOT NULL AUTO_INCREMENT, + `rule_id` int DEFAULT NULL COMMENT 'Foreign Key to #__workflow_automation_rules.id', + `item_id` int NOT NULL DEFAULT 0, + `extension` varchar(50) NOT NULL, + `transition_id` int NOT NULL, + `from_stage_id` int NOT NULL DEFAULT 0, + `to_stage_id` int NOT NULL DEFAULT 0, + `run_as_user_id` int NOT NULL DEFAULT 0, + `trigger_type` varchar(20) NOT NULL DEFAULT 'rule', + `exit_code` tinyint NOT NULL DEFAULT 0 COMMENT '0 ok, 1 permission denied, 2 invalid transition, 3 exception, 4 no longer applicable', + `note` varchar(500) DEFAULT NULL, + `executed_at` datetime NOT NULL, + PRIMARY KEY (`id`), + KEY `idx_item_id` (`item_id`), + KEY `idx_executed_at` (`executed_at`), + KEY `idx_exit_code` (`exit_code`) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 DEFAULT COLLATE = utf8mb4_unicode_ci; + +INSERT INTO `#__extensions` (`package_id`, `name`, `type`, `element`, `folder`, `client_id`, `enabled`, `access`, `protected`, `locked`, `manifest_cache`, `params`, `custom_data`, `ordering`, `state`) +SELECT 0, 'plg_task_workflowtransition', 'plugin', 'workflowtransition', 'task', 0, 1, 1, 0, 1, '', '{}', '', 10, 0 +WHERE NOT EXISTS (SELECT * FROM `#__extensions` e WHERE e.`type` = 'plugin' AND e.`element` = 'workflowtransition' AND e.`folder` = 'task' AND e.`client_id` = 0); + +INSERT INTO `#__extensions` (`package_id`, `name`, `type`, `element`, `folder`, `client_id`, `enabled`, `access`, `protected`, `locked`, `manifest_cache`, `params`, `custom_data`, `ordering`, `state`) +SELECT 0, 'plg_workflow_automation', 'plugin', 'automation', 'workflow', 0, 1, 1, 0, 1, '', '{}', '', 4, 0 +WHERE NOT EXISTS (SELECT * FROM `#__extensions` e WHERE e.`type` = 'plugin' AND e.`element` = 'automation' AND e.`folder` = 'workflow' AND e.`client_id` = 0); + +INSERT INTO `#__extensions` (`package_id`, `name`, `type`, `element`, `folder`, `client_id`, `enabled`, `access`, `protected`, `locked`, `manifest_cache`, `params`, `custom_data`, `ordering`, `state`) +SELECT 0, 'plg_workflow_remotecheck', 'plugin', 'remotecheck', 'workflow', 0, 0, 1, 0, 1, '', '{}', '', 5, 0 +WHERE NOT EXISTS (SELECT * FROM `#__extensions` e WHERE e.`type` = 'plugin' AND e.`element` = 'remotecheck' AND e.`folder` = 'workflow' AND e.`client_id` = 0); + +-- entered_at is stamped with the upgrade time rather than a guess. Nothing records when these +-- items actually entered their stage, and using a date like `modified` would leave anything +-- edited long ago instantly overdue, transitioning a whole back catalogue on the first run. +INSERT INTO `#__workflow_item_state` (`item_id`, `extension`, `stage_id`, `entered_at`, `triggered_by`, `requires_intervention`) +SELECT a.`item_id`, a.`extension`, a.`stage_id`, UTC_TIMESTAMP(), 'manual', 0 +FROM `#__workflow_associations` a +LEFT JOIN `#__workflow_item_state` s ON s.`item_id` = a.`item_id` AND s.`extension` = a.`extension` +WHERE s.`id` IS NULL; diff --git a/administrator/components/com_admin/sql/updates/postgresql/6.3.0-2026-09-20.sql b/administrator/components/com_admin/sql/updates/postgresql/6.3.0-2026-09-20.sql new file mode 100644 index 0000000000000..5879dd7180bce --- /dev/null +++ b/administrator/components/com_admin/sql/updates/postgresql/6.3.0-2026-09-20.sql @@ -0,0 +1,114 @@ +CREATE TABLE IF NOT EXISTS "#__workflow_automation_rules" ( + "id" serial NOT NULL, + "transition_id" integer NOT NULL, + "published" smallint DEFAULT 0 NOT NULL, + "ordering" integer DEFAULT 0 NOT NULL, + "rule_type" varchar(20) DEFAULT 'delay' NOT NULL, + "delay_value" integer, + "delay_unit" varchar(10), + "cron_expression" varchar(100), + "item_filter" text, + "fire_condition" text, + "run_as_user_id" integer DEFAULT 0 NOT NULL, + "created" timestamp without time zone NOT NULL, + "created_by" integer DEFAULT 0 NOT NULL, + "modified" timestamp without time zone NOT NULL, + "modified_by" integer DEFAULT 0 NOT NULL, + PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "#__workflow_automation_rules_idx_transition" ON "#__workflow_automation_rules" ("transition_id"); + +CREATE INDEX "#__workflow_automation_rules_idx_published" ON "#__workflow_automation_rules" ("published"); + +COMMENT ON COLUMN "#__workflow_automation_rules"."transition_id" IS 'Foreign Key to #__workflow_transitions.id'; + +COMMENT ON COLUMN "#__workflow_automation_rules"."rule_type" IS 'delay or cron'; + +COMMENT ON COLUMN "#__workflow_automation_rules"."delay_unit" IS 'minutes, hours, days, months'; + +COMMENT ON COLUMN "#__workflow_automation_rules"."item_filter" IS 'JSON filter tree: which items this rule applies to (evaluated at selection)'; + +COMMENT ON COLUMN "#__workflow_automation_rules"."fire_condition" IS 'JSON expression tree: gate evaluated live at fire time'; + +COMMENT ON COLUMN "#__workflow_automation_rules"."run_as_user_id" IS 'User identity used to execute the transition'; + +CREATE TABLE IF NOT EXISTS "#__workflow_item_state" ( + "id" serial NOT NULL, + "item_id" integer DEFAULT 0 NOT NULL, + "extension" varchar(50) NOT NULL, + "stage_id" integer NOT NULL, + "entered_at" timestamp without time zone NOT NULL, + "triggered_by" varchar(20) DEFAULT 'manual' NOT NULL, + "requires_intervention" smallint DEFAULT 0 NOT NULL, + "last_checked_at" timestamp without time zone, + "last_failure_at" timestamp without time zone, + "last_failure_reason" varchar(500), + PRIMARY KEY ("id"), + CONSTRAINT "#__workflow_item_state_idx_item_extension" UNIQUE ("item_id", "extension") +); + +CREATE INDEX "#__workflow_item_state_idx_stage_entered" ON "#__workflow_item_state" ("stage_id", "entered_at"); + +CREATE INDEX "#__workflow_item_state_idx_requires_intervention" ON "#__workflow_item_state" ("requires_intervention"); + +COMMENT ON COLUMN "#__workflow_item_state"."item_id" IS 'Extension table id value'; + +COMMENT ON COLUMN "#__workflow_item_state"."stage_id" IS 'Foreign Key to #__workflow_stages.id'; + +COMMENT ON COLUMN "#__workflow_item_state"."entered_at" IS 'When the item arrived in stage_id'; + +COMMENT ON COLUMN "#__workflow_item_state"."triggered_by" IS 'Determine if a transition was triggered manually or by the automation'; + +COMMENT ON COLUMN "#__workflow_item_state"."requires_intervention" IS 'Set when an automated transition failed; excluded from the scheduler until an admin clears it'; + +COMMENT ON COLUMN "#__workflow_item_state"."last_checked_at" IS 'When the scheduler last considered this item; null means never'; + +COMMENT ON COLUMN "#__workflow_item_state"."last_failure_at" IS 'When this item last could not be evaluated; null means it evaluated cleanly'; + +COMMENT ON COLUMN "#__workflow_item_state"."last_failure_reason" IS 'Why it could not be evaluated, so the same failure is only reported once'; + +CREATE TABLE IF NOT EXISTS "#__workflow_automation_log" ( + "id" serial NOT NULL, + "rule_id" integer, + "item_id" integer DEFAULT 0 NOT NULL, + "extension" varchar(50) NOT NULL, + "transition_id" integer NOT NULL, + "from_stage_id" integer DEFAULT 0 NOT NULL, + "to_stage_id" integer DEFAULT 0 NOT NULL, + "run_as_user_id" integer DEFAULT 0 NOT NULL, + "trigger_type" varchar(20) DEFAULT 'rule' NOT NULL, + "exit_code" smallint DEFAULT 0 NOT NULL, + "note" varchar(500), + "executed_at" timestamp without time zone NOT NULL, + PRIMARY KEY ("id") +); + +CREATE INDEX "#__workflow_automation_log_idx_item_id" ON "#__workflow_automation_log" ("item_id"); + +CREATE INDEX "#__workflow_automation_log_idx_executed_at" ON "#__workflow_automation_log" ("executed_at"); + +CREATE INDEX "#__workflow_automation_log_idx_exit_code" ON "#__workflow_automation_log" ("exit_code"); + +COMMENT ON COLUMN "#__workflow_automation_log"."rule_id" IS 'Foreign Key to #__workflow_automation_rules.id'; + +COMMENT ON COLUMN "#__workflow_automation_log"."exit_code" IS '0 ok, 1 permission denied, 2 invalid transition, 3 exception, 4 no longer applicable'; + +INSERT INTO "#__extensions" ("package_id", "name", "type", "element", "folder", "client_id", "enabled", "access", "protected", "locked", "manifest_cache", "params", "custom_data", "ordering", "state") +SELECT 0, 'plg_task_workflowtransition', 'plugin', 'workflowtransition', 'task', 0, 1, 1, 0, 1, '', '{}', '', 10, 0 +WHERE NOT EXISTS (SELECT * FROM "#__extensions" e WHERE e."type" = 'plugin' AND e."element" = 'workflowtransition' AND e."folder" = 'task' AND e."client_id" = 0); + +INSERT INTO "#__extensions" ("package_id", "name", "type", "element", "folder", "client_id", "enabled", "access", "protected", "locked", "manifest_cache", "params", "custom_data", "ordering", "state") +SELECT 0, 'plg_workflow_automation', 'plugin', 'automation', 'workflow', 0, 1, 1, 0, 1, '', '{}', '', 4, 0 +WHERE NOT EXISTS (SELECT * FROM "#__extensions" e WHERE e."type" = 'plugin' AND e."element" = 'automation' AND e."folder" = 'workflow' AND e."client_id" = 0); + +INSERT INTO "#__extensions" ("package_id", "name", "type", "element", "folder", "client_id", "enabled", "access", "protected", "locked", "manifest_cache", "params", "custom_data", "ordering", "state") +SELECT 0, 'plg_workflow_remotecheck', 'plugin', 'remotecheck', 'workflow', 0, 0, 1, 0, 1, '', '{}', '', 5, 0 +WHERE NOT EXISTS (SELECT * FROM "#__extensions" e WHERE e."type" = 'plugin' AND e."element" = 'remotecheck' AND e."folder" = 'workflow' AND e."client_id" = 0); + +-- See the MySQL file for why entered_at is the upgrade time rather than a guess. +INSERT INTO "#__workflow_item_state" ("item_id", "extension", "stage_id", "entered_at", "triggered_by", "requires_intervention") +SELECT a."item_id", a."extension", a."stage_id", timezone('UTC', now()), 'manual', 0 +FROM "#__workflow_associations" a +LEFT JOIN "#__workflow_item_state" s ON s."item_id" = a."item_id" AND s."extension" = a."extension" +WHERE s."id" IS NULL; diff --git a/administrator/components/com_content/tmpl/articles/default.php b/administrator/components/com_content/tmpl/articles/default.php index 0c56baffad293..62192d62c8b6b 100644 --- a/administrator/components/com_content/tmpl/articles/default.php +++ b/administrator/components/com_content/tmpl/articles/default.php @@ -23,6 +23,9 @@ use Joomla\CMS\Router\Route; use Joomla\CMS\Session\Session; use Joomla\Component\Content\Administrator\Helper\ContentHelper; +use Joomla\Component\Workflow\Administrator\Automation\RelativeTime; +use Joomla\Component\Workflow\Administrator\Automation\UpcomingTransitionsCalculator; +use Joomla\Database\DatabaseInterface; use Joomla\Utilities\ArrayHelper; /** @var \Joomla\Component\Content\Administrator\View\Articles\HtmlView $this */ @@ -72,6 +75,19 @@ $this->filterForm->addControlField('transition_id', ''); endif; +$upcomingByItem = []; + +if ($workflow_enabled && !empty($this->items)) { + Factory::getApplication()->getLanguage()->load('com_workflow', JPATH_ADMINISTRATOR); + + $upcomingByItem = (new UpcomingTransitionsCalculator(Factory::getContainer()->get(DatabaseInterface::class))) + ->forItems(ArrayHelper::getColumn($this->items, 'id'), 'com_content.article'); + + if (!empty($upcomingByItem)) { + HTMLHelper::_('bootstrap.tooltip', '.hasTooltip'); + } +} + $assoc = Associations::isEnabled(); ?> @@ -227,6 +243,40 @@
stage_title); ?>
+ id])) : + $upcoming = $upcomingByItem[$item->id]; + $chipClass = match ($upcoming->status) { + 'needs_attention' => 'bg-danger', + 'rule_error' => 'bg-warning text-dark', + 'not_scheduled' => 'bg-secondary', + default => 'bg-info', + }; + $chipIcon = $upcoming->status === 'rule_error' ? 'icon-warning' : 'icon-clock'; + $chipTip = match ($upcoming->status) { + 'needs_attention' => Text::_('COM_WORKFLOW_UPCOMING_STATUS_ATTENTION'), + 'rule_error' => $upcoming->failureReason !== '' + ? $upcoming->failureReason + : Text::_('COM_WORKFLOW_UPCOMING_STATUS_RULE_ERROR'), + 'not_scheduled' => Text::_('COM_WORKFLOW_UPCOMING_STATUS_NOT_SCHEDULED'), + default => $upcoming->firesAt !== null + ? RelativeTime::until($upcoming->firesAt) . ' (' . HTMLHelper::_('date', $upcoming->firesAt->format('Y-m-d H:i:s'), Text::_('DATE_FORMAT_LC2')) . ')' + : '', + }; + + $chipTip .= $upcoming->status !== 'rule_error' && $upcoming->failureReason !== '' + ? ' - ' . $upcoming->failureReason + : ''; + ?> +
+ > + + transitionTitle), ENT_QUOTES, 'UTF-8'); ?> + + + + +
+ diff --git a/administrator/components/com_workflow/forms/automation_rule.xml b/administrator/components/com_workflow/forms/automation_rule.xml new file mode 100644 index 0000000000000..23f78ba00d976 --- /dev/null +++ b/administrator/components/com_workflow/forms/automation_rule.xml @@ -0,0 +1,72 @@ + +
+ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/administrator/components/com_workflow/forms/filter_logs.xml b/administrator/components/com_workflow/forms/filter_logs.xml new file mode 100644 index 0000000000000..a40af874b756b --- /dev/null +++ b/administrator/components/com_workflow/forms/filter_logs.xml @@ -0,0 +1,49 @@ + +
+ + + + + + + + + + + + + + + + + + + + + + +
diff --git a/administrator/components/com_workflow/forms/filter_upcoming.xml b/administrator/components/com_workflow/forms/filter_upcoming.xml new file mode 100644 index 0000000000000..7e6f195f4e765 --- /dev/null +++ b/administrator/components/com_workflow/forms/filter_upcoming.xml @@ -0,0 +1,43 @@ + +
+ + + + + + + + + +
diff --git a/administrator/components/com_workflow/forms/transition.xml b/administrator/components/com_workflow/forms/transition.xml index ac82312fc6a7a..d2d0301c17b06 100644 --- a/administrator/components/com_workflow/forms/transition.xml +++ b/administrator/components/com_workflow/forms/transition.xml @@ -1,5 +1,8 @@ -
+ + + +
+ +
+ + + + + + + + + + + +
+
diff --git a/administrator/components/com_workflow/layouts/upcoming/table.php b/administrator/components/com_workflow/layouts/upcoming/table.php new file mode 100644 index 0000000000000..325840d1a6d04 --- /dev/null +++ b/administrator/components/com_workflow/layouts/upcoming/table.php @@ -0,0 +1,146 @@ + + * @license GNU General Public License version 2 or later; see LICENSE.txt + */ + +defined('_JEXEC') or die; + +use Joomla\CMS\Factory; +use Joomla\CMS\HTML\HTMLHelper; +use Joomla\CMS\Language\Text; +use Joomla\CMS\Router\Route; +use Joomla\Component\Workflow\Administrator\Automation\RelativeTime; + +/** + * Renders a table of upcoming automated transitions. + * + * @var array $displayData + * @var \Joomla\Component\Workflow\Administrator\Automation\UpcomingTransition[] $items + * @var boolean $showWorkflow Whether to show the owning workflow as a column. + */ +$items = $displayData['items'] ?? []; +$showWorkflow = (bool) ($displayData['showWorkflow'] ?? false); +$arrow = Factory::getApplication()->getLanguage()->isRtl() ? 'arrow-left' : 'arrow-right'; + +if (empty($items)) : ?> +
+ + +
+ 'COM_WORKFLOW_AUTOMATION_UNIT_MINUTES', + 'hours' => 'COM_WORKFLOW_AUTOMATION_UNIT_HOURS', + 'days' => 'COM_WORKFLOW_AUTOMATION_UNIT_DAYS', + 'months' => 'COM_WORKFLOW_AUTOMATION_UNIT_MONTHS', +]; +?> + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ itemTitle !== '' + ? $transition->itemTitle + : Text::sprintf('COM_WORKFLOW_UPCOMING_ITEM_FALLBACK', $transition->itemId); + ?> + editUrl !== '') : ?> + + + + + + + + workflowTitle), ENT_QUOTES, 'UTF-8'); ?> + workflowId > 0) : ?> + + + + + + + + transitionTitle), ENT_QUOTES, 'UTF-8'); ?> + fromStage !== $transition->toStage) : ?> +
+ fromStage), ENT_QUOTES, 'UTF-8'); ?> + + toStage), ENT_QUOTES, 'UTF-8'); ?> +
+ +
+ status === 'needs_attention') : ?> + + status === 'rule_error') : ?> + + status === 'not_scheduled') : ?> + + firesAt === null) : ?> + + +
firesAt); ?>
+
firesAt->format('Y-m-d H:i:s'), Text::_('DATE_FORMAT_LC2')); ?>
+ hasCondition) : ?> + + + + + failureReason !== '') : ?> +
+ + failureReason, ENT_QUOTES, 'UTF-8'); ?> +
+ failedAt !== null) : ?> +
+ failedAt->format('Y-m-d H:i:s'), Text::_('DATE_FORMAT_LC2')) + ); ?> +
+ + +
+ ruleType === 'cron') : ?> + ' . htmlspecialchars((string) $transition->cronExpression, ENT_QUOTES, 'UTF-8') . ''); ?> + + delayUnit] ?? ''; + $unit = $unitKey !== '' ? Text::_($unitKey) : (string) $transition->delayUnit; + echo Text::sprintf('COM_WORKFLOW_UPCOMING_TRIGGER_DELAY', (int) $transition->delayValue, $unit); + ?> + +
diff --git a/administrator/components/com_workflow/src/Automation/BuiltinConditionFields.php b/administrator/components/com_workflow/src/Automation/BuiltinConditionFields.php new file mode 100644 index 0000000000000..77b6d370c3317 --- /dev/null +++ b/administrator/components/com_workflow/src/Automation/BuiltinConditionFields.php @@ -0,0 +1,709 @@ + + * @license GNU General Public License version 2 or later; see LICENSE.txt + */ + +namespace Joomla\Component\Workflow\Administrator\Automation; + +use Joomla\CMS\Event\Workflow\WorkflowConditionFieldsEvent; +use Joomla\CMS\Helper\UserGroupsHelper; +use Joomla\CMS\HTML\HTMLHelper; +use Joomla\CMS\Language\Text; +use Joomla\Database\DatabaseInterface; + +// phpcs:disable PSR1.Files.SideEffects +\defined('_JEXEC') or die; +// phpcs:enable PSR1.Files.SideEffects + +/** + * The condition checks Joomla ships with. + * + * Asked before the workflow plugins, so a plugin cannot redefine a built-in check and change + * what an already-saved rule means. + * + * @since __DEPLOY_VERSION__ + */ +final class BuiltinConditionFields +{ + /** + * @var DatabaseInterface + * @since __DEPLOY_VERSION__ + */ + private DatabaseInterface $database; + + /** + * Content type rows already looked up by extension. Null means the extension + * is not registered as a content type at all + * + * @var array + * @since __DEPLOY_VERSION__ + */ + private array $contentTypes = []; + + /** + * @param DatabaseInterface $database The database driver. + * + * @since __DEPLOY_VERSION__ + */ + public function __construct(DatabaseInterface $database) + { + $this->database = $database; + } + + /** + * The built-in checks available for an extension. + * + * @param string $extension The workflow extension, e.g com_content.article. + * + * @return array Check definitions keyed by name. + * + * @since __DEPLOY_VERSION__ + */ + public function declarations(string $extension): array + { + $fields = []; + $fields['day_of_week'] = $this->field( + 'day_of_week', + Text::_('COM_WORKFLOW_AUTOMATION_FIELD_DAY_OF_WEEK'), + WorkflowConditionFieldsEvent::SCOPE_MOMENT, + [ + WorkflowConditionFieldsEvent::OPERATOR_IN, + WorkflowConditionFieldsEvent::OPERATOR_NOT_IN, + ], + WorkflowConditionFieldsEvent::VALUE_MULTISELECT, + $this->getWeekdayOptions() + ); + + $fields['date'] = $this->field( + 'date', + Text::_('COM_WORKFLOW_AUTOMATION_FIELD_DATE'), + WorkflowConditionFieldsEvent::SCOPE_MOMENT, + [ + WorkflowConditionFieldsEvent::OPERATOR_AFTER, + WorkflowConditionFieldsEvent::OPERATOR_BEFORE, + WorkflowConditionFieldsEvent::OPERATOR_ON, + WorkflowConditionFieldsEvent::OPERATOR_NOT_ON, + ], + WorkflowConditionFieldsEvent::VALUE_DATE + ); + + if ($this->typeIdFor($extension) !== null) { + $fields['tag'] = $this->field( + 'tag', + Text::_('COM_WORKFLOW_AUTOMATION_FIELD_TAG'), + WorkflowConditionFieldsEvent::SCOPE_ITEM, + [ + WorkflowConditionFieldsEvent::OPERATOR_HAS_ANY, + WorkflowConditionFieldsEvent::OPERATOR_HAS_ALL, + WorkflowConditionFieldsEvent::OPERATOR_HAS_NONE, + ], + WorkflowConditionFieldsEvent::VALUE_MULTISELECT, + $this->getTagOptions() + ); + } + + // Each check below is offered only where the extension's own item table has its column. + if ($this->itemTableHasColumn($extension, 'catid')) { + $fields['category'] = $this->field( + 'category', + Text::_('COM_WORKFLOW_AUTOMATION_FIELD_CATEGORY'), + WorkflowConditionFieldsEvent::SCOPE_ITEM, + [ + WorkflowConditionFieldsEvent::OPERATOR_IS, + WorkflowConditionFieldsEvent::OPERATOR_IS_NOT, + ], + WorkflowConditionFieldsEvent::VALUE_SELECT, + $this->getCategoryOptions($extension) + ); + } + + if ($this->itemTableHasColumn($extension, 'created_by')) { + $fields['author_group'] = $this->field( + 'author_group', + Text::_('COM_WORKFLOW_AUTOMATION_FIELD_AUTHOR_GROUP'), + WorkflowConditionFieldsEvent::SCOPE_ITEM, + [ + WorkflowConditionFieldsEvent::OPERATOR_HAS_ANY, + WorkflowConditionFieldsEvent::OPERATOR_HAS_ALL, + WorkflowConditionFieldsEvent::OPERATOR_HAS_NONE, + ], + WorkflowConditionFieldsEvent::VALUE_MULTISELECT, + $this->getUserGroupOptions() + ); + } + + if ($this->itemTableHasColumn($extension, 'hits')) { + $fields['hits'] = $this->field( + 'hits', + Text::_('JGLOBAL_HITS'), + WorkflowConditionFieldsEvent::SCOPE_ITEM, + [ + WorkflowConditionFieldsEvent::OPERATOR_GREATER_THAN, + WorkflowConditionFieldsEvent::OPERATOR_LESS_THAN, + WorkflowConditionFieldsEvent::OPERATOR_IS, + WorkflowConditionFieldsEvent::OPERATOR_IS_NOT, + ], + WorkflowConditionFieldsEvent::VALUE_NUMBER + ); + } + + $fields['days_in_stage'] = $this->field( + 'days_in_stage', + Text::_('COM_WORKFLOW_AUTOMATION_FIELD_DAYS_IN_STAGE'), + WorkflowConditionFieldsEvent::SCOPE_ITEM, + [ + WorkflowConditionFieldsEvent::OPERATOR_GREATER_THAN, + WorkflowConditionFieldsEvent::OPERATOR_LESS_THAN, + WorkflowConditionFieldsEvent::OPERATOR_IS, + WorkflowConditionFieldsEvent::OPERATOR_IS_NOT, + ], + WorkflowConditionFieldsEvent::VALUE_NUMBER + ); + + if ($this->itemTableHasColumn($extension, 'modified')) { + $fields['days_since_modified'] = $this->field( + 'days_since_modified', + Text::_('COM_WORKFLOW_AUTOMATION_FIELD_DAYS_SINCE_MODIFIED'), + WorkflowConditionFieldsEvent::SCOPE_ITEM, + [ + WorkflowConditionFieldsEvent::OPERATOR_GREATER_THAN, + WorkflowConditionFieldsEvent::OPERATOR_LESS_THAN, + WorkflowConditionFieldsEvent::OPERATOR_IS, + WorkflowConditionFieldsEvent::OPERATOR_IS_NOT, + ], + WorkflowConditionFieldsEvent::VALUE_NUMBER + ); + } + + return $fields; + } + + /** + * Resolves one built-in check for a whole batch of items. + * + * @param string $fieldName The check being resolved + * @param int[] $itemIds The items to resolve it for + * @param string $extension The workflow extension, e.g com_content.article + * @param \DateTime|null $moment The moment clock-based checks should describe. + * + * @return array|null Values keyed by item id, or null when the name is not a built-in. + * + * @since __DEPLOY_VERSION__ + */ + public function resolve( + string $fieldName, + array $itemIds, + string $extension, + ?\DateTime $moment = null + ): ?array { + $itemIds = array_map('intval', $itemIds); + $when = $moment ?? new \DateTime('now', new \DateTimeZone('UTC')); + + switch ($fieldName) { + case 'day_of_week': + return $this->sameForEveryItem($itemIds, (int) $when->format('w')); + + case 'date': + return $this->sameForEveryItem($itemIds, $when->format('Y-m-d H:i:s')); + + case 'tag': + return $this->loadTagIds($itemIds, $extension); + + case 'category': + return $this->loadColumnPerItem($itemIds, $extension, 'catid', 0); + + case 'hits': + return $this->loadColumnPerItem($itemIds, $extension, 'hits', 0); + + case 'author_group': + return $this->loadAuthorGroupIds($itemIds, $extension); + + case 'days_in_stage': + return $this->loadDaysInStage($itemIds, $extension, $when); + + case 'days_since_modified': + return $this->loadDaysSinceModified($itemIds, $extension, $when); + } + + return null; + } + + /** + * Gives every item the same value, for checks that describe the clock rather than the item + * + * @param int[] $itemIds The items being resolved. + * @param mixed $value The value they all share. + * + * @return array + * + * @since __DEPLOY_VERSION__ + */ + private function sameForEveryItem(array $itemIds, $value): array + { + return $itemIds === [] ? [] : array_fill_keys($itemIds, $value); + } + + /** + * Tag ids per item, in one query. + * + * Filters by type_id through a subquery, because the tag map's primary key starts with + * type_id and type_alias has no index. + * + * @param int[] $itemIds The items. + * @param string $extension The workflow extension + * + * @return array + * + * @since __DEPLOY_VERSION__ + */ + private function loadTagIds(array $itemIds, string $extension): array + { + if ($itemIds === [] || $this->typeIdFor($extension) === null) { + return []; + } + + $db = $this->database; + + $tagTypeSubquery = '(SELECT ' . $db->quoteName('ct.type_id') + . ' FROM ' . $db->quoteName('#__content_types', 'ct') + . ' WHERE ' . $db->quoteName('ct.type_alias') . ' = :extension)'; + + $query = $db->getQuery(true) + ->select($db->quoteName(['content_item_id', 'tag_id'])) + ->from($db->quoteName('#__contentitem_tag_map')) + ->where($db->quoteName('type_id') . ' = ' . $tagTypeSubquery) + ->whereIn($db->quoteName('content_item_id'), $itemIds) + ->bind(':extension', $extension); + + // An item with no tags gets an empty list. A missing answer would be treated as an error. + $values = array_fill_keys($itemIds, []); + + foreach ($db->setQuery($query)->loadObjectList() ?: [] as $row) { + $values[(int) $row->content_item_id][] = (int) $row->tag_id; + } + + return $values; + } + + /** + * One column of the extension's own item table, per item, in one query. + * + * @param int[] $itemIds The items. + * @param string $extension The workflow extension. + * @param string $column The column to read. + * @param mixed $default The value for an item the query does not return. + * + * @return array + * + * @since __DEPLOY_VERSION__ + */ + private function loadColumnPerItem(array $itemIds, string $extension, string $column, $default): array + { + $storage = $this->itemStorageFor($extension); + + if ($itemIds === [] || $storage === null || !$this->itemTableHasColumn($extension, $column)) { + return []; + } + + $db = $this->database; + $itemValuesQuery = $db->getQuery(true) + ->select($db->quoteName([$storage['key'], $column])) + ->from($db->quoteName($storage['table'])) + ->whereIn($db->quoteName($storage['key']), $itemIds); + + $values = array_fill_keys($itemIds, $default); + + foreach ($db->setQuery($itemValuesQuery)->loadAssocList() ?: [] as $row) { + $values[(int) $row[$storage['key']]] = $row[$column]; + } + + return $values; + } + + /** + * How many whole days each item has been sitting in its current stage. + * + * An item with no state row yet counts as 0 days, so a filter never silently skips it. + * + * @param int[] $itemIds The items. + * @param string $extension The workflow extension. + * @param \DateTime $when The moment to measure against. + * + * @return array + * + * @since __DEPLOY_VERSION__ + */ + private function loadDaysInStage(array $itemIds, string $extension, \DateTime $when): array + { + if ($itemIds === []) { + return []; + } + + $db = $this->database; + $query = $db->getQuery(true) + ->select($db->quoteName(['item_id', 'entered_at'])) + ->from($db->quoteName('#__workflow_item_state')) + ->where($db->quoteName('extension') . ' = :extension') + ->whereIn($db->quoteName('item_id'), $itemIds) + ->bind(':extension', $extension); + + $ages = array_fill_keys($itemIds, 0); + + foreach ($db->setQuery($query)->loadAssocList() ?: [] as $row) { + if ($this->isRealDate($row['entered_at'])) { + $ages[(int) $row['item_id']] = $this->wholeDaysSince($row['entered_at'], $when); + } + } + + return $ages; + } + + /** + * How many whole days since each item was last edited. + * + * Falls back to the creation date for an item whose modified date is empty. + * + * @param int[] $itemIds The items. + * @param string $extension The workflow extension. + * @param \DateTime $when The moment to measure against. + * + * @return array + * + * @since __DEPLOY_VERSION__ + */ + private function loadDaysSinceModified(array $itemIds, string $extension, \DateTime $when): array + { + $modified = $this->loadColumnPerItem($itemIds, $extension, 'modified', null); + $created = $this->itemTableHasColumn($extension, 'created') + ? $this->loadColumnPerItem($itemIds, $extension, 'created', null) + : []; + + $ages = []; + + foreach ($itemIds as $itemId) { + $stamp = $modified[$itemId] ?? null; + + if (!$this->isRealDate($stamp)) { + $stamp = $created[$itemId] ?? null; + } + + $ages[$itemId] = $this->isRealDate($stamp) ? $this->wholeDaysSince($stamp, $when) : 0; + } + + return $ages; + } + + /** + * Whole days between a stored timestamp and a moment, never negative. + * + * Floored, so 7 means from seven days up to just under eight. + * + * @param string $stamp A database timestamp, in UTC. + * @param \DateTime $when The moment to measure against. + * + * @return integer + * + * @since __DEPLOY_VERSION__ + */ + private function wholeDaysSince(string $stamp, \DateTime $when): int + { + $then = date_create_immutable($stamp, new \DateTimeZone('UTC')); + + if ($then === false) { + return 0; + } + + return (int) max(0, floor(($when->getTimestamp() - $then->getTimestamp()) / 86400)); + } + + /** + * Whether a stored timestamp is a real date rather than null or a zero date. + * + * @param mixed $stamp The stored value. + * + * @return boolean + * + * @since __DEPLOY_VERSION__ + */ + private function isRealDate($stamp): bool + { + return \is_string($stamp) && $stamp !== '' && strpos($stamp, '0000-00-00') !== 0; + } + + /** + * The author's user groups per item, in two queries: the authors, then their groups. + * + * @param int[] $itemIds The items. + * @param string $extension The workflow extension. + * + * @return array + * + * @since __DEPLOY_VERSION__ + */ + private function loadAuthorGroupIds(array $itemIds, string $extension): array + { + $authorPerItem = $this->loadColumnPerItem($itemIds, $extension, 'created_by', 0); + + if ($authorPerItem === []) { + return []; + } + + $values = array_fill_keys(array_keys($authorPerItem), []); + $itemIdsByAuthor = []; + + foreach ($authorPerItem as $itemId => $authorId) { + if ((int) $authorId > 0) { + $itemIdsByAuthor[(int) $authorId][] = (int) $itemId; + } + } + + if ($itemIdsByAuthor === []) { + return $values; + } + + $db = $this->database; + $groupQuery = $db->getQuery(true) + ->select($db->quoteName(['user_id', 'group_id'])) + ->from($db->quoteName('#__user_usergroup_map')) + ->whereIn($db->quoteName('user_id'), array_keys($itemIdsByAuthor)); + + $groupsByAuthor = []; + + foreach ($db->setQuery($groupQuery)->loadObjectList() ?: [] as $row) { + $groupsByAuthor[(int) $row->user_id][] = (int) $row->group_id; + } + + foreach ($itemIdsByAuthor as $authorId => $authoredItemIds) { + foreach ($authoredItemIds as $authoredItemId) { + $values[$authoredItemId] = $groupsByAuthor[$authorId] ?? []; + } + } + + return $values; + } + + /** + * Weekday options, Sunday (0) through Saturday (6). + * + * @return array> + * + * @since __DEPLOY_VERSION__ + */ + private function getWeekdayOptions(): array + { + $weekdayKeys = ['SUNDAY', 'MONDAY', 'TUESDAY', 'WEDNESDAY', 'THURSDAY', 'FRIDAY', 'SATURDAY']; + $options = []; + + foreach ($weekdayKeys as $dayNumber => $languageKey) { + $options[] = ['value' => (string) $dayNumber, 'label' => Text::_($languageKey)]; + } + + return $options; + } + + /** + * Published content tags. + * + * @return array> + * + * @since __DEPLOY_VERSION__ + */ + private function getTagOptions(): array + { + $db = $this->database; + $query = $db->getQuery(true) + ->select($db->quoteName(['id', 'title'])) + ->from($db->quoteName('#__tags')) + ->where($db->quoteName('published') . ' = 1') + ->where($db->quoteName('id') . ' > 1') + ->order($db->quoteName('title') . ' ASC'); + + $options = []; + + foreach ($db->setQuery($query)->loadObjectList() ?: [] as $tag) { + $options[] = ['value' => (string) $tag->id, 'label' => $tag->title]; + } + + return $options; + } + + /** + * The category options for an extension, e.g. com_contact's categories for a contact + * workflow rather than always com_content's. + * + * @param string $extension The workflow extension, e.g. com_content.article. + * + * @return array> + * + * @since __DEPLOY_VERSION__ + */ + private function getCategoryOptions(string $extension): array + { + // Categories belong to the component, so the section part of the extension is dropped. + $component = strtok($extension, '.'); + $options = []; + + foreach (HTMLHelper::_('category.options', $component) as $category) { + $options[] = ['value' => (string) $category->value, 'label' => $category->text]; + } + + return $options; + } + + /** + * All user groups. + * + * @return array> + * + * @since __DEPLOY_VERSION__ + */ + private function getUserGroupOptions(): array + { + $options = []; + + foreach (UserGroupsHelper::getInstance()->getAll() as $group) { + $options[] = ['value' => (string) $group->id, 'label' => $group->title]; + } + + return $options; + } + + /** + * Builds one declaration. + * + * @param string $name Stored key for the check. + * @param string $label Translated label. + * @param string $scope SCOPE_ITEM or SCOPE_MOMENT. + * @param string[] $operators Operators the check supports. + * @param string $valueType How the value is entered. + * @param array $options Selectable values if any. + * + * @return array + * + * @since __DEPLOY_VERSION__ + */ + private function field( + string $name, + string $label, + string $scope, + array $operators, + string $valueType = WorkflowConditionFieldsEvent::VALUE_SELECT, + array $options = [] + ): array { + return [ + 'name' => $name, + 'label' => $label, + 'scope' => $scope, + 'operators' => $operators, + 'valueType' => $valueType, + 'options' => $options, + ]; + } + + /** + * The content type row for an extension, or null when it is not registered as one. + * + * @param string $extension The workflow extension, e.g com_content.article. + * + * @return object|null + * + * @since __DEPLOY_VERSION__ + */ + private function contentTypeFor(string $extension): ?object + { + if (!\array_key_exists($extension, $this->contentTypes)) { + $db = $this->database; + $contentTypeQuery = $db->getQuery(true) + ->select($db->quoteName(['type_id', 'table'])) + ->from($db->quoteName('#__content_types')) + ->where($db->quoteName('type_alias') . ' = :extension') + ->bind(':extension', $extension); + + $this->contentTypes[$extension] = $db->setQuery($contentTypeQuery)->loadObject() ?: null; + } + + return $this->contentTypes[$extension]; + } + + /** + * The tag map's numeric type id of an extension, or null when it has none. + * + * @param string $extension The workflow's extension. + * + * @return integer|null + * + * @since __DEPLOY_VERSION__ + */ + private function typeIdFor(string $extension): ?int + { + $contentType = $this->contentTypeFor($extension); + return $contentType === null ? null : (int) $contentType->type_id; + } + + /** + * Where an extension keeps its own items: the table and its key column. + * + * @param string $extension The workflow extension, e.g. com_content.article. + * + * @return array{table: string, key: string}|null + * + * @since __DEPLOY_VERSION__ + */ + private function itemStorageFor(string $extension): ?array + { + $contentType = $this->contentTypeFor($extension); + + if ($contentType === null) { + return null; + } + + $definition = json_decode((string) $contentType->table, true); + + // Only the special table is the extension's own. The common one is always + // #__ucm_content, which is a different thing and is not reliably populated. + $table = $definition['special']['dbtable'] ?? ''; + $key = $definition['special']['key'] ?? ''; + + // Used as identifiers in a query, so validated even though they come from the database. + if (!\is_string($table) || !preg_match('/^#__[a-zA-Z0-9_]+$/', $table)) { + return null; + } + + if (!\is_string($key) || !preg_match('/^[a-zA-Z0-9_]+$/', $key)) { + return null; + } + + return ['table' => $table, 'key' => $key]; + } + + /** + * Whether an extension's own item table carries a column. + * + * @param string $extension The workflow extension. + * @param string $column The column the check needs. + * + * @return boolean + * + * @since __DEPLOY_VERSION__ + */ + private function itemTableHasColumn(string $extension, string $column): bool + { + $storage = $this->itemStorageFor($extension); + + if ($storage === null) { + return false; + } + + try { + return isset($this->database->getTableColumns($storage['table'], false)[$column]); + } catch (\Throwable) { + // A registration can outlive its table, for instance after a failed uninstall. + return false; + } + } +} diff --git a/administrator/components/com_workflow/src/Automation/ConditionEvaluationException.php b/administrator/components/com_workflow/src/Automation/ConditionEvaluationException.php new file mode 100644 index 0000000000000..87ecb08089f9b --- /dev/null +++ b/administrator/components/com_workflow/src/Automation/ConditionEvaluationException.php @@ -0,0 +1,24 @@ + + * @license GNU General Public License version 2 or later; see LICENSE.txt + */ + +namespace Joomla\Component\Workflow\Administrator\Automation; + +// phpcs:disable PSR1.Files.SideEffects +\defined('_JEXEC') or die; +// phpcs:enable PSR1.Files.SideEffects + +/** + * Thrown when a stored automation condition cannot be evaluated. + * + * @since __DEPLOY_VERSION__ + */ +final class ConditionEvaluationException extends \RuntimeException +{ +} diff --git a/administrator/components/com_workflow/src/Automation/ConditionEvaluator.php b/administrator/components/com_workflow/src/Automation/ConditionEvaluator.php new file mode 100644 index 0000000000000..939e312b279c5 --- /dev/null +++ b/administrator/components/com_workflow/src/Automation/ConditionEvaluator.php @@ -0,0 +1,356 @@ + + * @license GNU General Public License version 2 or later; see LICENSE.txt + */ + +namespace Joomla\Component\Workflow\Administrator\Automation; + +// phpcs:disable PSR1.Files.SideEffects +\defined('_JEXEC') or die; +// phpcs:enable PSR1.Files.SideEffects + +/** + * Evaluates a stored automation expression against one item's field values. + * + * Node shapes: + * check: { "field": string, "operator": string, "value": mixed, "not": true? } + * expression: { "items": [ ...nodes ], "ops": [ "and"|"or", ... ], "not": true? } + * + * Rows combine top to bottom with no AND/OR precedence, so "A or B and C" means + * "(A or B) and C". Nested expressions are the brackets. An empty expression is true. + * + * @since __DEPLOY_VERSION__ + */ +final class ConditionEvaluator +{ + /** + * Evaluates a stored expression in JSON form. + * + * @param string|null $expressionJson The stored JSON tree, or null/empty for "no restriction". + * @param callable $resolveField fn(string $fieldName): mixed - the item's value for a field. + * + * @return boolean + * + * @throws ConditionEvaluationException When the stored expression is malformed. + * + * @since __DEPLOY_VERSION__ + */ + public function evaluate(?string $expressionJson, callable $resolveField): bool + { + $decodedTree = $this->decode($expressionJson); + + return $decodedTree === null || $this->evaluateNode($decodedTree, $resolveField); + } + + /** + * Reads an expression into a tree, or null when there is no expression at all. + * + * @param string|null $expressionJson The stored expression. + * + * @return array|null + * + * @throws ConditionEvaluationException + * + * @since __DEPLOY_VERSION__ + */ + public function decode(?string $expressionJson): ?array + { + if ($expressionJson === null || trim($expressionJson) === '') { + return null; + } + + $decodedTree = json_decode($expressionJson, true); + + if (!\is_array($decodedTree)) { + throw new ConditionEvaluationException('The stored condition is not valid JSON: ' . json_last_error_msg()); + } + + return $decodedTree; + } + + /** + * Evaluates one already-decoded node. Public so unit tests can pass array trees directly. + * + * @param array $node A check or expression node. + * @param callable $resolveField The field resolver. + * + * @return boolean + * + * @throws ConditionEvaluationException + * + * @since __DEPLOY_VERSION__ + */ + public function evaluateNode(array $node, callable $resolveField): bool + { + if (\array_key_exists('items', $node)) { + return $this->evaluateExpression($node, $resolveField); + } + + if (\array_key_exists('field', $node)) { + return $this->evaluateCheck($node, $resolveField); + } + + if (\array_key_exists('op', $node) || \array_key_exists('children', $node)) { + throw new ConditionEvaluationException( + 'The condition is stored in the retired group format; open the rule in the builder and save it again.' + ); + } + + throw new ConditionEvaluationException('A condition node is neither a check nor an expression: ' . json_encode($node)); + } + + /** + * Evaluates an expression: its rows combined top to bottom through the connectors. + * + * There is no short-circuiting, so a malformed row always surfaces. + * + * @param array $expressionNode The expression node. + * @param callable $resolveField The field resolver. + * + * @return boolean + * + * @throws ConditionEvaluationException + * + * @since __DEPLOY_VERSION__ + */ + private function evaluateExpression(array $expressionNode, callable $resolveField): bool + { + $items = $expressionNode['items']; + $connectors = $expressionNode['ops'] ?? null; + + if (!\is_array($items) || $items === []) { + throw new ConditionEvaluationException('An expression has no items; empty expressions must be pruned before saving.'); + } + + if (!\is_array($connectors) || \count($connectors) !== \count($items) - 1) { + throw new ConditionEvaluationException(\sprintf( + 'An expression has %d items but %s connectors; expected exactly %d.', + \count($items), + \is_array($connectors) ? \count($connectors) : 'no', + \count($items) - 1 + )); + } + + $result = $this->evaluateNode($items[0], $resolveField); + + foreach ($connectors as $index => $connector) { + $nextResult = $this->evaluateNode($items[$index + 1], $resolveField); + + $result = match ($connector) { + 'and' => $result && $nextResult, + 'or' => $result || $nextResult, + default => throw new ConditionEvaluationException( + 'Unknown connector "' . $connector . '" in an expression; expected "and" or "or".' + ), + }; + } + + return ($expressionNode['not'] ?? false) === true ? !$result : $result; + } + + /** + * Evaluates a single check by resolving the item's value and comparing it. + * + * @param array $checkNode The check node. + * @param callable $resolveField The field resolver. + * + * @return boolean + * + * @throws ConditionEvaluationException + * + * @since __DEPLOY_VERSION__ + */ + private function evaluateCheck(array $checkNode, callable $resolveField): bool + { + $fieldName = (string) ($checkNode['field'] ?? ''); + $operatorName = strtolower(trim((string) ($checkNode['operator'] ?? ''))); + + if ($fieldName === '') { + throw new ConditionEvaluationException('A check has no field.'); + } + + if ($operatorName === '') { + throw new ConditionEvaluationException('The check on "' . $fieldName . '" has no operator.'); + } + + if (!\array_key_exists('value', $checkNode)) { + throw new ConditionEvaluationException('The check on "' . $fieldName . '" has no value.'); + } + + $result = $this->compare($resolveField($fieldName), $operatorName, $checkNode['value'], $fieldName); + + return ($checkNode['not'] ?? false) === true ? !$result : $result; + } + + /** + * Applies a single comparison operator. + * + * A list is never narrowed to its first element, but a single value may widen to a list. + * + * @param mixed $actualValue The item's value. + * @param string $operatorName The comparison operator. + * @param mixed $expectedValue The value stored on the rule. + * @param string $fieldName The field name, for error messages. + * + * @return boolean + * + * @throws ConditionEvaluationException + * + * @since __DEPLOY_VERSION__ + */ + private function compare($actualValue, string $operatorName, $expectedValue, string $fieldName): bool + { + switch ($operatorName) { + case 'is': + return $this->asScalarString($actualValue, $fieldName) === $this->asScalarString($expectedValue, $fieldName); + + case 'is not': + return !$this->compare($actualValue, 'is', $expectedValue, $fieldName); + + case 'in': + return \in_array($this->asScalarString($actualValue, $fieldName), $this->asStringList($expectedValue), true); + + case 'not in': + return !$this->compare($actualValue, 'in', $expectedValue, $fieldName); + + case 'has any': + return array_intersect($this->asStringList($actualValue), $this->asStringList($expectedValue)) !== []; + + case 'has all': + return array_diff($this->asStringList($expectedValue), $this->asStringList($actualValue)) === []; + + case 'has none': + return !$this->compare($actualValue, 'has any', $expectedValue, $fieldName); + + case 'before': + return $this->toTimestamp($actualValue, $fieldName) < $this->toTimestamp($expectedValue, $fieldName); + + case 'after': + return $this->toTimestamp($actualValue, $fieldName) > $this->toTimestamp($expectedValue, $fieldName); + + case 'on': + // Dates compare at calendar-day granularity: the resolver returns a + // full datetime while the form submits Y-m-d. + return gmdate('Y-m-d', $this->toTimestamp($actualValue, $fieldName)) + === gmdate('Y-m-d', $this->toTimestamp($expectedValue, $fieldName)); + + case 'not on': + return !$this->compare($actualValue, 'on', $expectedValue, $fieldName); + + case 'greater than': + return $this->asNumber($actualValue, $fieldName) > $this->asNumber($expectedValue, $fieldName); + + case 'less than': + return $this->asNumber($actualValue, $fieldName) < $this->asNumber($expectedValue, $fieldName); + + default: + throw new ConditionEvaluationException('Unknown operator "' . $operatorName . '" on the "' . $fieldName . '" check.'); + } + } + + /** + * Asserts a value is a single scalar and returns it as a string. + * + * @param mixed $value The value to check. + * @param string $fieldName The field name, for error messages. + * + * @return string + * + * @throws ConditionEvaluationException When the value is a list or missing. + * + * @since __DEPLOY_VERSION__ + */ + private function asScalarString($value, string $fieldName): string + { + if (\is_array($value)) { + throw new ConditionEvaluationException( + 'The "' . $fieldName . '" check expected a single value but got a list; use a membership operator instead.' + ); + } + + if ($value === null) { + throw new ConditionEvaluationException('The "' . $fieldName . '" check has no value to compare.'); + } + + return (string) $value; + } + + /** + * Asserts a value is numeric and returns it as a float. + * + * @param mixed $value The value to check. + * @param string $fieldName The field name, for error messages. + * + * @return float + * + * @throws ConditionEvaluationException When the value is not a number. + * + * @since __DEPLOY_VERSION__ + */ + private function asNumber($value, string $fieldName): float + { + if (\is_array($value)) { + throw new ConditionEvaluationException( + 'The "' . $fieldName . '" check expected a single number but got a list.' + ); + } + + // Not cast, because text would read as zero and quietly pass numeric comparisons. + if (!is_numeric($value)) { + throw new ConditionEvaluationException( + 'The "' . $fieldName . '" check expected a number but got "' . var_export($value, true) . '".' + ); + } + + return (float) $value; + } + + /** + * Normalises a value into a list of strings. A single value widens to a + * one-element list; an existing list keeps every element. + * + * @param mixed $value A scalar or a list. + * + * @return string[] + * + * @since __DEPLOY_VERSION__ + */ + private function asStringList($value): array + { + return array_map('strval', array_values((array) $value)); + } + + /** + * Parses a datetime string into a Unix timestamp, failing loudly on garbage. + * + * @param mixed $value The datetime string. + * @param string $fieldName The field name, for error messages. + * + * @return integer + * + * @throws ConditionEvaluationException When the value cannot be parsed. + * + * @since __DEPLOY_VERSION__ + */ + private function toTimestamp($value, string $fieldName): int + { + if (\is_array($value)) { + throw new ConditionEvaluationException('The "' . $fieldName . '" check expected a date but got a list.'); + } + + $parsedTimestamp = strtotime((string) $value); + + if ($parsedTimestamp === false) { + throw new ConditionEvaluationException( + 'Cannot parse "' . $value . '" as a date/time on the "' . $fieldName . '" check.' + ); + } + + return $parsedTimestamp; + } +} diff --git a/administrator/components/com_workflow/src/Automation/ConditionWindowCalculator.php b/administrator/components/com_workflow/src/Automation/ConditionWindowCalculator.php new file mode 100644 index 0000000000000..c4ef1f6550912 --- /dev/null +++ b/administrator/components/com_workflow/src/Automation/ConditionWindowCalculator.php @@ -0,0 +1,125 @@ + + * @license GNU General Public License version 2 or later; see LICENSE.txt + */ + +namespace Joomla\Component\Workflow\Administrator\Automation; + +use Joomla\Database\DatabaseInterface; + +// phpcs:disable PSR1.Files.SideEffects +\defined('_JEXEC') or die; +// phpcs:enable PSR1.Files.SideEffects + +/** + * Works out when a rule's fire condition next opens its execution window. + * + * Steps one UTC day at a time from the deadline, because the built-in moment checks change at + * most once a day. Gives up after HORIZON_DAYS. + * + * @since __DEPLOY_VERSION__ + */ +final class ConditionWindowCalculator +{ + /** + * How far ahead to search before reporting that the window never opens. A year covers + * weekly and annual patterns while keeping the worst case bounded. + * + * @var integer + * @since __DEPLOY_VERSION__ + */ + private const HORIZON_DAYS = 366; + + /** + * @var ConditionEvaluator + * @since __DEPLOY_VERSION__ + */ + private ConditionEvaluator $conditionEvaluator; + + /** + * @var ItemFieldResolver + * @since __DEPLOY_VERSION__ + */ + private ItemFieldResolver $itemFieldResolver; + + /** + * @param DatabaseInterface $database The database driver. + * + * @since __DEPLOY_VERSION__ + */ + public function __construct(DatabaseInterface $database) + { + $this->conditionEvaluator = new ConditionEvaluator(); + $this->itemFieldResolver = new ItemFieldResolver($database); + } + + /** + * Returns the first moment at or after $earliestExecutionTime at which the condition holds. + * + * @param string|null $conditionJson The stored fire condition, or null/empty for none. + * @param \DateTime $earliestExecutionTime The deadline to start searching from (UTC). + * @param integer $itemId The content item id. + * @param string $extension The workflow extension. + * + * @return \DateTime|null The moment the rule may fire, or null if the window never opens + * within the search horizon. + * + * @throws ConditionEvaluationException When the stored condition is malformed. + * + * @since __DEPLOY_VERSION__ + */ + public function firstMatchAtOrAfter( + ?string $conditionJson, + \DateTime $earliestExecutionTime, + int $itemId, + string $extension + ): ?\DateTime { + if ($conditionJson === null || trim($conditionJson) === '') { + return $earliestExecutionTime; + } + + if ($this->holdsAt($conditionJson, $earliestExecutionTime, $itemId, $extension)) { + return $earliestExecutionTime; + } + + // Midnight is the earliest moment of any day that qualifies. + $candidate = (clone $earliestExecutionTime)->setTime(0, 0, 0)->modify('+1 day'); + + for ($dayOffset = 0; $dayOffset < self::HORIZON_DAYS; $dayOffset++) { + if ($this->holdsAt($conditionJson, $candidate, $itemId, $extension)) { + return clone $candidate; + } + + $candidate->modify('+1 day'); + } + + return null; + } + + /** + * Whether the condition would hold for this item at a given moment. + * + * @param string $conditionJson The stored fire condition. + * @param \DateTime $evaluationTime The moment to test. + * @param integer $itemId The content item id. + * @param string $extension The workflow extension. + * + * @return boolean + * + * @throws ConditionEvaluationException + * + * @since __DEPLOY_VERSION__ + */ + private function holdsAt(string $conditionJson, \DateTime $evaluationTime, int $itemId, string $extension): bool + { + return $this->conditionEvaluator->evaluate( + $conditionJson, + $this->itemFieldResolver->forItem($itemId, $extension, $evaluationTime) + ); + } +} diff --git a/administrator/components/com_workflow/src/Automation/DeadlineCalculator.php b/administrator/components/com_workflow/src/Automation/DeadlineCalculator.php new file mode 100644 index 0000000000000..6ed17917c5f23 --- /dev/null +++ b/administrator/components/com_workflow/src/Automation/DeadlineCalculator.php @@ -0,0 +1,80 @@ + + * @license GNU General Public License version 2 or later; see LICENSE.txt + */ + +namespace Joomla\Component\Workflow\Administrator\Automation; + +use Cron\CronExpression; +use DateTime; +use Joomla\CMS\Factory; + +// phpcs:disable PSR1.Files.SideEffects +\defined('_JEXEC') or die; +// phpcs:enable PSR1.Files.SideEffects + +/** + * Works out when an automation rule fires for a given stage-entry time. + * + * Shared by the scheduler and the upcoming transitions views, so both compute a deadline the + * same way. + * + * @since __DEPLOY_VERSION__ + */ +final class DeadlineCalculator +{ + /** + * Computes the datetime a rule fires for a given entry time. + * + * @param string $enteredAt When the item entered the stage (SQL datetime, UTC). + * @param object $rule The rule row (rule_type, delay_value, delay_unit, cron_expression). + * + * @return \DateTime|null The fire time in UTC, or null if it cannot be computed. + * + * @since __DEPLOY_VERSION__ + */ + public static function forRule(string $enteredAt, object $rule): ?\DateTime + { + // Every value parsed here comes from the database. The form validates a rule on save, so a + // row that fails below was written by something else: a restored backup, direct SQL, a + // partial import. + try { + if ($rule->rule_type === 'cron') { + if (empty($rule->cron_expression)) { + return null; + } + + $cronExpression = new CronExpression($rule->cron_expression); + $deadline = $cronExpression->getNextRunDate( + $enteredAt, + 0, + false, + Factory::getApplication()->get('offset', 'UTC') + ); + $deadline->setTimezone(new \DateTimeZone('UTC')); + + return $deadline; + } + + $date = new \DateTime($enteredAt, new \DateTimeZone('UTC')); + $delay = match ($rule->delay_unit) { + 'minutes' => new \DateInterval('PT' . $rule->delay_value . 'M'), + 'hours' => new \DateInterval('PT' . $rule->delay_value . 'H'), + 'days' => new \DateInterval('P' . $rule->delay_value . 'D'), + 'months' => new \DateInterval('P' . $rule->delay_value . 'M'), + default => null, + }; + + return $delay ? $date->add($delay) : null; + } catch (\Throwable) { + // Null tells the caller this rule cannot be scheduled: the scheduler records that + // against the item, a view leaves the row out. + return null; + } + } +} diff --git a/administrator/components/com_workflow/src/Automation/FilterPreview.php b/administrator/components/com_workflow/src/Automation/FilterPreview.php new file mode 100644 index 0000000000000..9ee62557d85d1 --- /dev/null +++ b/administrator/components/com_workflow/src/Automation/FilterPreview.php @@ -0,0 +1,226 @@ + + * @license GNU General Public License version 2 or later; see LICENSE.txt + */ + +namespace Joomla\Component\Workflow\Administrator\Automation; + +use Joomla\Database\DatabaseInterface; +use Joomla\Database\ParameterType; + +// phpcs:disable PSR1.Files.SideEffects +\defined('_JEXEC') or die; +// phpcs:enable PSR1.Files.SideEffects + +/** + * Answers which items an item filter would match, before the rule is saved. + * + * Uses the same ConditionEvaluator and ItemFieldResolver as the scheduler. + * + * @since __DEPLOY_VERSION__ + */ +final class FilterPreview +{ + /** + * How many items are examined before the answer is reported as a partial one. + * + * @since __DEPLOY_VERSION__ + */ + public const DEFAULT_LIMIT = 200; + + /** + * How many matched items are described back to the caller, independent of the scan cap. + * + * @var integer + * @since __DEPLOY_VERSION__ + */ + public const LISTED_LIMIT = 200; + + /** + * @var DatabaseInterface + * @since __DEPLOY_VERSION__ + */ + private DatabaseInterface $database; + + /** + * @param DatabaseInterface $database The database driver. + * + * @since __DEPLOY_VERSION__ + */ + public function __construct(DatabaseInterface $database) + { + $this->database = $database; + } + + /** + * Evaluates an unsaved filter against the items a transition could act on. + * + * @param integer $transitionId The transition the rule belongs to. + * @param string|null $filterJson The expression as the builder currently has it. + * @param integer $limit How many items to examine at most. + * + * @return array{scanned:int, matched:int, items:array, extension:string, capped:bool} + * + * @throws ConditionEvaluationException When the expression cannot be read. + * + * @since __DEPLOY_VERSION__ + */ + public function forTransition( + int $transitionId, + ?string $filterJson, + int $limit = self::DEFAULT_LIMIT + ): array { + $empty = ['scanned' => 0, 'matched' => 0, 'titles' => [], 'capped' => false]; + $evaluator = new ConditionEvaluator(); + + $filterTree = $evaluator->decode($filterJson); + $transition = $this->describeTransition($transitionId); + if ($transition === null) { + return $empty; + } + + $extension = $transition->extension; + $candidateIds = $this->itemsInScope($transition, $limit); + + if ($candidateIds === []) { + return $empty; + } + + $itemStorage = new ItemStorage($this->database); + $scanIds = array_values( + array_diff($candidateIds, $itemStorage->trashedOrArchivedIds($candidateIds, $extension)) + ); + + $fieldResolver = new ItemFieldResolver($this->database); + $fieldResolver->preload($scanIds, $extension); + + $matchedIds = []; + + foreach ($scanIds as $itemId) { + if ( + $filterTree === null + || $evaluator->evaluateNode($filterTree, $fieldResolver->forItem($itemId, $extension)) + ) { + $matchedIds[] = $itemId; + } + } + + return [ + 'scanned' => \count($scanIds), + 'matched' => \count($matchedIds), + 'items' => $this->describeMatches($itemStorage, $matchedIds, $extension), + 'extension' => $extension, + 'capped' => \count($candidateIds) >= $limit, + ]; + } + + /** + * Pairs each matched id with its title, so a caller can link to the item. + * + * An id with no title is labelled with the id rather than dropped. + * + * @param ItemStorage $itemStorage Resolves titles for the extension. + * @param int[] $matchedIds Every id that passed the filter. + * @param string $extension The workflow extension. + * + * @return array + * + * @since __DEPLOY_VERSION__ + */ + private function describeMatches(ItemStorage $itemStorage, array $matchedIds, string $extension): array + { + $listedIds = \array_slice($matchedIds, 0, self::LISTED_LIMIT); + $titles = $itemStorage->titlesFor($listedIds, $extension); + $described = []; + + foreach ($listedIds as $itemId) { + $described[] = [ + 'id' => $itemId, + 'title' => $titles[$itemId] ?? (string) $itemId, + ]; + } + + return $described; + } + + /** + * The transition's source stage and the extension its workflow belongs to. + * + * The extension comes from the workflow, not the request, because URLs may carry only + * "com_content" while #__workflow_associations stores "com_content.article". + * + * @param integer $transitionId The transition to describe. + * + * @return object|null + * + * @since __DEPLOY_VERSION__ + */ + public function describeTransition(int $transitionId): ?object + { + $db = $this->database; + + $query = $db->getQuery(true) + ->select( + [ + $db->quoteName('t.from_stage_id'), + $db->quoteName('t.workflow_id'), + $db->quoteName('w.extension'), + ] + ) + ->from($db->quoteName('#__workflow_transitions', 't')) + ->join( + 'INNER', + $db->quoteName('#__workflows', 'w'), + $db->quoteName('w.id') . ' = ' . $db->quoteName('t.workflow_id') + ) + ->where($db->quoteName('t.id') . ' = :transitionId') + ->bind(':transitionId', $transitionId, ParameterType::INTEGER); + + return $db->setQuery($query)->loadObject(); + } + + /** + * The items a transition could act on, newest first. + * + * @param object $transition A row from loadTransition(). + * @param integer $limit How many ids to return at most. + * + * @return int[] + * + * @since __DEPLOY_VERSION__ + */ + private function itemsInScope(object $transition, int $limit): array + { + $db = $this->database; + $fromStageId = (int) $transition->from_stage_id; + $workflowId = (int) $transition->workflow_id; + $extension = $transition->extension; + + $query = $db->getQuery(true) + ->select($db->quoteName('wis.item_id')) + ->from($db->quoteName('#__workflow_item_state', 'wis')) + ->where($db->quoteName('wis.extension') . ' = :extension') + ->bind(':extension', $extension, ParameterType::STRING) + ->order($db->quoteName('wis.item_id') . ' DESC'); + + if ($fromStageId === -1) { + // A wildcard transition starts from any stage in its own workflow. + $query->join( + 'INNER', + $db->quoteName('#__workflow_stages', 'ws'), + $db->quoteName('ws.id') . ' = ' . $db->quoteName('wis.stage_id') + ) + ->where($db->quoteName('ws.workflow_id') . ' = :workflowId') + ->bind(':workflowId', $workflowId, ParameterType::INTEGER); + } else { + $query->where($db->quoteName('wis.stage_id') . ' = :stageId') + ->bind(':stageId', $fromStageId, ParameterType::INTEGER); + } + + return array_map('intval', $db->setQuery($query, 0, $limit)->loadColumn()); + } +} diff --git a/administrator/components/com_workflow/src/Automation/ItemFieldResolver.php b/administrator/components/com_workflow/src/Automation/ItemFieldResolver.php new file mode 100644 index 0000000000000..e78452a9f64ae --- /dev/null +++ b/administrator/components/com_workflow/src/Automation/ItemFieldResolver.php @@ -0,0 +1,233 @@ + + * @license GNU General Public License version 2 or later; see LICENSE.txt + */ + +namespace Joomla\Component\Workflow\Administrator\Automation; + +use Joomla\CMS\Event\Workflow\WorkflowResolveFieldsEvent; +use Joomla\CMS\Factory; +use Joomla\CMS\Plugin\PluginHelper; +use Joomla\Database\DatabaseInterface; +use Joomla\Event\DispatcherInterface; + +// phpcs:disable PSR1.Files.SideEffects +\defined('_JEXEC') or die; +// phpcs:enable PSR1.Files.SideEffects + +/** + * Resolves check values for the items a rule is being evaluated against. + * + * Batched lazily: callers announce their items with preload(), and each check is resolved for + * the whole set the first time it is needed. + * + * @since __DEPLOY_VERSION__ + */ +final class ItemFieldResolver +{ + /** + * The checks Joomla ships with. Held for the resolver's lifetime so its content type + * lookups are cached across every check a run evaluates. + * + * @var BuiltinConditionFields + * @since __DEPLOY_VERSION__ + */ + private BuiltinConditionFields $builtinFields; + + /** + * The items a caller announced through preload(), grouped by extension, so a check can be + * resolved for a whole set at once the first time it is asked for. + * + * @var array + * @since __DEPLOY_VERSION__ + */ + private array $batchItemIds = []; + + /** + * Resolved values, keyed by extension, check and moment, then by item id. + * + * @var array> + * @since __DEPLOY_VERSION__ + */ + private array $resolved = []; + + /** + * Which items have already been asked about, keyed like $resolved. Needed because a key + * resolved earlier may not cover the items announced after it. + * + * @var array> + * @since __DEPLOY_VERSION__ + */ + private array $asked = []; + + /** + * @param DatabaseInterface $database The database driver. + * + * @since __DEPLOY_VERSION__ + */ + public function __construct(DatabaseInterface $database) + { + $this->builtinFields = new BuiltinConditionFields($database); + } + + /** + * Announces the items about to be evaluated, so checks can be resolved for the whole set. + * + * @param int[] $itemIds The content item ids. + * @param string $extension The workflow extension those ids belong to. + * + * @return void + * + * @since __DEPLOY_VERSION__ + */ + public function preload(array $itemIds, string $extension): void + { + $this->batchItemIds[$extension] = array_values(array_unique( + array_merge($this->batchItemIds[$extension] ?? [], array_map('intval', $itemIds)) + )); + } + + /** + * Builds a resolver callback bound to one item, for use by the ConditionEvaluator. + * + * @param integer $itemId The content item id. + * @param string $extension The workflow extension, e.g. com_content.article. + * @param \DateTime|null $evaluationTime The moment clock-based checks should describe, + * or null for now. + * + * @return callable fn(string $fieldName): mixed + * + * @since __DEPLOY_VERSION__ + */ + public function forItem(int $itemId, string $extension, ?\DateTime $evaluationTime = null): callable + { + return function (string $fieldName) use ($itemId, $extension, $evaluationTime) { + return $this->valueFor($fieldName, $itemId, $extension, $evaluationTime); + }; + } + + /** + * One item's value for one check, resolving the whole batch on first use. + * + * @param string $fieldName The check. + * @param integer $itemId The item. + * @param string $extension The workflow extension. + * @param \DateTime|null $evaluationTime The moment to describe, or null for now. + * + * @return mixed + * + * @throws ConditionEvaluationException When nothing provides the check, or whatever does + * had no value for this item. + * + * @since __DEPLOY_VERSION__ + */ + private function valueFor(string $fieldName, int $itemId, string $extension, ?\DateTime $evaluationTime) + { + $cacheKey = $extension . '|' . $fieldName . '|' + . ($evaluationTime ? $evaluationTime->getTimestamp() : 'now'); + + $announced = $this->batchItemIds[$extension] ?? []; + + if (!isset($this->asked[$cacheKey][$itemId])) { + $itemIds = \in_array($itemId, $announced, true) ? $announced : [$itemId]; + + // Merged, because an earlier batch under this key may hold other items' answers. + $this->resolved[$cacheKey] = ($this->resolved[$cacheKey] ?? []) + + $this->resolveBatch($fieldName, $itemIds, $extension, $evaluationTime); + + foreach ($itemIds as $askedItemId) { + $this->asked[$cacheKey][$askedItemId] = true; + } + } + + // A missing value, for example from a remote check that timed out, is not an empty one. + // Comparing against it would be a guess, so the item is skipped instead. + if (!\array_key_exists($itemId, $this->resolved[$cacheKey])) { + throw new ConditionEvaluationException(\sprintf( + 'Nothing could resolve a value for the "%s" check on item %s.%d.', + $fieldName, + $extension, + $itemId + )); + } + + return $this->resolved[$cacheKey][$itemId]; + } + + /** + * Resolves one check for a set of items, from Joomla's own checks or from a plugin. + * + * @param string $fieldName The check. + * @param int[] $itemIds The items. + * @param string $extension The workflow extension. + * @param \DateTime|null $evaluationTime The moment to describe, or null for now + * + * @return array + * + * @throws ConditionEvaluationException When nothing provides the check. + * + * @since __DEPLOY_VERSION__ + */ + private function resolveBatch( + string $fieldName, + array $itemIds, + string $extension, + ?\DateTime $evaluationTime + ): array { + $builtinValues = $this->builtinFields->resolve($fieldName, $itemIds, $extension, $evaluationTime); + + // Null means the check is not a built-in; an empty array is a built-in with no values. + if ($builtinValues !== null) { + return $builtinValues; + } + + return $this->askPlugins($fieldName, $itemIds, $extension, $evaluationTime); + } + + /** + * Asks the workflow plugins to resolve one check for a set of items. + * + * @param string $fieldName The check. + * @param int[] $itemIds The items. + * @param string $extension The workflow extension. + * @param \DateTime|null $evaluationTime The moment to describe, or null for now. + * + * @return array + * + * @throws ConditionEvaluationException When no plugin claims the check. + * + * @since __DEPLOY_VERSION__ + */ + private function askPlugins(string $fieldName, array $itemIds, string $extension, ?\DateTime $evaluationTime): array + { + PluginHelper::importPlugin('workflow'); + + $event = new WorkflowResolveFieldsEvent( + 'onWorkflowResolveConditionFields', + [ + 'field' => $fieldName, + 'itemIds' => $itemIds, + 'extension' => $extension, + 'evaluationTime' => $evaluationTime, + ] + ); + + Factory::getContainer()->get(DispatcherInterface::class)->dispatch($event->getName(), $event); + + if (!$event->isAnswered()) { + throw new ConditionEvaluationException(\sprintf( + 'No installed extension provides the "%s" check that this rule uses on %s. ' + . 'The extension that supplied it has probably been disabled or uninstalled.', + $fieldName, + $extension + )); + } + + return $event->getValues(); + } +} diff --git a/administrator/components/com_workflow/src/Automation/ItemStorage.php b/administrator/components/com_workflow/src/Automation/ItemStorage.php new file mode 100644 index 0000000000000..8c306bfbf3e09 --- /dev/null +++ b/administrator/components/com_workflow/src/Automation/ItemStorage.php @@ -0,0 +1,375 @@ + + * @license GNU General Public License version 2 or later; see LICENSE.txt + */ + +namespace Joomla\Component\Workflow\Administrator\Automation; + +use Joomla\CMS\Table\Table; +use Joomla\CMS\Workflow\Workflow; +use Joomla\Database\DatabaseInterface; + +// phpcs:disable PSR1.Files.SideEffects +\defined('_JEXEC') or die; +// phpcs:enable PSR1.Files.SideEffects + +/** + * Finds where an extension keeps its items, and which of them are trashed or archived. + * + * @since __DEPLOY_VERSION__ + */ +final class ItemStorage +{ + /** + * @var DatabaseInterface + * @since __DEPLOY_VERSION__ + */ + private DatabaseInterface $database; + + /** + * Content type rows already looked up, keyed by extension. Null means not registered. + * + * @var array + * @since __DEPLOY_VERSION__ + */ + private array $contentTypes = []; + + /** + * @param DatabaseInterface $database The database driver. + * + * @since __DEPLOY_VERSION__ + */ + public function __construct(DatabaseInterface $database) + { + $this->database = $database; + } + + /** + * Which of these items are trashed or archived. + * + * An extension this cannot answer for returns nothing, so automation carries on rather than + * silently stopping for the whole component. + * + * @param int[] $itemIds The items being considered. + * @param string $extension The workflow extension, e.g. com_content.article. + * + * @return int[] + * + * @since __DEPLOY_VERSION__ + */ + public function trashedOrArchivedIds(array $itemIds, string $extension): array + { + $itemIds = array_values(array_unique(array_map('intval', $itemIds))); + $storage = $this->locate($extension); + + if ($itemIds === [] || $storage === null) { + return []; + } + + $stateColumn = $this->columnFor($extension, 'published'); + + if ($stateColumn === null) { + return []; + } + + $db = $this->database; + $trashedOrArchivedQuery = $db->getQuery(true) + ->select($db->quoteName($storage['key'])) + ->from($db->quoteName($storage['table'])) + ->whereIn($db->quoteName($storage['key']), $itemIds) + ->whereIn($db->quoteName($stateColumn), [Workflow::CONDITION_TRASHED, Workflow::CONDITION_ARCHIVED]); + + return array_map('intval', $db->setQuery($trashedOrArchivedQuery)->loadColumn() ?: []); + } + + /** + * The display title of each item, keyed by id. + * + * @param int[] $itemIds The items to name. + * @param string $extension The workflow extension, e.g. com_content.article. + * + * @return array Item id to title, omitting any the query did not return. + * + * @since __DEPLOY_VERSION__ + */ + + public function titlesFor(array $itemIds, string $extension): array + { + $itemIds = array_values(array_unique(array_map('intval', $itemIds))); + $storage = $this->locate($extension); + + if ($itemIds === [] || $storage === null) { + return []; + } + + $titleColumn = $this->columnFor($extension, 'title'); + + if ($titleColumn === null) { + return []; + } + + $db = $this->database; + $query = $db->getQuery(true) + ->select($db->quoteName([$storage['key'], $titleColumn])) + ->from($db->quoteName($storage['table'])) + ->whereIn($db->quoteName($storage['key']), $itemIds); + + $titles = []; + + foreach ($db->setQuery($query)->loadAssocList() ?: [] as $row) { + $titles[(int) $row[$storage['key']]] = (string) $row[$titleColumn]; + } + + return $titles; + } + + /** + * The category each item is filed in, keyed by item id. + * + * @param int[] $itemIds The items to look up. + * @param string $extension The workflow extension, e.g. com_content.article. + * + * @return array Omits items the extension does not file. + * + * @since __DEPLOY_VERSION__ + */ + public function categoriesFor(array $itemIds, string $extension): array + { + $itemIds = array_values(array_unique(array_map('intval', $itemIds))); + $storage = $this->locate($extension); + + if ($itemIds === [] || $storage === null) { + return []; + } + + $categoryColumn = $this->columnFor($extension, 'catid'); + + if ($categoryColumn === null) { + return []; + } + + $db = $this->database; + $query = $db->getQuery(true) + ->select([ + $db->quoteName('item.' . $storage['key'], 'item_id'), + $db->quoteName('category.id', 'category_id'), + $db->quoteName('category.title', 'category_title'), + $db->quoteName('category.extension', 'category_extension'), + ]) + ->from($db->quoteName($storage['table'], 'item')) + ->join( + 'INNER', + $db->quoteName('#__categories', 'category'), + $db->quoteName('category.id') . ' = ' . $db->quoteName('item.' . $categoryColumn) + ) + ->whereIn($db->quoteName('item.' . $storage['key']), $itemIds); + + $categories = []; + + foreach ($db->setQuery($query)->loadAssocList() ?: [] as $row) { + $categories[(int) $row['item_id']] = [ + 'id' => (int) $row['category_id'], + 'title' => (string) $row['category_title'], + 'extension' => (string) $row['category_extension'], + ]; + } + + return $categories; + } + + /** + * Fills in each row's item title and category, keyed by that row's own extension. + * + * Titles repeat, so the category is what tells two items of the same name apart. + * + * @param object[] $rows Rows carrying item_id and extension. + * + * @return object[] + * + * @since __DEPLOY_VERSION__ + */ + public function annotateTitles(array $rows): array + { + if ($rows === []) { + return $rows; + } + + $itemIdsByExtension = []; + + foreach ($rows as $row) { + $itemIdsByExtension[$row->extension][] = (int) $row->item_id; + } + + $titles = []; + $categories = []; + + foreach ($itemIdsByExtension as $extension => $itemIds) { + foreach ($this->titlesFor($itemIds, $extension) as $itemId => $title) { + // An item id is only unique within its own extension. + $titles[$extension . '.' . $itemId] = $title; + } + + foreach ($this->categoriesFor($itemIds, $extension) as $itemId => $category) { + $categories[$extension . '.' . $itemId] = $category; + } + } + + foreach ($rows as $row) { + $key = $row->extension . '.' . $row->item_id; + $category = $categories[$key] ?? null; + + $row->item_title = $titles[$key] ?? null; + $row->item_category_id = $category['id'] ?? null; + $row->item_category_title = $category['title'] ?? null; + $row->item_category_extension = $category['extension'] ?? null; + } + + return $rows; + } + + /** + * Where an extension keeps its own items: the table and its key column. + * + * @param string $extension The workflow extension. + * + * @return array{table: string, key: string}|null + * + * @since __DEPLOY_VERSION__ + */ + public function locate(string $extension): ?array + { + $contentType = $this->contentTypeFor($extension); + + if ($contentType === null) { + return null; + } + + $definition = json_decode((string) $contentType->table, true); + + // Only the special table is the extension's own. The common one is always #__ucm_content, + // which is not reliably populated. + $table = $definition['special']['dbtable'] ?? ''; + $key = $definition['special']['key'] ?? ''; + + if (!\is_string($table) || !preg_match('/^#__[a-zA-Z0-9_]+$/', $table)) { + return null; + } + + if (!\is_string($key) || !preg_match('/^[a-zA-Z0-9_]+$/', $key)) { + return null; + } + + return ['table' => $table, 'key' => $key]; + } + + /** + * Where an extension keeps its items and which column holds their titles. + * + * Every value returned has been validated, so it is safe to pass through quoteName(). + * + * @param string $extension The workflow extension, e.g. com_content.article. + * + * @return array|null ['table' => ..., 'key' => ..., 'titleColumn' => ...], or null when + * the extension does not describe itself well enough to be searched. + * + * @since __DEPLOY_VERSION__ + */ + public function titleLocation(string $extension): ?array + { + $location = $this->locate($extension); + + if ($location === null) { + return null; + } + + $titleColumn = $this->columnFor($extension, 'title'); + + if ($titleColumn === null) { + return null; + } + + return $location + ['titleColumn' => $titleColumn]; + } + + /** + * Which real column an extension uses for one of Joomla's logical field names. + * + * Asks the extension's Table class through getColumnAlias(), because guessing from the columns + * fails: #__contact_details has a "state" column that holds an address. + * + * @param string $extension The workflow extension. + * @param string $logicalName A Joomla field name, for instance published or title. + * + * @return string|null + * + * @since __DEPLOY_VERSION__ + */ + private function columnFor(string $extension, string $logicalName): ?string + { + $contentType = $this->contentTypeFor($extension); + + if ($contentType === null) { + return null; + } + + $definition = json_decode((string) $contentType->table, true); + $class = ($definition['special']['prefix'] ?? '') . ($definition['special']['type'] ?? ''); + + // A registration can outlive its extension, for instance after a failed uninstall. + if (!\is_string($class) || $class === '' || !class_exists($class)) { + return null; + } + + try { + $table = new $class($this->database); + } catch (\Throwable) { + // Some Table constructors need more than a database driver. + return null; + } + + if (!$table instanceof Table) { + return null; + } + + // getColumnAlias() hands back the name unchanged when there is no alias, whether or not the + // table has such a column, so a table without categories would otherwise reach the query. + if (!$table->hasField($logicalName)) { + return null; + } + + $column = $table->getColumnAlias($logicalName); + + // Validated because it reaches a query as an identifier. + return preg_match('/^[a-zA-Z0-9_]+$/', (string) $column) ? (string) $column : null; + } + + /** + * The content type row for an extension, or null when it is not registered as one. + * + * @param string $extension The workflow extension + * + * @return object|null + * + * @since __DEPLOY_VERSION__ + */ + private function contentTypeFor(string $extension): ?object + { + if (!\array_key_exists($extension, $this->contentTypes)) { + $db = $this->database; + $query = $db->getQuery(true) + ->select($db->quoteName('table')) + ->from($db->quoteName('#__content_types')) + ->where($db->quoteName('type_alias') . ' = :extension') + ->bind(':extension', $extension); + + $this->contentTypes[$extension] = $db->setQuery($query)->loadObject() ?: null; + } + + return $this->contentTypes[$extension]; + } +} diff --git a/administrator/components/com_workflow/src/Automation/RelativeTime.php b/administrator/components/com_workflow/src/Automation/RelativeTime.php new file mode 100644 index 0000000000000..c2495413fdaab --- /dev/null +++ b/administrator/components/com_workflow/src/Automation/RelativeTime.php @@ -0,0 +1,65 @@ + + * @license GNU General Public License version 2 or later; see LICENSE.txt + */ + +namespace Joomla\Component\Workflow\Administrator\Automation; + +use Joomla\CMS\Language\Text; + +// phpcs:disable PSR1.Files.SideEffects +\defined('_JEXEC') or die; +// phpcs:enable PSR1.Files.SideEffects + +/** + * Formats how long until an automated transition fires, as a short translated phrase. + * + * @since __DEPLOY_VERSION__ + */ +final class RelativeTime +{ + /** + * Returns a translated "In X units" phrase for a future time, or "Due now" for a past one. + * + * @param \DateTime $target The moment to count down to (UTC). + * @param \DateTime|null $now The reference now (UTC); defaults to the current time + * + * @return string + * + * @since __DEPLOY_VERSION__ + */ + public static function until(\DateTime $target, ?\DateTime $now = null): string + { + $now = $now ?? new \DateTime('now', new \DateTimeZone('UTC')); + $seconds = $target->getTimestamp() - $now->getTimestamp(); + + if ($seconds <= 0) { + return Text::_('COM_WORKFLOW_UPCOMING_DUE_NOW'); + } + + $minutes = (int) round($seconds / 60); + + if ($minutes < 60) { + return Text::plural('COM_WORKFLOW_UPCOMING_IN_MINUTES', max(1, $minutes)); + } + + $hours = (int) round($seconds / 3600); + + if ($hours < 24) { + return Text::plural('COM_WORKFLOW_UPCOMING_IN_HOURS', $hours); + } + + $days = (int) round($seconds / 86400); + + if ($days < 30) { + return Text::plural('COM_WORKFLOW_UPCOMING_IN_DAYS', $days); + } + + return Text::plural('COM_WORKFLOW_UPCOMING_IN_MONTHS', max(1, (int) round($seconds / 2592000))); + } +} diff --git a/administrator/components/com_workflow/src/Automation/UpcomingTransition.php b/administrator/components/com_workflow/src/Automation/UpcomingTransition.php new file mode 100644 index 0000000000000..fcd13a638355d --- /dev/null +++ b/administrator/components/com_workflow/src/Automation/UpcomingTransition.php @@ -0,0 +1,68 @@ + + * @license GNU General Public License version 2 or later; see LICENSE.txt + */ + +namespace Joomla\Component\Workflow\Administrator\Automation; + +// phpcs:disable PSR1.Files.SideEffects +\defined('_JEXEC') or die; +// phpcs:enable PSR1.Files.SideEffects + +/** + * One item's next automated transition, as shown in the upcoming-transitions view. + * + * @since __DEPLOY_VERSION__ + */ + +final class UpcomingTransition +{ + /** + * @param integer $itemId The content item id. + * @param string $extension The workflow extension, e.g. com_content.article. + * @param string $itemTitle The item's title, or '' when it could not be resolved. + * @param string $editUrl Un-routed admin edit link, or '' when not linkable. + * @param string $fromStage Title of the stage the item is leaving. + * @param string $toStage Title of the stage the item moves to. + * @param string $transitionTitle Title of the transition that will run. + * @param \DateTime|null $firesAt When the move is due (UTC), or null if uncomputable. + * @param string $status scheduled | needs_attention | not_scheduled | rule_error. + * @param string $failureReason Why the rule could not be read, or '' when it could. + * @param \DateTime|null $failedAt When the scheduler stored the fault, or null if found now. + * @param string $ruleType delay | cron. + * @param integer|null $delayValue Delay amount for a delay rule. + * @param string|null $delayUnit minutes | hours | days | months. + * @param string|null $cronExpression Cron expression for a cron rule. + * @param boolean $hasCondition Whether a fire condition gates this move. + * @param string $workflowTitle Title of the owning workflow. + * @param integer $workflowId Id of the owning workflow, or 0 when unknown. + * + * @since __DEPLOY_VERSION__ + */ + public function __construct( + public readonly int $itemId, + public readonly string $extension, + public readonly string $itemTitle, + public readonly string $editUrl, + public readonly string $fromStage, + public readonly string $toStage, + public readonly string $transitionTitle, + public readonly ?\DateTime $firesAt, + public readonly string $status, + public readonly string $failureReason, + public readonly ?\DateTime $failedAt, + public readonly string $ruleType, + public readonly ?int $delayValue, + public readonly ?string $delayUnit, + public readonly ?string $cronExpression, + public readonly bool $hasCondition, + public readonly string $workflowTitle = '', + public readonly int $workflowId = 0 + ) { + } +} diff --git a/administrator/components/com_workflow/src/Automation/UpcomingTransitionsCalculator.php b/administrator/components/com_workflow/src/Automation/UpcomingTransitionsCalculator.php new file mode 100644 index 0000000000000..133ddb7bba1b8 --- /dev/null +++ b/administrator/components/com_workflow/src/Automation/UpcomingTransitionsCalculator.php @@ -0,0 +1,813 @@ + + * @license GNU General Public License version 2 or later; see LICENSE.txt + */ + +namespace Joomla\Component\Workflow\Administrator\Automation; + +use Joomla\Database\DatabaseInterface; +use Joomla\Database\ParameterType; +use Joomla\Database\QueryInterface; + +// phpcs:disable PSR1.Files.SideEffects +\defined('_JEXEC') or die; +// phpcs:enable PSR1.Files.SideEffects + +/** + * Works out, on the fly, each item's next automated transition in a workflow. + * + * @since __DEPLOY_VERSION__ + */ +final class UpcomingTransitionsCalculator +{ + /** + * How many items the panel on the workflow edit screen reports on. That panel sits inside an + * edit form, where a pagination bar would submit the form and lose unsaved changes, so it + * shows the head of the list rather than all of it. + * + * @var integer + * @since __DEPLOY_VERSION__ + */ + public const MAX_ITEMS_PER_PANEL = 20; + + /** + * @var DatabaseInterface + * @since __DEPLOY_VERSION__ + */ + private DatabaseInterface $database; + + /** + * @var ConditionEvaluator + * @since __DEPLOY_VERSION__ + */ + private ConditionEvaluator $conditionEvaluator; + + /** + * @var ItemFieldResolver + * @since __DEPLOY_VERSION__ + */ + private ItemFieldResolver $itemFieldResolver; + + /** + * @var ConditionWindowCalculator + * @since __DEPLOY_VERSION__ + */ + private ConditionWindowCalculator $conditionWindowCalculator; + + /** + * @param DatabaseInterface $database The database driver. + * + * @since __DEPLOY_VERSION__ + */ + public function __construct(DatabaseInterface $database) + { + $this->database = $database; + $this->conditionEvaluator = new ConditionEvaluator(); + $this->itemFieldResolver = new ItemFieldResolver($database); + $this->conditionWindowCalculator = new ConditionWindowCalculator($database); + } + + /** + * Calculates the next automated transition for one page of a workflow's items. + * + * @param integer $workflowId The workflow id. + * @param integer $limit Page size, or 0 for every item. Defaults to the panel's cap. + * @param integer $start How many items to skip. + * @param integer $stageId Only items currently in this stage, or 0 for any stage. + * + * @return UpcomingTransition[] Items needing attention first, then longest waiting. + * + * @since __DEPLOY_VERSION__ + */ + public function forWorkflow(int $workflowId, int $limit = self::MAX_ITEMS_PER_PANEL, int $start = 0, int $stageId = 0): array + { + if ($limit <= 0) { + return $this->buildFromRows($this->fetchRowsForWorkflow($workflowId, [], $stageId)); + } + + $db = $this->database; + $scope = $this->inStage($this->inScopeItemStateQuery(), $stageId) + ->where($db->quoteName('wt.workflow_id') . ' = :workflowId') + ->bind(':workflowId', $workflowId, ParameterType::INTEGER); + + $itemStateIds = $this->itemStateIds($scope, $limit, $start); + + if ($itemStateIds === []) { + return []; + } + + return $this->buildFromRows($this->fetchRowsForWorkflow($workflowId, $itemStateIds)); + } + + /** + * Counts the items in one workflow that carry a live automation rule. An upper bound on what + * gets listed, for the same reason as countForExtension(). + * + * @param integer $workflowId The workflow id. + * @param integer $stageId Only items currently in this stage, or 0 for any stage. + * + * @return integer + * + * @since __DEPLOY_VERSION__ + */ + public function countForWorkflow(int $workflowId, int $stageId = 0): int + { + $db = $this->database; + $query = $this->inStage($this->inScopeItemStateQuery(), $stageId) + ->select('COUNT(DISTINCT ' . $db->quoteName('wis.id') . ')') + ->where($db->quoteName('wt.workflow_id') . ' = :workflowId') + ->bind(':workflowId', $workflowId, ParameterType::INTEGER); + + return (int) $db->setQuery($query)->loadResult(); + } + + /** + * Calculates the next automated transition for one page of the items under an extension, + * across all of its workflows. + * + * @param string $extension The workflow extension, e.g. com_content.article. + * @param integer $limit Page size, or 0 for every item, which is what the user asks + * for by choosing All in the limit box. + * @param integer $start How many items to skip. + * @param integer $stageId Only items currently in this stage, or 0 for any stage. + * + * @return UpcomingTransition[] Items needing attention first, then longest waiting. + * + * @since __DEPLOY_VERSION__ + */ + public function forExtension(string $extension, int $limit = 0, int $start = 0, int $stageId = 0): array + { + if ($limit <= 0) { + return $this->buildFromRows($this->fetchRowsForExtension($extension, [], $stageId)); + } + + $db = $this->database; + $scope = $this->inStage($this->inScopeItemStateQuery(), $stageId) + ->where($db->quoteName('wis.extension') . ' = :extension') + ->bind(':extension', $extension); + + $itemStateIds = $this->itemStateIds($scope, $limit, $start); + + if ($itemStateIds === []) { + return []; + } + + return $this->buildFromRows($this->fetchRowsForExtension($extension, $itemStateIds)); + } + + /** + * Counts the items under an extension that carry a live automation rule. + * + * This is an upper bound on what the view lists, not an exact figure: an item whose rule + * filters it out is counted here and not shown, because only evaluating the filter in PHP + * can tell them apart, which is the work pagination exists to avoid. + * + * @param string $extension The workflow extension. + * @param integer $stageId Only items currently in this stage, or 0 for any stage. + * + * @return integer + * + * @since __DEPLOY_VERSION__ + */ + public function countForExtension(string $extension, int $stageId = 0): int + { + $db = $this->database; + $query = $this->inStage($this->inScopeItemStateQuery(), $stageId) + ->select('COUNT(DISTINCT ' . $db->quoteName('wis.id') . ')') + ->where($db->quoteName('wis.extension') . ' = :extension') + ->bind(':extension', $extension); + + return (int) $db->setQuery($query)->loadResult(); + } + + /** + * Calculates the next automated transition for a single item, or null if it has none. + * + * @param integer $itemId The content item id. + * @param string $extension The workflow extension, e.g. com_content.article. + * + * @return UpcomingTransition|null + * + * @since __DEPLOY_VERSION__ + */ + public function forItem(int $itemId, string $extension): ?UpcomingTransition + { + $upcoming = $this->buildFromRows($this->fetchRowsForItem($itemId, $extension)); + + return $upcoming[0] ?? null; + } + + /** + * Turns candidate rows into upcoming transitions: soonest in-scope rule per item. + * + * @param object[] $rows Candidate (item, transition, rule) rows. + * + * @return UpcomingTransition[] Soonest first; uncomputable times last. + * + * @since __DEPLOY_VERSION__ + */ + private function buildFromRows(array $rows): array + { + $bestByItem = []; + + // Items whose filter cannot be read are kept aside rather than dropped, so a broken rule + // still shows in the view. The first message wins, as in the scheduler. + $liveFailureByItem = []; + $fallbackRowByItem = []; + + $itemIdsByExtension = []; + foreach ($rows as $row) { + $itemIdsByExtension[$row->extension][] = (int) $row->item_id; + } + + foreach ($itemIdsByExtension as $extension => $itemIds) { + $this->itemFieldResolver->preload($itemIds, $extension); + } + + foreach ($rows as $row) { + $itemKey = $row->extension . '.' . $row->item_id; + $resolveField = $this->itemFieldResolver->forItem((int) $row->item_id, $row->extension); + + try { + if (!$this->conditionEvaluator->evaluate($row->item_filter, $resolveField)) { + continue; + } + } catch (ConditionEvaluationException $invalidCondition) { + $liveFailureByItem[$itemKey] ??= $invalidCondition->getMessage(); + $fallbackRowByItem[$itemKey] ??= $row; + + continue; + } + + // Rules compete on their deadline, before any fire condition is applied. + $deadline = DeadlineCalculator::forRule($row->entered_at, $row); + + if (!isset($bestByItem[$itemKey]) || $this->isSooner($deadline, $bestByItem[$itemKey]['deadline'])) { + $bestByItem[$itemKey] = ['row' => $row, 'deadline' => $deadline]; + } + } + + // An item whose every rule was unreadable is shown through the first rule that failed. + foreach ($fallbackRowByItem as $itemKey => $row) { + $bestByItem[$itemKey] ??= ['row' => $row, 'deadline' => null]; + } + + $now = new \DateTime('now', new \DateTimeZone('UTC')); + $upcoming = []; + + foreach ($bestByItem as $itemKey => $winner) { + $upcoming[] = $this->buildUpcomingTransition( + $winner['row'], + $winner['deadline'], + $now, + $liveFailureByItem[$itemKey] ?? '' + ); + } + + return $upcoming; + } + + /** + * The item state rows that could produce an upcoming transition, with no scope clause yet. + * + * Goes through joinLiveRules() like baseRowsQuery(), so a count taken here and a page of rows + * taken there cannot disagree about what is live. + * + * @return QueryInterface + * + * @since __DEPLOY_VERSION__ + */ + private function inScopeItemStateQuery(): QueryInterface + { + $db = $this->database; + + return $this->joinLiveRules($db->getQuery(true)->from($db->quoteName('#__workflow_item_state', 'wis'))); + } + + /** + * Joins item state rows to the live rules that could move them, matching the scheduler. + * + * A transition from any stage has from_stage_id -1 and applies to every item in its own workflow. + * A rule that has already run for an item since the item entered its current stage is left out, + * because the scheduler runs each rule once per stay in a stage. + * + * @param QueryInterface $query A query selecting from #__workflow_item_state as wis. + * + * @return QueryInterface + * + * @since __DEPLOY_VERSION__ + */ + private function joinLiveRules(QueryInterface $query): QueryInterface + { + $db = $this->database; + + // 0 is a successful run in #__workflow_automation_log.exit_code. + $alreadyRan = $db->getQuery(true) + ->select('1') + ->from($db->quoteName('#__workflow_automation_log', 'wal')) + ->where($db->quoteName('wal.rule_id') . ' = ' . $db->quoteName('war.id')) + ->where($db->quoteName('wal.item_id') . ' = ' . $db->quoteName('wis.item_id')) + ->where($db->quoteName('wal.extension') . ' = ' . $db->quoteName('wis.extension')) + ->where($db->quoteName('wal.exit_code') . ' = 0') + ->where($db->quoteName('wal.executed_at') . ' >= ' . $db->quoteName('wis.entered_at')); + + return $query + ->join( + 'INNER', + $db->quoteName('#__workflow_stages', 'ws'), + $db->quoteName('ws.id') . ' = ' . $db->quoteName('wis.stage_id') + ) + ->join( + 'INNER', + $db->quoteName('#__workflow_transitions', 'wt'), + $db->quoteName('wt.workflow_id') . ' = ' . $db->quoteName('ws.workflow_id') + . ' AND ' . $db->quoteName('wt.from_stage_id') . ' IN (' . $db->quoteName('wis.stage_id') . ', -1)' + ) + ->join( + 'INNER', + $db->quoteName('#__workflow_automation_rules', 'war'), + $db->quoteName('war.transition_id') . ' = ' . $db->quoteName('wt.id') + ) + ->join( + 'INNER', + $db->quoteName('#__workflows', 'w'), + $db->quoteName('w.id') . ' = ' . $db->quoteName('wt.workflow_id') + ) + ->where($db->quoteName('w.published') . ' = 1') + ->where($db->quoteName('wt.published') . ' = 1') + ->where($db->quoteName('war.published') . ' = 1') + ->where('NOT EXISTS (' . $alreadyRan . ')'); + } + + /** + * Runs a scoped item state query and returns one page of ids. + * + * @param QueryInterface $scope A query from inScopeItemStateQuery(), scoped by the caller. + * @param integer $limit Page size. + * @param integer $start How many rows to skip. + * + * @return int[] + * + * @since __DEPLOY_VERSION__ + */ + private function itemStateIds(QueryInterface $scope, int $limit, int $start): array + { + $db = $this->database; + + // The two sort columns are selected as well as ordered by, which PostgreSQL requires of a + // DISTINCT query. loadColumn() reads the first column, so it still returns ids. + $scope->select( + 'DISTINCT ' . implode( + ', ', + $db->quoteName(['wis.id', 'wis.requires_intervention', 'wis.entered_at']) + ) + ) + ->order($db->quoteName('wis.requires_intervention') . ' DESC') + ->order($db->quoteName('wis.entered_at') . ' ASC') + // Unique last key: items that entered at the same second would otherwise swap places between + // pages, repeating one item and skipping another. + ->order($db->quoteName('wis.id') . ' ASC') + ->setLimit($limit, $start); + + return array_map('intval', $db->setQuery($scope)->loadColumn()); + } + + /** + * Narrows a query on #__workflow_item_state as wis to items currently in one stage. + * + * @param QueryInterface $query The query to narrow. + * @param integer $stageId The stage, or 0 to leave the query as it is. + * + * @return QueryInterface + * + * @since __DEPLOY_VERSION__ + */ + private function inStage(QueryInterface $query, int $stageId): QueryInterface + { + if ($stageId > 0) { + $query->where($this->database->quoteName('wis.stage_id') . ' = ' . $stageId); + } + + return $query; + } + + /** + * Applies the display order: items needing attention first, then the longest waiting, and + * within a single item the transition order, which settles ties the way the scheduler does. + * + * @param QueryInterface $query A rows query from baseRowsQuery(). + * + * @return QueryInterface + * + * @since __DEPLOY_VERSION__ + */ + private function orderedByWait(QueryInterface $query): QueryInterface + { + $db = $this->database; + + return $query->order($db->quoteName('wis.requires_intervention') . ' DESC') + ->order($db->quoteName('wis.entered_at') . ' ASC') + ->order($db->quoteName('wis.id') . ' ASC') + ->order($db->quoteName('wt.ordering') . ' ASC'); + } + + + /** + * Loads the candidate rows for a set of item states in a workflow. + * + * @param integer $workflowId The workflow id. + * @param int[] $itemStateIds The item state rows to report on, or none for all of them. + * @param integer $stageId Only items currently in this stage, or 0 for any stage. + * + * @return object[] + * + * @since __DEPLOY_VERSION__ + */ + private function fetchRowsForWorkflow(int $workflowId, array $itemStateIds = [], int $stageId = 0): array + { + $db = $this->database; + $query = $this->inStage($this->baseRowsQuery(), $stageId) + ->where($db->quoteName('wt.workflow_id') . ' = :workflowId') + ->bind(':workflowId', $workflowId, ParameterType::INTEGER); + + if ($itemStateIds !== []) { + $query->whereIn($db->quoteName('wis.id'), $itemStateIds); + } + + return $this->fetchRows($this->orderedByWait($query)); + } + + /** + * Loads the candidate rows for every workflow under an extension. + * + * @param string $extension The workflow extension. + * @param int[] $itemStateIds The item state rows to report on, or none for all of them. + * @param integer $stageId Only items currently in this stage, or 0 for any stage. + * + * @return object[] + * + * @since __DEPLOY_VERSION__ + */ + private function fetchRowsForExtension(string $extension, array $itemStateIds = [], int $stageId = 0): array + { + $db = $this->database; + $query = $this->inStage($this->baseRowsQuery(), $stageId) + ->where($db->quoteName('wis.extension') . ' = :extension') + ->bind(':extension', $extension); + + if ($itemStateIds !== []) { + $query->whereIn($db->quoteName('wis.id'), $itemStateIds); + } + + return $this->fetchRows($this->orderedByWait($query)); + } + + /** + * Loads the candidate rows for a single item. + * + * @param integer $itemId The content item id. + * @param string $extension The workflow extension. + * + * @return object[] + * + * @since __DEPLOY_VERSION__ + */ + private function fetchRowsForItem(int $itemId, string $extension): array + { + $db = $this->database; + $query = $this->baseRowsQuery() + ->where($db->quoteName('wis.item_id') . ' = :itemId') + ->where($db->quoteName('wis.extension') . ' = :extension') + ->bind(':itemId', $itemId, ParameterType::INTEGER) + ->bind(':extension', $extension) + ->order($db->quoteName('wt.ordering') . ' ASC'); + + return $this->fetchRows($query); + } + + /** + * Builds the shared select and joins, without a scope clause. + * + * @return QueryInterface + * + * @since __DEPLOY_VERSION__ + */ + private function baseRowsQuery(): QueryInterface + { + $db = $this->database; + $query = $db->getQuery(true) + ->select( + [ + $db->quoteName('wis.item_id'), + $db->quoteName('wis.extension'), + $db->quoteName('wis.entered_at'), + $db->quoteName('wis.requires_intervention'), + $db->quoteName('wis.last_failure_at'), + $db->quoteName('wis.last_failure_reason'), + $db->quoteName('wt.id', 'transition_id'), + $db->quoteName('wt.title', 'transition_title'), + // The item's own stage, since a transition from any stage stores -1 as its from stage. + $db->quoteName('wis.stage_id', 'from_stage_id'), + $db->quoteName('wt.to_stage_id'), + $db->quoteName('ws.title', 'from_stage_title'), + $db->quoteName('sto.title', 'to_stage_title'), + $db->quoteName('war.rule_type'), + $db->quoteName('war.delay_value'), + $db->quoteName('war.delay_unit'), + $db->quoteName('war.cron_expression'), + $db->quoteName('war.item_filter'), + $db->quoteName('war.fire_condition'), + $db->quoteName('wt.ordering'), + $db->quoteName('w.id', 'workflow_id'), + $db->quoteName('w.title', 'workflow_title'), + ] + ) + ->from($db->quoteName('#__workflow_item_state', 'wis')); + + return $this->joinLiveRules($query) + ->join( + 'LEFT', + $db->quoteName('#__workflow_stages', 'sto'), + $db->quoteName('sto.id') . ' = ' . $db->quoteName('wt.to_stage_id') + ); + } + + /** + * Builds one upcoming transition from a winning row. + * + * @param object $row The chosen (item, transition, rule) row. + * @param \DateTime|null $deadline The moment the rule becomes due, or null. + * @param \DateTime $now Current time (UTC). + * + * @return UpcomingTransition + * + * @since __DEPLOY_VERSION__ + */ + private function buildUpcomingTransition( + object $row, + ?\DateTime $deadline, + \DateTime $now, + string $liveFailureReason + ): UpcomingTransition { + $requiresIntervention = (int) $row->requires_intervention === 1; + $hasCondition = $row->fire_condition !== null && trim((string) $row->fire_condition) !== ''; + + [$firesAt, $status, $discoveredReason] = $this->resolveFireTime( + $row, + $deadline, + $now, + $hasCondition, + $requiresIntervention, + $liveFailureReason + ); + + $storedReason = (string) ($row->last_failure_reason ?? ''); + $storedAt = $row->last_failure_at !== null + ? new \DateTime((string) $row->last_failure_at, new \DateTimeZone('UTC')) + : null; + + // A fault found by this render wins over the one the scheduler stored, which may be out of + // date. A stored fault alone is shown with its time but does not change the status. + $failureReason = $discoveredReason !== '' ? $discoveredReason : $storedReason; + $failedAt = $discoveredReason !== '' ? null : $storedAt; + + return new UpcomingTransition( + itemId: (int) $row->item_id, + extension: (string) $row->extension, + itemTitle: (string) ($row->item_title ?? ''), + editUrl: $this->buildEditUrl($row), + fromStage: (string) ($row->from_stage_title ?? ''), + toStage: (string) ($row->to_stage_title ?? ''), + transitionTitle: (string) ($row->transition_title ?? ''), + firesAt: $firesAt, + status: $status, + failureReason: $failureReason, + failedAt: $failedAt, + ruleType: (string) $row->rule_type, + delayValue: $row->delay_value !== null ? (int) $row->delay_value : null, + delayUnit: $row->delay_unit, + cronExpression: $row->cron_expression, + hasCondition: $hasCondition, + workflowTitle: (string) ($row->workflow_title ?? ''), + workflowId: (int) ($row->workflow_id ?? 0) + ); + } + + /** + * Works out when a rule will really fire, and the status to show for it. + * + * A gated rule fires at the first moment its condition holds, searching from the deadline, + * or from now when the rule is already overdue. + * + * @param object $row The rule row. + * @param \DateTime|null $deadline The moment the rule becomes due. + * @param \DateTime $now Current time (UTC). + * @param boolean $hasCondition Whether a fire condition exists. + * @param boolean $requiresIntervention Whether the item is flagged stuck. + * @param string $liveFailureReason A filter fault this render found, or ''. + * + * @return array{0: \DateTime|null, 1: string, 2: string} Fire time, status, and any fault found. + * + * @since __DEPLOY_VERSION__ + */ + private function resolveFireTime( + object $row, + ?\DateTime $deadline, + \DateTime $now, + bool $hasCondition, + bool $requiresIntervention, + string $liveFailureReason + ): array { + // Blocked outranks everything: the scheduler skips the item until a person clears it. + if ($requiresIntervention) { + return [$deadline, 'needs_attention', '']; + } + + if ($liveFailureReason !== '') { + return [null, 'rule_error', $liveFailureReason]; + } + + if ($deadline === null) { + return [null, 'not_scheduled', '']; + } + + if (!$hasCondition) { + return [$deadline, 'scheduled', '']; + } + + try { + $firesAt = $this->conditionWindowCalculator->firstMatchAtOrAfter( + $row->fire_condition, + max($deadline, $now), + (int) $row->item_id, + (string) $row->extension + ); + } catch (ConditionEvaluationException $invalidCondition) { + // Not needs_attention: the item is not stuck. It is retried every run and recovers once + // the rule is fixed. + return [null, 'rule_error', $invalidCondition->getMessage()]; + } + + if ($firesAt === null) { + return [null, 'not_scheduled', '']; + } + + return [$firesAt, 'scheduled', '']; + } + + /** + * Builds an un-routed admin edit link for the item, or '' when the extension is not linkable. + * + * @param object $row The rule row. + * + * @return string + * + * @since __DEPLOY_VERSION__ + */ + private function buildEditUrl(object $row): string + { + $parts = explode('.', (string) $row->extension); + $option = $parts[0] ?? ''; + $type = $parts[1] ?? ''; + + if ($option === '' || $type === '') { + return ''; + } + + return 'index.php?option=' . $option . '&task=' . $type . '.edit&id=' . (int) $row->item_id; + } + + /** + * Whether a candidate fire time is sooner than the current best. Null (uncomputable) is + * treated as the latest possible, so a computable time always wins. + * + * @param \DateTime|null $candidate The candidate time. + * @param \DateTime|null $current The current best time. + * + * @return boolean + * + * @since __DEPLOY_VERSION__ + */ + private function isSooner(?\DateTime $candidate, ?\DateTime $current): bool + { + if ($candidate === null) { + return false; + } + + if ($current === null) { + return true; + } + + return $candidate < $current; + } + + /** + * Calculates the next automated transition for a set of items, keyed by item id. + * + * @param int[] $itemIds The content item ids. + * @param string $extension The workflow extension, e.g. com_content.article. + * + * @return array + * + * @since __DEPLOY_VERSION__ + */ + public function forItems(array $itemIds, string $extension): array + { + $itemIds = array_values(array_unique(array_map('intval', $itemIds))); + + if (empty($itemIds)) { + return []; + } + + $byItem = []; + + foreach ($this->buildFromRows($this->fetchRowsForItems($itemIds, $extension)) as $transition) { + $byItem[$transition->itemId] = $transition; + } + + return $byItem; + } + + /** + * Loads the candidate row for a set of items + * + * @param int[] $itemIds The content item ids. + * @param string $extension The workflow extension. + * + * @return object[] + * + * @since __DEPLOY_VERSION__ + */ + private function fetchRowsForItems(array $itemIds, string $extension): array + { + $db = $this->database; + $scheduledRowsQuery = $this->baseRowsQuery() + ->whereIn($db->quoteName('wis.item_id'), $itemIds) + ->where($db->quoteName('wis.extension') . ' = :extension') + ->bind(':extension', $extension) + ->order($db->quoteName('wt.ordering') . ' ASC'); + + return $this->fetchRows($scheduledRowsQuery); + } + + /** + * Runs a rows query, drops trashed and archived items, and fills in item titles. + * + * Both need the extension's own item table, which one query cannot join per row. + * + * @param QueryInterface $query The prepared rows query. + * + * @return object[] + * + * @since __DEPLOY_VERSION__ + */ + private function fetchRows(QueryInterface $query): array + { + $rows = $this->database->setQuery($query)->loadObjectList() ?: []; + + if ($rows === []) { + return []; + } + + $itemIdsByExtension = []; + + foreach ($rows as $row) { + $itemIdsByExtension[$row->extension][] = (int) $row->item_id; + } + + $itemStorage = new ItemStorage($this->database); + $excluded = []; + $titles = []; + + foreach ($itemIdsByExtension as $extension => $itemIds) { + foreach ($itemStorage->trashedOrArchivedIds($itemIds, $extension) as $itemId) { + // An item id is only unique within its own extension. + $excluded[$extension . '.' . $itemId] = true; + } + + foreach ($itemStorage->titlesFor($itemIds, $extension) as $itemId => $title) { + $titles[$extension . '.' . $itemId] = $title; + } + } + + $kept = []; + + foreach ($rows as $row) { + $key = $row->extension . '.' . $row->item_id; + + if (isset($excluded[$key])) { + continue; + } + + $row->item_title = $titles[$key] ?? null; + $kept[] = $row; + } + + return $kept; + } +} diff --git a/administrator/components/com_workflow/src/Controller/GraphController.php b/administrator/components/com_workflow/src/Controller/GraphController.php index 6ea2d0dffcbbf..9d7f178521f70 100644 --- a/administrator/components/com_workflow/src/Controller/GraphController.php +++ b/administrator/components/com_workflow/src/Controller/GraphController.php @@ -317,6 +317,7 @@ public function getTransitions() 'from_stage_id' => (int) $transition->from_stage_id, 'to_stage_id' => (int) $transition->to_stage_id, 'ordering' => (int) $transition->ordering, + 'automated' => (bool) $transition->automated, 'workflow_id' => (int) $transition->workflow_id, 'permissions' => [ 'edit' => $canEdit, diff --git a/administrator/components/com_workflow/src/Controller/LogsController.php b/administrator/components/com_workflow/src/Controller/LogsController.php new file mode 100644 index 0000000000000..023f07732f64f --- /dev/null +++ b/administrator/components/com_workflow/src/Controller/LogsController.php @@ -0,0 +1,68 @@ + + * @license GNU General Public License version 2 or later; see LICENSE.txt + */ + +namespace Joomla\Component\Workflow\Administrator\Controller; + +use Joomla\CMS\Language\Text; +use Joomla\CMS\MVC\Controller\BaseController; +use Joomla\CMS\Router\Route; + +// phpcs:disable PSR1.Files.SideEffects +\defined('_JEXEC') or die; +// phpcs:enable PSR1.Files.SideEffects + +/** + * Controller for the automation log + * + * @since __DEPLOY_VERSION__ + */ +class LogsController extends BaseController +{ + /** + * Clears the requires_intervention flag on an item so it is retried. + * + * @return boolean + * + * @since __DEPLOY_VERSION__ + */ + public function reset() + { + $this->checkToken('get'); + + $user = $this->app->getIdentity(); + $itemId = $this->input->getInt('item_id', 0); + $extension = $this->input->getCmd('extension', ''); + $redirect = 'index.php?option=com_workflow&view=logs&extension=' . $extension; + + if (!$user->authorise('core.admin', 'com_workflow')) { + $this->setRedirect(Route::_($redirect, false), Text::_('JERROR_ALERTNOAUTHOR'), 'error'); + + return false; + } + + if ($itemId > 0 && $extension !== '') { + /** @var \Joomla\Component\Workflow\Administrator\Model\LogsModel $model */ + $model = $this->getModel('Logs', 'Administrator'); + + // The model checks the permission too, and its answer is the one that counts. + if (!$model->clearIntervention($itemId, $extension)) { + $this->setRedirect(Route::_($redirect, false), Text::_('JERROR_ALERTNOAUTHOR'), 'error'); + + return false; + } + + $this->setMessage(Text::_('COM_WORKFLOW_LOGS_RESET_SUCCESS')); + } + + $this->setRedirect(Route::_($redirect, false)); + + return true; + } +} diff --git a/administrator/components/com_workflow/src/Controller/TransitionController.php b/administrator/components/com_workflow/src/Controller/TransitionController.php index c63b65f741c18..bfb7c6bfa9b94 100644 --- a/administrator/components/com_workflow/src/Controller/TransitionController.php +++ b/administrator/components/com_workflow/src/Controller/TransitionController.php @@ -10,11 +10,17 @@ namespace Joomla\Component\Workflow\Administrator\Controller; +use Joomla\CMS\Access\Exception\NotAllowed; use Joomla\CMS\Application\CMSApplication; +use Joomla\CMS\Factory; use Joomla\CMS\Language\Text; use Joomla\CMS\MVC\Controller\FormController; use Joomla\CMS\MVC\Factory\MVCFactoryInterface; +use Joomla\CMS\Response\JsonResponse; use Joomla\CMS\Router\Route; +use Joomla\Component\Workflow\Administrator\Automation\ConditionEvaluationException; +use Joomla\Component\Workflow\Administrator\Automation\FilterPreview; +use Joomla\Database\DatabaseInterface; use Joomla\Input\Input; // phpcs:disable PSR1.Files.SideEffects @@ -228,4 +234,64 @@ public function cancel($key = null) } return $result; } + + /** + * Reports which items the item filter currently in the builder would match. + * + * @return void + * + * @since __DEPLOY_VERSION__ + */ + public function previewFilter(): void + { + try { + if (!$this->checkToken('post', false)) { + throw new \RuntimeException(Text::_('JINVALID_TOKEN_NOTICE')); + } + + $transitionId = $this->input->post->getInt('transition_id'); + + if ($transitionId <= 0) { + throw new \InvalidArgumentException(Text::_('COM_WORKFLOW_PREVIEW_ERROR_NO_TRANSITION')); + } + + $preview = new FilterPreview(Factory::getContainer()->get(DatabaseInterface::class)); + $transition = $preview->describeTransition($transitionId); + + if ($transition === null) { + throw new \InvalidArgumentException(Text::_('COM_WORKFLOW_PREVIEW_ERROR_NO_TRANSITION')); + } + + // Checked against the workflow the transition belongs to, not the workflow_id in the + // request, which anyone could change. + $owningParts = explode('.', (string) $transition->extension); + + if ( + !$this->app->getIdentity()->authorise( + 'core.edit', + array_shift($owningParts) . '.workflow.' . (int) $transition->workflow_id + ) + ) { + throw new NotAllowed(Text::_('JERROR_ALERTNOAUTHOR'), 403); + } + + echo new JsonResponse( + $preview->forTransition( + $transitionId, + $this->input->post->getRaw('item_filter') + ) + ); + } catch (ConditionEvaluationException $invalidFilter) { + // An incomplete filter is normal while typing, so this is an answer, not an error. + echo new JsonResponse(null, $invalidFilter->getMessage(), true); + } catch (NotAllowed $notAllowed) { + $this->app->setHeader('status', 403, true); + echo new JsonResponse(null, $notAllowed->getMessage(), true); + } catch (\Exception $e) { + $this->app->setHeader('status', 500); + echo new JsonResponse(null, $e->getMessage(), true); + } + + $this->app->close(); + } } diff --git a/administrator/components/com_workflow/src/Field/ConditionbuilderField.php b/administrator/components/com_workflow/src/Field/ConditionbuilderField.php new file mode 100644 index 0000000000000..79d2d5506f02b --- /dev/null +++ b/administrator/components/com_workflow/src/Field/ConditionbuilderField.php @@ -0,0 +1,405 @@ + + * @license GNU General Public License version 2 or later; see LICENSE.txt + */ + +namespace Joomla\Component\Workflow\Administrator\Field; + +// phpcs:disable PSR1.Files.SideEffects +\defined('_JEXEC') or die; +// phpcs:enable PSR1.Files.SideEffects + +use Joomla\CMS\Event\Workflow\WorkflowConditionFieldsEvent; +use Joomla\CMS\Factory; +use Joomla\CMS\Form\FormField; +use Joomla\CMS\Language\Text; +use Joomla\CMS\Plugin\PluginHelper; +use Joomla\CMS\Router\Route; +use Joomla\CMS\Session\Session; +use Joomla\Component\Workflow\Administrator\Automation\BuiltinConditionFields; +use Joomla\Database\DatabaseInterface; +use Joomla\Database\ParameterType; +use Joomla\Event\DispatcherInterface; +use Joomla\Registry\Registry; + +/** + * Renders one automation condition builder. + * + * The mode attribute picks the filter, which offers item checks, or the condition, which offers + * moment checks. The builder itself is drawn by condition-builder.js from the config output here. + * + * @since __DEPLOY_VERSION__ + */ +class ConditionbuilderField extends FormField +{ + /** + * The form field type. + * + * @var string + * @since __DEPLOY_VERSION__ + */ + protected $type = 'Conditionbuilder'; + + /** + * Builds the field markup: a hidden input for the JSON value, plus a config + * blob (scoped to this field's mode) that the client-side builder reads. + * + * @return string + * + * @since __DEPLOY_VERSION__ + */ + protected function getInput() + { + $document = Factory::getApplication()->getDocument(); + $document->getWebAssetManager() + ->useStyle('com_workflow.condition-builder') + ->useScript('com_workflow.condition-builder') + ->usePreset('choicesjs') + ->useScript('webcomponent.field-fancy-select') + ->useScript('joomla.dialog-autocreate'); + + Text::script('JGLOBAL_SELECT_NO_RESULTS_MATCH'); + Text::script('JGLOBAL_SELECT_PRESS_TO_SELECT'); + + $storedValue = $this->value; + + // The value round trips as a JSON string; normalise anything else back to one. + if (\is_array($storedValue) || \is_object($storedValue)) { + $storedValue = json_encode($storedValue); + } + + $availableFields = $this->getAvailableFields(); + + $configuration = json_encode([ + 'fields' => $this->getFieldChoices($availableFields), + 'operators' => $this->getOperatorChoices($availableFields), + 'valueTypes' => $this->getValueTypes($availableFields), + 'valueOptions' => $this->getValueChoices($availableFields), + 'text' => $this->getInterfaceText(), + 'preview' => $this->getPreviewConfig(), + 'expert' => $this->isExpertMode(), + 'expertDialog' => $this->expertModeDialog(), + ]); + + return '
' + . '' + . '
'; + } + + /** + * The checks that may be offered here. + * + * @return array Check definitions keyed by name. + * + * @since __DEPLOY_VERSION__ + */ + private function getAvailableFields(): array + { + $app = Factory::getApplication(); + $extension = $this->resolveExtension($app); + $database = Factory::getContainer()->get(DatabaseInterface::class); + + $builtInFields = (new BuiltinConditionFields($database))->declarations($extension); + + PluginHelper::importPlugin('workflow'); + + $event = new WorkflowConditionFieldsEvent( + 'onWorkflowListConditionFields', + ['extension' => $extension] + ); + + Factory::getContainer()->get(DispatcherInterface::class)->dispatch($event->getName(), $event); + + // Union, not array_merge, so a plugin cannot replace a built-in check of the same name. + $fields = $builtInFields + $event->getFields(); + + $wantedScope = (string) $this->element['mode'] === 'filter' + ? WorkflowConditionFieldsEvent::SCOPE_ITEM + : WorkflowConditionFieldsEvent::SCOPE_MOMENT; + + return array_filter( + $fields, + static fn (array $field): bool => $field['scope'] === $wantedScope + ); + } + + /** + * Works out which workflow extension the builder is being drawn for. + * + * @param \Joomla\CMS\Application\CMSApplicationInterface $app The application. + * + * @return string + * + * @since __DEPLOY_VERSION__ + */ + private function resolveExtension($app): string + { + // Prefer the workflow row: it stores com_content.article, while the request may only carry + // com_content. + $workflowId = (int) $app->getInput()->getInt('workflow_id'); + + if ($workflowId > 0) { + $db = Factory::getContainer()->get(DatabaseInterface::class); + $query = $db->getQuery(true) + ->select($db->quoteName('extension')) + ->from($db->quoteName('#__workflows')) + ->where($db->quoteName('id') . ' = :workflowId') + ->bind(':workflowId', $workflowId, ParameterType::INTEGER); + + $extension = (string) $db->setQuery($query)->loadResult(); + + if ($extension !== '') { + return $extension; + } + } + + $requestedExtension = (string) $app->getInput()->getCmd('extension', ''); + + return $requestedExtension !== '' ? $requestedExtension : 'com_content.article'; + } + + /** + * The checks the builder may offer, as value and label pairs. + * + * @param array $fields The available check definitions. + * + * @return array> + * + * @since __DEPLOY_VERSION__ + */ + private function getFieldChoices(array $fields): array + { + $choices = []; + + foreach ($fields as $field) { + $choices[] = ['value' => $field['name'], 'label' => $field['label']]; + } + + return $choices; + } + + /** + * The operators each check supports. + * + * @param array $fields The available check definitions. + * + * @return array>> + * + * @since __DEPLOY_VERSION__ + */ + private function getOperatorChoices(array $fields): array + { + $operatorLabels = [ + WorkflowConditionFieldsEvent::OPERATOR_IS => Text::_('COM_WORKFLOW_AUTOMATION_OP_IS'), + WorkflowConditionFieldsEvent::OPERATOR_IS_NOT => Text::_('COM_WORKFLOW_AUTOMATION_OP_IS_NOT'), + WorkflowConditionFieldsEvent::OPERATOR_IN => Text::_('COM_WORKFLOW_AUTOMATION_OP_IN'), + WorkflowConditionFieldsEvent::OPERATOR_NOT_IN => Text::_('COM_WORKFLOW_AUTOMATION_OP_NOT_IN'), + WorkflowConditionFieldsEvent::OPERATOR_HAS_ANY => Text::_('COM_WORKFLOW_AUTOMATION_OP_HAS_ANY'), + WorkflowConditionFieldsEvent::OPERATOR_HAS_ALL => Text::_('COM_WORKFLOW_AUTOMATION_OP_HAS_ALL'), + WorkflowConditionFieldsEvent::OPERATOR_HAS_NONE => Text::_('COM_WORKFLOW_AUTOMATION_OP_HAS_NONE'), + WorkflowConditionFieldsEvent::OPERATOR_BEFORE => Text::_('COM_WORKFLOW_AUTOMATION_OP_BEFORE'), + WorkflowConditionFieldsEvent::OPERATOR_AFTER => Text::_('COM_WORKFLOW_AUTOMATION_OP_AFTER'), + WorkflowConditionFieldsEvent::OPERATOR_ON => Text::_('COM_WORKFLOW_AUTOMATION_OP_ON'), + WorkflowConditionFieldsEvent::OPERATOR_NOT_ON => Text::_('COM_WORKFLOW_AUTOMATION_OP_NOT_ON'), + WorkflowConditionFieldsEvent::OPERATOR_GREATER_THAN => Text::_('COM_WORKFLOW_AUTOMATION_OP_GREATER_THAN'), + WorkflowConditionFieldsEvent::OPERATOR_LESS_THAN => Text::_('COM_WORKFLOW_AUTOMATION_OP_LESS_THAN'), + ]; + + $choices = []; + + foreach ($fields as $field) { + foreach ($field['operators'] as $operator) { + // Skipped, so a check written for a newer Joomla does not break the builder. + if (!isset($operatorLabels[$operator])) { + continue; + } + + $choices[$field['name']][] = ['value' => $operator, 'label' => $operatorLabels[$operator]]; + } + } + + return $choices; + } + + /** + * How each check's value is entered. + * + * @param array $fields The available check definitions. + * + * @return array + * + * @since __DEPLOY_VERSION__ + */ + private function getValueTypes(array $fields): array + { + $types = []; + + foreach ($fields as $field) { + $types[$field['name']] = $field['valueType']; + } + + return $types; + } + + /** + * The selectable values for each check, resolved once here so the builder needs no + * further requests. + * + * @param array $fields The available check definitions. + * + * @return array>> + * + * @since __DEPLOY_VERSION__ + */ + private function getValueChoices(array $fields): array + { + $choices = []; + + foreach ($fields as $field) { + if ($field['options'] !== []) { + $choices[$field['name']] = $field['options']; + } + } + + return $choices; + } + + /** + * Translated strings the builder renders in its own markup. + * + * @return array + * + * @since __DEPLOY_VERSION__ + */ + private function getInterfaceText(): array + { + $emptyKey = (string) $this->element['mode'] === 'filter' + ? 'COM_WORKFLOW_AUTOMATION_BUILDER_EMPTY_FILTER' + : 'COM_WORKFLOW_AUTOMATION_BUILDER_EMPTY_CONDITION'; + return [ + 'addCheck' => Text::_('COM_WORKFLOW_AUTOMATION_BUILDER_ADD_CHECK'), + 'expertHint' => Text::_('COM_WORKFLOW_AUTOMATION_BUILDER_EXPERT_HINT'), + 'expertLink' => Text::_('COM_WORKFLOW_AUTOMATION_BUILDER_EXPERT_LINK'), + 'expertOffHint' => Text::_('COM_WORKFLOW_AUTOMATION_BUILDER_EXPERT_OFF_HINT'), + 'expertOffLink' => Text::_('COM_WORKFLOW_AUTOMATION_BUILDER_EXPERT_OFF_LINK'), + 'expertHeader' => Text::_('COM_WORKFLOW_AUTOMATION_BUILDER_EXPERT_HEADER'), + 'addExpression' => Text::_('COM_WORKFLOW_AUTOMATION_BUILDER_ADD_EXPRESSION'), + 'remove' => Text::_('COM_WORKFLOW_AUTOMATION_BUILDER_REMOVE'), + 'check' => Text::_('COM_WORKFLOW_AUTOMATION_BUILDER_CHECK'), + 'expression' => Text::_('COM_WORKFLOW_AUTOMATION_BUILDER_EXPRESSION'), + 'negate' => Text::_('COM_WORKFLOW_AUTOMATION_BUILDER_NEGATE'), + 'joinWith' => Text::_('COM_WORKFLOW_AUTOMATION_BUILDER_JOIN_WITH'), + 'opAnd' => Text::_('COM_WORKFLOW_AUTOMATION_BUILDER_OP_AND'), + 'opOr' => Text::_('COM_WORKFLOW_AUTOMATION_BUILDER_OP_OR'), + 'cancel' => Text::_('JCANCEL'), + 'empty' => Text::_($emptyKey), + 'placeholder' => Text::_('JGLOBAL_TYPE_OR_SELECT_SOME_OPTIONS'), + 'emptyExpression' => Text::_('COM_WORKFLOW_AUTOMATION_BUILDER_EMPTY_EXPRESSION'), + 'preview' => Text::_('COM_WORKFLOW_AUTOMATION_PREVIEW_BUTTON'), + 'previewRunning' => Text::_('COM_WORKFLOW_AUTOMATION_PREVIEW_RUNNING'), + 'previewResult' => Text::_('COM_WORKFLOW_AUTOMATION_PREVIEW_RESULT'), + 'previewCapped' => Text::_('COM_WORKFLOW_AUTOMATION_PREVIEW_CAPPED'), + 'previewEmpty' => Text::_('COM_WORKFLOW_AUTOMATION_PREVIEW_EMPTY'), + 'previewShowAll' => Text::_('COM_WORKFLOW_AUTOMATION_PREVIEW_SHOW_ALL'), + 'previewListHeader' => Text::_('COM_WORKFLOW_AUTOMATION_PREVIEW_LIST_HEADER'), + 'previewListTrimmed' => Text::_('COM_WORKFLOW_AUTOMATION_PREVIEW_LIST_TRIMMED'), + 'previewListRange' => Text::_('COM_WORKFLOW_AUTOMATION_PREVIEW_LIST_RANGE'), + 'field' => Text::_('COM_WORKFLOW_AUTOMATION_BUILDER_FIELD'), + 'operator' => Text::_('COM_WORKFLOW_AUTOMATION_BUILDER_OPERATOR'), + 'value' => Text::_('COM_WORKFLOW_AUTOMATION_BUILDER_VALUE'), + 'removeCheck' => Text::_('COM_WORKFLOW_AUTOMATION_BUILDER_REMOVE_CHECK'), + 'previous' => Text::_('JPREVIOUS'), + 'next' => Text::_('JNEXT'), + 'close' => Text::_('JCLOSE'), + ]; + } + + /** + * Whether the Workflow Automation plugin is set to offer OR, sub-expressions and NOT. + * + * @return boolean + * + * @since __DEPLOY_VERSION__ + */ + private function isExpertMode(): bool + { + $plugin = PluginHelper::getPlugin('workflow', 'automation'); + + return \is_object($plugin) && (int) (new Registry($plugin->params))->get('expert_mode', 0) === 1; + } + + /** + * Where Expert Mode is switched on, for the hint the simple builder shows. + * + * @return string The plugin's settings link, or '' when this user may not open it. + * + * @since __DEPLOY_VERSION__ + */ + private function expertModeDialog(): ?array + { + if (!Factory::getApplication()->getIdentity()->authorise('core.manage', 'com_plugins')) { + return null; + } + + $db = Factory::getContainer()->get(DatabaseInterface::class); + $query = $db->getQuery(true) + ->select($db->quoteName('extension_id')) + ->from($db->quoteName('#__extensions')) + ->where($db->quoteName('type') . ' = ' . $db->quote('plugin')) + ->where($db->quoteName('folder') . ' = ' . $db->quote('workflow')) + ->where($db->quoteName('element') . ' = ' . $db->quote('automation')); + + $extensionId = (int) $db->setQuery($query)->loadResult(); + + if ($extensionId < 1) { + return null; + } + + return [ + 'src' => Route::_( + 'index.php?option=com_plugins&client_id=0&task=plugin.edit&extension_id=' . $extensionId + . '&tmpl=component&layout=modal', + false + ), + + // Opening the plugin checks it out, so closing the dialog has to check it back in. + 'checkinUrl' => Route::_( + 'index.php?option=com_plugins&task=plugins.checkin&format=json&cid[]=' . $extensionId, + false + ), + ]; + } + + /** + * What the browser needs to ask the server which items this filter matches. + * + * Null for the condition field, which answers when a rule may run rather than which items match. + * + * @return array|null + * + * @since __DEPLOY_VERSION__ + */ + private function getPreviewConfig(): ?array + { + if ((string) $this->element['mode'] !== 'filter') { + return null; + } + + $input = Factory::getApplication()->getInput(); + + return [ + 'url' => 'index.php?option=com_workflow&task=transition.previewFilter', + 'extension' => $input->getCmd('extension'), + 'workflowId' => $input->getInt('workflow_id'), + 'transitionId' => $input->getInt('id'), + 'token' => Session::getFormToken(), + ]; + } +} diff --git a/administrator/components/com_workflow/src/Field/UpcomingtransitionField.php b/administrator/components/com_workflow/src/Field/UpcomingtransitionField.php new file mode 100644 index 0000000000000..703c928f97307 --- /dev/null +++ b/administrator/components/com_workflow/src/Field/UpcomingtransitionField.php @@ -0,0 +1,182 @@ + + * @license GNU General Public License version 2 or later; see LICENSE.txt + */ + +namespace Joomla\Component\Workflow\Administrator\Field; + +use Joomla\CMS\Factory; +use Joomla\CMS\Form\FormField; +use Joomla\CMS\HTML\HTMLHelper; +use Joomla\CMS\Language\Text; +use Joomla\Component\Workflow\Administrator\Automation\RelativeTime; +use Joomla\Component\Workflow\Administrator\Automation\UpcomingTransition; +use Joomla\Component\Workflow\Administrator\Automation\UpcomingTransitionsCalculator; +use Joomla\Database\DatabaseInterface; + +// phpcs:disable PSR1.Files.SideEffects +\defined('_JEXEC') or die; +// phpcs:enable PSR1.Files.SideEffects + +/** + * Read-only field that shows an item's next automated transition on its edit screen. + * + * Outputs nothing for an unsaved item or one with no automated move pending. + * + * @since __DEPLOY_VERSION__ + */ +class UpcomingtransitionField extends FormField +{ + /** + * The form field type. + * + * @var string + * @since __DEPLOY_VERSION__ + */ + protected $type = 'Upcomingtransition'; + + /** + * Result of buildCard(), kept so renderField() and getInput() do not run the calculator twice. + * + * @var string|null + * @since __DEPLOY_VERSION__ + */ + private $card; + + /** + * @return string + * + * @since __DEPLOY_VERSION__ + */ + protected function getInput() + { + if ($this->card === null) { + $this->card = $this->buildCard(); + } + + return $this->card; + } + + /** + * Drops the label and wrapper when there is nothing pending, the way SpacerField does, so the + * sidebar does not show an empty row on unsaved items. + * + * @param array $options Rendering options. + * + * @return string + * + * @since __DEPLOY_VERSION__ + */ + public function renderField($options = []) + { + return $this->getInput() === '' ? '' : parent::renderField($options); + } + + /** + * @return string + * + * @since __DEPLOY_VERSION__ + */ + private function buildCard() + { + $itemId = (int) $this->form->getValue('id'); + $extension = (string) $this->form->getName(); + + if ($itemId <= 0) { + return ''; + } + + Factory::getApplication()->getLanguage()->load('com_workflow', JPATH_ADMINISTRATOR); + + $database = Factory::getContainer()->get(DatabaseInterface::class); + $calculator = new UpcomingTransitionsCalculator($database); + $upcoming = $calculator->forItem($itemId, $extension); + + if ($upcoming === null) { + return ''; + } + + return $this->renderCard($upcoming); + } + + /** + * Renders the block for one upcoming transition + * + * @param UpcomingTransition $upcoming The item's next move. + * + * @return string + * + * @since __DEPLOY_VERSION__ + */ + private function renderCard(UpcomingTransition $upcoming): string + { + $fires = match ($upcoming->status) { + 'needs_attention' => '' . Text::_('COM_WORKFLOW_UPCOMING_STATUS_ATTENTION') . '', + 'rule_error' => '' . Text::_('COM_WORKFLOW_UPCOMING_STATUS_RULE_ERROR') . '', + 'not_scheduled' => '' . Text::_('COM_WORKFLOW_UPCOMING_STATUS_NOT_SCHEDULED') . '', + // Guarded anyway, so a future status without a fire time cannot break the edit screen. + default => $upcoming->firesAt === null + ? '' . Text::_('COM_WORKFLOW_UPCOMING_STATUS_NOT_SCHEDULED') . '' + : '
' . RelativeTime::until($upcoming->firesAt) . '
' + . '
' . HTMLHelper::_('date', $upcoming->firesAt->format('Y-m-d H:i:s'), Text::_('DATE_FORMAT_LC2')) . '
' + . ($upcoming->hasCondition + ? '' . Text::_('COM_WORKFLOW_UPCOMING_SUBJECT_CONDITION') . '' + : ''), + }; + + // Shown whatever the status: the fault may belong to another rule on this stage. + if ($upcoming->failureReason !== '') { + $fires .= '
' + . ' ' + . htmlspecialchars($upcoming->failureReason, ENT_QUOTES, 'UTF-8') + . '
'; + + if ($upcoming->failedAt !== null) { + $fires .= '
' + . Text::sprintf( + 'COM_WORKFLOW_UPCOMING_LAST_FAILED', + HTMLHelper::_('date', $upcoming->failedAt->format('Y-m-d H:i:s'), Text::_('DATE_FORMAT_LC2')) + ) + . '
'; + } + } + + if ($upcoming->ruleType === 'cron') { + $trigger = Text::sprintf( + 'COM_WORKFLOW_UPCOMING_TRIGGER_CRON', + '' . htmlspecialchars((string) $upcoming->cronExpression, ENT_QUOTES, 'UTF-8') . '' + ); + } else { + $unitKeys = [ + 'minutes' => 'COM_WORKFLOW_AUTOMATION_UNIT_MINUTES', + 'hours' => 'COM_WORKFLOW_AUTOMATION_UNIT_HOURS', + 'days' => 'COM_WORKFLOW_AUTOMATION_UNIT_DAYS', + 'months' => 'COM_WORKFLOW_AUTOMATION_UNIT_MONTHS', + ]; + $unitKey = $unitKeys[$upcoming->delayUnit] ?? ''; + $unit = $unitKey !== '' ? Text::_($unitKey) : (string) $upcoming->delayUnit; + $trigger = Text::sprintf('COM_WORKFLOW_UPCOMING_TRIGGER_DELAY', (int) $upcoming->delayValue, $unit); + } + + $arrow = Factory::getApplication()->getLanguage()->isRtl() ? 'arrow-left' : 'arrow-right'; + return '
' + . '
' + . '

' . Text::_('COM_WORKFLOW_UPCOMING_ARTICLE_LABEL') . '

' + . '
' . htmlspecialchars(Text::_($upcoming->transitionTitle), ENT_QUOTES, 'UTF-8') . '
' + . ($upcoming->fromStage !== $upcoming->toStage + ? '
' + . '' . htmlspecialchars(Text::_($upcoming->fromStage), ENT_QUOTES, 'UTF-8') . ' ' + . ' ' + . '' . htmlspecialchars(Text::_($upcoming->toStage), ENT_QUOTES, 'UTF-8') . '' + . '
' + : '') + . '
' . $fires . '
' + . '
' . $trigger . '
' + . '
'; + } +} diff --git a/administrator/components/com_workflow/src/Model/LogsModel.php b/administrator/components/com_workflow/src/Model/LogsModel.php new file mode 100644 index 0000000000000..338c8f4cb5ea6 --- /dev/null +++ b/administrator/components/com_workflow/src/Model/LogsModel.php @@ -0,0 +1,217 @@ + + * @license GNU General Public License version 2 or later; see LICENSE.txt + */ + +namespace Joomla\Component\Workflow\Administrator\Model; + +use Joomla\CMS\Factory; +use Joomla\CMS\MVC\Factory\MVCFactoryInterface; +use Joomla\CMS\MVC\Model\ListModel; +use Joomla\Component\Workflow\Administrator\Automation\ItemStorage; +use Joomla\Database\ParameterType; + +// phpcs:disable PSR1.Files.SideEffects +\defined('_JEXEC') or die; +// phpcs:enable PSR1.Files.SideEffects + +/** + * Model for the automation execution log (read-only list). + * + * @since __DEPLOY_VERSION__ + */ +class LogsModel extends ListModel +{ + public function __construct($config = [], ?MVCFactoryInterface $factory = null) + { + if (empty($config['filter_fields'])) { + $config['filter_fields'] = [ + 'id', + 'l.id', + 'item_id', + 'l.item_id', + 'transition_id', + 'l.transition_id', + 'exit_code', + 'l.exit_code', + 'executed_at', + 'l.executed_at', + 'run_as_user_id', + 'l.run_as_user_id', + ]; + } + + parent::__construct($config, $factory); + } + + protected function populateState($ordering = 'l.executed_at', $direction = 'DESC') + { + /** @var \Joomla\CMS\Application\AdministratorApplication $app */ + $app = Factory::getApplication(); + $extension = $app->getUserStateFromRequest($this->context . '.filter.extension', 'extension', null, 'cmd'); + $this->setState('filter.extension', $extension); + + // filter.search and filter.exit_code are auto-populated from filter_logs.xml. + parent::populateState($ordering, $direction); + } + + protected function getStoreId($id = '') + { + $id .= ':' . $this->getState('filter.extension'); + $id .= ':' . $this->getState('filter.search'); + $id .= ':' . $this->getState('filter.exit_code'); + + return parent::getStoreId($id); + } + + public function getListQuery() + { + $db = $this->getDatabase(); + $automationLogQuery = $db->createQuery(); + + $automationLogQuery->select( + [ + $db->quoteName('l.id'), + $db->quoteName('l.item_id'), + $db->quoteName('l.extension'), + $db->quoteName('l.transition_id'), + $db->quoteName('t.workflow_id'), + $db->quoteName('l.from_stage_id'), + $db->quoteName('l.to_stage_id'), + $db->quoteName('l.run_as_user_id'), + $db->quoteName('l.trigger_type'), + $db->quoteName('l.exit_code'), + $db->quoteName('l.note'), + $db->quoteName('l.executed_at'), + $db->quoteName('t.title', 'transition_title'), + $db->quoteName('f_stage.title', 'from_stage'), + $db->quoteName('t_stage.title', 'to_stage'), + $db->quoteName('u.name', 'run_as_name'), + $db->quoteName('s.requires_intervention'), + ] + ) + ->from($db->quoteName('#__workflow_automation_log', 'l')) + ->join('LEFT', $db->quoteName('#__workflow_transitions', 't'), $db->quoteName('t.id') . ' = ' . $db->quoteName('l.transition_id')) + ->join('LEFT', $db->quoteName('#__workflow_stages', 'f_stage'), $db->quoteName('f_stage.id') . ' = ' . $db->quoteName('l.from_stage_id')) + ->join('LEFT', $db->quoteName('#__workflow_stages', 't_stage'), $db->quoteName('t_stage.id') . ' = ' . $db->quoteName('l.to_stage_id')) + ->join('LEFT', $db->quoteName('#__users', 'u'), $db->quoteName('u.id') . ' = ' . $db->quoteName('l.run_as_user_id')) + ->join('LEFT', $db->quoteName('#__workflow_item_state', 's'), $db->quoteName('s.item_id') . ' = ' . $db->quoteName('l.item_id') . ' AND ' . $db->quoteName('s.extension') . ' = ' . $db->quoteName('l.extension')); + + if ($extension = (string) $this->getState('filter.extension')) { + $automationLogQuery->where($db->quoteName('l.extension') . ' = :extension') + ->bind(':extension', $extension); + } + + $result = (string) $this->getState('filter.exit_code'); + + if ($result === 'ok') { + $automationLogQuery->where($db->quoteName('l.exit_code') . ' = 0'); + } elseif ($result === 'failed') { + $automationLogQuery->where($db->quoteName('l.exit_code') . ' NOT IN (0, 4)'); + } elseif ($result === 'skipped') { + $automationLogQuery->where($db->quoteName('l.exit_code') . ' = 4'); + } + + $search = $this->getState('filter.search'); + + if (!empty($search)) { + if (is_numeric($search)) { + $itemId = (int) $search; + $automationLogQuery->where($db->quoteName('l.item_id') . ' = :itemId') + ->bind(':itemId', $itemId, ParameterType::INTEGER); + } else { + $search = '%' . str_replace(' ', '%', trim($search)) . '%'; + + $titleLocation = $extension !== '' + ? (new ItemStorage($db))->titleLocation($extension) + : null; + + if ($titleLocation !== null) { + $automationLogQuery->where( + '(' . $db->quoteName('l.note') . ' LIKE :search' + . ' OR ' . $db->quoteName('l.item_id') . ' IN (' + . 'SELECT ' . $db->quoteName($titleLocation['key']) + . ' FROM ' . $db->quoteName($titleLocation['table']) + . ' WHERE ' . $db->quoteName($titleLocation['titleColumn']) . ' LIKE :titleSearch' + . '))' + ) + ->bind(':search', $search) + ->bind(':titleSearch', $search); + } else { + // Without a title table to search, only the note can match. + $automationLogQuery->where($db->quoteName('l.note') . ' LIKE :search') + ->bind(':search', $search); + } + } + } + + $orderColumn = $this->state->get('list.ordering', 'l.executed_at'); + $orderDirection = strtoupper($this->state->get('list.direction', 'DESC')); + + if (!empty($orderColumn)) { + $automationLogQuery->order($db->quoteName($db->escape($orderColumn)) . ' ' . $db->escape($orderDirection)); + } + + // Ties on executed_at would otherwise sort unpredictably, which breaks LIMIT/OFFSET paging. + if ($orderColumn !== 'l.id') { + $automationLogQuery->order($db->quoteName('l.id') . ' ' . $db->escape($orderDirection)); + } + + return $automationLogQuery; + } + + /** + * Adds each row's item title, which no join can supply. + * + * @return object[]|false + * + * @since __DEPLOY_VERSION__ + */ + public function getItems() + { + $items = parent::getItems(); + + if (empty($items)) { + return $items; + } + + return (new ItemStorage($this->getDatabase()))->annotateTitles($items); + } + + /** + * Clears the requires_intervention flag for an item so the scheduler retries it. + * + * Checks the permission itself, so it is safe to call from anywhere, not only the controller. + * + * @param integer $itemId The content item id. + * @param string $extension The workflow extension, e.g. com_content.article. + * + * @return boolean True when the flag was cleared, false when the current user may not. + * + * @since __DEPLOY_VERSION__ + */ + public function clearIntervention(int $itemId, string $extension): bool + { + if (!$this->getCurrentUser()->authorise('core.admin', 'com_workflow')) { + return false; + } + + $db = $this->getDatabase(); + $clearInterventionQuery = $db->getQuery(true) + ->update($db->quoteName('#__workflow_item_state')) + ->set($db->quoteName('requires_intervention') . ' = 0') + ->where($db->quoteName('item_id') . ' = :itemId') + ->where($db->quoteName('extension') . ' = :extension') + ->bind(':itemId', $itemId, ParameterType::INTEGER) + ->bind(':extension', $extension, ParameterType::STRING); + + $db->setQuery($clearInterventionQuery)->execute(); + + return true; + } +} diff --git a/administrator/components/com_workflow/src/Model/TransitionModel.php b/administrator/components/com_workflow/src/Model/TransitionModel.php index 448625d4dc658..9c74d6f02c0a7 100644 --- a/administrator/components/com_workflow/src/Model/TransitionModel.php +++ b/administrator/components/com_workflow/src/Model/TransitionModel.php @@ -13,8 +13,13 @@ use Joomla\CMS\Factory; use Joomla\CMS\Form\Form; +use Joomla\CMS\HTML\HTMLHelper; +use Joomla\CMS\Language\Text; use Joomla\CMS\MVC\Model\AdminModel; use Joomla\CMS\Plugin\PluginHelper; +use Joomla\CMS\Router\Route; +use Joomla\CMS\User\UserFactoryInterface; +use Joomla\Database\ParameterType; use Joomla\Registry\Registry; use Joomla\String\StringHelper; @@ -118,6 +123,36 @@ public function getItem($pk = null) $item->options = $registry->toArray(); } + if (!empty($item->id)) { + $db = $this->getDatabase(); + $query = $db->getQuery(true) + ->select($db->quoteName([ + 'rule_type', + 'delay_value', + 'delay_unit', + 'cron_expression', + 'run_as_user_id', + 'item_filter', + 'fire_condition', + ])) + ->from($db->quoteName('#__workflow_automation_rules')) + ->where($db->quoteName('transition_id') . ' = :id') + ->bind(':id', $item->id, ParameterType::INTEGER) + ->setLimit(1); + + $rule = $db->setQuery($query)->loadAssoc(); + + if ($rule) { + $item->automation = [ + 'automation_enabled' => 1, + 'run_as_user_id' => (int) ($rule['run_as_user_id'] ?? 0), + 'automation_rules' => $rule, + ]; + } else { + $item->automation = ['automation_enabled' => 0, 'run_as_user_id' => (int) $this->getCurrentUser()->id, 'automation_rules' => []]; + } + } + return $item; } @@ -132,6 +167,18 @@ public function getItem($pk = null) */ public function save($data) { + // Switching automation off saves an empty rule, which deletes the stored one. + $automationData = $data['automation'] ?? []; + $automationEnabled = !empty($automationData['automation_enabled']); + $automationRule = $automationEnabled ? ($automationData['automation_rules'] ?? []) : []; + unset($data['automation']); + + if (!empty($automationRule)) { + $automationRule['run_as_user_id'] = (int) ($automationData['run_as_user_id'] ?? 0); + } + + $transitionId = (int) ($data['id'] ?? $this->getState($this->getName() . '.id')); + $table = $this->getTable(); $context = $this->option . '.' . $this->name; $app = Factory::getApplication(); @@ -178,7 +225,31 @@ public function save($data) $data['published'] = 0; } - return parent::save($data); + if (!parent::save($data)) { + return false; + } + + $pk = (int) $this->getState($this->getName() . '.id'); + + try { + $this->saveAutomationRule($pk, $automationRule); + } catch (\Throwable $error) { + // The transition itself saved and the old rule survived the rollback, so this reports + // what failed rather than claiming the whole save did. + Factory::getApplication()->enqueueMessage( + Text::sprintf('COM_WORKFLOW_AUTOMATION_RULE_SAVE_FAILED', $error->getMessage()), + 'error' + ); + } + if ($automationEnabled && !$this->canExecuteTransition((int) ($automationRule['run_as_user_id'] ?? 0), $pk)) { + // Only a warning, because the permission is often granted after the rule is written. + Factory::getApplication()->enqueueMessage( + Text::_('COM_WORKFLOW_AUTOMATION_WARNING_RUN_AS_CANNOT_EXECUTE'), + 'warning' + ); + } + + return true; } /** @@ -325,9 +396,215 @@ protected function preprocessForm(Form $form, $data, $group = 'content') // Set the access control rules field component value. $form->setFieldAttribute('rules', 'component', $extension); + $user = $this->getCurrentUser(); + + // Anyone but a super user may only keep the run-as user already saved or pick themselves, so the + // picker becomes a list of those two. Changing the type in place keeps the field where it is. + if (!$user->authorise('core.admin')) { + $choices = array_unique([ + $this->storedRunAsUserId((int) $this->getState($this->getName() . '.id')), + (int) $user->id, + ]); + + $db = $this->getDatabase(); + $query = $db->getQuery(true) + ->select($db->quoteName(['id', 'name'])) + ->from($db->quoteName('#__users')) + ->where($db->quoteName('id') . ' IN (' . implode(',', $choices) . ')') + ->order($db->quoteName('name')); + + $form->setFieldAttribute('run_as_user_id', 'type', 'sql', 'automation'); + $form->setFieldAttribute('run_as_user_id', 'key_field', 'id', 'automation'); + $form->setFieldAttribute('run_as_user_id', 'value_field', 'name', 'automation'); + $form->setFieldAttribute('run_as_user_id', 'query', (string) $query, 'automation'); + } + + // The Automation fieldset lives in this form rather than in the plugin that implements it, + // so it has to be taken out when that plugin is off. Otherwise rules can be written and + // saved that nothing will ever execute. + if (!PluginHelper::isEnabled('workflow', 'automation')) { + $form->removeGroup('automation'); + } elseif (!PluginHelper::isEnabled('task', 'workflowtransition')) { + // Here the rules are still valid, they simply have nothing to run them, so this warns + // rather than hiding work the user has already done. + Factory::getApplication()->enqueueMessage( + Text::sprintf( + 'COM_WORKFLOW_AUTOMATION_WARNING_TASK_PLUGIN_DISABLED', + $this->taskPluginLink() + ), + 'warning' + ); + } + // Import the appropriate plugin group. PluginHelper::importPlugin('workflow'); parent::preprocessForm($form, $data, $group); } + + /** + * A link that opens the automation task plugin in a dialog, for the warning shown when it is off. + * + * Falls back to plain text for a user who may not edit plugins, so the sentence still reads. + * + * @return string + * + * @since __DEPLOY_VERSION__ + */ + private function taskPluginLink(): string + { + $label = Text::_('COM_WORKFLOW_AUTOMATION_TASK_PLUGIN'); + /** @var \Joomla\CMS\Application\CMSWebApplicationInterface $app */ + $app = Factory::getApplication(); + + if (!$app->getIdentity()->authorise('core.manage', 'com_plugins')) { + return $label; + } + + $db = $this->getDatabase(); + $query = $db->getQuery(true) + ->select($db->quoteName('extension_id')) + ->from($db->quoteName('#__extensions')) + ->where($db->quoteName('type') . ' = ' . $db->quote('plugin')) + ->where($db->quoteName('folder') . ' = ' . $db->quote('task')) + ->where($db->quoteName('element') . ' = ' . $db->quote('workflowtransition')); + + $extensionId = (int) $db->setQuery($query)->loadResult(); + + if ($extensionId < 1) { + return $label; + } + + // The dialog script is loaded by the condition builder too, but the warning can appear + // before any builder renders, so it is asked for here as well. + $app->getDocument()->getWebAssetManager()->useScript('joomla.dialog-autocreate'); + + $popup = [ + 'popupType' => 'iframe', + 'textHeader' => $label, + 'src' => Route::_( + 'index.php?option=com_plugins&client_id=0&task=plugin.edit&extension_id=' . $extensionId + . '&tmpl=component&layout=modal', + false + ), + ]; + + return HTMLHelper::_('link', '#', $label, [ + 'data-joomla-dialog' => htmlspecialchars( + json_encode($popup, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE), + ENT_QUOTES, + 'UTF-8' + ), + 'data-checkin-url' => Route::_('index.php?option=com_plugins&task=plugins.checkin&format=json&cid[]=' . $extensionId, false), + 'data-close-on-message' => '', + 'data-reload-on-close' => '', + ]); + } + + /** + * Persists the automation rule for a transition, replacing whatever was there. + * + * @param integer $transitionId The transition id. + * @param array $automationRule The submitted rule, or empty to clear. + * + * @return void + * + * @since __DEPLOY_VERSION__ + */ + private function saveAutomationRule(int $transitionId, array $automationRule): void + { + $db = $this->getDatabase(); + $user = Factory::getApplication()->getIdentity(); + $now = Factory::getDate()->toSql(); + + try { + $db->transactionStart(); + + $db->setQuery( + $db->getQuery(true) + ->delete($db->quoteName('#__workflow_automation_rules')) + ->where($db->quoteName('transition_id') . ' = :id') + ->bind(':id', $transitionId, ParameterType::INTEGER) + )->execute(); + + if (!empty($automationRule)) { + $ruleType = ($automationRule['rule_type'] ?? 'delay') === 'cron' ? 'cron' : 'delay'; + + $ruleRow = (object) [ + 'transition_id' => $transitionId, + 'published' => 1, + 'ordering' => 0, + 'rule_type' => $ruleType, + 'delay_value' => $ruleType === 'delay' ? (int) ($automationRule['delay_value'] ?? 0) : 0, + 'delay_unit' => $ruleType === 'delay' ? ($automationRule['delay_unit'] ?? 'minutes') : 'minutes', + 'cron_expression' => $ruleType === 'cron' ? ($automationRule['cron_expression'] ?? '') : '', + 'run_as_user_id' => (int) ($automationRule['run_as_user_id'] ?? 0), + 'item_filter' => ($automationRule['item_filter'] ?? '') !== '' ? $automationRule['item_filter'] : null, + 'fire_condition' => ($automationRule['fire_condition'] ?? '') !== '' ? $automationRule['fire_condition'] : null, + 'created' => $now, + 'created_by' => $user->id, + 'modified' => $now, + 'modified_by' => $user->id, + ]; + + $db->insertObject('#__workflow_automation_rules', $ruleRow); + } + + $db->transactionCommit(); + } catch (\Throwable $error) { + $db->transactionRollback(); + + throw $error; + } + } + + /** + * The run-as user already stored for this transition, or 0 when there is no rule yet. + * + * @param integer $transitionId The transition being saved. + * + * @return integer + * + * @since __DEPLOY_VERSION__ + */ + private function storedRunAsUserId(int $transitionId): int + { + if ($transitionId <= 0) { + return 0; + } + + $db = $this->getDatabase(); + $query = $db->getQuery(true) + ->select($db->quoteName('run_as_user_id')) + ->from($db->quoteName('#__workflow_automation_rules')) + ->where($db->quoteName('transition_id') . ' = :transitionId') + ->bind(':transitionId', $transitionId, ParameterType::INTEGER); + + return (int) $db->setQuery($query)->loadResult(); + } + + /** + * Whether an account holds the permission the scheduler will need at run time. + * + * Asks the same question that Workflow::getValidTransition() asks at run time. + * + * @param integer $userId The run-as account. + * @param integer $transitionId The transition it would execute. + * + * @return boolean + * + * @since __DEPLOY_VERSION__ + */ + private function canExecuteTransition(int $userId, int $transitionId): bool + { + $runAsUser = Factory::getContainer()->get(UserFactoryInterface::class)->loadUserById($userId); + + if ((int) $runAsUser->id !== $userId) { + return false; + } + + $parts = explode('.', (string) Factory::getApplication()->getInput()->get('extension')); + + return $runAsUser->authorise('core.execute.transition', array_shift($parts) . '.transition.' . $transitionId); + } } diff --git a/administrator/components/com_workflow/src/Model/TransitionsModel.php b/administrator/components/com_workflow/src/Model/TransitionsModel.php index 68c8349e55c0a..c274d1550fdd2 100644 --- a/administrator/components/com_workflow/src/Model/TransitionsModel.php +++ b/administrator/components/com_workflow/src/Model/TransitionsModel.php @@ -156,6 +156,13 @@ public function getListQuery() ->join('LEFT', $db->quoteName('#__workflow_stages', 't_stage'), $db->quoteName('t_stage.id') . ' = ' . $db->quoteName('t.to_stage_id')) ->join('LEFT', $db->quoteName('#__users', 'uc'), $db->quoteName('uc.id') . ' = ' . $db->quoteName('t.checked_out')); + $query->select( + '(SELECT MAX(' . $db->quoteName('war.published') . ')' + . ' FROM ' . $db->quoteName('#__workflow_automation_rules', 'war') + . ' WHERE ' . $db->quoteName('war.transition_id') . ' = ' . $db->quoteName('t.id') . ')' + . ' AS ' . $db->quoteName('automated') + ); + // Filter by extension if ($workflowID = (int) $this->getState('filter.workflow_id')) { $query->where($db->quoteName('t.workflow_id') . ' = :id') diff --git a/administrator/components/com_workflow/src/Model/UpcomingModel.php b/administrator/components/com_workflow/src/Model/UpcomingModel.php new file mode 100644 index 0000000000000..2b8ab352f94e3 --- /dev/null +++ b/administrator/components/com_workflow/src/Model/UpcomingModel.php @@ -0,0 +1,195 @@ + + * @license GNU General Public License version 2 or later; see LICENSE.txt + */ + +namespace Joomla\Component\Workflow\Administrator\Model; + +use Joomla\CMS\Factory; +use Joomla\CMS\Form\Form; +use Joomla\CMS\MVC\Model\ListModel; +use Joomla\Component\Workflow\Administrator\Automation\UpcomingTransitionsCalculator; +use Joomla\Database\ParameterType; + +// phpcs:disable PSR1.Files.SideEffects +\defined('_JEXEC') or die; +// phpcs:enable PSR1.Files.SideEffects + +/** + * Model for the extension-wide upcoming automated transitions view. + * + * A list model for the paging and the filter bar, but the rows are computed rather than queried, + * so getItems() and getTotal() do their own work instead of going through getListQuery(). + * + * @since __DEPLOY_VERSION__ + */ +class UpcomingModel extends ListModel +{ + /** + * Shared by the page and the count, so both see one instance. + * + * @var UpcomingTransitionsCalculator|null + * @since __DEPLOY_VERSION__ + */ + private ?UpcomingTransitionsCalculator $calculator = null; + + /** + * Auto-populate the model state from the request. + * + * @param string $ordering Unused: the order is fixed, attention first then longest waiting. + * @param string $direction Unused, for the same reason. + * + * @return void + * + * @since __DEPLOY_VERSION__ + */ + protected function populateState($ordering = null, $direction = null) + { + $extension = Factory::getApplication()->getUserStateFromRequest( + $this->context . '.filter.extension', + 'extension', + 'com_content.article', + 'cmd' + ); + + $this->setState('filter.extension', $extension); + + // list.limit, list.start, list.workflow_id and list.stage_id all arrive through the parent. + parent::populateState($ordering, $direction); + } + + /** + * The filter form, with its workflow and stage lists narrowed to what can be chosen. + * + * @param array $data Data for the form. + * @param boolean $loadData Whether to load the form data. + * + * @return Form|null + * + * @since __DEPLOY_VERSION__ + */ + public function getFilterForm($data = [], $loadData = true) + { + $form = parent::getFilterForm($data, $loadData); + + if ($form instanceof Form) { + $db = $this->getDatabase(); + $extension = $db->quote((string) $this->getState('filter.extension')); + $workflowId = (int) $this->getState('list.workflow_id'); + + // Narrowed here rather than in the XML, because the extension is only known at runtime. + $form->setFieldAttribute( + 'workflow_id', + 'sql_where', + $db->quoteName('published') . ' = 1 AND ' . $db->quoteName('extension') . ' = ' . $extension, + 'list' + ); + + // The chosen workflow's stages, or every stage in the extension while no workflow is chosen. + $form->setFieldAttribute( + 'stage_id', + 'sql_where', + $db->quoteName('s.published') . ' = 1 AND ' . $db->quoteName('w.extension') . ' = ' . $extension + . ($workflowId > 0 ? ' AND ' . $db->quoteName('s.workflow_id') . ' = ' . $workflowId : ''), + 'list' + ); + } + + return $form; + } + + /** + * Returns one page of upcoming automated transitions. + * + * @return \Joomla\Component\Workflow\Administrator\Automation\UpcomingTransition[] + * + * @since __DEPLOY_VERSION__ + */ + public function getItems(): array + { + $extension = (string) $this->getState('filter.extension'); + + if ($extension === '') { + return []; + } + + $limit = (int) $this->getState('list.limit'); + $start = (int) $this->getStart(); + $workflowId = (int) $this->getState('list.workflow_id'); + $stageId = $this->getStageFilter(); + + return $workflowId > 0 + ? $this->getCalculator()->forWorkflow($workflowId, $limit, $start, $stageId) + : $this->getCalculator()->forExtension($extension, $limit, $start, $stageId); + } + + /** + * The number of items the list can page through. + * + * @return integer + * + * @since __DEPLOY_VERSION__ + */ + public function getTotal(): int + { + $extension = (string) $this->getState('filter.extension'); + + if ($extension === '') { + return 0; + } + + $workflowId = (int) $this->getState('list.workflow_id'); + $stageId = $this->getStageFilter(); + + return $workflowId > 0 + ? $this->getCalculator()->countForWorkflow($workflowId, $stageId) + : $this->getCalculator()->countForExtension($extension, $stageId); + } + + /** + * The stage to filter by, or 0 for any stage. + * + * A stage that does not belong to the chosen workflow is ignored. It is left behind when the + * workflow filter changes, and the stage list no longer offers it, so honouring it would show an + * empty list with nothing on screen to explain why. + * + * @return integer + * + * @since __DEPLOY_VERSION__ + */ + private function getStageFilter(): int + { + $stageId = (int) $this->getState('list.stage_id'); + $workflowId = (int) $this->getState('list.workflow_id'); + + if ($stageId <= 0 || $workflowId <= 0) { + return max($stageId, 0); + } + + $db = $this->getDatabase(); + $query = $db->getQuery(true) + ->select('1') + ->from($db->quoteName('#__workflow_stages')) + ->where($db->quoteName('id') . ' = :stageId') + ->where($db->quoteName('workflow_id') . ' = :workflowId') + ->bind(':stageId', $stageId, ParameterType::INTEGER) + ->bind(':workflowId', $workflowId, ParameterType::INTEGER); + + return $db->setQuery($query)->loadResult() ? $stageId : 0; + } + + /** + * @return UpcomingTransitionsCalculator + * + * @since __DEPLOY_VERSION__ + */ + private function getCalculator(): UpcomingTransitionsCalculator + { + return $this->calculator ??= new UpcomingTransitionsCalculator($this->getDatabase()); + } +} diff --git a/administrator/components/com_workflow/src/Model/WorkflowModel.php b/administrator/components/com_workflow/src/Model/WorkflowModel.php index 9ca07c065f60f..d937b36106305 100644 --- a/administrator/components/com_workflow/src/Model/WorkflowModel.php +++ b/administrator/components/com_workflow/src/Model/WorkflowModel.php @@ -16,6 +16,9 @@ use Joomla\CMS\Language\Text; use Joomla\CMS\Log\Log; use Joomla\CMS\MVC\Model\AdminModel; +use Joomla\Component\Workflow\Administrator\Automation\ItemStorage; +use Joomla\Component\Workflow\Administrator\Automation\UpcomingTransitionsCalculator; +use Joomla\Database\ParameterType; use Joomla\String\StringHelper; // phpcs:disable PSR1.Files.SideEffects @@ -49,6 +52,89 @@ public function populateState() $this->setState('filter.extension', $extension); } + /** + * Returns this workflow's upcoming automated transitions, computed on the fly. + * + * @param integer $workflowId The workflow id. + * + * @return \Joomla\Component\Workflow\Administrator\Automation\UpcomingTransition[] + * + * @since __DEPLOY_VERSION__ + */ + public function getUpcomingTransitions(int $workflowId): array + { + if ($workflowId <= 0) { + return []; + } + + $calculator = new UpcomingTransitionsCalculator($this->getDatabase()); + + return $calculator->forWorkflow($workflowId); + } + + /** + * Returns the most recent automation log entries for this workflow. + * + * @param integer $workflowId The workflow id. + * @param integer $limit How many entries to return, newest first. + * + * @return object[] + * + * @since __DEPLOY_VERSION__ + */ + public function getAutomationLog(int $workflowId, int $limit = 20): array + { + if ($workflowId <= 0) { + return []; + } + + $db = $this->getDatabase(); + $query = $db->getQuery(true) + ->select( + [ + $db->quoteName('l.item_id'), + $db->quoteName('l.transition_id'), + $db->quoteName('l.extension'), + $db->quoteName('l.exit_code'), + $db->quoteName('l.note'), + $db->quoteName('l.executed_at'), + $db->quoteName('t.title', 'transition_title'), + $db->quoteName('t.workflow_id'), + $db->quoteName('f_stage.title', 'from_stage'), + $db->quoteName('t_stage.title', 'to_stage'), + $db->quoteName('u.name', 'run_as_name'), + ] + ) + ->from($db->quoteName('#__workflow_automation_log', 'l')) + ->join( + 'INNER', + $db->quoteName('#__workflow_transitions', 't'), + $db->quoteName('t.id') . ' = ' . $db->quoteName('l.transition_id') + ) + ->join( + 'LEFT', + $db->quoteName('#__workflow_stages', 'f_stage'), + $db->quoteName('f_stage.id') . ' = ' . $db->quoteName('l.from_stage_id') + ) + ->join( + 'LEFT', + $db->quoteName('#__workflow_stages', 't_stage'), + $db->quoteName('t_stage.id') . ' = ' . $db->quoteName('l.to_stage_id') + ) + ->join( + 'LEFT', + $db->quoteName('#__users', 'u'), + $db->quoteName('u.id') . ' = ' . $db->quoteName('l.run_as_user_id') + ) + ->where($db->quoteName('t.workflow_id') . ' = :workflowId') + ->bind(':workflowId', $workflowId, ParameterType::INTEGER) + ->order($db->quoteName('l.executed_at') . ' DESC'); + + $entries = $db->setQuery($query, 0, $limit)->loadObjectList() ?: []; + + return (new ItemStorage($db))->annotateTitles($entries); + } + /** * Method to change the title * @@ -288,8 +374,8 @@ public function setDefault($pk, $value = 1) if ( $table->load( [ - 'default' => '1', - 'extension' => $table->extension, + 'default' => '1', + 'extension' => $table->extension, ] ) ) { diff --git a/administrator/components/com_workflow/src/Rule/AutomationRule.php b/administrator/components/com_workflow/src/Rule/AutomationRule.php new file mode 100644 index 0000000000000..bb432676ee77c --- /dev/null +++ b/administrator/components/com_workflow/src/Rule/AutomationRule.php @@ -0,0 +1,124 @@ + + * @license GNU General Public License version 2 or later; see LICENSE.txt + */ + +namespace Joomla\Component\Workflow\Administrator\Rule; + +use Cron\CronExpression; +use Joomla\CMS\Form\Form; +use Joomla\CMS\Form\FormRule; +use Joomla\CMS\Language\Text; +use Joomla\Registry\Registry; + +// phpcs:disable PSR1.Files.SideEffects +\defined('_JEXEC') or die; +// phpcs:enable PSR1.Files.SideEffects + +/** + * Validates the whole automation rule at once. + * + * The fields live in a subform, and core's SubformRule returns only the first error its child form + * produced, so validating them individually can report one problem per save however many there are. + * Checking the row here means every problem is named in one message. + * + * @since __DEPLOY_VERSION__ + */ +class AutomationRule extends FormRule +{ + /** + * @param \SimpleXMLElement $element The field's XML definition. + * @param mixed $value The submitted automation row. + * @param ?string $group The field's group. + * @param ?Registry $input Every value submitted with the form. + * @param ?Form $form The form being validated. + * + * @return boolean + * + * @since __DEPLOY_VERSION__ + */ + public function test(\SimpleXMLElement $element, $value, $group = null, ?Registry $input = null, ?Form $form = null): bool + { + $row = (array) $value; + + if ($row === []) { + return true; + } + + $problems = []; + $ruleType = $row['rule_type'] ?? 'delay'; + + if ($ruleType === 'cron') { + $expression = trim((string) ($row['cron_expression'] ?? '')); + + if ($expression === '' || !CronExpression::isValidExpression($expression)) { + $problems[] = Text::_('COM_WORKFLOW_AUTOMATION_ERROR_CRON'); + } + } elseif ((int) ($row['delay_value'] ?? 0) < 0) { + $problems[] = Text::_('COM_WORKFLOW_AUTOMATION_ERROR_DELAY_VALUE'); + } + + $builders = [ + 'item_filter' => 'COM_WORKFLOW_AUTOMATION_FILTER_LABEL', + 'fire_condition' => 'COM_WORKFLOW_AUTOMATION_CONDITION_LABEL', + ]; + + $incomplete = []; + + foreach ($builders as $key => $label) { + if ($this->hasIncompleteCheck(json_decode((string) ($row[$key] ?? ''), true))) { + $incomplete[] = Text::_($label); + } + } + + if (\count($incomplete) === 2) { + $problems[] = Text::sprintf('COM_WORKFLOW_AUTOMATION_ERROR_INCOMPLETE_CHECKS', $incomplete[0], $incomplete[1]); + } elseif ($incomplete !== []) { + $problems[] = Text::sprintf('COM_WORKFLOW_AUTOMATION_ERROR_INCOMPLETE_CHECK', $incomplete[0]); + } + + if ($problems === []) { + return true; + } + + throw new \RuntimeException(implode(' ', $problems)); + } + + /** + * Whether an expression holds a check with no field, operator or value. + * + * @param mixed $node A decoded expression or check, or null when there is none. + * + * @return boolean + * + * @since __DEPLOY_VERSION__ + */ + private function hasIncompleteCheck(mixed $node): bool + { + if (!\is_array($node)) { + return false; + } + + if (\array_key_exists('items', $node)) { + foreach ((array) $node['items'] as $child) { + if ($this->hasIncompleteCheck($child)) { + return true; + } + } + + return false; + } + + $value = $node['value'] ?? ''; + + return ($node['field'] ?? '') === '' + || ($node['operator'] ?? '') === '' + || $value === '' + || $value === []; + } +} diff --git a/administrator/components/com_workflow/src/Rule/RunAsUserRule.php b/administrator/components/com_workflow/src/Rule/RunAsUserRule.php new file mode 100644 index 0000000000000..49d32acaac6f1 --- /dev/null +++ b/administrator/components/com_workflow/src/Rule/RunAsUserRule.php @@ -0,0 +1,97 @@ + + * @license GNU General Public License version 2 or later; see LICENSE.txt + */ + +namespace Joomla\Component\Workflow\Administrator\Rule; + +use Joomla\CMS\Factory; +use Joomla\CMS\Form\Form; +use Joomla\CMS\Form\FormRule; +use Joomla\CMS\Language\Text; +use Joomla\Database\DatabaseAwareInterface; +use Joomla\Database\DatabaseAwareTrait; +use Joomla\Database\ParameterType; +use Joomla\Registry\Registry; + +// phpcs:disable PSR1.Files.SideEffects +\defined('_JEXEC') or die; +// phpcs:enable PSR1.Files.SideEffects + +/** + * Decides whether the current user may make an automation rule run as the chosen account. + * + * @since __DEPLOY_VERSION__ + */ +class RunAsUserRule extends FormRule implements DatabaseAwareInterface +{ + use DatabaseAwareTrait; + + /** + * @param \SimpleXMLElement $element The field's XML definition. + * @param mixed $value The chosen user id. + * @param ?string $group The field's group. + * @param ?Registry $input Every value submitted with the form. + * @param ?Form $form The form being validated. + * + * @return boolean + * + * @since __DEPLOY_VERSION__ + */ + public function test(\SimpleXMLElement $element, $value, $group = null, ?Registry $input = null, ?Form $form = null): bool + { + if ($input === null || (int) $input->get('automation.automation_enabled', 0) !== 1) { + return true; + } + + $candidate = (int) $value; + + if ($candidate === 0) { + throw new \RuntimeException(Text::_('COM_WORKFLOW_AUTOMATION_ERROR_NO_RUN_AS')); + } + + $user = Factory::getApplication()->getIdentity(); + + // A super user may choose anyone. Anyone else may choose themselves, or keep the account + // the rule already runs as, so an existing rule survives being edited by a lesser user. + if ($user->authorise('core.admin') || $candidate === (int) $user->id) { + return true; + } + + if ($candidate === $this->storedRunAsUserId((int) $input->get('id', 0))) { + return true; + } + + throw new \RuntimeException(Text::_('COM_WORKFLOW_AUTOMATION_ERROR_RUN_AS_NOT_ALLOWED')); + } + + /** + * The run-as user already stored for this transition, or 0 when there is no rule yet. + * + * @param integer $transitionId The transition being saved. + * + * @return integer + * + * @since __DEPLOY_VERSION__ + */ + private function storedRunAsUserId(int $transitionId): int + { + if ($transitionId <= 0) { + return 0; + } + + $db = $this->getDatabase(); + $query = $db->getQuery(true) + ->select($db->quoteName('run_as_user_id')) + ->from($db->quoteName('#__workflow_automation_rules')) + ->where($db->quoteName('transition_id') . ' = :transitionId') + ->bind(':transitionId', $transitionId, ParameterType::INTEGER); + + return (int) $db->setQuery($query)->loadResult(); + } +} diff --git a/administrator/components/com_workflow/src/View/Logs/HtmlView.php b/administrator/components/com_workflow/src/View/Logs/HtmlView.php new file mode 100644 index 0000000000000..887c8a53f7321 --- /dev/null +++ b/administrator/components/com_workflow/src/View/Logs/HtmlView.php @@ -0,0 +1,81 @@ + + * @license GNU General Public License version 2 or later; see LICENSE.txt + */ + +namespace Joomla\Component\Workflow\Administrator\View\Logs; + +use Joomla\CMS\Access\Exception\NotAllowed; +use Joomla\CMS\Language\Text; +use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView; +use Joomla\CMS\Router\Route; +use Joomla\CMS\Toolbar\ToolbarHelper; +use Joomla\Component\Workflow\Administrator\Model\LogsModel; + +// phpcs:disable PSR1.Files.SideEffects +\defined('_JEXEC') or die; +// phpcs:enable PSR1.Files.SideEffects + +/** + * Automation log view. + * + * @since __DEPLOY_VERSION__ + */ +class HtmlView extends BaseHtmlView +{ + protected $items; + protected $state; + protected $pagination; + public $filterForm; + public $activeFilters; + protected $extension; + + public function display($tpl = null) + { + // The dropdown that links here is core.admin only, because these rows carry failure + // details. Without this the screen is merely hidden, not protected: the URL still works. + if (!$this->getCurrentUser()->authorise('core.admin', 'com_workflow')) { + throw new NotAllowed(Text::_('JERROR_ALERTNOAUTHOR'), 403); + } + + /** @var LogsModel $model */ + $model = $this->getModel(); + + $this->state = $model->getState(); + $this->items = $model->getItems(); + $this->pagination = $model->getPagination(); + $this->filterForm = $model->getFilterForm(); + $this->activeFilters = $model->getActiveFilters(); + + $extension = (string) $this->state->get('filter.extension'); + $parts = explode('.', $extension); + $this->extension = array_shift($parts) ?: 'com_content'; + + $this->filterForm->addControlField('extension', $extension); + + $this->addToolbar(); + + parent::display($tpl); + } + + protected function addToolbar() + { + /** @var \Joomla\CMS\Document\HtmlDocument $document */ + $document = $this->getDocument(); + $toolbar = $document->getToolbar(); + + ToolbarHelper::title(Text::_('COM_WORKFLOW_LOGS_LIST'), 'list'); + + $arrow = $this->getLanguage()->isRtl() ? 'arrow-right' : 'arrow-left'; + + $toolbar->link( + 'JTOOLBAR_BACK', + Route::_('index.php?option=com_workflow&view=workflows&extension=' . $this->escape((string) $this->state->get('filter.extension'))) + )->icon('icon-' . $arrow); + } +} diff --git a/administrator/components/com_workflow/src/View/Transition/HtmlView.php b/administrator/components/com_workflow/src/View/Transition/HtmlView.php index 5e4c4a04ab5e8..2772a833b9e2a 100644 --- a/administrator/components/com_workflow/src/View/Transition/HtmlView.php +++ b/administrator/components/com_workflow/src/View/Transition/HtmlView.php @@ -214,5 +214,6 @@ function (Toolbar $childBar) use ($canCreate) { } $toolbar->divider(); + $toolbar->inlinehelp(); } } diff --git a/administrator/components/com_workflow/src/View/Upcoming/HtmlView.php b/administrator/components/com_workflow/src/View/Upcoming/HtmlView.php new file mode 100644 index 0000000000000..9a067dc6021c3 --- /dev/null +++ b/administrator/components/com_workflow/src/View/Upcoming/HtmlView.php @@ -0,0 +1,125 @@ + + * @license GNU General Public License version 2 or later; see LICENSE.txt + */ + +namespace Joomla\Component\Workflow\Administrator\View\Upcoming; + +use Joomla\CMS\Access\Exception\NotAllowed; +use Joomla\CMS\Language\Text; +use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView; +use Joomla\CMS\Router\Route; +use Joomla\CMS\Toolbar\ToolbarHelper; +use Joomla\Component\Workflow\Administrator\Model\UpcomingModel; + +// phpcs:disable PSR1.Files.SideEffects +\defined('_JEXEC') or die; +// phpcs:enable PSR1.Files.SideEffects + +/** + * Upcoming automated transitions across every workflow of an extension. + * + * @since __DEPLOY_VERSION__ + */ +class HtmlView extends BaseHtmlView +{ + /** + * The upcoming transitions to display. + * + * @var \Joomla\Component\Workflow\Administrator\Automation\UpcomingTransition[] + * @since __DEPLOY_VERSION__ + */ + protected $items = []; + + /** + * The model state. + * + * @var \Joomla\Registry\Registry + * @since __DEPLOY_VERSION__ + */ + protected $state; + + /** + * The pagination object. + * + * @var \Joomla\CMS\Pagination\Pagination + * @since __DEPLOY_VERSION__ + */ + protected $pagination; + + /** + * The filter form. + * + * @var \Joomla\CMS\Form\Form + * @since __DEPLOY_VERSION__ + */ + public $filterForm; + + /** + * The active filters. + * + * @var array + * @since __DEPLOY_VERSION__ + */ + public $activeFilters = []; + + /** + * Display the view. + * + * @param string $tpl The name of the template file to parse. + * + * @return void + * + * @since __DEPLOY_VERSION__ + */ + public function display($tpl = null) + { + // The dropdown that links here is core.admin only, because this lists every queued item + // across the site. Without this the screen is merely hidden, not protected. + if (!$this->getCurrentUser()->authorise('core.admin', 'com_workflow')) { + throw new NotAllowed(Text::_('JERROR_ALERTNOAUTHOR'), 403); + } + + /** @var UpcomingModel $model */ + $model = $this->getModel(); + + $this->state = $model->getState(); + $this->items = $model->getItems(); + $this->pagination = $model->getPagination(); + $this->filterForm = $model->getFilterForm(); + $this->activeFilters = $model->getActiveFilters(); + + // Keeps the extension on the form when the filter bar submits it. + $this->filterForm->addControlField('extension', (string) $this->state->get('filter.extension')); + + $this->addToolbar(); + + parent::display($tpl); + } + + /** + * Add the page title and toolbar. + * + * @return void + * + * @since __DEPLOY_VERSION__ + */ + protected function addToolbar() + { + $toolbar = $this->getDocument()->getToolbar(); + + ToolbarHelper::title(Text::_('COM_WORKFLOW_UPCOMING_LIST'), 'clock'); + + $arrow = $this->getLanguage()->isRtl() ? 'arrow-right' : 'arrow-left'; + + $toolbar->link( + 'JTOOLBAR_BACK', + Route::_('index.php?option=com_workflow&view=workflows&extension=' . $this->escape((string) $this->state->get('filter.extension'))) + )->icon('icon-' . $arrow); + } +} diff --git a/administrator/components/com_workflow/src/View/Workflow/HtmlView.php b/administrator/components/com_workflow/src/View/Workflow/HtmlView.php index 21cb043217a1b..0860740386ab5 100644 --- a/administrator/components/com_workflow/src/View/Workflow/HtmlView.php +++ b/administrator/components/com_workflow/src/View/Workflow/HtmlView.php @@ -75,6 +75,22 @@ class HtmlView extends BaseHtmlView */ protected $section; + /** + * The workflow's upcoming automated transitions. + * + * @var \Joomla\Component\Workflow\Administrator\Automation\UpcomingTransition[] + * @since __DEPLOY_VERSION__ + */ + protected $upcomingTransitions = []; + + /** + * The workflow's recent automation log entries. + * + * @var object[] + * @since __DEPLOY_VERSION__ + */ + protected $automationLog = []; + /** * Display item view * @@ -94,7 +110,9 @@ public function display($tpl = null) $this->form = $model->getForm(); $this->item = $model->getItem(); - $extension = $this->state->get('filter.extension'); + $this->upcomingTransitions = $model->getUpcomingTransitions((int) $this->item->id); + $this->automationLog = $model->getAutomationLog((int) $this->item->id); + $extension = $this->state->get('filter.extension'); $parts = explode('.', $extension); diff --git a/administrator/components/com_workflow/src/View/Workflows/HtmlView.php b/administrator/components/com_workflow/src/View/Workflows/HtmlView.php index a132d883a8855..8f2bc7179528d 100644 --- a/administrator/components/com_workflow/src/View/Workflows/HtmlView.php +++ b/administrator/components/com_workflow/src/View/Workflows/HtmlView.php @@ -13,6 +13,7 @@ use Joomla\CMS\Helper\ContentHelper; use Joomla\CMS\Language\Text; use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView; +use Joomla\CMS\Router\Route; use Joomla\CMS\Toolbar\Button\DropdownButton; use Joomla\CMS\Toolbar\ToolbarHelper; use Joomla\Component\Workflow\Administrator\Model\WorkflowsModel; @@ -152,6 +153,29 @@ protected function addToolbar() $toolbar->addNew('workflow.add'); } + // core.admin only, because these views expose failure details. + if ($canDo->get('core.admin')) { + $extension = $this->extension . ($this->section ? '.' . $this->section : ''); + + /** @var DropdownButton $automations */ + $automations = $toolbar->dropdownButton('automation-group', 'COM_WORKFLOW_AUTOMATIONS_TOOLBAR') + ->toggleSplit(false) + ->icon('icon-cogs') + ->buttonClass('btn btn-action'); + + $automationBar = $automations->getChildToolbar(); + + $automationBar->link( + 'COM_WORKFLOW_UPCOMING_LIST', + Route::_('index.php?option=com_workflow&view=upcoming&extension=' . $extension) + )->icon('icon-clock'); + + $automationBar->link( + 'COM_WORKFLOW_LOGS_LIST', + Route::_('index.php?option=com_workflow&view=logs&extension=' . $extension) + )->icon('icon-list'); + } + if ($canDo->get('core.edit.state') || $user->authorise('core.admin')) { /** @var DropdownButton $dropdown */ $dropdown = $toolbar->dropdownButton('status-group', 'JTOOLBAR_CHANGE_STATUS') diff --git a/administrator/components/com_workflow/tmpl/graph/default_texts.php b/administrator/components/com_workflow/tmpl/graph/default_texts.php index 6b5feef6ed435..69dc70f24fd68 100644 --- a/administrator/components/com_workflow/tmpl/graph/default_texts.php +++ b/administrator/components/com_workflow/tmpl/graph/default_texts.php @@ -80,6 +80,7 @@ 'COM_WORKFLOW_GRAPH_TRANSITION', 'COM_WORKFLOW_GRAPH_TRANSITIONS', 'COM_WORKFLOW_GRAPH_TRANSITION_ACTIONS', + 'COM_WORKFLOW_GRAPH_TRANSITION_AUTOMATED', 'COM_WORKFLOW_GRAPH_TRANSITION_COUNT', 'COM_WORKFLOW_GRAPH_TRANSITION_DESCRIPTION', 'COM_WORKFLOW_GRAPH_TRANSITION_PATH', diff --git a/administrator/components/com_workflow/tmpl/logs/default.php b/administrator/components/com_workflow/tmpl/logs/default.php new file mode 100644 index 0000000000000..cd2c22ea634aa --- /dev/null +++ b/administrator/components/com_workflow/tmpl/logs/default.php @@ -0,0 +1,141 @@ + + * @license GNU General Public License version 2 or later; see LICENSE.txt + */ + +defined('_JEXEC') or die; + +use Joomla\CMS\HTML\HTMLHelper; +use Joomla\CMS\Language\Text; +use Joomla\CMS\Layout\LayoutHelper; +use Joomla\CMS\Router\Route; +use Joomla\CMS\Session\Session; + +/** @var \Joomla\Component\Workflow\Administrator\View\Logs\HtmlView $this */ + +$wa = $this->getDocument()->getWebAssetManager(); +$wa->useScript('table.columns'); + +$listOrder = $this->escape($this->state->get('list.ordering')); +$listDirn = $this->escape($this->state->get('list.direction')); +$extension = $this->escape((string) $this->state->get('filter.extension')); +$user = $this->getCurrentUser(); +?> +
+
+
+
+ $this]); ?> + items)) : ?> +
+ + +
+ + + + + + + + + + + + + + + + + items as $item) : + $parts = explode('.', (string) $item->extension); + $editLink = (!empty($parts[0]) && !empty($parts[1])) ? Route::_('index.php?option=' . $parts[0] . '&task=' . $parts[1] . '.edit&id=' . (int) $item->item_id) : ''; + + $itemTitle = (string) ($item->item_title ?? ''); + $itemLabel = $itemTitle !== '' ? $itemTitle : (string) (int) $item->item_id; + + $resetLink = ((int) $item->exit_code !== 0 && !empty($item->requires_intervention)) + ? Route::_('index.php?option=com_workflow&task=logs.reset&item_id=' . (int) $item->item_id . '&extension=' . urlencode($item->extension) . '&' . Session::getFormToken() . '=1') + : ''; + ?> + + + + + + + + + + + + +
executed_at, Text::_('DATE_FORMAT_LC5')); ?> + + escape($itemLabel); ?> + + escape($itemLabel); ?> + + +
item_id); ?>
+ +
+ item_category_id)) : ?> + + escape($item->item_category_title); ?> + + + + transition_title + ? $this->escape(Text::_($item->transition_title)) + : (string) (int) $item->transition_id; + + $transitionEdit = $user->authorise('core.edit', $parts[0] . '.transition.' . (int) $item->transition_id) + ? Route::_( + 'index.php?option=com_workflow&task=transition.edit&id=' . (int) $item->transition_id + . '&workflow_id=' . (int) $item->workflow_id + . '&extension=' . $this->escape((string) $item->extension) + ) + : ''; + ?> + + + + + + + from_stage_id !== $item->to_stage_id) : ?> + from_stage ? $this->escape(Text::_($item->from_stage)) : (int) $item->from_stage_id; ?> + + to_stage ? $this->escape(Text::_($item->to_stage)) : (int) $item->to_stage_id; ?> + + run_as_name ? $this->escape($item->run_as_name) : Text::_('COM_WORKFLOW_LOGS_RUN_AS_SYSTEM'); ?> + exit_code === 0) : ?> + + exit_code === 4) : ?> + + + + +
+ +
+ + +
note ? $this->escape($item->note) : ''; ?>
+ pagination->getListFooter(); ?> + + + + + +
+
+
+
diff --git a/administrator/components/com_workflow/tmpl/transition/edit.php b/administrator/components/com_workflow/tmpl/transition/edit.php index e6894ba30c9f1..d84e841e1f8ae 100644 --- a/administrator/components/com_workflow/tmpl/transition/edit.php +++ b/administrator/components/com_workflow/tmpl/transition/edit.php @@ -26,7 +26,7 @@ $app = Factory::getApplication(); $user = $app->getIdentity(); -$this->ignore_fieldsets = ['params', 'transition', 'permissions']; +$this->ignore_fieldsets = ['params', 'transition', 'permissions', 'automation']; $this->useCoreUI = true; // In case of modal @@ -66,6 +66,19 @@ + form->getGroup('automation')) : ?> + +
+ +
+
+ form->renderFieldset('automation'); ?> +
+
+
+ + + authorise('core.admin', $this->extension)) : ?>
diff --git a/administrator/components/com_workflow/tmpl/transitions/default.php b/administrator/components/com_workflow/tmpl/transitions/default.php index c416ff7e2acfc..feaac03e9b232 100644 --- a/administrator/components/com_workflow/tmpl/transitions/default.php +++ b/administrator/components/com_workflow/tmpl/transitions/default.php @@ -135,6 +135,12 @@ escape(Text::_($item->title)); ?>
escape(Text::_($item->description)); ?>
+ automated) : ?> + + + + + from_stage_id < 0) : ?> diff --git a/administrator/components/com_workflow/tmpl/upcoming/default.php b/administrator/components/com_workflow/tmpl/upcoming/default.php new file mode 100644 index 0000000000000..281f006ca848b --- /dev/null +++ b/administrator/components/com_workflow/tmpl/upcoming/default.php @@ -0,0 +1,39 @@ + + * @license GNU General Public License version 2 or later; see LICENSE.txt + */ + +defined('_JEXEC') or die; + +use Joomla\CMS\HTML\HTMLHelper; +use Joomla\CMS\Layout\LayoutHelper; +use Joomla\CMS\Router\Route; + +/** @var \Joomla\Component\Workflow\Administrator\View\Upcoming\HtmlView $this */ +?> +
+
+
+
+ $this]); ?> + $this->items, 'showWorkflow' => true], + JPATH_ADMINISTRATOR . '/components/com_workflow/layouts' + ); + ?> + pagination->getListFooter(); ?> +
+
+
+ + + +
diff --git a/administrator/components/com_workflow/tmpl/workflow/edit.php b/administrator/components/com_workflow/tmpl/workflow/edit.php index 8c0e2a8ae1d0a..0f70796ce869f 100644 --- a/administrator/components/com_workflow/tmpl/workflow/edit.php +++ b/administrator/components/com_workflow/tmpl/workflow/edit.php @@ -84,6 +84,17 @@ + + item->id)) : ?> + + loadTemplate('upcoming'); ?> + + + + loadTemplate('log'); ?> + + + form->getInput('extension'); ?> diff --git a/administrator/components/com_workflow/tmpl/workflow/edit_log.php b/administrator/components/com_workflow/tmpl/workflow/edit_log.php new file mode 100644 index 0000000000000..558363bee65db --- /dev/null +++ b/administrator/components/com_workflow/tmpl/workflow/edit_log.php @@ -0,0 +1,109 @@ + + * @license GNU General Public License version 2 or later; see LICENSE.txt + */ + +defined('_JEXEC') or die; + +use Joomla\CMS\HTML\HTMLHelper; +use Joomla\CMS\Language\Text; +use Joomla\CMS\Router\Route; + +/** @var \Joomla\Component\Workflow\Administrator\View\Workflow\HtmlView $this */ + +$arrow = $this->getLanguage()->isRtl() ? 'arrow-left' : 'arrow-right'; +if (empty($this->automationLog)) : ?> +
+ + +
+ + + + + + + + + + + + + + + + + automationLog as $entry) : + $extensionParts = explode('.', (string) $entry->extension); + $itemEditLink = (!empty($extensionParts[0]) && !empty($extensionParts[1])) + ? Route::_('index.php?option=' . $extensionParts[0] . '&task=' . $extensionParts[1] . '.edit&id=' . (int) $entry->item_id) + : ''; + $itemTitle = (string) ($entry->item_title ?? ''); + $itemLabel = $itemTitle !== '' ? $itemTitle : (string) (int) $entry->item_id; + ?> + + + + + + + + + + + + +
executed_at, Text::_('DATE_FORMAT_LC5')); ?> + + escape($itemLabel); ?> + + escape($itemLabel); ?> + + +
item_id); ?>
+ +
+ item_category_id)) : ?> + + escape($entry->item_category_title); ?> + + + + transition_id + . '&workflow_id=' . (int) $entry->workflow_id + . '&extension=' . $this->escape((string) $entry->extension) + ); + ?> + + escape(Text::_((string) $entry->transition_title)); ?> + + + from_stage !== $entry->to_stage) : ?> + escape(Text::_((string) $entry->from_stage)); ?> + + escape(Text::_((string) $entry->to_stage)); ?> + + escape((string) $entry->run_as_name); ?> + exit_code === 0) : ?> + + + + + escape((string) $entry->note); ?>
+

+ + + +

diff --git a/administrator/components/com_workflow/tmpl/workflow/edit_upcoming.php b/administrator/components/com_workflow/tmpl/workflow/edit_upcoming.php new file mode 100644 index 0000000000000..062ec033a7549 --- /dev/null +++ b/administrator/components/com_workflow/tmpl/workflow/edit_upcoming.php @@ -0,0 +1,29 @@ + + * @license GNU General Public License version 2 or later; see LICENSE.txt + */ + +defined('_JEXEC') or die; + +use Joomla\CMS\Language\Text; +use Joomla\CMS\Layout\LayoutHelper; +use Joomla\CMS\Router\Route; + +/** @var \Joomla\Component\Workflow\Administrator\View\Workflow\HtmlView $this */ + +echo LayoutHelper::render( + 'upcoming.table', + ['items' => $this->upcomingTransitions, 'showWorkflow' => false], + JPATH_ADMINISTRATOR . '/components/com_workflow/layouts' +); +?> +

+ + + +

diff --git a/administrator/language/en-GB/com_workflow.ini b/administrator/language/en-GB/com_workflow.ini index 366e3fdb74369..6c56ef4595bbb 100644 --- a/administrator/language/en-GB/com_workflow.ini +++ b/administrator/language/en-GB/com_workflow.ini @@ -3,6 +3,94 @@ ; License GNU General Public License version 2 or later; see LICENSE.txt ; Note : All ini files need to be saved as UTF-8 +COM_WORKFLOW_AUTOMATIONS_TOOLBAR="Automations" +COM_WORKFLOW_AUTOMATION_BUILDER_ADD_CHECK="Add check" +COM_WORKFLOW_AUTOMATION_BUILDER_ADD_EXPRESSION="Add expression" +COM_WORKFLOW_AUTOMATION_BUILDER_CHECK="Check" +COM_WORKFLOW_AUTOMATION_BUILDER_EMPTY_CONDITION="No conditions yet. This rule runs whenever it is due." +COM_WORKFLOW_AUTOMATION_BUILDER_EMPTY_EXPRESSION="This expression is empty. Add a check to use it." +COM_WORKFLOW_AUTOMATION_BUILDER_EMPTY_FILTER="No filters yet. This rule applies to all items." +COM_WORKFLOW_AUTOMATION_BUILDER_EXPERT_HEADER="Workflow - Automation Options" +COM_WORKFLOW_AUTOMATION_BUILDER_EXPERT_HINT="For more advanced expressions, enable %s." +COM_WORKFLOW_AUTOMATION_BUILDER_EXPERT_LINK="Expert Mode" +COM_WORKFLOW_AUTOMATION_BUILDER_EXPERT_OFF_HINT="Expert Mode is on for this site. %s to go back to a simpler builder." +COM_WORKFLOW_AUTOMATION_BUILDER_EXPERT_OFF_LINK="Switch it off" +COM_WORKFLOW_AUTOMATION_BUILDER_EXPRESSION="Expression" +COM_WORKFLOW_AUTOMATION_BUILDER_FIELD="Field" +COM_WORKFLOW_AUTOMATION_BUILDER_JOIN_WITH="Join with" +COM_WORKFLOW_AUTOMATION_BUILDER_NEGATE="Not" +COM_WORKFLOW_AUTOMATION_BUILDER_OPERATOR="Operator" +COM_WORKFLOW_AUTOMATION_BUILDER_OP_AND="AND" +COM_WORKFLOW_AUTOMATION_BUILDER_OP_OR="OR" +COM_WORKFLOW_AUTOMATION_BUILDER_REMOVE="Remove" +COM_WORKFLOW_AUTOMATION_BUILDER_REMOVE_CHECK="Remove check: %s" +COM_WORKFLOW_AUTOMATION_BUILDER_VALUE="Value" +COM_WORKFLOW_AUTOMATION_CONDITION_DESC="The transition only fires when these are true at run time. Leave empty to fire whenever due." +COM_WORKFLOW_AUTOMATION_CONDITION_LABEL="Condition (When to Fire)" +COM_WORKFLOW_AUTOMATION_CRON_DESC="Standard cron expression, for example */5 * * * * for every five minutes. The next run is computed from when the item entered the stage." +COM_WORKFLOW_AUTOMATION_CRON_LABEL="Cron Expression" +COM_WORKFLOW_AUTOMATION_DELAY_UNIT_DESC="The unit for the delay above, for example 3 Days." +COM_WORKFLOW_AUTOMATION_DELAY_UNIT_LABEL="Delay Unit" +COM_WORKFLOW_AUTOMATION_DELAY_VALUE_DESC="How long to wait after the item enters the stage before this transition becomes due. Use 0 to fire as soon as it enters." +COM_WORKFLOW_AUTOMATION_DELAY_VALUE_LABEL="Delay" +COM_WORKFLOW_AUTOMATION_ENABLE_TRANSITION_DESC="When off, this transition is not automated and its rules are hidden." +COM_WORKFLOW_AUTOMATION_ENABLE_TRANSITION_LABEL="Automate This Transition" +COM_WORKFLOW_AUTOMATION_ERROR_CRON="The cron expression is not valid." +COM_WORKFLOW_AUTOMATION_ERROR_DELAY_UNIT="Invalid automation delay unit." +COM_WORKFLOW_AUTOMATION_ERROR_DELAY_VALUE="The automation delay cannot be negative." +COM_WORKFLOW_AUTOMATION_ERROR_INCOMPLETE_CHECK="A check in %s is not filled in. Complete it or remove it, then save again." +COM_WORKFLOW_AUTOMATION_ERROR_INCOMPLETE_CHECKS="Checks in %s and %s are not filled in. Complete them or remove them, then save again." +COM_WORKFLOW_AUTOMATION_ERROR_NO_RUN_AS="Choose a Run As user. An automated transition has to execute as somebody." +COM_WORKFLOW_AUTOMATION_ERROR_RULE_TYPE="Invalid automation rule type." +COM_WORKFLOW_AUTOMATION_ERROR_RUN_AS_NOT_ALLOWED="You can only run this automation as yourself or as the user it already runs as." +COM_WORKFLOW_AUTOMATION_FIELDSET_LABEL="Automation" +COM_WORKFLOW_AUTOMATION_FIELD_AUTHOR_GROUP="Author group" +COM_WORKFLOW_AUTOMATION_FIELD_CATEGORY="Category" +COM_WORKFLOW_AUTOMATION_FIELD_DATE="Date" +COM_WORKFLOW_AUTOMATION_FIELD_DAYS_IN_STAGE="Days in current stage" +COM_WORKFLOW_AUTOMATION_FIELD_DAYS_SINCE_MODIFIED="Days since last edited" +COM_WORKFLOW_AUTOMATION_FIELD_DAY_OF_WEEK="Day of Week" +COM_WORKFLOW_AUTOMATION_FIELD_TAG="Tag" +COM_WORKFLOW_AUTOMATION_FILTER_DESC="Only items matching these conditions are affected by this rule. Leave empty to apply to all items." +COM_WORKFLOW_AUTOMATION_FILTER_LABEL="Filter (Which Items)" +COM_WORKFLOW_AUTOMATION_OP_AFTER="after" +COM_WORKFLOW_AUTOMATION_OP_BEFORE="before" +COM_WORKFLOW_AUTOMATION_OP_GREATER_THAN="greater than" +COM_WORKFLOW_AUTOMATION_OP_HAS_ALL="has all of" +COM_WORKFLOW_AUTOMATION_OP_HAS_ANY="has any of" +COM_WORKFLOW_AUTOMATION_OP_HAS_NONE="has none of" +COM_WORKFLOW_AUTOMATION_OP_IN="in" +COM_WORKFLOW_AUTOMATION_OP_IS="is" +COM_WORKFLOW_AUTOMATION_OP_IS_NOT="is not" +COM_WORKFLOW_AUTOMATION_OP_LESS_THAN="less than" +COM_WORKFLOW_AUTOMATION_OP_NOT_IN="not in" +COM_WORKFLOW_AUTOMATION_OP_NOT_ON="not on" +COM_WORKFLOW_AUTOMATION_OP_ON="on" +COM_WORKFLOW_AUTOMATION_PREVIEW_BUTTON="Preview matches" +COM_WORKFLOW_AUTOMATION_PREVIEW_CAPPED="%1$s of the first %2$s items in this stage match." +COM_WORKFLOW_AUTOMATION_PREVIEW_EMPTY="There are no items in this stage to check." +COM_WORKFLOW_AUTOMATION_PREVIEW_LIST_HEADER="Items matching this filter" +COM_WORKFLOW_AUTOMATION_PREVIEW_LIST_RANGE="%1$s to %2$s of %3$s" +COM_WORKFLOW_AUTOMATION_PREVIEW_LIST_TRIMMED="Showing the first %s." +COM_WORKFLOW_AUTOMATION_PREVIEW_RESULT="%1$s of %2$s items in this stage match." +COM_WORKFLOW_AUTOMATION_PREVIEW_RUNNING="Checking..." +COM_WORKFLOW_AUTOMATION_PREVIEW_SHOW_ALL="Show all %s" +COM_WORKFLOW_AUTOMATION_RULES_DESC="Automation rules for this transition. If two automated transitions leave the same stage and fall due at the same moment, the one higher up the Transitions list runs first." +COM_WORKFLOW_AUTOMATION_RULES_LABEL="Automation Rules" +COM_WORKFLOW_AUTOMATION_RULE_SAVE_FAILED="The transition was saved, but its automation rule could not be updated: %s. The rule that was already stored has been left unchanged." +COM_WORKFLOW_AUTOMATION_RULE_TYPE_CRON="Cron Expression" +COM_WORKFLOW_AUTOMATION_RULE_TYPE_DELAY="Delay" +COM_WORKFLOW_AUTOMATION_RULE_TYPE_DESC="Choose a simple delay or a cron expression." +COM_WORKFLOW_AUTOMATION_RULE_TYPE_LABEL="Rule Type" +COM_WORKFLOW_AUTOMATION_RUN_AS_DESC="The user identity the automated transition executes as. Permissions are checked against this user." +COM_WORKFLOW_AUTOMATION_RUN_AS_LABEL="Run As User" +COM_WORKFLOW_AUTOMATION_TASK_PLUGIN="Workflow - Automation Task plugin" +COM_WORKFLOW_AUTOMATION_UNIT_DAYS="Days" +COM_WORKFLOW_AUTOMATION_UNIT_HOURS="Hours" +COM_WORKFLOW_AUTOMATION_UNIT_MINUTES="Minutes" +COM_WORKFLOW_AUTOMATION_UNIT_MONTHS="Months" +COM_WORKFLOW_AUTOMATION_WARNING_RUN_AS_CANNOT_EXECUTE="The Run As user cannot execute this transition, so the rule will fail until they are given permission." +COM_WORKFLOW_AUTOMATION_WARNING_TASK_PLUGIN_DISABLED="The %s is disabled, so no automated transition will run until it is enabled." COM_WORKFLOW_BASIC_STAGE="Basic Stage" COM_WORKFLOW_BASIC_WORKFLOW="Basic Workflow" COM_WORKFLOW_CHOOSE_CONTEXT_LABEL="Context" @@ -23,11 +111,40 @@ COM_WORKFLOW_FORM_NEW="New Workflow" COM_WORKFLOW_FROM_STAGE="Current Stage" COM_WORKFLOW_FROM_STAGE_FILTER_ASC="Current Stage ascending" COM_WORKFLOW_FROM_STAGE_FILTER_DESC="Current Stage descending" +COM_WORKFLOW_GRAPH_TRANSITION_AUTOMATED="This transition runs automatically" COM_WORKFLOW_ID="ID" COM_WORKFLOW_INHERIT_WORKFLOW="Inherit (%s)" COM_WORKFLOW_INHERIT_WORKFLOW_NEW="Inherit" COM_WORKFLOW_ITEM_MUST_PUBLISHED="Item must be published to set default stage." COM_WORKFLOW_ITEM_UNSET_DEFAULT="Default stage unset." +COM_WORKFLOW_LOGS_EXECUTED_AT="Executed" +COM_WORKFLOW_LOGS_EXECUTED_AT_ASC="Executed ascending" +COM_WORKFLOW_LOGS_EXECUTED_AT_DESC="Executed descending" +COM_WORKFLOW_LOGS_FILTER_SEARCH_HINT="Search by title, item ID or note" +COM_WORKFLOW_LOGS_FILTER_SEARCH_LABEL="Search Log" +COM_WORKFLOW_LOGS_ITEM="Item" +COM_WORKFLOW_LOGS_ITEM_ASC="Item ascending" +COM_WORKFLOW_LOGS_ITEM_DESC="Item descending" +COM_WORKFLOW_LOGS_ITEM_ID_INLINE="ID %d" +COM_WORKFLOW_LOGS_LIST="Automation Log" +COM_WORKFLOW_LOGS_NOTE="Note" +COM_WORKFLOW_LOGS_RESET="Reset" +COM_WORKFLOW_LOGS_RESET_SUCCESS="The item was cleared and will be retried on the next scheduler run." +COM_WORKFLOW_LOGS_RESULT="Result" +COM_WORKFLOW_LOGS_RESULT_ASC="Result ascending" +COM_WORKFLOW_LOGS_RESULT_DESC="Result descending" +COM_WORKFLOW_LOGS_RESULT_FAILED="Failed" +COM_WORKFLOW_LOGS_RESULT_OK="Success" +COM_WORKFLOW_LOGS_RESULT_SKIPPED="Skipped" +COM_WORKFLOW_LOGS_RUN_AS="Run as" +COM_WORKFLOW_LOGS_RUN_AS_SYSTEM="System" +COM_WORKFLOW_LOGS_SELECT_RESULT="- Select result -" +COM_WORKFLOW_LOGS_STAGES="Stages" +COM_WORKFLOW_LOGS_TABLE_CAPTION="Table of automation log entries" +COM_WORKFLOW_LOGS_TRANSITION="Transition" +COM_WORKFLOW_LOG_EMPTY="No automated transitions have run in this workflow yet." +COM_WORKFLOW_LOG_TAB="Automation Log" +COM_WORKFLOW_LOG_VIEW_ALL="View the full automation log" COM_WORKFLOW_MODIFIED_LABEL="Date Modified" COM_WORKFLOW_MSG_DELETE_IS_DEFAULT="You can't delete the default item." COM_WORKFLOW_MSG_DELETE_STAGE_IS_ASSIGNED="This stage is assigned to an item of this component and has to remain in a published state." @@ -47,6 +164,7 @@ COM_WORKFLOW_N_ITEMS_TRASHED_1="Workflow trashed." COM_WORKFLOW_N_ITEMS_UNPUBLISHED="%d workflows disabled." COM_WORKFLOW_N_ITEMS_UNPUBLISHED_1="Workflow disabled." COM_WORKFLOW_PREVIEW="Preview" +COM_WORKFLOW_PREVIEW_ERROR_NO_TRANSITION="Save the transition before previewing its filter." COM_WORKFLOW_PUBLISHED_LABEL="Status" COM_WORKFLOW_REDO="Redo" COM_WORKFLOW_RULES_TAB="Permissions" @@ -101,6 +219,35 @@ COM_WORKFLOW_TRANSITION_FORM_NEW="New Transition" COM_WORKFLOW_TRANSITION_NOTE="Note" COM_WORKFLOW_UNDO="Undo" COM_WORKFLOW_UNPUBLISH_DEFAULT_ERROR="The default workflow cannot be disabled." +COM_WORKFLOW_UPCOMING_ARTICLE_LABEL="Automated Transition" +COM_WORKFLOW_UPCOMING_COL_FIRES="Fires" +COM_WORKFLOW_UPCOMING_COL_ITEM="Item" +COM_WORKFLOW_UPCOMING_COL_MOVE="Move" +COM_WORKFLOW_UPCOMING_COL_TRIGGER="Trigger" +COM_WORKFLOW_UPCOMING_COL_WORKFLOW="Workflow" +COM_WORKFLOW_UPCOMING_DUE_NOW="Due now" +COM_WORKFLOW_UPCOMING_EMPTY="No items are waiting on an automated transition." +COM_WORKFLOW_UPCOMING_FILTER_STAGE="- Select Stage -" +COM_WORKFLOW_UPCOMING_FILTER_WORKFLOW="- Select Workflow -" +COM_WORKFLOW_UPCOMING_IN_DAYS="In %d days" +COM_WORKFLOW_UPCOMING_IN_DAYS_1="In 1 day" +COM_WORKFLOW_UPCOMING_IN_HOURS="In %d hours" +COM_WORKFLOW_UPCOMING_IN_HOURS_1="In 1 hour" +COM_WORKFLOW_UPCOMING_IN_MINUTES="In %d minutes" +COM_WORKFLOW_UPCOMING_IN_MINUTES_1="In 1 minute" +COM_WORKFLOW_UPCOMING_IN_MONTHS="In %d months" +COM_WORKFLOW_UPCOMING_IN_MONTHS_1="In 1 month" +COM_WORKFLOW_UPCOMING_ITEM_FALLBACK="Item #%d" +COM_WORKFLOW_UPCOMING_LAST_FAILED="Last failed %s" +COM_WORKFLOW_UPCOMING_LIST="Upcoming Transitions" +COM_WORKFLOW_UPCOMING_STATUS_ATTENTION="Needs attention" +COM_WORKFLOW_UPCOMING_STATUS_NOT_SCHEDULED="Not scheduled" +COM_WORKFLOW_UPCOMING_STATUS_RULE_ERROR="Rule error" +COM_WORKFLOW_UPCOMING_SUBJECT_CONDITION="Subject to condition" +COM_WORKFLOW_UPCOMING_TAB="Upcoming Transitions" +COM_WORKFLOW_UPCOMING_TRIGGER_CRON="Schedule: %s" +COM_WORKFLOW_UPCOMING_TRIGGER_DELAY="%1$s %2$s after entering the stage" +COM_WORKFLOW_UPCOMING_VIEW_ALL="View all upcoming transitions" COM_WORKFLOW_USE_DEFAULT_WORKFLOW="Use default (%s)" COM_WORKFLOW_WORKFLOWS_ADD="Add Workflow" COM_WORKFLOW_WORKFLOWS_EDIT="Edit Workflow" diff --git a/administrator/language/en-GB/plg_workflow_automation.ini b/administrator/language/en-GB/plg_workflow_automation.ini new file mode 100644 index 0000000000000..c09babe97151e --- /dev/null +++ b/administrator/language/en-GB/plg_workflow_automation.ini @@ -0,0 +1,9 @@ +; Joomla! Project +; (C) 2026 Open Source Matters, Inc. +; License GNU General Public License version 2 or later; see LICENSE.txt +; Note : All ini files need to be saved as UTF-8 + +PLG_WORKFLOW_AUTOMATION="Workflow - Automation" +PLG_WORKFLOW_AUTOMATION_FIELD_EXPERT_MODE_DESC="Adds OR, sub-expressions and Not to the filter and condition builders. When off, checks are simply added and all must match. Rules that already use these options still open in full." +PLG_WORKFLOW_AUTOMATION_FIELD_EXPERT_MODE_LABEL="Expert Mode" +PLG_WORKFLOW_AUTOMATION_XML_DESCRIPTION="Enables time-based automated transitions between workflow stages" diff --git a/administrator/language/en-GB/plg_workflow_automation.sys.ini b/administrator/language/en-GB/plg_workflow_automation.sys.ini new file mode 100644 index 0000000000000..c1829b15b124e --- /dev/null +++ b/administrator/language/en-GB/plg_workflow_automation.sys.ini @@ -0,0 +1,7 @@ +; Joomla! Project +; (C) 2026 Open Source Matters, Inc. +; License GNU General Public License version 2 or later; see LICENSE.txt +; Note : All ini files need to be saved as UTF-8 + +PLG_WORKFLOW_AUTOMATION="Workflow - Automation" +PLG_WORKFLOW_AUTOMATION_XML_DESCRIPTION="Enables time-based automated transitions between workflow stages" diff --git a/installation/sql/mysql/base.sql b/installation/sql/mysql/base.sql index 61180d9da653b..7e2103393fe8b 100644 --- a/installation/sql/mysql/base.sql +++ b/installation/sql/mysql/base.sql @@ -25,7 +25,7 @@ CREATE TABLE IF NOT EXISTS `#__assets` ( -- INSERT INTO `#__assets` (`id`, `parent_id`, `lft`, `rgt`, `level`, `name`, `title`, `rules`) VALUES -(1, 0, 0, 183, 0, 'root.1', 'Root Asset', '{"core.login.site":{"6":1,"2":1},"core.login.admin":{"6":1},"core.login.api":{"8":1},"core.login.offline":{"6":1},"core.admin":{"8":1},"core.manage":{"7":1},"core.create":{"6":1,"3":1},"core.delete":{"6":1},"core.edit":{"6":1,"4":1},"core.edit.state":{"6":1,"5":1},"core.edit.own":{"6":1,"3":1}}'), +(1, 0, 0, 185, 0, 'root.1', 'Root Asset', '{"core.login.site":{"6":1,"2":1},"core.login.admin":{"6":1},"core.login.api":{"8":1},"core.login.offline":{"6":1},"core.admin":{"8":1},"core.manage":{"7":1},"core.create":{"6":1,"3":1},"core.delete":{"6":1},"core.edit":{"6":1,"4":1},"core.edit.state":{"6":1,"5":1},"core.edit.own":{"6":1,"3":1}}'), (2, 1, 1, 2, 1, 'com_admin', 'com_admin', '{}'), (3, 1, 3, 6, 1, 'com_banners', 'com_banners', '{"core.admin":{"7":1},"core.manage":{"6":1}}'), (4, 1, 7, 8, 1, 'com_cache', 'com_cache', '{"core.admin":{"7":1},"core.manage":{"7":1}}'), @@ -107,17 +107,17 @@ INSERT INTO `#__assets` (`id`, `parent_id`, `lft`, `rgt`, `level`, `name`, `titl (87, 18, 124, 125, 2, 'com_modules.module.97', 'Recently Added Articles', '{}'), (88, 18, 126, 127, 2, 'com_modules.module.98', 'Logged-in Users', '{}'), (89, 18, 128, 129, 2, 'com_modules.module.90', 'Login Support', '{}'), -(90, 1, 165, 172, 1, 'com_scheduler', 'com_scheduler', '{}'), -(91, 1, 173, 174, 1, 'com_associations', 'com_associations', '{}'), -(92, 1, 175, 176, 1, 'com_categories', 'com_categories', '{}'), -(93, 1, 177, 178, 1, 'com_fields', 'com_fields', '{}'), -(94, 1, 179, 180, 1, 'com_workflow', 'com_workflow', '{}'), -(95, 1, 181, 182, 1, 'com_guidedtours', 'com_guidedtours', '{}'), +(90, 1, 165, 174, 1, 'com_scheduler', 'com_scheduler', '{}'), +(91, 1, 175, 176, 1, 'com_associations', 'com_associations', '{}'), +(92, 1, 177, 178, 1, 'com_categories', 'com_categories', '{}'), +(93, 1, 179, 180, 1, 'com_fields', 'com_fields', '{}'), +(94, 1, 181, 182, 1, 'com_workflow', 'com_workflow', '{}'), +(95, 1, 183, 184, 1, 'com_guidedtours', 'com_guidedtours', '{}'), (96, 18, 130, 131, 2, 'com_modules.module.109', 'Guided Tours', '{}'), (97, 90, 166, 167, 2, 'com_scheduler.task.1', 'Rotate Logs', '{}'), (98, 90, 168, 169, 2, 'com_scheduler.task.2', 'Session GC', '{}'), -(99, 90, 170, 171, 2, 'com_scheduler.task.3', 'Update Notification', '{}'); - +(99, 90, 170, 171, 2, 'com_scheduler.task.3', 'Update Notification', '{}'), +(100, 90, 172, 173, 2, 'com_scheduler.task.4', 'Workflow Automation', '{}'); -- -------------------------------------------------------- -- @@ -380,6 +380,7 @@ INSERT INTO `#__extensions` (`package_id`, `name`, `type`, `element`, `folder`, (0, 'plg_task_sessiongc', 'plugin', 'sessiongc', 'task', 0, 1, 1, 0, 1, '', '{}', '', 7, 0), (0, 'plg_task_sitestatus', 'plugin', 'sitestatus', 'task', 0, 1, 1, 0, 1, '', '{}', '', 8, 0), (0, 'plg_task_updatenotification', 'plugin', 'updatenotification', 'task', 0, 1, 1, 0, 1, '', '{}', '', 9, 0), +(0, 'plg_task_workflowtransition', 'plugin', 'workflowtransition', 'task', 0, 1, 1, 0, 1, '', '{}', '', 10, 0), (0, 'plg_multifactorauth_totp', 'plugin', 'totp', 'multifactorauth', 0, 1, 1, 0, 1, '', '', '', 1, 0), (0, 'plg_multifactorauth_yubikey', 'plugin', 'yubikey', 'multifactorauth', 0, 1, 1, 0, 1, '', '', '', 2, 0), (0, 'plg_multifactorauth_webauthn', 'plugin', 'webauthn', 'multifactorauth', 0, 1, 1, 0, 1, '', '', '', 3, 0), @@ -411,6 +412,8 @@ INSERT INTO `#__extensions` (`package_id`, `name`, `type`, `element`, `folder`, (0, 'plg_workflow_featuring', 'plugin', 'featuring', 'workflow', 0, 1, 1, 0, 1, '', '{}', '', 1, 0), (0, 'plg_workflow_notification', 'plugin', 'notification', 'workflow', 0, 1, 1, 0, 1, '', '{}', '', 2, 0), (0, 'plg_workflow_publishing', 'plugin', 'publishing', 'workflow', 0, 1, 1, 0, 1, '', '{}', '', 3, 0), +(0, 'plg_workflow_automation', 'plugin', 'automation', 'workflow', 0, 1, 1, 0, 1, '', '{}', '', 4, 0), +(0, 'plg_workflow_remotecheck', 'plugin', 'remotecheck', 'workflow', 0, 0, 1, 0, 1, '', '{}', '', 5, 0), (0, 'plg_system_guidedtours', 'plugin', 'guidedtours', 'system', 0, 1, 1, 0, 1, '', '{}', '', 15, 0); -- Templates @@ -1266,3 +1269,72 @@ INSERT INTO `#__workflow_transitions` (`id`, `asset_id`, `published`, `ordering` (5, 62, 1, 5, 1, 'FEATURE', '', -1, 1, '{"featuring":"1"}'), (6, 63, 1, 6, 1, 'UNFEATURE', '', -1, 1, '{"featuring":"0"}'), (7, 64, 1, 7, 1, 'PUBLISH_AND_FEATURE', '', -1, 1, '{"publishing":"1","featuring":"1"}'); + +-- +-- Table structure for table `#__workflow_automation_rules` +-- + +CREATE TABLE IF NOT EXISTS `#__workflow_automation_rules` ( + `id` int NOT NULL AUTO_INCREMENT, + `transition_id` int NOT NULL COMMENT 'Foreign Key to #__workflow_transitions.id', + `published` tinyint NOT NULL DEFAULT 0, + `ordering` int NOT NULL DEFAULT 0, + `rule_type` varchar(20) NOT NULL DEFAULT 'delay' COMMENT 'delay or cron', + `delay_value` int DEFAULT NULL, + `delay_unit` varchar(10) DEFAULT NULL COMMENT 'minutes, hours, days, months', + `cron_expression` varchar(100) DEFAULT NULL, + `item_filter` text COMMENT 'JSON filter tree: which items this rule applies to (evaluated at selection)', + `fire_condition` text COMMENT 'JSON expression tree: gate evaluated live at fire time', + `run_as_user_id` int NOT NULL DEFAULT 0 COMMENT 'User identity used to execute the transition', + `created` datetime NOT NULL, + `created_by` int NOT NULL DEFAULT 0, + `modified` datetime NOT NULL, + `modified_by` int NOT NULL DEFAULT 0, + PRIMARY KEY (`id`), + UNIQUE KEY `idx_transition` (`transition_id`), + KEY `idx_published` (`published`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 DEFAULT COLLATE=utf8mb4_unicode_ci; + +-- +-- Table structure for table `#__workflow_item_state` +-- + +CREATE TABLE IF NOT EXISTS `#__workflow_item_state` ( + `id` int NOT NULL AUTO_INCREMENT, + `item_id` int NOT NULL DEFAULT 0 COMMENT 'Extension table id value', + `extension` varchar(50) NOT NULL, + `stage_id` int NOT NULL COMMENT 'Foreign Key to #__workflow_stages.id', + `entered_at` datetime NOT NULL COMMENT 'When the item arrived in stage_id', + `triggered_by` varchar(20) NOT NULL DEFAULT 'manual' COMMENT 'Determine if a transition was triggered manually or by the automation', + `requires_intervention` tinyint NOT NULL DEFAULT 0 COMMENT 'Set when an automated transition failed; excluded from the scheduler until an admin clears it', + `last_checked_at` datetime DEFAULT NULL COMMENT 'When the scheduler last considered this item; null means never', + `last_failure_at` datetime DEFAULT NULL COMMENT 'When this item last could not be evaluated; null means it evaluated cleanly', + `last_failure_reason` varchar(500) DEFAULT NULL COMMENT 'Why it could not be evaluated, so the same failure is only reported once', + PRIMARY KEY (`id`), + UNIQUE KEY `idx_item_extension` (`item_id`, `extension`), + KEY `idx_stage_entered` (`stage_id`, `entered_at`), + KEY `idx_requires_intervention` (`requires_intervention`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 DEFAULT COLLATE=utf8mb4_unicode_ci; + +-- +-- Table structure for table `#__workflow_automation_log` +-- + +CREATE TABLE IF NOT EXISTS `#__workflow_automation_log` ( + `id` int NOT NULL AUTO_INCREMENT, + `rule_id` int DEFAULT NULL COMMENT 'Foreign Key to #__workflow_automation_rules.id', + `item_id` int NOT NULL DEFAULT 0, + `extension` varchar(50) NOT NULL, + `transition_id` int NOT NULL, + `from_stage_id` int NOT NULL DEFAULT 0, + `to_stage_id` int NOT NULL DEFAULT 0, + `run_as_user_id` int NOT NULL DEFAULT 0, + `trigger_type` varchar(20) NOT NULL DEFAULT 'rule', + `exit_code` tinyint NOT NULL DEFAULT 0 COMMENT '0 ok, 1 permission denied, 2 invalid transition, 3 exception, 4 no longer applicable', + `note` varchar(500) DEFAULT NULL, + `executed_at` datetime NOT NULL, + PRIMARY KEY (`id`), + KEY `idx_item_id` (`item_id`), + KEY `idx_executed_at` (`executed_at`), + KEY `idx_exit_code` (`exit_code`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 DEFAULT COLLATE=utf8mb4_unicode_ci; diff --git a/installation/sql/mysql/extensions.sql b/installation/sql/mysql/extensions.sql index 0686a3a74649b..7d5e3cdb72e90 100644 --- a/installation/sql/mysql/extensions.sql +++ b/installation/sql/mysql/extensions.sql @@ -934,7 +934,8 @@ CREATE TABLE IF NOT EXISTS `#__scheduler_tasks` ( INSERT INTO `#__scheduler_tasks` (`id`, `asset_id`, `title`, `type`, `execution_rules`, `cron_rules`, `state`, `last_execution`, `next_execution`, `locked`, `params`, `created`, `created_by`) VALUES (1, 97, 'Rotate Logs', 'rotation.logs', CONCAT('{"rule-type":"interval-days","interval-days":"30","exec-day":"', DATE_FORMAT(NOW(), '%e'), '","exec-time":"', TIME_FORMAT(NOW(), '%H:00'), '"}'), '{"type":"interval","exp":"P30D"}', 1, NULL, DATE_FORMAT(NOW() + INTERVAL 30 DAY, '%Y-%m-%d %H:00:00'), NULL, '{"individual_log":false,"log_file":"","notifications":{"success_mail":"0","failure_mail":"1","fatal_failure_mail":"1","orphan_mail":"1"},"logstokeep":1}', NOW(), 42), (2, 98, 'Session GC', 'session.gc', CONCAT('{"rule-type":"interval-hours","interval-hours":"24","exec-day":"01","exec-time":"', TIME_FORMAT(NOW(), '%H:00'), '"}'), '{"type":"interval","exp":"PT24H"}', 1, NULL, DATE_FORMAT(NOW() + INTERVAL 24 HOUR, '%Y-%m-%d %H:00:00'), NULL, '{"individual_log":false,"log_file":"","notifications":{"success_mail":"0","failure_mail":"1","fatal_failure_mail":"1","orphan_mail":"1"},"enable_session_gc":1,"enable_session_metadata_gc":1}', NOW(), 42), -(3, 99, 'Update Notification', 'update.notification', CONCAT('{"rule-type":"interval-hours","interval-hours":"24","exec-day":"01","exec-time":"', TIME_FORMAT(NOW(), '%H:00'), '"}'), '{"type":"interval","exp":"PT24H"}', 1, NULL, DATE_FORMAT(NOW() + INTERVAL 24 HOUR, '%Y-%m-%d %H:00:00'), NULL, '{"individual_log":false,"log_file":"","notifications":{"success_mail":"0","failure_mail":"1","fatal_failure_mail":"1","orphan_mail":"1"},"email":"","language_override":""}', NOW(), 42); +(3, 99, 'Update Notification', 'update.notification', CONCAT('{"rule-type":"interval-hours","interval-hours":"24","exec-day":"01","exec-time":"', TIME_FORMAT(NOW(), '%H:00'), '"}'), '{"type":"interval","exp":"PT24H"}', 1, NULL, DATE_FORMAT(NOW() + INTERVAL 24 HOUR, '%Y-%m-%d %H:00:00'), NULL, '{"individual_log":false,"log_file":"","notifications":{"success_mail":"0","failure_mail":"1","fatal_failure_mail":"1","orphan_mail":"1"},"email":"","language_override":""}', NOW(), 42), +(4, 100, 'Workflow Automation', 'workflow.automation', CONCAT('{"rule-type":"interval-minutes","interval-minutes":"15","exec-day":"', DATE_FORMAT(NOW(), '%e'), '","exec-time":"', TIME_FORMAT(NOW(), '%H:00'), '"}'), '{"type":"interval","exp":"PT15M"}', 1, NULL, DATE_FORMAT(NOW() + INTERVAL 24 HOUR, '%Y-%m-%d %H:%i:00'), NULL, '{"individual_log":false,"log_file":"","notifications":{"success_mail":"0","failure_mail":"1","fatal_failure_mail":"1","orphan_mail":"1"}}', NOW(), 42); -- -------------------------------------------------------- diff --git a/installation/sql/postgresql/base.sql b/installation/sql/postgresql/base.sql index 2916c3f99c7a1..6ba5588decf36 100644 --- a/installation/sql/postgresql/base.sql +++ b/installation/sql/postgresql/base.sql @@ -31,7 +31,7 @@ COMMENT ON COLUMN "#__assets"."rules" IS 'JSON encoded access control.'; -- INSERT INTO "#__assets" ("id", "parent_id", "lft", "rgt", "level", "name", "title", "rules") VALUES -(1, 0, 0, 183, 0, 'root.1', 'Root Asset', '{"core.login.site":{"6":1,"2":1},"core.login.admin":{"6":1},"core.login.api":{"8":1},"core.login.offline":{"6":1},"core.admin":{"8":1},"core.manage":{"7":1},"core.create":{"6":1,"3":1},"core.delete":{"6":1},"core.edit":{"6":1,"4":1},"core.edit.state":{"6":1,"5":1},"core.edit.own":{"6":1,"3":1}}'), +(1, 0, 0, 185, 0, 'root.1', 'Root Asset', '{"core.login.site":{"6":1,"2":1},"core.login.admin":{"6":1},"core.login.api":{"8":1},"core.login.offline":{"6":1},"core.admin":{"8":1},"core.manage":{"7":1},"core.create":{"6":1,"3":1},"core.delete":{"6":1},"core.edit":{"6":1,"4":1},"core.edit.state":{"6":1,"5":1},"core.edit.own":{"6":1,"3":1}}'), (2, 1, 1, 2, 1, 'com_admin', 'com_admin', '{}'), (3, 1, 3, 6, 1, 'com_banners', 'com_banners', '{"core.admin":{"7":1},"core.manage":{"6":1}}'), (4, 1, 7, 8, 1, 'com_cache', 'com_cache', '{"core.admin":{"7":1},"core.manage":{"7":1}}'), @@ -113,19 +113,19 @@ INSERT INTO "#__assets" ("id", "parent_id", "lft", "rgt", "level", "name", "titl (87, 18, 124, 125, 2, 'com_modules.module.97', 'Recently Added Articles', '{}'), (88, 18, 126, 127, 2, 'com_modules.module.98', 'Logged-in Users', '{}'), (89, 18, 128, 129, 2, 'com_modules.module.90', 'Login Support', '{}'), -(90, 1, 165, 172, 1, 'com_scheduler', 'com_scheduler', '{}'), -(91, 1, 173, 174, 1, 'com_associations', 'com_associations', '{}'), -(92, 1, 175, 176, 1, 'com_categories', 'com_categories', '{}'), -(93, 1, 177, 178, 1, 'com_fields', 'com_fields', '{}'), -(94, 1, 179, 180, 1, 'com_workflow', 'com_workflow', '{}'), -(95, 1, 181, 182, 1, 'com_guidedtours', 'com_guidedtours', '{}'), +(90, 1, 165, 174, 1, 'com_scheduler', 'com_scheduler', '{}'), +(91, 1, 175, 176, 1, 'com_associations', 'com_associations', '{}'), +(92, 1, 177, 178, 1, 'com_categories', 'com_categories', '{}'), +(93, 1, 179, 180, 1, 'com_fields', 'com_fields', '{}'), +(94, 1, 181, 182, 1, 'com_workflow', 'com_workflow', '{}'), +(95, 1, 183, 184, 1, 'com_guidedtours', 'com_guidedtours', '{}'), (96, 18, 130, 131, 2, 'com_modules.module.109', 'Guided Tours', '{}'), (97, 90, 166, 167, 2, 'com_scheduler.task.1', 'Rotate Logs', '{}'), (98, 90, 168, 169, 2, 'com_scheduler.task.2', 'Session GC', '{}'), -(99, 90, 170, 171, 2, 'com_scheduler.task.3', 'Update Notification', '{}'); - -SELECT setval('#__assets_id_seq', 100, false); +(99, 90, 170, 171, 2, 'com_scheduler.task.3', 'Update Notification', '{}'), +(100, 90, 172, 173, 2, 'com_scheduler.task.4', 'Workflow Automation', '{}'); +SELECT setval ( '#__assets_id_seq', 101, false ); -- -- Table structure for table `#__extensions` -- @@ -386,6 +386,7 @@ INSERT INTO "#__extensions" ("package_id", "name", "type", "element", "folder", (0, 'plg_task_sessiongc', 'plugin', 'sessiongc', 'task', 0, 1, 1, 0, 1, '', '{}', '', 7, 0), (0, 'plg_task_sitestatus', 'plugin', 'sitestatus', 'task', 0, 1, 1, 0, 1, '', '{}', '', 8, 0), (0, 'plg_task_updatenotification', 'plugin', 'updatenotification', 'task', 0, 1, 1, 0, 1, '', '{}', '', 9, 0), +(0, 'plg_task_workflowtransition', 'plugin', 'workflowtransition', 'task', 0, 1, 1, 0, 1, '', '{}', '', 10, 0), (0, 'plg_multifactorauth_totp', 'plugin', 'totp', 'multifactorauth', 0, 1, 1, 0, 1, '', '', '', 1, 0), (0, 'plg_multifactorauth_yubikey', 'plugin', 'yubikey', 'multifactorauth', 0, 1, 1, 0, 1, '', '', '', 2, 0), (0, 'plg_multifactorauth_webauthn', 'plugin', 'webauthn', 'multifactorauth', 0, 1, 1, 0, 1, '', '', '', 3, 0), @@ -417,6 +418,8 @@ INSERT INTO "#__extensions" ("package_id", "name", "type", "element", "folder", (0, 'plg_workflow_featuring', 'plugin', 'featuring', 'workflow', 0, 1, 1, 0, 1, '', '{}', '', 1, 0), (0, 'plg_workflow_notification', 'plugin', 'notification', 'workflow', 0, 1, 1, 0, 1, '', '{}', '', 2, 0), (0, 'plg_workflow_publishing', 'plugin', 'publishing', 'workflow', 0, 1, 1, 0, 1, '', '{}', '', 3, 0), +(0, 'plg_workflow_automation', 'plugin', 'automation', 'workflow', 0, 1, 1, 0, 1, '', '{}', '', 4, 0), +(0, 'plg_workflow_remotecheck', 'plugin', 'remotecheck', 'workflow', 0, 0, 1, 0, 1, '', '{}', '', 5, 0), (0, 'plg_system_guidedtours', 'plugin', 'guidedtours', 'system', 0, 1, 1, 0, 1, '', '{}', '', 15, 0); -- Templates @@ -1289,3 +1292,100 @@ INSERT INTO "#__workflow_transitions" ("id", "asset_id", "published", "ordering" (7, 64, 1, 7, 1, 'PUBLISH_AND_FEATURE', '', -1, 1, '{"publishing":"1","featuring":"1"}'); SELECT setval('#__workflow_transitions_id_seq', 8, false); + +CREATE TABLE IF NOT EXISTS "#__workflow_automation_rules" ( + "id" serial NOT NULL, + "transition_id" integer NOT NULL, + "published" smallint DEFAULT 0 NOT NULL, + "ordering" integer DEFAULT 0 NOT NULL, + "rule_type" varchar(20) DEFAULT 'delay' NOT NULL, + "delay_value" integer, + "delay_unit" varchar(10), + "cron_expression" varchar(100), + "item_filter" text, + "fire_condition" text, + "run_as_user_id" integer DEFAULT 0 NOT NULL, + "created" timestamp without time zone NOT NULL, + "created_by" integer DEFAULT 0 NOT NULL, + "modified" timestamp without time zone NOT NULL, + "modified_by" integer DEFAULT 0 NOT NULL, + PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "#__workflow_automation_rules_idx_transition" ON "#__workflow_automation_rules" ("transition_id"); + +CREATE INDEX "#__workflow_automation_rules_idx_published" ON "#__workflow_automation_rules" ("published"); + + +COMMENT ON COLUMN "#__workflow_automation_rules"."transition_id" IS 'Foreign Key to #__workflow_transitions.id'; + +COMMENT ON COLUMN "#__workflow_automation_rules"."rule_type" IS 'delay or cron'; + +COMMENT ON COLUMN "#__workflow_automation_rules"."delay_unit" IS 'minutes, hours, days, months'; + +COMMENT ON COLUMN "#__workflow_automation_rules"."item_filter" IS 'JSON filter tree: which items this rule applies to (evaluated at selection)'; + +COMMENT ON COLUMN "#__workflow_automation_rules"."fire_condition" IS 'JSON expression tree: gate evaluated live at fire time'; + +COMMENT ON COLUMN "#__workflow_automation_rules"."run_as_user_id" IS 'User identity used to execute the transition'; + +CREATE TABLE IF NOT EXISTS "#__workflow_item_state" ( + "id" serial NOT NULL, + "item_id" integer DEFAULT 0 NOT NULL, + "extension" varchar(50) NOT NULL, + "stage_id" integer NOT NULL, + "entered_at" timestamp without time zone NOT NULL, + "triggered_by" varchar(20) DEFAULT 'manual' NOT NULL, + "requires_intervention" smallint DEFAULT 0 NOT NULL, + "last_checked_at" timestamp without time zone, + "last_failure_at" timestamp without time zone, + "last_failure_reason" varchar(500), + PRIMARY KEY ("id"), + CONSTRAINT "#__workflow_item_state_idx_item_extension" UNIQUE ("item_id", "extension") +); + +CREATE INDEX "#__workflow_item_state_idx_stage_entered" ON "#__workflow_item_state" ("stage_id", "entered_at"); + +CREATE INDEX "#__workflow_item_state_idx_requires_intervention" ON "#__workflow_item_state" ("requires_intervention"); + +COMMENT ON COLUMN "#__workflow_item_state"."item_id" IS 'Extension table id value'; + +COMMENT ON COLUMN "#__workflow_item_state"."stage_id" IS 'Foreign Key to #__workflow_stages.id'; + +COMMENT ON COLUMN "#__workflow_item_state"."entered_at" IS 'When the item arrived in stage_id'; + +COMMENT ON COLUMN "#__workflow_item_state"."triggered_by" IS 'Determine if a transition was triggered manually or by the automation'; + +COMMENT ON COLUMN "#__workflow_item_state"."requires_intervention" IS 'Set when an automated transition failed; excluded from the scheduler until an admin clears it'; + +COMMENT ON COLUMN "#__workflow_item_state"."last_checked_at" IS 'When the scheduler last considered this item; null means never'; + +COMMENT ON COLUMN "#__workflow_item_state"."last_failure_at" IS 'When this item last could not be evaluated; null means it evaluated cleanly'; + +COMMENT ON COLUMN "#__workflow_item_state"."last_failure_reason" IS 'Why it could not be evaluated, so the same failure is only reported once'; + +CREATE TABLE IF NOT EXISTS "#__workflow_automation_log" ( + "id" serial NOT NULL, + "rule_id" integer, + "item_id" integer DEFAULT 0 NOT NULL, + "extension" varchar(50) NOT NULL, + "transition_id" integer NOT NULL, + "from_stage_id" integer DEFAULT 0 NOT NULL, + "to_stage_id" integer DEFAULT 0 NOT NULL, + "run_as_user_id" integer DEFAULT 0 NOT NULL, + "trigger_type" varchar(20) DEFAULT 'rule' NOT NULL, + "exit_code" smallint DEFAULT 0 NOT NULL, + "note" varchar(500), + "executed_at" timestamp without time zone NOT NULL, + PRIMARY KEY ("id") +); + +CREATE INDEX "#__workflow_automation_log_idx_item_id" ON "#__workflow_automation_log" ("item_id"); + +CREATE INDEX "#__workflow_automation_log_idx_executed_at" ON "#__workflow_automation_log" ("executed_at"); + +CREATE INDEX "#__workflow_automation_log_idx_exit_code" ON "#__workflow_automation_log" ("exit_code"); + +COMMENT ON COLUMN "#__workflow_automation_log"."rule_id" IS 'Foreign Key to #__workflow_automation_rules.id'; + +COMMENT ON COLUMN "#__workflow_automation_log"."exit_code" IS '0 ok, 1 permission denied, 2 invalid transition, 3 exception, 4 no longer applicable'; diff --git a/installation/sql/postgresql/extensions.sql b/installation/sql/postgresql/extensions.sql index 2aac95993f3a9..7398a459d1c95 100644 --- a/installation/sql/postgresql/extensions.sql +++ b/installation/sql/postgresql/extensions.sql @@ -897,9 +897,11 @@ CREATE INDEX "#__scheduler_tasks_idx_checked_out" ON "#__scheduler_tasks" ("chec INSERT INTO "#__scheduler_tasks" ("id", "asset_id", "title", "type", "execution_rules", "cron_rules", "state", "last_execution", "next_execution", "locked", "params", "created", "created_by") VALUES (1, 97, 'Rotate Logs', 'rotation.logs', CONCAT('{"rule-type":"interval-days","interval-days":"30","exec-day":"', TO_CHAR(CURRENT_TIMESTAMP AT TIME ZONE 'UTC', 'FMDD'), '","exec-time":"', TO_CHAR(CURRENT_TIMESTAMP AT TIME ZONE 'UTC', 'HH24:00'), '"}'), '{"type":"interval","exp":"P30D"}', 1, NULL, TO_TIMESTAMP(TO_CHAR(CURRENT_TIMESTAMP AT TIME ZONE 'UTC' + INTERVAL '30 days', 'YYYY-MM-DD HH24:00:00'), 'YYYY-MM-DD HH24:MI:SS'), NULL, '{"individual_log":false,"log_file":"","notifications":{"success_mail":"0","failure_mail":"1","fatal_failure_mail":"1","orphan_mail":"1"},"logstokeep":1}', CURRENT_TIMESTAMP AT TIME ZONE 'UTC', 42), (2, 98, 'Session GC', 'session.gc', CONCAT('{"rule-type":"interval-hours","interval-hours":"24","exec-day":"01","exec-time":"', TO_CHAR(CURRENT_TIMESTAMP AT TIME ZONE 'UTC', 'HH24:00'), '"}'), '{"type":"interval","exp":"PT24H"}', 1, NULL, TO_TIMESTAMP(TO_CHAR(CURRENT_TIMESTAMP AT TIME ZONE 'UTC' + INTERVAL '24 hours', 'YYYY-MM-DD HH24:00:00'), 'YYYY-MM-DD HH24:MI:SS'), NULL, '{"individual_log":false,"log_file":"","notifications":{"success_mail":"0","failure_mail":"1","fatal_failure_mail":"1","orphan_mail":"1"},"enable_session_gc":1,"enable_session_metadata_gc":1}', CURRENT_TIMESTAMP AT TIME ZONE 'UTC', 42), -(3, 99, 'Update Notification', 'update.notification', CONCAT('{"rule-type":"interval-hours","interval-hours":"24","exec-day":"01","exec-time":"', TO_CHAR(CURRENT_TIMESTAMP AT TIME ZONE 'UTC', 'HH24:00'), '"}'), '{"type":"interval","exp":"PT24H"}', 1, NULL, TO_TIMESTAMP(TO_CHAR(CURRENT_TIMESTAMP AT TIME ZONE 'UTC' + INTERVAL '24 hours', 'YYYY-MM-DD HH24:00:00'), 'YYYY-MM-DD HH24:MI:SS'), NULL, '{"individual_log":false,"log_file":"","notifications":{"success_mail":"0","failure_mail":"1","fatal_failure_mail":"1","orphan_mail":"1"},"email":"","language_override":""}', CURRENT_TIMESTAMP AT TIME ZONE 'UTC', 42); +(3, 99, 'Update Notification', 'update.notification', CONCAT('{"rule-type":"interval-hours","interval-hours":"24","exec-day":"01","exec-time":"', TO_CHAR(CURRENT_TIMESTAMP AT TIME ZONE 'UTC', 'HH24:00'), '"}'), '{"type":"interval","exp":"PT24H"}', 1, NULL, TO_TIMESTAMP(TO_CHAR(CURRENT_TIMESTAMP AT TIME ZONE 'UTC' + INTERVAL '24 hours', 'YYYY-MM-DD HH24:00:00'), 'YYYY-MM-DD HH24:MI:SS'), NULL, '{"individual_log":false,"log_file":"","notifications":{"success_mail":"0","failure_mail":"1","fatal_failure_mail":"1","orphan_mail":"1"},"email":"","language_override":""}', CURRENT_TIMESTAMP AT TIME ZONE 'UTC', 42), +(4, 100, 'Workflow Automation', 'workflow.automation', CONCAT('{"rule-type":"interval-minutes","interval-minutes":"15","exec-day":"', TO_CHAR(CURRENT_TIMESTAMP AT TIME ZONE 'UTC', 'FMDD'), '","exec-time":"', TO_CHAR(CURRENT_TIMESTAMP AT TIME ZONE 'UTC', 'HH24:00'), '"}'), '{"type":"interval","exp":"PT15M"}', 1, NULL, TO_CHAR(CURRENT_TIMESTAMP AT TIME ZONE 'UTC' + INTERVAL '24 hours', 'YYYY-MM-DD HH24:MI:00')::timestamp, NULL, '{"individual_log":false,"log_file":"","notifications":{"success_mail":"0","failure_mail":"1","fatal_failure_mail":"1","orphan_mail":"1"}}', CURRENT_TIMESTAMP AT TIME ZONE 'UTC', 42); + +SELECT setval ( '#__scheduler_tasks_id_seq', 5, false ); -SELECT setval('#__scheduler_tasks_id_seq', 4, false); -- -------------------------------------------------------- -- diff --git a/layouts/joomla/edit/global.php b/layouts/joomla/edit/global.php index de34bdd4699e2..9185fd060ba37 100644 --- a/layouts/joomla/edit/global.php +++ b/layouts/joomla/edit/global.php @@ -30,6 +30,7 @@ $fields = $displayData->get('fields') ?: [ 'transition', + 'upcoming_transition', ['parent', 'parent_id'], ['published', 'state', 'enabled'], ['category', 'catid'], diff --git a/layouts/joomla/form/field/groupedlist-transition.php b/layouts/joomla/form/field/groupedlist-transition.php index 8fe9f2c1e35cd..1975c187500a5 100644 --- a/layouts/joomla/form/field/groupedlist-transition.php +++ b/layouts/joomla/form/field/groupedlist-transition.php @@ -91,6 +91,7 @@ 'COM_WORKFLOW_GRAPH_TRANSITION', 'COM_WORKFLOW_GRAPH_TRANSITIONS', 'COM_WORKFLOW_GRAPH_TRANSITION_ACTIONS', + 'COM_WORKFLOW_GRAPH_TRANSITION_AUTOMATED', 'COM_WORKFLOW_GRAPH_TRANSITION_COUNT', 'COM_WORKFLOW_GRAPH_TRANSITION_DESCRIPTION', 'COM_WORKFLOW_GRAPH_TRANSITION_PATH', diff --git a/libraries/src/Event/Workflow/WorkflowConditionFieldsEvent.php b/libraries/src/Event/Workflow/WorkflowConditionFieldsEvent.php new file mode 100644 index 0000000000000..4d29d0d533cfc --- /dev/null +++ b/libraries/src/Event/Workflow/WorkflowConditionFieldsEvent.php @@ -0,0 +1,175 @@ + + * @license GNU General Public License version 2 or later; see LICENSE.txt + */ + +namespace Joomla\CMS\Event\Workflow; + +use Joomla\Event\Event; + +// phpcs:disable PSR1.Files.SideEffects +\defined('_JEXEC') or die; +// phpcs:enable PSR1.Files.SideEffects + +/** + * Collects the checks that automation filters and conditions may be built on. + * + * Plugins call addField() once per check they provide. Item checks, such as a tag, are offered in + * a rule's filter, and moment checks, such as the day of the week, in its fire condition. + * + * @since __DEPLOY_VERSION__ + */ +class WorkflowConditionFieldsEvent extends Event +{ + /** + * A check that describes the item, offered in a rule's filter. + * + * @var string + * @since __DEPLOY_VERSION__ + */ + public const SCOPE_ITEM = 'item'; + + /** + * A check that describes the current moment, offered in a rule's condition. + * + * @var string + * @since __DEPLOY_VERSION__ + */ + public const SCOPE_MOMENT = 'moment'; + + /** + * The comparisons a check may declare support for. The values are stored inside saved + * rules, so they must never change. + * + * @var string + * @since __DEPLOY_VERSION__ + */ + public const OPERATOR_IS = 'is'; + + public const OPERATOR_IS_NOT = 'is not'; + + public const OPERATOR_IN = 'in'; + + public const OPERATOR_NOT_IN = 'not in'; + + public const OPERATOR_HAS_ANY = 'has any'; + + public const OPERATOR_HAS_ALL = 'has all'; + + public const OPERATOR_HAS_NONE = 'has none'; + + public const OPERATOR_BEFORE = 'before'; + + public const OPERATOR_AFTER = 'after'; + + public const OPERATOR_ON = 'on'; + + public const OPERATOR_NOT_ON = 'not on'; + + public const OPERATOR_GREATER_THAN = 'greater than'; + + public const OPERATOR_LESS_THAN = 'less than'; + + /** + * How a check's value is entered. Select and multiselect pick from the options + * a check supplies. + * + * @var string + * @since __DEPLOY_VERSION__ + */ + + public const VALUE_SELECT = 'select'; + + public const VALUE_MULTISELECT = 'multiselect'; + + public const VALUE_DATE = 'date'; + + public const VALUE_TEXT = 'text'; + + public const VALUE_NUMBER = 'number'; + + /** + * Constructor. + * + * @param string $name The event name. + * @param array $arguments Must contain 'extension', e.g. com_content.article. + * + * @since __DEPLOY_VERSION__ + */ + public function __construct($name, array $arguments = []) + { + $arguments['fields'] = []; + + parent::__construct($name, $arguments); + } + + /** + * The workflow extension the builder is being drawn for, e.g. com_content.article. + * + * @return string + * + * @since __DEPLOY_VERSION__ + */ + public function getExtension(): string + { + return (string) ($this->arguments['extension'] ?? ''); + } + + /** + * Offers one check to the condition builder. + * + * @param string $name Unique key for the check, stored in the saved rule. Prefix + * it with your extension to avoid clashing, e.g. "fields.priority". + * @param string $label Translated label shown in the field dropdown. + * @param string $scope self::SCOPE_ITEM or self::SCOPE_MOMENT. + * @param string[] $operators Operator keys this check supports, e.g. ['is', 'is not']. + * Only keys com_workflow knows are offered, so a check written + * against a newer Joomla loses that operator rather than + * breaking the builder. + * @param string $valueType How the value is entered: select or multiselect to pick from + * $options, or date, text or number to type a value freely. + * @param array $options Selectable values as ['value' => ..., 'label' => ...] pairs. + * Ignored by the typed value types. + * + * @return void + * + * @since __DEPLOY_VERSION__ + */ + public function addField( + string $name, + string $label, + string $scope, + array $operators, + string $valueType = 'select', + array $options = [] + ): void { + $fields = $this->arguments['fields']; + + $fields[$name] = [ + 'name' => $name, + 'label' => $label, + 'scope' => $scope === self::SCOPE_MOMENT ? self::SCOPE_MOMENT : self::SCOPE_ITEM, + 'operators' => $operators, + 'valueType' => $valueType, + 'options' => $options, + ]; + + $this->arguments['fields'] = $fields; + } + + /** + * Every check offered, keyed by name. + * + * @return array + * + * @since __DEPLOY_VERSION__ + */ + public function getFields(): array + { + return $this->arguments['fields']; + } +} diff --git a/libraries/src/Event/Workflow/WorkflowResolveFieldsEvent.php b/libraries/src/Event/Workflow/WorkflowResolveFieldsEvent.php new file mode 100644 index 0000000000000..75f3840adc844 --- /dev/null +++ b/libraries/src/Event/Workflow/WorkflowResolveFieldsEvent.php @@ -0,0 +1,140 @@ + + * @license GNU General Public License version 2 or later; see LICENSE.txt + */ + +namespace Joomla\CMS\Event\Workflow; + +use Joomla\Event\Event; + +// phpcs:disable PSR1.Files.SideEffects +\defined('_JEXEC') or die; +// phpcs:enable PSR1.Files.SideEffects + +/** + * Asks plugins for the value of one check across a set of items. + * + * Batched, so a plugin can answer for every item with one query. A moment check returns the + * same value for every id, computed at the evaluation time rather than now. + * + * @since __DEPLOY_VERSION__ + */ +class WorkflowResolveFieldsEvent extends Event +{ + /** + * Constructor. + * + * @param string $name The event name. + * @param array $arguments Must contain 'field', 'itemIds', 'extension' and may contain + * 'evaluationTime'. + * + * @since __DEPLOY_VERSION__ + */ + public function __construct($name, array $arguments = []) + { + $arguments['values'] = []; + $arguments['answered'] = false; + + parent::__construct($name, $arguments); + } + + /** + * The check being resolved, as named by addField(). + * + * @return string + * + * @since __DEPLOY_VERSION__ + */ + public function getField(): string + { + return (string) ($this->arguments['field'] ?? ''); + } + + /** + * The items to resolve it for. + * + * @return int[] + * + * @since __DEPLOY_VERSION__ + */ + public function getItemIds(): array + { + return (array) ($this->arguments['itemIds'] ?? []); + } + + /** + * The workflow extension, e.g. com_content.article. + * + * @return string + * + * @since __DEPLOY_VERSION__ + */ + public function getExtension(): string + { + return (string) ($this->arguments['extension'] ?? ''); + } + + /** + * The moment to describe, for checks that depend on the clock. Null means now. + * + * @return \DateTime|null + * + * @since __DEPLOY_VERSION__ + */ + public function getEvaluationTime(): ?\DateTime + { + return $this->arguments['evaluationTime'] ?? null; + } + + /** + * Supplies the resolved values, and claims the check. + * + * Call this even with an empty array when the check is yours, so it counts as claimed. Leave + * out any item you cannot answer for rather than guessing, and no rule will fire on it. + * + * @param array $values Item id to value. A value may be a scalar or a list, depending + * on the check. + * + * @return void + * + * @since __DEPLOY_VERSION__ + */ + public function setValues(array $values): void + { + $this->arguments['values'] = $values + $this->arguments['values']; + $this->arguments['answered'] = true; + + $this->stopPropagation(); + } + + /** + * Whether any plugin claimed this check, regardless of how many items it answered for. + * + * An owner that answered for no items is not the same as no owner at all. No owner means the + * extension that provided the check is gone. + * + * @return boolean + * + * @since __DEPLOY_VERSION__ + */ + public function isAnswered(): bool + { + return $this->arguments['answered'] === true; + } + + /** + * The resolved values, keyed by item id. + * + * @return array + * + * @since __DEPLOY_VERSION__ + */ + public function getValues(): array + { + return $this->arguments['values']; + } +} diff --git a/libraries/src/Event/Workflow/WorkflowTransitionEvent.php b/libraries/src/Event/Workflow/WorkflowTransitionEvent.php index b1f37a105c88a..3c103ea0c2b00 100644 --- a/libraries/src/Event/Workflow/WorkflowTransitionEvent.php +++ b/libraries/src/Event/Workflow/WorkflowTransitionEvent.php @@ -54,4 +54,108 @@ public function setStopTransition($value = true) $this->stopPropagation(); } } + + /** + * Pre-process the pks argument. + * + * @param mixed $value The raw argument value. + * + * @return array + * + * @since __DEPLOY_VERSION__ + */ + protected function onGetPks($value): array + { + return (array) $value; + } + + /** + * Get the primary keys of the items being transitioned. + * + * @return array + * + * @since __DEPLOY_VERSION__ + */ + public function getPks(): array + { + return $this->getArgument('pks'); + } + + /** + * Pre-process the extension argument. + * + * @param mixed $value The raw argument value. + * + * @return string + * + * @since __DEPLOY_VERSION__ + */ + protected function onGetExtension($value): string + { + return (string) $value; + } + + /** + * Get the extension context (e.g. com_content.article). + * + * @return string + * + * @since __DEPLOY_VERSION__ + */ + public function getExtension(): string + { + return $this->getArgument('extension'); + } + + /** + * Pre-process the transition argument. + * + * @param mixed $value The raw argument value. + * + * @return object + * + * @since __DEPLOY_VERSION__ + */ + protected function onGetTransition($value): object + { + return (object) $value; + } + + /** + * Get the transition object being executed. + * + * @return object + * + * @since __DEPLOY_VERSION__ + */ + public function getTransition(): object + { + return $this->getArgument('transition'); + } + + /** + * Pre-process the triggeredBy argument. + * + * @param mixed $value The raw argument value + * + * @return string + * + * @since __DEPLOY_VERSION__ + */ + protected function onGetTriggeredBy($value): string + { + return (string) ($value ?? 'manual'); + } + + /** + * Get the trigger source - 'manual' or 'automation' + * + * @return string + * + * @since __DEPLOY_VERSION__ + */ + public function getTriggeredBy(): string + { + return $this->getArgument('triggeredBy'); + } } diff --git a/libraries/src/Extension/ExtensionHelper.php b/libraries/src/Extension/ExtensionHelper.php index 9d52b7dc900be..a86d602df86ca 100644 --- a/libraries/src/Extension/ExtensionHelper.php +++ b/libraries/src/Extension/ExtensionHelper.php @@ -331,6 +331,7 @@ class ExtensionHelper ['plugin', 'sessiongc', 'task', 0], ['plugin', 'sitestatus', 'task', 0], ['plugin', 'updatenotification', 'task', 0], + ['plugin', 'workflowtransition', 'task', 0], // Core plugin extensions - user ['plugin', 'contactcreator', 'user', 0], @@ -360,6 +361,7 @@ class ExtensionHelper ['plugin', 'users', 'webservices', 0], // Core plugin extensions - workflow + ['plugin', 'automation', 'workflow', 0], ['plugin', 'featuring', 'workflow', 0], ['plugin', 'notification', 'workflow', 0], ['plugin', 'publishing', 'workflow', 0], diff --git a/libraries/src/Workflow/TransitionStatus.php b/libraries/src/Workflow/TransitionStatus.php new file mode 100644 index 0000000000000..a0cb96195d325 --- /dev/null +++ b/libraries/src/Workflow/TransitionStatus.php @@ -0,0 +1,58 @@ + + * @license GNU General Public License version 2 or later; see LICENSE.txt + */ + +namespace Joomla\CMS\Workflow; + +// phpcs:disable PSR1.Files.SideEffects +\defined('_JEXEC') or die; +// phpcs:enable PSR1.Files.SideEffects + +/** + * A namespace mapping transition outcomes to integer values. + * + * @since __DEPLOY_VERSION__ + */ +abstract class TransitionStatus +{ + /** + * The transition ran, or there was nothing to move. + * + * @since __DEPLOY_VERSION__ + */ + public const SUCCESS = 0; + + /** + * The transition does not exist, or the current user may not run it. + * + * @since __DEPLOY_VERSION__ + */ + public const INVALID_TRANSITION = 1; + + /** + * An item is not in the stage the transition starts from, or belongs to another workflow. + * + * @since __DEPLOY_VERSION__ + */ + public const STAGE_MISMATCH = 2; + + /** + * A plugin vetoed the transition through onWorkflowBeforeTransition. + * + * @since __DEPLOY_VERSION__ + */ + public const STOPPED_BY_PLUGIN = 3; + + /** + * The stage change itself could not be written. + * + * @since __DEPLOY_VERSION__ + */ + public const UPDATE_FAILED = 4; +} diff --git a/libraries/src/Workflow/Workflow.php b/libraries/src/Workflow/Workflow.php index 809f2c51aaaf5..b071ac53fa4e5 100644 --- a/libraries/src/Workflow/Workflow.php +++ b/libraries/src/Workflow/Workflow.php @@ -345,22 +345,43 @@ public function getValidTransition(array $pks, int $transitionId) * * @param integer[] $pks The item IDs, which should use the transition * @param integer $transitionId The transition which should be executed + * @param string $triggeredBy What caused the transition to run * * @return boolean */ - public function executeTransition(array $pks, int $transitionId): bool + public function executeTransition(array $pks, int $transitionId, string $triggeredBy = 'manual'): bool + { + return $this->attemptTransition($pks, $transitionId, $triggeredBy) === TransitionStatus::SUCCESS; + } + + /** + * Executes a transition and reports which outcome it reached. + * + * Unlike executeTransition(), which returns a boolean, this tells a stage mismatch apart from a + * permission failure or a plugin veto. + * + * @param integer[] $pks The item IDs, which should use the transition + * @param integer $transitionId The transition which should be executed + * @param string $triggeredBy What caused the transition to run + * + * @return integer A TransitionStatus constant + * + * @since __DEPLOY_VERSION__ + */ + public function attemptTransition(array $pks, int $transitionId, string $triggeredBy = 'manual'): int { $pks = ArrayHelper::toInteger($pks); $pks = array_filter($pks); + // Nothing to move is not a failure, and executeTransition() has always answered true here. if (!\count($pks)) { - return true; + return TransitionStatus::SUCCESS; } $transition = $this->getValidTransition($pks, $transitionId); if (\is_null($transition)) { - return false; + return TransitionStatus::INVALID_TRANSITION; } $transition->options = new Registry($transition->options); @@ -376,7 +397,7 @@ public function executeTransition(array $pks, int $transitionId): bool -1, ]) || $transition->workflow_id !== $assoc->workflow_id ) { - return false; + return TransitionStatus::STAGE_MISMATCH; } } @@ -398,7 +419,7 @@ public function executeTransition(array $pks, int $transitionId): bool ); if ($eventResult->getArgument('stopTransition')) { - return false; + return TransitionStatus::STOPPED_BY_PLUGIN; } $success = $this->updateAssociations($pks, (int) $transition->to_stage_id); @@ -409,17 +430,18 @@ public function executeTransition(array $pks, int $transitionId): bool AbstractEvent::create( 'onWorkflowAfterTransition', [ - 'eventClass' => WorkflowTransitionEvent::class, - 'subject' => $this, - 'extension' => $this->extension, - 'pks' => $pks, - 'transition' => $transition, + 'eventClass' => WorkflowTransitionEvent::class, + 'subject' => $this, + 'extension' => $this->extension, + 'pks' => $pks, + 'transition' => $transition, + 'triggeredBy' => $triggeredBy, ] ) ); } - return $success; + return $success ? TransitionStatus::SUCCESS : TransitionStatus::UPDATE_FAILED; } /** diff --git a/media_source/com_workflow/joomla.asset.json b/media_source/com_workflow/joomla.asset.json index 99b29813026a9..8dc4df01fa129 100644 --- a/media_source/com_workflow/joomla.asset.json +++ b/media_source/com_workflow/joomla.asset.json @@ -9,13 +9,25 @@ "name": "com_workflow.admin-items-workflow-buttons", "type": "script", "uri": "com_workflow/admin-items-workflow-buttons.min.js", - "dependencies": [ - "core" - ], + "dependencies": ["core"], "attributes": { "type": "module" } }, + { + "name": "com_workflow.condition-builder", + "type": "style", + "uri": "com_workflow/condition-builder.css" + }, + { + "name": "com_workflow.condition-builder", + "type": "script", + "uri": "com_workflow/condition-builder.js", + "dependencies": ["core", "joomla.dialog"], + "attributes": { + "defer": true + } + }, { "name": "com_workflow.workflowgraph", "type": "style", @@ -25,15 +37,13 @@ "name": "com_workflow.workflowgraph", "type": "script", "uri": "com_workflow/workflow-graph.min.js", - "dependencies": [ - "core" - ], + "dependencies": ["core"], "attributes": { "type": "module" } }, { - "name" : "com_workflow.workflowgraphclient", + "name": "com_workflow.workflowgraphclient", "type": "style", "uri": "com_workflow/workflow-graph-client.min.css" }, @@ -41,9 +51,7 @@ "name": "com_workflow.workflowgraphclient", "type": "script", "uri": "com_workflow/workflow-graph-client.min.js", - "dependencies": [ - "core" - ], + "dependencies": ["core"], "attributes": { "type": "module" } diff --git a/media_source/com_workflow/js/condition-builder.es6.js b/media_source/com_workflow/js/condition-builder.es6.js new file mode 100644 index 0000000000000..ccb303959af31 --- /dev/null +++ b/media_source/com_workflow/js/condition-builder.es6.js @@ -0,0 +1,934 @@ +/** + * @copyright (C) 2026 Open Source Matters, Inc. + * @license GNU General Public License version 2 or later; see LICENSE.txt + */ + +/** + * Builds the JSON expression stored by ConditionbuilderField. See ConditionEvaluator for its shape. + */ +((document) => { + 'use strict'; + + // Tiny DOM helper. Keys with "-" become attributes; "text" sets textContent. + const el = (tag, attrs, ...children) => { + const node = document.createElement(tag); + Object.entries(attrs || {}).forEach(([key, value]) => { + if (key === 'class') node.className = value; + else if (key === 'text') node.textContent = value; + else if (key === 'role' || key.includes('-')) node.setAttribute(key, value); + else { + try { + node[key] = value; + } catch (error) { + node.setAttribute(key, value); + } + } + }); + children.flat().forEach((child) => { + if (child === null || child === undefined || child === false) return; + node.appendChild( + typeof child === 'string' ? document.createTextNode(child) : child, + ); + }); + return node; + }; + + class ConditionBuilder { + constructor(root) { + this.root = root; + this.input = root.querySelector('input[type="hidden"]'); + this.config = JSON.parse(root.dataset.config || '{}'); + this.tree = this.deserialize(this.input.value); + + // Simple mode joins checks with AND and nothing else. A rule saved with OR, sub-expressions or NOT + // opens in the full builder anyway, so switching expert mode off never hides part of a rule. + this.simple = !this.config.expert && this.fitsSimpleMode(this.tree); + + // { path, kind } while the AND/OR prompt for a new row is showing, otherwise null. + this.pendingAdd = null; + + this.ui = el('div', { class: 'cb-ui' }); + root.appendChild(this.ui); + + root.addEventListener('click', (event) => this.onClick(event)); + root.addEventListener('change', (event) => this.onChange(event)); + + // Without this a typed value only commits on blur, so saving from the keyboard would store + // the value from before the last edit. + root.addEventListener('input', (event) => { + if ( + event.target.tagName === 'INPUT' + && event.target.getAttribute('data-role') === 'value' + ) { + this.onChange(event); + } + }); + + this.render(); + } + + deserialize(jsonString) { + let parsed = null; + try { + parsed = JSON.parse(jsonString); + } catch (error) { + parsed = null; + } + if (!parsed || typeof parsed !== 'object') { + return this.emptyChain(); + } + return this.nodeFromJson(parsed); + } + + nodeFromJson(node) { + if (node && typeof node.field !== 'undefined') { + return { + type: 'check', + field: node.field, + operator: node.operator || '', + value: node.value ?? '', + not: node.not === true, + }; + } + + const items = Array.isArray(node.items) + ? node.items.map((child) => this.nodeFromJson(child)) + : []; + const wanted = Math.max(0, items.length - 1); + const ops = Array.isArray(node.ops) + ? node.ops.slice(0, wanted).map((op) => (op === 'or' ? 'or' : 'and')) + : []; + while (ops.length < wanted) ops.push('and'); + + return { type: 'chain', not: node.not === true, items, ops }; + } + + nodeToJson(node) { + if (node.type === 'check') { + // Kept even when incomplete: the server refuses to save it and the form comes back with the + // expression intact, instead of it being dropped without a word. + const json = { + field: node.field, + operator: node.operator, + value: node.value, + }; + if (node.not) json.not = true; + return json; + } + + // An empty sub-expression holds nothing the user entered, so it is dropped with its connector. + const items = []; + const ops = []; + + node.items.forEach((child, index) => { + const childJson = this.nodeToJson(child); + if (childJson === null) return; + if (items.length > 0) { + ops.push(node.ops[index - 1] === 'or' ? 'or' : 'and'); + } + items.push(childJson); + }); + + if (items.length === 0) { + return null; + } + + const json = { items, ops }; + if (node.not) json.not = true; + return json; + } + + sync() { + const serialised = this.nodeToJson(this.tree); + this.input.value = serialised ? JSON.stringify(serialised) : ''; + + // Any edit makes a previous answer describe an expression that no longer exists. + const output = this.root.querySelector('[data-role="preview-output"]'); + + if (output) { + output.className = 'w-100 small'; + output.textContent = ''; + } + } + + fitsSimpleMode(tree) { + return ( + !tree.not + && tree.ops.every((op) => op === 'and') + && tree.items.every((item) => item.type === 'check' && !item.not) + ); + } + + emptyChain() { + return { type: 'chain', not: false, items: [], ops: [] }; + } + + emptyCheck() { + const firstField = ((this.config.fields || [])[0] || {}).value || ''; + return { + type: 'check', + field: firstField, + operator: this.firstOperator(firstField), + value: this.emptyValue(firstField), + not: false, + }; + } + + firstOperator(field) { + const operators = (this.config.operators || {})[field] || []; + return (operators[0] || {}).value || ''; + } + + emptyValue(field) { + return (this.config.valueTypes || {})[field] === 'multiselect' ? [] : ''; + } + + nodeAtPath(path) { + if (!path) return this.tree; + return path + .split('.') + .reduce((node, index) => node.items[Number(index)], this.tree); + } + + removeAt(path) { + const parts = path.split('.'); + const index = Number(parts.pop()); + const parent = this.nodeAtPath(parts.join('.')); + + parent.items.splice(index, 1); + + // Removing a row also removes the operator that joined it: the one + // before it, or the first one when the removed row was on top. + if (parent.ops.length > 0) { + parent.ops.splice(index === 0 ? 0 : index - 1, 1); + } + } + + onClick(event) { + const button = event.target.closest('[data-action]'); + if (!button || !this.root.contains(button)) return; + event.preventDefault(); + + const pathEl = button.closest('[data-path]'); + const path = pathEl ? pathEl.getAttribute('data-path') : ''; + const action = button.getAttribute('data-action'); + const chain = this.nodeAtPath(path); + + if (action === 'add-check' && this.simple) { + if (chain.items.length >= 1) { + chain.ops.push('and'); + } + chain.items.push(this.emptyCheck()); + this.render(); + return; + } + + if (action === 'add-check' || action === 'add-expression') { + const kind = action === 'add-check' ? 'check' : 'chain'; + + if (chain.items.length >= 1) { + this.pendingAdd = { path, kind }; + } else { + chain.items.push( + kind === 'check' ? this.emptyCheck() : this.emptyChain(), + ); + this.pendingAdd = null; + } + this.render(); + return; + } + + if (action === 'preview') { + this.runPreview(); + return; + } + + if (action === 'choose-op') { + if (this.pendingAdd && this.pendingAdd.path === path) { + chain.ops.push( + button.getAttribute('data-op') === 'or' ? 'or' : 'and', + ); + chain.items.push( + this.pendingAdd.kind === 'check' + ? this.emptyCheck() + : this.emptyChain(), + ); + } + this.pendingAdd = null; + this.render(); + return; + } + + if (action === 'cancel-add') { + this.pendingAdd = null; + this.render(); + return; + } + + if (action === 'remove') { + this.pendingAdd = null; + this.removeAt(path); + this.render(); + this.focusAfterRemove(path); + } + } + + onChange(event) { + const role = event.target.getAttribute('data-role'); + if (!role) return; + + const node = this.nodeAtPath( + event.target.closest('[data-path]').getAttribute('data-path'), + ); + + if (role === 'op') { + const opIndex = Number(event.target.getAttribute('data-index')); + node.ops[opIndex] = event.target.value === 'or' ? 'or' : 'and'; + this.sync(); + return; + } + + if (role === 'field') { + node.field = event.target.value; + node.operator = this.firstOperator(node.field); + node.value = this.emptyValue(node.field); + this.render(); + return; + } + + if (role === 'operator') node.operator = event.target.value; + else if (role === 'value') + node.value = this.readValue(event.target, node.field); + else if (role === 'not') node.not = event.target.checked; + + this.sync(); + } + + readValue(target, field) { + if ((this.config.valueTypes || {})[field] === 'multiselect') { + return Array.from(target.selectedOptions).map((option) => option.value); + } + return target.value; + } + + render() { + this.ui.innerHTML = ''; + this.ui.appendChild(this.renderChain(this.tree, '', true)); + this.sync(); + } + + focusAfterRemove(path) { + const replacement = this.ui.querySelector('[data-path="' + path + '"]'); + const target = replacement + ? replacement.querySelector( + '[data-role="field"], button[data-action="remove"]', + ) + : this.ui.querySelector('button[data-action="add-check"]'); + + if (target) target.focus(); + } + + renderChain(node, path, isRoot) { + const text = this.config.text; + const parts = []; + + if (!isRoot) { + parts.push( + el( + 'div', + { class: 'cb-expression-head' }, + el('span', { + class: 'cb-chip cb-chip-expression', + text: text.expression, + }), + this.renderNot(node), + el('button', { + type: 'button', + class: 'btn btn-sm btn-danger cb-remove', + 'data-action': 'remove', + 'aria-label': text.remove, + text: '\u00d7', + }), + ), + ); + } + + const list = el('div', { + class: isRoot ? 'cb-list cb-list-root' : 'cb-list', + }); + + node.items.forEach((child, index) => { + if (index > 0) { + list.appendChild( + el( + 'div', + { class: 'cb-band' }, + this.simple + ? el('span', { class: 'cb-join-label', text: text.opAnd }) + : this.renderOpSelect(node, index - 1), + ), + ); + } + const childPath = path === '' ? String(index) : path + '.' + index; + list.appendChild( + child.type === 'check' + ? this.renderCheck(child, childPath) + : this.renderChain(child, childPath, false), + ); + }); + + if (node.items.length === 0) { + list.appendChild( + el('div', { + class: 'cb-empty', + text: isRoot ? text.empty : text.emptyExpression, + }), + ); + } + parts.push(list); + + parts.push(this.renderAddArea(path)); + + const attrs = { + class: isRoot ? 'cb-root' : 'cb-expression', + 'data-path': path, + }; + + if (!isRoot) { + // Without this the nesting is invisible: checks inside a sub-expression are announced + // as siblings of the ones outside it, which inverts the meaning of the rule. + attrs.role = 'group'; + attrs['aria-label'] = text.expression; + } + + return el('div', attrs, ...parts); + } + + renderOpSelect(chainNode, opIndex) { + const text = this.config.text; + const select = el('select', { + class: 'form-select cb-op', + 'data-role': 'op', + 'data-index': String(opIndex), + 'aria-label': text.joinWith, + }); + + [ + ['and', text.opAnd], + ['or', text.opOr], + ].forEach(([value, label]) => { + const option = el('option', { value, text: label }); + if (value === chainNode.ops[opIndex]) option.selected = true; + select.appendChild(option); + }); + + return select; + } + + renderAddArea(path) { + const text = this.config.text; + + if (this.pendingAdd && this.pendingAdd.path === path) { + return el( + 'div', + { class: 'cb-add cb-add-op' }, + el('span', { class: 'cb-join-label', text: text.joinWith }), + el( + 'button', + { + type: 'button', + class: 'btn btn-sm btn-primary', + 'data-action': 'choose-op', + 'data-op': 'and', + }, + text.opAnd, + ), + el( + 'button', + { + type: 'button', + class: 'btn btn-sm btn-primary', + 'data-action': 'choose-op', + 'data-op': 'or', + }, + text.opOr, + ), + el( + 'button', + { + type: 'button', + class: 'btn btn-sm btn-link cb-cancel-add', + 'data-action': 'cancel-add', + }, + text.cancel, + ), + ); + } + + const addArea = el( + 'div', + { class: 'cb-add flex-wrap align-items-center' }, + el( + 'button', + { + type: 'button', + class: 'btn btn-sm btn-success', + 'data-action': 'add-check', + }, + '+ ' + text.addCheck, + ), + ); + + if (!this.simple) { + addArea.appendChild( + el( + 'button', + { + type: 'button', + class: 'btn btn-sm btn-success', + 'data-action': 'add-expression', + }, + '+ ' + text.addExpression, + ), + ); + } + + const expertHint = path === '' && (this.simple ? text.expertHint : text.expertOffHint) + ? this.renderExpertHint() + : null; + + if (this.config.preview && path === '' && this.tree.items.length > 0) { + addArea.appendChild( + el( + 'button', + { + type: 'button', + class: 'btn btn-sm btn-secondary', + 'data-action': 'preview', + }, + text.preview, + ), + ); + + // Before the output, which takes a line of its own, so the hint stays on the button row. + if (expertHint) { + addArea.appendChild(expertHint); + } + + addArea.appendChild( + el('div', { + class: 'w-100 small', + 'data-role': 'preview-output', + 'aria-live': 'polite', + }), + ); + } else if (expertHint) { + addArea.appendChild(expertHint); + } + + return addArea; + } + + renderExpertHint() { + const text = this.config.text; + const sentence = this.simple ? text.expertHint : text.expertOffHint; + const linkText = this.simple ? text.expertLink : text.expertOffLink; + // One sentence with %s where the link goes, so it stays translatable as a whole. + const [before, after] = sentence.split('%s'); + const hint = el('div', { class: 'small' }, before || ''); + + hint.appendChild( + this.config.expertDialog + ? this.renderExpertLink(linkText || '') + : document.createTextNode(linkText || ''), + ); + hint.appendChild(document.createTextNode(after || '')); + + return hint; + } + + renderExpertLink(label) { + const dialog = this.config.expertDialog; + + return el('a', { + href: '#', + text: label, + 'data-joomla-dialog': JSON.stringify({ + popupType: 'iframe', + textHeader: this.config.text.expertHeader || label, + src: dialog.src, + }), + 'data-checkin-url': dialog.checkinUrl, + 'data-close-on-message': '', + 'data-reload-on-close': '', + }); + } + + renderCheck(node, path) { + const text = this.config.text; + return el( + 'div', + { class: 'cb-leaf', 'data-path': path }, + this.simple + ? null + : el('span', { class: 'cb-chip cb-chip-check', text: text.check }), + this.simple ? null : this.renderNot(node), + el( + 'div', + { class: 'cb-leaf-fields' }, + this.renderFieldSelect(node), + this.renderOperatorSelect(node), + this.renderValue(node), + ), + el('button', { + type: 'button', + class: 'btn btn-sm btn-danger cb-remove', + 'data-action': 'remove', + 'aria-label': this.removeCheckLabel(node), + text: '\u00d7', + }), + ); + } + + removeCheckLabel(node) { + const text = this.config.text; + const field = (this.config.fields || []).find( + (f) => f.value === node.field, + ); + + return field && text.removeCheck + ? text.removeCheck.replace('%s', field.label) + : text.remove; + } + + renderNot(node) { + const label = el('label', { class: 'cb-not' }); + const checkbox = el('input', { type: 'checkbox', 'data-role': 'not' }); + checkbox.checked = !!node.not; + label.appendChild(checkbox); + label.appendChild(document.createTextNode(' ' + this.config.text.negate)); + return label; + } + + renderFieldSelect(node) { + const select = el('select', { + class: 'form-select', + 'data-role': 'field', + 'aria-label': this.config.text.field, + }); + this.config.fields.forEach((field) => { + const option = el('option', { value: field.value, text: field.label }); + if (field.value === node.field) option.selected = true; + select.appendChild(option); + }); + return select; + } + + renderOperatorSelect(node) { + const select = el('select', { + class: 'form-select', + 'data-role': 'operator', + 'aria-label': this.config.text.operator, + }); + ((this.config.operators || {})[node.field] || []).forEach((operator) => { + const option = el('option', { + value: operator.value, + text: operator.label, + }); + if (operator.value === node.operator) option.selected = true; + select.appendChild(option); + }); + return select; + } + + renderValue(node) { + const type = (this.config.valueTypes || {})[node.field]; + + if (type === 'date' || type === 'text' || type === 'number') { + return el('input', { + type: type, + class: 'form-control', + 'data-role': 'value', + 'aria-label': this.config.text.value, + value: + node.value === null || node.value === undefined + ? '' + : String(node.value), + }); + } + + const options = (this.config.valueOptions || {})[node.field] || []; + const multiple = type === 'multiselect'; + const selected = multiple + ? Array.isArray(node.value) + ? node.value.map(String) + : [] + : [String(node.value)]; + + const select = el('select', { + class: 'form-select', + 'data-role': 'value', + 'aria-label': this.config.text.value, + }); + if (multiple) select.multiple = true; + else select.appendChild(el('option', { value: '', text: '\u2014' })); + + options.forEach((choice) => { + const option = el('option', { + value: choice.value, + text: choice.label, + }); + if (selected.includes(String(choice.value))) { + option.selected = true; + option.setAttribute('selected', 'selected'); + } + select.appendChild(option); + }); + + if (multiple) { + const fancy = el('joomla-field-fancy-select', { + placeholder: (this.config.text || {}).placeholder || '', + }); + fancy.appendChild(select); + return fancy; + } + return select; + } + + async runPreview() { + const preview = this.config.preview; + const text = this.config.text || {}; + const output = this.root.querySelector('[data-role="preview-output"]'); + const button = this.root.querySelector('[data-action="preview"]'); + + if (!output) return; + + if (button) button.disabled = true; + output.className = 'w-100 small'; + output.textContent = ''; + output.appendChild(el('div', { text: text.previewRunning || '' })); + + const body = new FormData(); + body.append('extension', preview.extension); + body.append('workflow_id', preview.workflowId); + body.append('transition_id', preview.transitionId); + body.append('item_filter', this.input.value); + body.append(preview.token, '1'); + + try { + const response = await fetch(preview.url, { + method: 'POST', + body, + }); + + if (!response.ok) { + throw new Error(`${response.status} ${response.statusText}`); + } + + const payload = await response.json(); + + if (!payload.success) { + output.className = 'w-100 small text-danger'; + output.textContent = payload.message || ''; + return; + } + + this.showPreviewResult(output, payload.data); + } catch (error) { + output.className = 'w-100 small text-danger'; + output.textContent = error.message; + } finally { + if (button) button.disabled = false; + } + } + + showPreviewResult(output, data) { + const text = this.config.text || {}; + + output.textContent = ''; + + if (!data.scanned) { + output.appendChild(el('div', { text: text.previewEmpty || '' })); + return; + } + + const template = data.capped ? text.previewCapped : text.previewResult; + + output.appendChild( + el('div', { + text: (template || '%1$s / %2$s') + .replaceAll('%1$s', data.matched) + .replaceAll('%2$s', data.scanned), + }), + ); + + const items = data.items || []; + + if (!items.length) return; + + const inline = el('div', { class: 'mt-1' }); + items.slice(0, 5).forEach((item, index) => { + if (index) inline.appendChild(document.createTextNode(', ')); + inline.appendChild(this.previewItemLink(item, data.extension)); + }); + + output.appendChild(inline); + + if (data.matched > 5) { + const showAll = el('button', { + type: 'button', + class: 'btn btn-link btn-sm p-0 ms-1', + text: (text.previewShowAll || 'Show all %s').replaceAll( + '%s', + data.matched, + ), + }); + + showAll.addEventListener('click', () => + this.openPreviewList(data, text), + ); + inline.appendChild(document.createTextNode(' ')); + inline.appendChild(showAll); + } + } + + // Only link when the extension names both a component and a view, rather than guess a route. + previewItemLink(item, extension) { + const [component, view] = String(extension || '').split('.'); + + if (!component || !view) { + return el('span', { text: item.title }); + } + + return el('a', { + href: `index.php?option=${component}&task=${view}.edit&id=${item.id}`, + target: '_blank', + rel: 'noopener', + text: item.title, + }); + } + + openPreviewList(data, text) { + const items = data.items || []; + const pageSize = 20; + const lastPage = Math.max(0, Math.ceil(items.length / pageSize) - 1); + let page = 0; + + const list = el('ul', { class: 'list-unstyled mb-0' }); + const range = el('span', { class: 'small' }); + + const previous = el('button', { + type: 'button', + class: 'btn btn-sm btn-secondary', + text: text.previous || 'Previous', + }); + + const next = el('button', { + type: 'button', + class: 'btn btn-sm btn-secondary', + text: text.next || 'Next', + }); + + const renderPage = () => { + const from = page * pageSize; + const shown = items.slice(from, from + pageSize); + + list.textContent = ''; + + shown.forEach((item) => { + list.appendChild( + el( + 'li', + { class: 'mb-1' }, + this.previewItemLink(item, data.extension), + ), + ); + }); + + range.textContent = (text.previewListRange || '%1$s to %2$s of %3$s') + .replaceAll('%1$s', from + 1) + .replaceAll('%2$s', from + shown.length) + .replaceAll('%3$s', items.length); + + previous.disabled = page === 0; + next.disabled = page === lastPage; + }; + + previous.addEventListener('click', () => { + if (page === 0) return; + page -= 1; + renderPage(); + }); + + next.addEventListener('click', () => { + if (page === lastPage) return; + page += 1; + renderPage(); + }); + + renderPage(); + + const buttons = el('div', { class: 'btn-group' }, previous, next); + + buttons.hidden = lastPage === 0; + + const body = el( + 'div', + { class: 'p-3' }, + list, + el( + 'div', + { class: 'd-flex align-items-center justify-content-between mt-3' }, + range, + buttons, + ), + ); + + // Only reachable if the listed cap is set below the scan cap. + if (data.matched > items.length) { + body.appendChild( + el('p', { + class: 'small mt-2 mb-0', + text: ( + text.previewListTrimmed || 'Showing the first %s.' + ).replaceAll('%s', items.length), + }), + ); + } + + const dialog = document.createElement('joomla-dialog'); + + dialog.popupType = 'inline'; + dialog.textHeader = text.previewListHeader || ''; + dialog.textClose = text.close || 'Close'; + dialog.popupContent = body; + dialog.width = '600px'; + dialog.height = 'fit-content'; + + document.body.appendChild(dialog); + dialog.show(); + } + } + + const initialiseWithin = (scope) => { + (scope || document) + .querySelectorAll('[data-condition-builder]') + .forEach((root) => { + if (root.dataset.cbInit) return; + root.dataset.cbInit = '1'; + new ConditionBuilder(root); + }); + }; + + document.addEventListener('joomla:updated', (event) => + initialiseWithin(event.target), + ); + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', () => + initialiseWithin(document), + ); + } else { + initialiseWithin(document); + } +})(document); diff --git a/media_source/com_workflow/js/workflow-graph-client.es6.js b/media_source/com_workflow/js/workflow-graph-client.es6.js index 0ce13da5018ac..3a8b535999089 100644 --- a/media_source/com_workflow/js/workflow-graph-client.es6.js +++ b/media_source/com_workflow/js/workflow-graph-client.es6.js @@ -261,6 +261,7 @@ Joomla = window.Joomla || {}; id: `transition-${tr.id}`, pathData, label: tr.title, + automated: !!tr.automated, labelPosition: { x: labelX, y: labelY }, fromId, toId, @@ -340,11 +341,16 @@ Joomla = window.Joomla || {}; path.classList.toggle('highlighted', state.highlightedEdge === edge.id); path.setAttribute('marker-end', getMarkerUrl('arrowhead')); - let foreignObject = labelsLayer.querySelector(`foreignObject[data-edge-id="${edge.id}"]`); + let foreignObject = labelsLayer.querySelector( + `foreignObject[data-edge-id="${edge.id}"]`, + ); let labelDiv; if (!foreignObject) { - foreignObject = document.createElementNS('http://www.w3.org/2000/svg', 'foreignObject'); + foreignObject = document.createElementNS( + 'http://www.w3.org/2000/svg', + 'foreignObject', + ); foreignObject.dataset.edgeId = edge.id; foreignObject.style.overflow = 'visible'; @@ -352,7 +358,8 @@ Joomla = window.Joomla || {}; labelDiv.className = 'transition-label-content'; labelDiv.addEventListener('click', (e) => { e.stopPropagation(); - state.highlightedEdge = state.highlightedEdge === edge.id ? null : edge.id; + state.highlightedEdge + = state.highlightedEdge === edge.id ? null : edge.id; renderGraph(modal); }); foreignObject.appendChild(labelDiv); @@ -361,8 +368,29 @@ Joomla = window.Joomla || {}; labelDiv = foreignObject.querySelector('div'); } - labelDiv.textContent = edge.label; - labelDiv.classList.toggle('highlighted', state.highlightedEdge === edge.id); + // Rebuilt each render, because setting textContent removes the icon. + labelDiv.textContent = ''; + + if (edge.automated) { + const clock = document.createElement('span'); + clock.className = 'icon icon-clock me-1'; + clock.setAttribute('aria-hidden', 'true'); + clock.title = Joomla.Text._('COM_WORKFLOW_GRAPH_TRANSITION_AUTOMATED'); + labelDiv.appendChild(clock); + + const automatedLabel = document.createElement('span'); + automatedLabel.className = 'visually-hidden'; + automatedLabel.textContent = Joomla.Text._( + 'COM_WORKFLOW_GRAPH_TRANSITION_AUTOMATED', + ); + labelDiv.appendChild(automatedLabel); + } + + labelDiv.appendChild(document.createTextNode(edge.label)); + labelDiv.classList.toggle( + 'highlighted', + state.highlightedEdge === edge.id, + ); graph.style.transform = `translate(${state.panX}px, ${state.panY}px) scale(${state.scale})`; requestAnimationFrame(() => { @@ -370,13 +398,19 @@ Joomla = window.Joomla || {}; const rect = labelDiv.getBoundingClientRect(); if (rect.width === 0 && rect.height === 0) return; const measuredWidth = rect.width / state.scale; - const measuredHeight = (rect.height / state.scale) || 24; + const measuredHeight = rect.height / state.scale || 24; foreignObject.setAttribute('width', measuredWidth + 4); foreignObject.setAttribute('height', measuredHeight + 4); - foreignObject.setAttribute('x', edge.labelPosition.x - (measuredWidth / 2)); - foreignObject.setAttribute('y', edge.labelPosition.y - (measuredHeight / 2)); + foreignObject.setAttribute( + 'x', + edge.labelPosition.x - measuredWidth / 2, + ); + foreignObject.setAttribute( + 'y', + edge.labelPosition.y - measuredHeight / 2, + ); }); }); diff --git a/media_source/com_workflow/scss/condition-builder.scss b/media_source/com_workflow/scss/condition-builder.scss new file mode 100644 index 0000000000000..69069cf7fad4d --- /dev/null +++ b/media_source/com_workflow/scss/condition-builder.scss @@ -0,0 +1,193 @@ +.condition-builder .cb-ui { + margin-top: .5rem; +} + +.cb-expression { + padding: 10px; + border: 1px solid rgba(128, 128, 128, .35); + border-radius: 8px; +} + +.cb-expression .cb-expression { + border-inline-start: 3px solid #2a7de1; +} + +.cb-expression-head { + display: flex; + flex-wrap: wrap; + align-items: center; + margin-bottom: 10px; + gap: 10px; +} + +.cb-list { + display: flex; + flex-direction: column; + gap: 8px; + padding-inline-start: 12px; + border-inline-start: 2px solid rgba(128, 128, 128, .25); +} + +.cb-leaf { + display: flex; + align-items: center; + padding: 8px; + border: 1px solid rgba(128, 128, 128, .35); + border-radius: 6px; + gap: 8px; +} + +.cb-leaf-fields { + display: flex; + gap: 8px; + flex: 1; + flex-wrap: wrap; + align-items: center; +} + +.cb-leaf-fields .form-select, +.cb-leaf-fields .form-control, +.cb-leaf-fields joomla-field-fancy-select { + flex: 1; + width: auto; + min-width: 130px; +} + +.cb-match { + width: auto; + min-width: 140px; +} + +.cb-chip { + padding: 2px 8px; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: .03em; + white-space: nowrap; + border-radius: 12px; +} + +.cb-chip-check { + color: #1a5fb4; + background: rgba(42, 125, 225, .16); +} + +.cb-chip-expression { + color: #5b3fb0; + background: rgba(120, 90, 220, .18); +} + +.cb-band { + display: flex; + align-items: center; + padding-inline-start: 12px; +} + +.cb-band .cb-op { + width: auto; + min-width: 90px; + font-size: 12px; + font-weight: 600; +} + +.cb-not { + display: inline-flex; + align-items: center; + gap: 4px; + margin: 0; + font-size: 12px; + white-space: nowrap; +} + +.cb-add-op { + align-items: center; +} + +.cb-join-label { + font-size: 13px; + color: rgba(128, 128, 128, .95); +} + +.cb-add { + display: flex; + gap: 8px; + margin-top: 10px; +} + +.cb-empty { + padding: 4px 0; + font-size: 13px; + font-style: italic; + color: rgba(128, 128, 128, .9); +} + +.cb-remove { + margin-inline-start: auto; + line-height: 1; +} + +.cb-root { + display: flex; + flex-direction: column; + gap: 10px; +} + +.cb-root-head { + margin-bottom: 0; +} + +.subform-wrapper { + display: flex; + flex-wrap: wrap; + column-gap: 1.5%; +} + +.subform-wrapper > .control-group { + flex-basis: 100%; +} + +.control-group.cb-rule-type { + flex-basis: 32%; +} + +.control-group.cb-delay-value { + flex-basis: 32%; +} + +.control-group.cb-delay-unit { + flex-basis: 33%; +} + +.control-group.cb-cron { + flex-basis: 66.5%; +} + +.control-group.cb-rule-type, +.control-group.cb-delay-value:not(.hidden), +.control-group.cb-delay-unit:not(.hidden), +.control-group.cb-cron:not(.hidden) { + display: block; +} + +.control-group.cb-rule-type .control-label, +.control-group.cb-delay-value .control-label, +.control-group.cb-delay-unit .control-label, +.control-group.cb-cron .control-label { + display: block; + float: none; + width: 100%; + padding: 0; + margin: 0 0 4px; + text-align: start; +} + +.control-group.cb-rule-type .controls, +.control-group.cb-delay-value .controls, +.control-group.cb-delay-unit .controls, +.control-group.cb-cron .controls { + display: block; + float: none; + width: 100%; + margin-inline-start: 0; +} diff --git a/media_source/com_workflow/src/components/edges/CustomEdge.vue b/media_source/com_workflow/src/components/edges/CustomEdge.vue index 451fc23d1e3e4..a52e5563cefea 100644 --- a/media_source/com_workflow/src/components/edges/CustomEdge.vue +++ b/media_source/com_workflow/src/components/edges/CustomEdge.vue @@ -96,7 +96,20 @@