-
-
Notifications
You must be signed in to change notification settings - Fork 3.7k
[6.1] Implementation of graphical workflow editor feature #46021
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: 6.1-dev
Are you sure you want to change the base?
Changes from 85 commits
80d1ffa
29f35db
3a86a6e
3bfb0ad
9f2a264
f918892
3a57671
638815f
d7e9e98
b199e86
1e3ff7c
211d03f
ef15204
eaa0ec0
2ec20b8
9a7ded3
0106cd4
22af2dd
77febbd
6729a5b
693efae
f802d55
e1937d3
239deee
48504bf
fff2f64
8187d7d
69a901a
03f3fdd
06a36ad
1c38d65
439e5d9
5153db2
c20e368
542ad2c
2113502
bfb0410
6bcd4e1
53f199d
70b642a
f098b47
ae05b98
1958429
14fc988
a799a07
4e50ea1
bedd9e8
5c66af1
c2cede8
56d2258
fae346b
9e5f2b3
b4dc905
d0c04ad
ab0128b
cd2eaed
900d128
2288738
48e024a
5a533ca
b63d859
16e1dc1
a446fc0
2bb7f9c
80cc021
2897829
d8b3d26
bc7a18f
9bd6574
245572d
8402df4
0a56846
0adc955
53d68c1
fc54e8e
73b0ae4
9fe999a
fcb3192
df147bb
f1af875
817771a
253d64a
91c6786
75fd79c
eecbe26
f381723
836cebe
f8b6f66
da35661
e9c5711
0347162
c052a11
bf808aa
0ad971e
3c8076d
043c03d
ebcfc4e
74d1cd2
0580183
f7970b9
f1f9d37
f10abcf
cd68c7b
231e38b
a1c01b3
ce379c4
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| -- | ||
| -- Add position column to workflow stages table | ||
| -- | ||
|
|
||
| ALTER TABLE `#__workflow_stages` ADD COLUMN `position` text DEFAULT NULL AFTER `default`; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| -- | ||
| -- Add position column to workflow stages table | ||
| -- | ||
|
|
||
| ALTER TABLE "#__workflow_stages" ADD COLUMN "position" text DEFAULT NULL; | ||
Dileepadari marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,31 @@ | ||
| <?php | ||
|
|
||
| /** | ||
| * @package Joomla.Administrator | ||
| * @subpackage com_workflow | ||
| * | ||
| * @copyright (C) 2025 Open Source Matters, Inc. <https://www.joomla.org> | ||
| * @license GNU General Public License version 2 or later; see LICENSE.txt | ||
| */ | ||
|
|
||
| defined('_JEXEC') or die; | ||
|
|
||
| use Joomla\CMS\Factory; | ||
| use Joomla\CMS\Language\Text; | ||
|
|
||
| Factory::getApplication()->getDocument()->getWebAssetManager() | ||
| ->useScript('webcomponent.toolbar-button'); | ||
|
|
||
| ?> | ||
| <joomla-toolbar-button> | ||
| <button id="redo-workflow" class="btn btn-info action-button" tabindex="0"> | ||
| <span class="icon-redo icon-fw" aria-hidden="true"></span> | ||
| <?php echo Text::_('COM_WORKFLOW_REDO'); ?> | ||
| </button> | ||
| </joomla-toolbar-button> | ||
|
|
||
| <script> | ||
Dileepadari marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| document.getElementById('redo-workflow')?.addEventListener('click', () => { | ||
| WorkflowGraph.Event.fire('onClickRedoWorkflow'); | ||
| }); | ||
| </script> | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,37 @@ | ||
| <?php | ||
|
|
||
| /** | ||
| * @package Joomla.Administrator | ||
| * @subpackage com_workflow | ||
| * | ||
| * @copyright (C) 2025 Open Source Matters, Inc. <https://www.joomla.org> | ||
| * @license GNU General Public License version 2 or later; see LICENSE.txt | ||
| */ | ||
|
|
||
| defined('_JEXEC') or die; | ||
|
|
||
| use Joomla\CMS\Factory; | ||
| use Joomla\CMS\Language\Text; | ||
|
|
||
| Factory::getApplication()->getDocument()->getWebAssetManager() | ||
| ->useScript('webcomponent.toolbar-button'); | ||
|
|
||
| $shortcutsPopupOptions = json_encode([ | ||
| 'src' => '#shortcuts-popup-content', | ||
| 'width' => '800px', | ||
| 'height' => 'fit-content', | ||
| 'textHeader' => Text::_('COM_WORKFLOW_GRAPH_SHORTCUTS_TITLE'), | ||
| 'preferredParent' => 'body', | ||
| ]); | ||
| ?> | ||
| <joomla-toolbar-button> | ||
| <button | ||
| class="btn btn-info action-button" | ||
| data-joomla-dialog="<?php echo htmlspecialchars($shortcutsPopupOptions, ENT_QUOTES, 'UTF-8'); ?>" | ||
| tabindex="0" | ||
| title | ||
|
||
| > | ||
| <span class="fa fa-keyboard" aria-hidden="true"></span> | ||
| <?php echo Text::_('COM_WORKFLOW_GRAPH_SHORTCUTS'); ?> | ||
| </button> | ||
| </joomla-toolbar-button> | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,33 @@ | ||
| <?php | ||
|
|
||
| /** | ||
| * @package Joomla.Administrator | ||
| * @subpackage com_workflow | ||
| * | ||
| * @copyright (C) 2025 Open Source Matters, Inc. <https://www.joomla.org> | ||
| * @license GNU General Public License version 2 or later; see LICENSE.txt | ||
| */ | ||
|
|
||
| defined('_JEXEC') or die; | ||
|
|
||
| use Joomla\CMS\Factory; | ||
| use Joomla\CMS\Language\Text; | ||
|
|
||
| Factory::getApplication()->getDocument()->getWebAssetManager() | ||
| ->useScript('webcomponent.toolbar-button'); | ||
|
|
||
| ?> | ||
| <joomla-toolbar-button> | ||
| <button id="undo-workflow" class="btn btn-info action-button" tabindex="0"> | ||
| <span class="icon-undo-2 icon-fw" aria-hidden="true"></span> | ||
| <?php echo Text::_('COM_WORKFLOW_UNDO'); ?> | ||
| </button> | ||
| </joomla-toolbar-button> | ||
|
|
||
|
|
||
| <script> | ||
Dileepadari marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| document.getElementById('undo-workflow')?.addEventListener('click', () => { | ||
| WorkflowGraph.Event.fire('onClickUndoWorkflow'); | ||
| }); | ||
| </script> | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,45 @@ | ||
| /** | ||
| * Simple Event Bus for cross-module communication | ||
| * Used to communicate between Joomla buttons and Vue app | ||
| */ | ||
| export default new class EventBus { | ||
| /** | ||
| * Internal registry of events | ||
| * @type {Object<string, Function[]>} | ||
| */ | ||
| constructor() { | ||
| this.events = {}; | ||
| } | ||
|
|
||
| /** | ||
| * Trigger a custom event with optional payload | ||
| * @param {string} event - Event name | ||
| * @param {*} [data=null] - Optional payload | ||
| */ | ||
| fire(event, data = null) { | ||
| (this.events[event] || []).forEach((fn) => fn(data)); | ||
| } | ||
|
|
||
| /** | ||
| * Register a callback for an event | ||
| * @param {string} event - Event name | ||
| * @param {Function} callback - Function to invoke on event | ||
| */ | ||
| listen(event, callback) { | ||
| if (!this.events[event]) { | ||
| this.events[event] = []; | ||
| } | ||
| this.events[event].push(callback); | ||
| } | ||
|
|
||
| /** | ||
| * Remove a listener from an event | ||
| * @param {string} event - Event name | ||
| * @param {Function} callback - Function to remove | ||
| */ | ||
| off(event, callback) { | ||
| if (this.events[event]) { | ||
| this.events[event] = this.events[event].filter((fn) => fn !== callback); | ||
| } | ||
| } | ||
| }(); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,206 @@ | ||
| /** | ||
| * Handles API communication for the workflow graph. | ||
| */ | ||
| class WorkflowGraphApi { | ||
| /** | ||
| * Initializes the WorkflowGraphApi instance. | ||
| * | ||
| * @throws {TypeError} If required options are missing. | ||
| */ | ||
| constructor() { | ||
| const { | ||
| apiBaseUrl, | ||
| extension, | ||
| } = Joomla.getOptions('com_workflow', {}); | ||
|
|
||
| if (!apiBaseUrl) { | ||
| throw new TypeError(Joomla.Text._('COM_WORKFLOW_GRAPH_API_BASEURL_NOT_SET', 'Workflow API baseUrl is not defined')); | ||
| } | ||
|
|
||
| if (!extension) { | ||
| throw new TypeError(Joomla.Text._('COM_WORKFLOW_GRAPH_ERROR_EXTENSION_NOT_SET', 'Workflow extension is not set')); | ||
| } | ||
|
|
||
| this.baseUrl = apiBaseUrl; | ||
| this.extension = extension; | ||
| this.csrfToken = Joomla.getOptions('csrf.token', null); | ||
|
|
||
| if (!this.csrfToken) { | ||
| throw new TypeError(Joomla.Text._('COM_WORKFLOW_GRAPH_ERROR_CSRF_TOKEN_NOT_SET', 'CSRF token is not set')); | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Makes a request using Joomla.request with better error handling. | ||
| * | ||
| * @param {string} url - The endpoint relative to baseUrl. | ||
| * @param {Object} [options={}] - Request config (method, data, headers). | ||
| * @returns {Promise<any>} The parsed response or error. | ||
| */ | ||
| async makeRequest(url, options = {}) { | ||
| const headers = options.headers || {}; | ||
| headers['X-Requested-With'] = 'XMLHttpRequest'; | ||
| options.headers = headers; | ||
| options[this.csrfToken] = 1; | ||
|
|
||
| return new Promise((resolve, reject) => { | ||
| Joomla.request({ | ||
| url: `${this.baseUrl}${url}&extension=${this.extension}`, | ||
| ...options, | ||
| onSuccess: (response) => { | ||
| const data = JSON.parse(response); | ||
| resolve(data); | ||
| }, | ||
| onError: (xhr) => { | ||
| let message = 'Network error'; | ||
| try { | ||
| const errorData = JSON.parse(xhr.responseText); | ||
| message = errorData.data || errorData.message || message; | ||
| } catch (e) { | ||
| message = xhr.statusText || message; | ||
| } | ||
| if (window.Joomla && window.Joomla.renderMessages) { | ||
| window.Joomla.renderMessages({ error: [message] }); | ||
| } | ||
| reject(new Error(message)); | ||
| }, | ||
| }); | ||
| }); | ||
| } | ||
|
|
||
| /** | ||
| * Fetches workflow data by ID. | ||
| * | ||
| * @param {number} id - Workflow ID. | ||
| * @returns {Promise<Object|null>} | ||
| */ | ||
| async getWorkflow(id) { | ||
| return this.makeRequest(`&task=graph.getWorkflow&workflow_id=${id}&format=json`); | ||
| } | ||
|
|
||
| /** | ||
| * Fetches stages for a given workflow. | ||
| * | ||
| * @param {number} workflowId - Workflow ID. | ||
| * @returns {Promise<Object[]|null>} | ||
| */ | ||
| async getStages(workflowId) { | ||
| return this.makeRequest(`&task=graph.getStages&workflow_id=${workflowId}&format=json`); | ||
| } | ||
|
|
||
| /** | ||
| * Fetches transitions for a given workflow. | ||
| * | ||
| * @param {number} workflowId - Workflow ID. | ||
| * @returns {Promise<Object[]|null>} | ||
| */ | ||
| async getTransitions(workflowId) { | ||
| return this.makeRequest(`&task=graph.getTransitions&workflow_id=${workflowId}&format=json`); | ||
| } | ||
|
|
||
| /** | ||
| * Deletes a stage from a workflow. | ||
| * | ||
| * @param {number} id - Stage ID. | ||
| * @param {number} workflowId - Workflow ID. | ||
| * @param {boolean} [stageDelete=0] - Optional flag to indicate if the stage should be deleted or just trashed. | ||
| * | ||
| * @returns {Promise<boolean>} | ||
| */ | ||
| async deleteStage(id, workflowId, stageDelete = false) { | ||
| try { | ||
| const formData = new FormData(); | ||
| formData.append('cid[]', id); | ||
| formData.append('workflow_id', workflowId); | ||
| formData.append('type', 'stage'); | ||
| formData.append(this.csrfToken, '1'); | ||
|
|
||
| const response = await this.makeRequest(`&task=${stageDelete ? 'graph.delete' : 'graph.trash'}&workflow_id=${workflowId}&format=json`, { | ||
| method: 'POST', | ||
| data: formData, | ||
| }); | ||
|
|
||
| if (response && response.success) { | ||
| if (window.Joomla && window.Joomla.renderMessages) { | ||
| window.Joomla.renderMessages({ | ||
| success: [response?.data?.message || response?.message], | ||
| }); | ||
| } | ||
| } | ||
| } catch (error) { | ||
| window.WorkflowGraph.Event.fire('Error', { error: error.message }); | ||
| throw error; | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Deletes a transition from a workflow. | ||
| * | ||
| * @param {number} id - Transition ID. | ||
| * @param {number} workflowId - Workflow ID. | ||
| * @param {boolean} [transitionDelete=false] - Optional flag to indicate if the transition should be deleted or just trashed. | ||
| * | ||
| * @returns {Promise<boolean>} | ||
| */ | ||
| async deleteTransition(id, workflowId, transitionDelete = false) { | ||
| try { | ||
| const formData = new FormData(); | ||
| formData.append('cid[]', id); | ||
| formData.append('workflow_id', workflowId); | ||
| formData.append('type', 'transition'); | ||
| formData.append(this.csrfToken, '1'); | ||
|
|
||
| const response = await this.makeRequest(`&task=${transitionDelete ? 'graph.delete' : 'graph.trash'}&workflow_id=${workflowId}&format=json`, { | ||
| method: 'POST', | ||
| data: formData, | ||
| }); | ||
|
|
||
| if (response && response.success) { | ||
| if (window.Joomla && window.Joomla.renderMessages) { | ||
| window.Joomla.renderMessages({ | ||
| success: [response?.data?.message || response?.message], | ||
| }); | ||
| } | ||
| } | ||
| } catch (error) { | ||
| window.WorkflowGraph.Event.fire('Error', { error: error.message }); | ||
| throw error; | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Updates the position of a stage. | ||
| * | ||
| * @param {number} workflowId - Workflow ID. | ||
| * @param {Object} positions - Position objects {x, y} of updated stages. | ||
| * @returns {Promise<Object|null>} | ||
| */ | ||
| async updateStagePosition(workflowId, positions) { | ||
| try { | ||
| const formData = new FormData(); | ||
| formData.append('workflow_id', workflowId); | ||
| formData.append(this.csrfToken, '1'); | ||
|
|
||
| if (positions === null || Object.keys(positions).length === 0) { | ||
| return true; | ||
| } | ||
|
|
||
| Object.entries(positions).forEach(([id, position]) => { | ||
| formData.append(`positions[${id}][x]`, position.x); | ||
| formData.append(`positions[${id}][y]`, position.y); | ||
| }); | ||
|
|
||
| const response = await this.makeRequest('&task=stages.updateStagesPosition&format=json', { | ||
| method: 'POST', | ||
| data: formData, | ||
| }); | ||
|
|
||
| return !!(response && response.success); | ||
| } catch (error) { | ||
| window.WorkflowGraph.Event.fire('Error', { error }); | ||
| throw error; | ||
| } | ||
| } | ||
| } | ||
|
|
||
| export default new WorkflowGraphApi(); |
Uh oh!
There was an error while loading. Please reload this page.