Skip to content

Commit 817771a

Browse files
authored
Implementation of graphical workflow editor feature
Merging from gsoc-workflow-dileep
2 parents 7d12a9f + f1af875 commit 817771a

File tree

69 files changed

+6732
-4
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

69 files changed

+6732
-4
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
--
2+
-- Add position column to workflow stages table
3+
--
4+
5+
ALTER TABLE `#__workflow_stages` ADD COLUMN `position` text DEFAULT NULL AFTER `default`;
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
--
2+
-- Add position column to workflow stages table
3+
--
4+
5+
ALTER TABLE "#__workflow_stages" ADD COLUMN "position" text DEFAULT NULL;

administrator/components/com_content/src/Model/ArticleModel.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1044,6 +1044,8 @@ protected function preprocessForm(Form $form, $data, $group = 'content')
10441044

10451045
$this->workflowPreprocessForm($form, $data);
10461046

1047+
$form->setFieldAttribute('transition', 'layout', 'joomla.form.field.groupedlist-transition');
1048+
10471049
parent::preprocessForm($form, $data, $group);
10481050
}
10491051

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
<?php
2+
3+
/**
4+
* @package Joomla.Administrator
5+
* @subpackage com_workflow
6+
*
7+
* @copyright (C) 2025 Open Source Matters, Inc. <https://www.joomla.org>
8+
* @license GNU General Public License version 2 or later; see LICENSE.txt
9+
*/
10+
11+
defined('_JEXEC') or die;
12+
13+
use Joomla\CMS\Factory;
14+
use Joomla\CMS\Language\Text;
15+
16+
Factory::getApplication()->getDocument()->getWebAssetManager()
17+
->useScript('webcomponent.toolbar-button');
18+
19+
?>
20+
<joomla-toolbar-button>
21+
<button id="redo-workflow" class="btn btn-info action-button" tabindex="0">
22+
<span class="icon-redo icon-fw" aria-hidden="true"></span>
23+
<?php echo Text::_('COM_WORKFLOW_REDO'); ?>
24+
</button>
25+
</joomla-toolbar-button>
26+
27+
<script>
28+
document.getElementById('redo-workflow')?.addEventListener('click', () => {
29+
WorkflowGraph.Event.fire('onClickRedoWorkflow');
30+
});
31+
</script>
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
<?php
2+
3+
/**
4+
* @package Joomla.Administrator
5+
* @subpackage com_workflow
6+
*
7+
* @copyright (C) 2025 Open Source Matters, Inc. <https://www.joomla.org>
8+
* @license GNU General Public License version 2 or later; see LICENSE.txt
9+
*/
10+
11+
defined('_JEXEC') or die;
12+
13+
use Joomla\CMS\Factory;
14+
use Joomla\CMS\Language\Text;
15+
16+
Factory::getApplication()->getDocument()->getWebAssetManager()
17+
->useScript('webcomponent.toolbar-button');
18+
19+
$shortcutsPopupOptions = json_encode([
20+
'src' => '#shortcuts-popup-content',
21+
'width' => '800px',
22+
'height' => 'fit-content',
23+
'textHeader' => Text::_('COM_WORKFLOW_GRAPH_SHORTCUTS_TITLE'),
24+
'preferredParent' => 'body',
25+
]);
26+
?>
27+
<joomla-toolbar-button>
28+
<button
29+
class="btn btn-info action-button"
30+
data-joomla-dialog="<?php echo htmlspecialchars($shortcutsPopupOptions, ENT_QUOTES, 'UTF-8'); ?>"
31+
tabindex="0"
32+
title
33+
>
34+
<span class="fa fa-keyboard" aria-hidden="true"></span>
35+
<?php echo Text::_('COM_WORKFLOW_GRAPH_SHORTCUTS'); ?>
36+
</button>
37+
</joomla-toolbar-button>
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
<?php
2+
3+
/**
4+
* @package Joomla.Administrator
5+
* @subpackage com_workflow
6+
*
7+
* @copyright (C) 2025 Open Source Matters, Inc. <https://www.joomla.org>
8+
* @license GNU General Public License version 2 or later; see LICENSE.txt
9+
*/
10+
11+
defined('_JEXEC') or die;
12+
13+
use Joomla\CMS\Factory;
14+
use Joomla\CMS\Language\Text;
15+
16+
Factory::getApplication()->getDocument()->getWebAssetManager()
17+
->useScript('webcomponent.toolbar-button');
18+
19+
?>
20+
<joomla-toolbar-button>
21+
<button id="undo-workflow" class="btn btn-info action-button" tabindex="0">
22+
<span class="icon-undo-2 icon-fw" aria-hidden="true"></span>
23+
<?php echo Text::_('COM_WORKFLOW_UNDO'); ?>
24+
</button>
25+
</joomla-toolbar-button>
26+
27+
28+
<script>
29+
document.getElementById('undo-workflow')?.addEventListener('click', () => {
30+
WorkflowGraph.Event.fire('onClickUndoWorkflow');
31+
});
32+
</script>
33+
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
/**
2+
* Simple Event Bus for cross-module communication
3+
* Used to communicate between Joomla buttons and Vue app
4+
*/
5+
export default new class EventBus {
6+
/**
7+
* Internal registry of events
8+
* @type {Object<string, Function[]>}
9+
*/
10+
constructor() {
11+
this.events = {};
12+
}
13+
14+
/**
15+
* Trigger a custom event with optional payload
16+
* @param {string} event - Event name
17+
* @param {*} [data=null] - Optional payload
18+
*/
19+
fire(event, data = null) {
20+
(this.events[event] || []).forEach((fn) => fn(data));
21+
}
22+
23+
/**
24+
* Register a callback for an event
25+
* @param {string} event - Event name
26+
* @param {Function} callback - Function to invoke on event
27+
*/
28+
listen(event, callback) {
29+
if (!this.events[event]) {
30+
this.events[event] = [];
31+
}
32+
this.events[event].push(callback);
33+
}
34+
35+
/**
36+
* Remove a listener from an event
37+
* @param {string} event - Event name
38+
* @param {Function} callback - Function to remove
39+
*/
40+
off(event, callback) {
41+
if (this.events[event]) {
42+
this.events[event] = this.events[event].filter((fn) => fn !== callback);
43+
}
44+
}
45+
}();
Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
/**
2+
* Handles API communication for the workflow graph.
3+
*/
4+
class WorkflowGraphApi {
5+
/**
6+
* Initializes the WorkflowGraphApi instance.
7+
*
8+
* @throws {TypeError} If required options are missing.
9+
*/
10+
constructor() {
11+
const {
12+
apiBaseUrl,
13+
extension,
14+
} = Joomla.getOptions('com_workflow', {});
15+
16+
if (!apiBaseUrl) {
17+
throw new TypeError(Joomla.Text._('COM_WORKFLOW_GRAPH_API_BASEURL_NOT_SET', 'Workflow API baseUrl is not defined'));
18+
}
19+
20+
if (!extension) {
21+
throw new TypeError(Joomla.Text._('COM_WORKFLOW_GRAPH_ERROR_EXTENSION_NOT_SET', 'Workflow extension is not set'));
22+
}
23+
24+
this.baseUrl = apiBaseUrl;
25+
this.extension = extension;
26+
this.csrfToken = Joomla.getOptions('csrf.token', null);
27+
28+
if (!this.csrfToken) {
29+
throw new TypeError(Joomla.Text._('COM_WORKFLOW_GRAPH_ERROR_CSRF_TOKEN_NOT_SET', 'CSRF token is not set'));
30+
}
31+
}
32+
33+
/**
34+
* Makes a request using Joomla.request with better error handling.
35+
*
36+
* @param {string} url - The endpoint relative to baseUrl.
37+
* @param {Object} [options={}] - Request config (method, data, headers).
38+
* @returns {Promise<any>} The parsed response or error.
39+
*/
40+
async makeRequest(url, options = {}) {
41+
const headers = options.headers || {};
42+
headers['X-Requested-With'] = 'XMLHttpRequest';
43+
options.headers = headers;
44+
options[this.csrfToken] = 1;
45+
46+
return new Promise((resolve, reject) => {
47+
Joomla.request({
48+
url: `${this.baseUrl}${url}&extension=${this.extension}`,
49+
...options,
50+
onSuccess: (response) => {
51+
const data = JSON.parse(response);
52+
resolve(data);
53+
},
54+
onError: (xhr) => {
55+
let message = 'Network error';
56+
try {
57+
const errorData = JSON.parse(xhr.responseText);
58+
message = errorData.data || errorData.message || message;
59+
} catch (e) {
60+
message = xhr.statusText || message;
61+
}
62+
if (window.Joomla && window.Joomla.renderMessages) {
63+
window.Joomla.renderMessages({ error: [message] });
64+
}
65+
reject(new Error(message));
66+
},
67+
});
68+
});
69+
}
70+
71+
/**
72+
* Fetches workflow data by ID.
73+
*
74+
* @param {number} id - Workflow ID.
75+
* @returns {Promise<Object|null>}
76+
*/
77+
async getWorkflow(id) {
78+
return this.makeRequest(`&task=graph.getWorkflow&workflow_id=${id}&format=json`);
79+
}
80+
81+
/**
82+
* Fetches stages for a given workflow.
83+
*
84+
* @param {number} workflowId - Workflow ID.
85+
* @returns {Promise<Object[]|null>}
86+
*/
87+
async getStages(workflowId) {
88+
return this.makeRequest(`&task=graph.getStages&workflow_id=${workflowId}&format=json`);
89+
}
90+
91+
/**
92+
* Fetches transitions for a given workflow.
93+
*
94+
* @param {number} workflowId - Workflow ID.
95+
* @returns {Promise<Object[]|null>}
96+
*/
97+
async getTransitions(workflowId) {
98+
return this.makeRequest(`&task=graph.getTransitions&workflow_id=${workflowId}&format=json`);
99+
}
100+
101+
/**
102+
* Deletes a stage from a workflow.
103+
*
104+
* @param {number} id - Stage ID.
105+
* @param {number} workflowId - Workflow ID.
106+
* @param {boolean} [stageDelete=0] - Optional flag to indicate if the stage should be deleted or just trashed.
107+
*
108+
* @returns {Promise<boolean>}
109+
*/
110+
async deleteStage(id, workflowId, stageDelete = false) {
111+
try {
112+
const formData = new FormData();
113+
formData.append('cid[]', id);
114+
formData.append('workflow_id', workflowId);
115+
formData.append('type', 'stage');
116+
formData.append(this.csrfToken, '1');
117+
118+
const response = await this.makeRequest(`&task=${stageDelete ? 'graph.delete' : 'graph.trash'}&workflow_id=${workflowId}&format=json`, {
119+
method: 'POST',
120+
data: formData,
121+
});
122+
123+
if (response && response.success) {
124+
if (window.Joomla && window.Joomla.renderMessages) {
125+
window.Joomla.renderMessages({
126+
success: [response?.data?.message || response?.message],
127+
});
128+
}
129+
}
130+
} catch (error) {
131+
window.WorkflowGraph.Event.fire('Error', { error: error.message });
132+
throw error;
133+
}
134+
}
135+
136+
/**
137+
* Deletes a transition from a workflow.
138+
*
139+
* @param {number} id - Transition ID.
140+
* @param {number} workflowId - Workflow ID.
141+
* @param {boolean} [transitionDelete=false] - Optional flag to indicate if the transition should be deleted or just trashed.
142+
*
143+
* @returns {Promise<boolean>}
144+
*/
145+
async deleteTransition(id, workflowId, transitionDelete = false) {
146+
try {
147+
const formData = new FormData();
148+
formData.append('cid[]', id);
149+
formData.append('workflow_id', workflowId);
150+
formData.append('type', 'transition');
151+
formData.append(this.csrfToken, '1');
152+
153+
const response = await this.makeRequest(`&task=${transitionDelete ? 'graph.delete' : 'graph.trash'}&workflow_id=${workflowId}&format=json`, {
154+
method: 'POST',
155+
data: formData,
156+
});
157+
158+
if (response && response.success) {
159+
if (window.Joomla && window.Joomla.renderMessages) {
160+
window.Joomla.renderMessages({
161+
success: [response?.data?.message || response?.message],
162+
});
163+
}
164+
}
165+
} catch (error) {
166+
window.WorkflowGraph.Event.fire('Error', { error: error.message });
167+
throw error;
168+
}
169+
}
170+
171+
/**
172+
* Updates the position of a stage.
173+
*
174+
* @param {number} workflowId - Workflow ID.
175+
* @param {Object} positions - Position objects {x, y} of updated stages.
176+
* @returns {Promise<Object|null>}
177+
*/
178+
async updateStagePosition(workflowId, positions) {
179+
try {
180+
const formData = new FormData();
181+
formData.append('workflow_id', workflowId);
182+
formData.append(this.csrfToken, '1');
183+
184+
if (positions === null || Object.keys(positions).length === 0) {
185+
return true;
186+
}
187+
188+
Object.entries(positions).forEach(([id, position]) => {
189+
formData.append(`positions[${id}][x]`, position.x);
190+
formData.append(`positions[${id}][y]`, position.y);
191+
});
192+
193+
const response = await this.makeRequest('&task=stages.updateStagesPosition&format=json', {
194+
method: 'POST',
195+
data: formData,
196+
});
197+
198+
return !!(response && response.success);
199+
} catch (error) {
200+
window.WorkflowGraph.Event.fire('Error', { error });
201+
throw error;
202+
}
203+
}
204+
}
205+
206+
export default new WorkflowGraphApi();

0 commit comments

Comments
 (0)