diff --git a/administrator/components/com_admin/sql/updates/mysql/6.1.0-2025-08-31.sql b/administrator/components/com_admin/sql/updates/mysql/6.1.0-2025-08-31.sql new file mode 100644 index 0000000000000..3d262cb076b42 --- /dev/null +++ b/administrator/components/com_admin/sql/updates/mysql/6.1.0-2025-08-31.sql @@ -0,0 +1,5 @@ +-- +-- Add position column to workflow stages table +-- + +ALTER TABLE `#__workflow_stages` ADD COLUMN `position` text NULL AFTER `default` /** CAN FAIL **/; diff --git a/administrator/components/com_admin/sql/updates/postgresql/6.1.0-2025-08-31.sql b/administrator/components/com_admin/sql/updates/postgresql/6.1.0-2025-08-31.sql new file mode 100644 index 0000000000000..b456e37ba4e34 --- /dev/null +++ b/administrator/components/com_admin/sql/updates/postgresql/6.1.0-2025-08-31.sql @@ -0,0 +1,5 @@ +-- +-- Add position column to workflow stages table +-- + +ALTER TABLE "#__workflow_stages" ADD COLUMN "position" text NULL /** CAN FAIL **/; diff --git a/administrator/components/com_content/src/Model/ArticleModel.php b/administrator/components/com_content/src/Model/ArticleModel.php index f4f94e90c643e..1d6edff4c6ac7 100644 --- a/administrator/components/com_content/src/Model/ArticleModel.php +++ b/administrator/components/com_content/src/Model/ArticleModel.php @@ -1044,6 +1044,8 @@ protected function preprocessForm(Form $form, $data, $group = 'content') $this->workflowPreprocessForm($form, $data); + $form->setFieldAttribute('transition', 'layout', 'joomla.form.field.groupedlist-transition'); + parent::preprocessForm($form, $data, $group); } diff --git a/administrator/components/com_workflow/layouts/toolbar/redo.php b/administrator/components/com_workflow/layouts/toolbar/redo.php new file mode 100644 index 0000000000000..b96ef7cae1fd1 --- /dev/null +++ b/administrator/components/com_workflow/layouts/toolbar/redo.php @@ -0,0 +1,26 @@ + + * @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'); + +$onclickAttr = 'onclick="WorkflowGraph.Event.fire(\'onClickRedoWorkflow\')"'; +?> + + + diff --git a/administrator/components/com_workflow/layouts/toolbar/shortcuts.php b/administrator/components/com_workflow/layouts/toolbar/shortcuts.php new file mode 100644 index 0000000000000..48629e29421df --- /dev/null +++ b/administrator/components/com_workflow/layouts/toolbar/shortcuts.php @@ -0,0 +1,35 @@ + + * @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', +]); +?> + + + diff --git a/administrator/components/com_workflow/layouts/toolbar/undo.php b/administrator/components/com_workflow/layouts/toolbar/undo.php new file mode 100644 index 0000000000000..4cf6ebfea3bde --- /dev/null +++ b/administrator/components/com_workflow/layouts/toolbar/undo.php @@ -0,0 +1,26 @@ + + * @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'); + +$onclickAttr = 'onclick="WorkflowGraph.Event.fire(\'onClickUndoWorkflow\')"'; +?> + + + diff --git a/administrator/components/com_workflow/resources/scripts/app/Event.es6.js b/administrator/components/com_workflow/resources/scripts/app/Event.es6.js new file mode 100644 index 0000000000000..3fddff6f7f9c2 --- /dev/null +++ b/administrator/components/com_workflow/resources/scripts/app/Event.es6.js @@ -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} + */ + 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); + } + } +}(); diff --git a/administrator/components/com_workflow/resources/scripts/app/WorkflowGraphApi.es6.js b/administrator/components/com_workflow/resources/scripts/app/WorkflowGraphApi.es6.js new file mode 100644 index 0000000000000..fac87f3ae4dac --- /dev/null +++ b/administrator/components/com_workflow/resources/scripts/app/WorkflowGraphApi.es6.js @@ -0,0 +1,194 @@ +import notifications from '../plugins/Notifications.es6.js'; + +/** + * 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 || !extension) { + throw new TypeError(Joomla.Text._('COM_WORKFLOW_GRAPH_API_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')); + } + } + + /** + * Makes a request using Joomla.request. + * + * @param {string} url - The endpoint relative to baseUrl. + * @param {Object} [options={}] - Request config (method, data, headers). + * @returns {Promise} 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 = 'COM_WORKFLOW_GRAPH_ERROR_UNKNOWN'; + try { + const errorData = JSON.parse(xhr.responseText); + message = errorData.data || errorData.message || message; + } catch (e) { + message = xhr.statusText || message; + } + notifications.error(message); + reject(new Error(Joomla.Text._(message))); + }, + }); + }); + } + + /** + * Fetches workflow data by ID. + * + * @param {number} id - Workflow ID. + * @returns {Promise} + */ + 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} + */ + 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} + */ + 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} + */ + 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) { + notifications.success(response?.data?.message || response?.message); + } + } catch (error) { + notifications.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} + */ + 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) { + notifications.success(response?.data?.message || response?.message); + } + } catch (error) { + notifications.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} + */ + 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) { + notifications.error(error.message); + throw error; + } + } +} + +export default new WorkflowGraphApi(); diff --git a/administrator/components/com_workflow/resources/scripts/components/App.vue b/administrator/components/com_workflow/resources/scripts/components/App.vue new file mode 100644 index 0000000000000..6afaaf6c97725 --- /dev/null +++ b/administrator/components/com_workflow/resources/scripts/components/App.vue @@ -0,0 +1,74 @@ + + + + + diff --git a/administrator/components/com_workflow/resources/scripts/components/Titlebar.vue b/administrator/components/com_workflow/resources/scripts/components/Titlebar.vue new file mode 100644 index 0000000000000..9c8c1961d0d63 --- /dev/null +++ b/administrator/components/com_workflow/resources/scripts/components/Titlebar.vue @@ -0,0 +1,92 @@ + + + diff --git a/administrator/components/com_workflow/resources/scripts/components/canvas/ControlsPanel.vue b/administrator/components/com_workflow/resources/scripts/components/canvas/ControlsPanel.vue new file mode 100644 index 0000000000000..e3f5cada17629 --- /dev/null +++ b/administrator/components/com_workflow/resources/scripts/components/canvas/ControlsPanel.vue @@ -0,0 +1,50 @@ + + + diff --git a/administrator/components/com_workflow/resources/scripts/components/canvas/CustomControls.vue b/administrator/components/com_workflow/resources/scripts/components/canvas/CustomControls.vue new file mode 100644 index 0000000000000..77bee60da3242 --- /dev/null +++ b/administrator/components/com_workflow/resources/scripts/components/canvas/CustomControls.vue @@ -0,0 +1,75 @@ + + + diff --git a/administrator/components/com_workflow/resources/scripts/components/canvas/WorkflowCanvas.vue b/administrator/components/com_workflow/resources/scripts/components/canvas/WorkflowCanvas.vue new file mode 100644 index 0000000000000..256bcc0fecddd --- /dev/null +++ b/administrator/components/com_workflow/resources/scripts/components/canvas/WorkflowCanvas.vue @@ -0,0 +1,588 @@ + + + diff --git a/administrator/components/com_workflow/resources/scripts/components/edges/CustomEdge.vue b/administrator/components/com_workflow/resources/scripts/components/edges/CustomEdge.vue new file mode 100644 index 0000000000000..c80a69d4491e5 --- /dev/null +++ b/administrator/components/com_workflow/resources/scripts/components/edges/CustomEdge.vue @@ -0,0 +1,309 @@ + + + diff --git a/administrator/components/com_workflow/resources/scripts/components/nodes/StageNode.vue b/administrator/components/com_workflow/resources/scripts/components/nodes/StageNode.vue new file mode 100644 index 0000000000000..62789c2d2acab --- /dev/null +++ b/administrator/components/com_workflow/resources/scripts/components/nodes/StageNode.vue @@ -0,0 +1,287 @@ + + + diff --git a/administrator/components/com_workflow/resources/scripts/plugins/Notifications.es6.js b/administrator/components/com_workflow/resources/scripts/plugins/Notifications.es6.js new file mode 100644 index 0000000000000..d12b27ee9cc2c --- /dev/null +++ b/administrator/components/com_workflow/resources/scripts/plugins/Notifications.es6.js @@ -0,0 +1,54 @@ +/** + * Send a notification + * @param {String} message + * @param {{}} options + * + */ +function notify(message, options) { + let timer; + if (options.type === 'message') { + timer = 3000; + } + Joomla.renderMessages( + { + [options.type]: [Joomla.Text._(message)], + }, + undefined, + true, + timer, + ); +} + +const notifications = { + /* Send a success notification */ + success: (message, options) => { + notify(message, { + type: 'message', // @todo rename it to success + dismiss: true, + ...options, + }); + }, + + /* Send an error notification */ + error: (message, options) => { + notify(message, { + type: 'error', // @todo rename it to danger + dismiss: true, + ...options, + }); + }, + + /* Send a general notification */ + notify: (message, options) => { + notify(message, { + type: 'message', + dismiss: true, + ...options, + }); + }, + + /* Ask the user a question */ + ask: (message) => window.confirm(message), +}; + +export default notifications; diff --git a/administrator/components/com_workflow/resources/scripts/plugins/translate.es6.js b/administrator/components/com_workflow/resources/scripts/plugins/translate.es6.js new file mode 100644 index 0000000000000..42d8c31b38aba --- /dev/null +++ b/administrator/components/com_workflow/resources/scripts/plugins/translate.es6.js @@ -0,0 +1,54 @@ +/** + * Joomla Translation Plugin Wrapper + * Provides global `translate` and `sprintf` methods to all Vue components + */ +const Translate = { + /** + * Translate a Joomla key + * Falls back to key if translation is missing + * @param {string} key + * @returns {string} + */ + translate: (key) => Joomla.Text._(key, key), + + /** + * Format string using Joomla `sprintf` + * @param {string} string + * @param {...*} args + * @returns {string} + */ + sprintf: (string, ...args) => { + const base = Translate.translate(string); + let i = 0; + return base.replace(/%((%)|s|d)/g, (m) => { + let val = args[i]; + + if (m === '%d') { + val = parseFloat(val); + if (Number.isNaN(val)) { + val = 0; + } + } + i += 1; + return val; + }); + }, + + /** + * Vue plugin install method + * Adds $translate and $sprintf globally + * @param {App} Vue + */ + install: (Vue) => Vue.mixin({ + methods: { + translate(key) { + return Translate.translate(key); + }, + sprintf(key, ...args) { + return Translate.sprintf(key, args); + }, + }, + }), +}; + +export default Translate; diff --git a/administrator/components/com_workflow/resources/scripts/store/actions.es6.js b/administrator/components/com_workflow/resources/scripts/store/actions.es6.js new file mode 100644 index 0000000000000..842543ff51b1d --- /dev/null +++ b/administrator/components/com_workflow/resources/scripts/store/actions.es6.js @@ -0,0 +1,202 @@ +import workflowGraphApi from '../app/WorkflowGraphApi.es6.js'; +import notifications from '../plugins/Notifications.es6'; + +/** + * Vuex Actions for asynchronous operations and workflows + * Handles logic and commits to mutations + */ +export default { + /** + * Load a workflow by its ID, including stages and transitions. + * @param commit + * @param dispatch + * @param id - The ID of the workflow + * @returns {Promise<{workflow: Object, stages: Array, transitions: Array}>} + */ + async loadWorkflow({ commit, dispatch }, id) { + commit('SET_LOADING', true); + commit('SET_ERROR', null); + try { + // Load workflow, stages, and transitions in parallel + const [workflowRes, stagesRes, transitionsRes] = await Promise.all([ + await workflowGraphApi.getWorkflow(id), + await workflowGraphApi.getStages(id), + await workflowGraphApi.getTransitions(id), + ]); + + commit('SET_WORKFLOW_ID', id); + commit('SET_WORKFLOW', workflowRes?.data); + commit('SET_STAGES', stagesRes?.data); + commit('SET_TRANSITIONS', transitionsRes?.data); + + dispatch('saveToHistory'); + } catch (error) { + notifications.error(error?.response?.data?.message || error?.message || 'COM_WORKFLOW_GRAPH_ERROR_UNKNOWN'); + } finally { + commit('SET_LOADING', false); + } + }, + + /** + * Delete a stage from the workflow. + * @param commit + * @param dispatch + * @param state + * @param id - The ID of the stage to delete + * @param workflowId - The ID of the workflow + * @returns {Promise} + */ + async deleteStage({ commit, dispatch, state }, { id, workflowId }) { + commit('SET_LOADING', true); + commit('SET_ERROR', null); + + try { + const transitions = state.transitions.filter( + (t) => t.from_stage_id.toString() === id || t.to_stage_id.toString() === id, + ); + + if ( + state.stages.length <= 1 + || state.stages.find((s) => s.id.toString() === id).default + ) { + notifications.error('COM_WORKFLOW_GRAPH_ERROR_STAGE_DEFAULT_CANT_DELETED'); + return; + } + + if (transitions.length > 0) { + notifications.error('COM_WORKFLOW_GRAPH_ERROR_STAGE_HAS_TRANSITIONS'); + return; + } + + const stageDelete = state.stages.find( + (s) => s.id.toString() === id, + ).published === -1; + + await workflowGraphApi.deleteStage(id, workflowId, stageDelete); + } catch (error) { + notifications.error(error?.response?.data?.message || error?.message || 'COM_WORKFLOW_GRAPH_TRASH_STAGE_FAILED'); + } finally { + commit('SET_LOADING', false); + await dispatch('loadWorkflow', workflowId); + } + }, + + /** + * Delete a transition from the workflow. + * @param commit + * @param dispatch + * @param state + * @param id - The ID of the transition to delete + * @param workflowId - The ID of the workflow + * @returns {Promise} + */ + async deleteTransition({ commit, dispatch, state }, { id, workflowId }) { + commit('SET_LOADING', true); + commit('SET_ERROR', null); + try { + const transitionDelete = state.transitions.find( + (t) => t.id.toString() === id, + ).published === -1; + await workflowGraphApi.deleteTransition(id, workflowId, transitionDelete); + } catch (error) { + notifications.error(error?.response?.data?.message || error?.message || 'COM_WORKFLOW_GRAPH_TRASH_TRANSITION_FAILED'); + } finally { + commit('SET_LOADING', false); + await dispatch('loadWorkflow', workflowId); + } + }, + + /** + * Update the position of a stage in the workflow locally. + * @param commit + * @param dispatch + * @param id - The ID of the stage + * @param x - The new x position of the stage + * @param y - The new y position of the stage + */ + updateStagePosition({ commit, dispatch }, { id, x, y }) { + commit('UPDATE_STAGE_POSITION', { id, x, y }); + dispatch('saveToHistory'); + }, + + + /** + * Update the position of a stage in the workflow via API in database. + * @param commit + * @param state + * @returns {Promise} + */ + async updateStagePositionAjax({ commit, state }) { + const response = workflowGraphApi.updateStagePosition( + state.workflowId, + state.stages.reduce((acc, stage) => { + if (stage.position) { + acc[stage.id] = { + x: stage.position.x, + y: stage.position.y, + }; + } + return acc; + }, {}), + ); + + if (response) { + commit('SET_ERROR', null); + return true; + } + + notifications.error('COM_WORKFLOW_GRAPH_UPDATE_STAGE_POSITION_FAILED'); + return false; + }, + + /** + * Update the canvas viewport (zoom and pan) for the workflow graph. + * @param commit + * @param zoom - The zoom level + * @param panX - The pan offset on the X axis + * @param panY - The pan offset on the Y axis + */ + updateCanvasViewport({ commit }, { zoom, panX, panY }) { + commit('SET_CANVAS_VIEWPORT', { zoom, panX, panY }); + }, + + /** + * Save the current state of the workflow to history. + * @param commit + * @param state + * @returns {Promise} + */ + saveToHistory({ commit, state }) { + const snapshot = { + stagePositions: state.stages.map((stage) => ({ + id: stage.id, + position: stage.position, + })), + }; + commit('ADD_TO_HISTORY', snapshot); + }, + + /** + * Undo the last action in the workflow. + * @param commit + * @returns {Promise} + */ + undo({ commit }) { + commit('SET_LOADING', true); + commit('SET_ERROR', null); + commit('UNDO_REDO', -1); + commit('SET_LOADING', false); + }, + + /** + * Redo the last undone action in the workflow. + * @param commit + * @returns {Promise} + */ + redo({ commit }) { + commit('SET_LOADING', true); + commit('SET_ERROR', null); + commit('UNDO_REDO', 1); + commit('SET_LOADING', false); + }, +}; diff --git a/administrator/components/com_workflow/resources/scripts/store/getters.es6.js b/administrator/components/com_workflow/resources/scripts/store/getters.es6.js new file mode 100644 index 0000000000000..b74ee9e509d88 --- /dev/null +++ b/administrator/components/com_workflow/resources/scripts/store/getters.es6.js @@ -0,0 +1,15 @@ +/** + * Vuex Getters for accessing state in components + * Provides reusable computed-like access to store data + */ +export default { + workflowId: (state) => state.workflowId, + workflow: (state) => state.workflow, + stages: (state) => state.stages, + transitions: (state) => state.transitions, + loading: (state) => state.loading, + error: (state) => state.error, + canUndo: (state) => state.historyIndex > 0, + canRedo: (state) => state.historyIndex < state.history.length - 1, + canvas: (state) => state.canvas, +}; diff --git a/administrator/components/com_workflow/resources/scripts/store/mutations.es6.js b/administrator/components/com_workflow/resources/scripts/store/mutations.es6.js new file mode 100644 index 0000000000000..3aac8365da942 --- /dev/null +++ b/administrator/components/com_workflow/resources/scripts/store/mutations.es6.js @@ -0,0 +1,90 @@ +/** + * Vuex Mutations for synchronously modifying workflow state + */ +export default { + SET_WORKFLOW_ID(state, id) { + state.workflowId = id; + }, + SET_WORKFLOW(state, workflow) { + state.workflow = workflow; + }, + SET_STAGES(state, stages) { + state.stages = stages.map((stage, idx) => ({ + ...stage, + position: { + x: typeof stage?.position?.x === 'number' && !Number.isNaN(stage.position.x) + ? stage.position.x + : 100 + (idx % 4) * 400, + y: typeof stage?.position?.y === 'number' && !Number.isNaN(stage.position.y) + ? stage.position.y + : 100 + Math.floor(idx / 4) * 300, + }, + })); + }, + SET_TRANSITIONS(state, transitions) { + state.transitions = transitions; + }, + SET_LOADING(state, loading) { + state.loading = loading; + }, + SET_ERROR(state, error) { + state.error = error; + }, + UPDATE_STAGE_POSITION(state, { id, x, y }) { + state.stages = state.stages.map((stage) => { + if (stage.id.toString() === id) { + return { + ...stage, + position: { + x, + y, + }, + }; + } + return stage; + }); + }, + SET_CANVAS_VIEWPORT(state, { zoom, panX, panY }) { + state.canvas.zoom = zoom; + state.canvas.panX = panX; + state.canvas.panY = panY; + }, + ADD_TO_HISTORY(state, snapshot) { + if (snapshot === state.history[state.historyIndex]) { + return; + } + + // Remove any future states if we're in the middle of the history + if (state.historyIndex < state.history.length - 1) { + state.history = state.history.slice(0, state.historyIndex + 1); + } + // Add the new state to history + state.history.push(snapshot); + state.historyIndex = state.history.length - 1; + + // Limit history size + if (state.history.length > 100) { + state.history.shift(); + state.historyIndex -= 1; + } + }, + UNDO_REDO(state, direction) { + if ( + (state.historyIndex > 0 && direction === -1) + || (state.historyIndex < state.history.length - 1 && direction === 1) + ) { + state.historyIndex += direction; + const snapshot = state.history[state.historyIndex]; + state.stages = state.stages.map((stage) => { + const historyStage = snapshot.stagePositions.find((s) => s.id === stage.id); + if (historyStage) { + return { + ...stage, + position: historyStage.position, + }; + } + return { ...stage }; + }); + } + }, +}; diff --git a/administrator/components/com_workflow/resources/scripts/store/plugins/persisted-state.es6.js b/administrator/components/com_workflow/resources/scripts/store/plugins/persisted-state.es6.js new file mode 100644 index 0000000000000..29ee02fef5bdb --- /dev/null +++ b/administrator/components/com_workflow/resources/scripts/store/plugins/persisted-state.es6.js @@ -0,0 +1,33 @@ +/** + * Vuex plugin for persisting selected store data to localStorage + * Typically used for preserving UI state across reloads + */ +export default function createPersistedState({ key = 'vuex', paths = [] } = {}) { + return (store) => { + try { + const stored = localStorage.getItem(key); + if (stored) { + const parsed = JSON.parse(stored); + paths.forEach((path) => { + if (parsed[path] !== undefined) { + store.state[path] = parsed[path]; + } + }); + } + + store.subscribe((mutation, state) => { + const partial = {}; + paths.forEach((path) => { + partial[path] = state[path]; + }); + localStorage.setItem(key, JSON.stringify(partial)); + }); + } catch (err) { + if (window.Joomla && window.Joomla.renderMessages) { + window.Joomla.renderMessages({ + error: [err], + }); + } + } + }; +} diff --git a/administrator/components/com_workflow/resources/scripts/store/state.es6.js b/administrator/components/com_workflow/resources/scripts/store/state.es6.js new file mode 100644 index 0000000000000..085e29b171923 --- /dev/null +++ b/administrator/components/com_workflow/resources/scripts/store/state.es6.js @@ -0,0 +1,19 @@ +/** + * Reactive base state for the workflow graph + * Includes workflow ID, workflow, stages, loading, transitions, history, and canvas viewport + */ +export default { + workflowId: null, + workflow: null, + stages: [], + transitions: [], + loading: false, + error: null, + history: [], + historyIndex: -1, + canvas: { + zoom: null, + panX: null, + panY: null, + }, +}; diff --git a/administrator/components/com_workflow/resources/scripts/store/store.es6.js b/administrator/components/com_workflow/resources/scripts/store/store.es6.js new file mode 100644 index 0000000000000..0b2b543d9b9a3 --- /dev/null +++ b/administrator/components/com_workflow/resources/scripts/store/store.es6.js @@ -0,0 +1,23 @@ +import { createStore } from 'vuex'; +import state from './state.es6.js'; +import mutations from './mutations.es6.js'; +import actions from './actions.es6.js'; +import getters from './getters.es6.js'; +import createPersistedState from './plugins/persisted-state.es6'; + +/** + * Vuex Store for Workflow Graph + * Handles state, mutations, actions, getters, and persistence of workflow graph data + */ +export default createStore({ + state, + mutations, + actions, + getters, + plugins: [ + createPersistedState({ + key: 'workflow-graph-state', + paths: ['workflowId', 'stages', 'transitions'], + }), + ], +}); diff --git a/administrator/components/com_workflow/resources/scripts/utils/accessibility-fixer.es6.js b/administrator/components/com_workflow/resources/scripts/utils/accessibility-fixer.es6.js new file mode 100644 index 0000000000000..c8c4a53373e37 --- /dev/null +++ b/administrator/components/com_workflow/resources/scripts/utils/accessibility-fixer.es6.js @@ -0,0 +1,359 @@ +/** + * VueFlow Accessibility Fixer + * Handles accessibility issues that cannot be fixed with CSS alone + */ + +export class AccessibilityFixer { + constructor() { + this.observer = null; + this.processedElements = new WeakSet(); + } + + /** + * Initialize the accessibility fixer + */ + init() { + // Initial fix + this.fixVueFlowAccessibility(); + + // Set up mutation observer to handle dynamically added elements + this.setupMutationObserver(); + + // Fix elements when VueFlow updates + this.setupVueFlowObserver(); + } + + /** + * Fix all VueFlow accessibility issues + */ + fixVueFlowAccessibility() { + // Fix SVG elements + this.fixSVGElements(); + + // Fix tabbable groups + this.fixTabbableGroups(); + + // Fix graphics-document elements + this.fixGraphicsDocuments(); + + // Fix button accessible names + this.fixButtonAccessibleNames(); + + // Fix duplicate SVG element IDs + this.fixDuplicateSVGIds(); + } + + /** + * Hide all SVG elements from screen readers + */ + fixSVGElements() { + const svgSelectors = [ + '.vue-flow svg', + '.vue-flow [role="graphics-document"]', + '.vue-flow__background svg', + '.vue-flow__minimap svg', + '.vue-flow__edge svg', + '.vue-flow__nodes svg', + '.vue-flow__edges svg', + 'svg[role="graphics-document"]', + 'g[role="group"] svg', + 'g[role="group"] [role="graphics-document"]', + ]; + + svgSelectors.forEach((selector) => { + const elements = document.querySelectorAll(selector); + elements.forEach((element) => { + if (!this.processedElements.has(element)) { + this.hideSVGFromScreenReaders(element); + this.processedElements.add(element); + } + }); + }); + } + + /** + * Hide individual SVG element from screen readers + */ + hideSVGFromScreenReaders(element) { + // Only add aria-hidden to elements where it's valid + if (!this.isInvalidForAriaHidden(element)) { + element.setAttribute('aria-hidden', 'true'); + } + + // Only set role="presentation" on elements where it's valid + if (!this.isInvalidForPresentationRole(element)) { + element.setAttribute('role', 'presentation'); + } + + // Remove ARIA attributes that shouldn't be on decorative elements + if (!this.isInvalidForAriaAttributes(element)) { + element.removeAttribute('aria-label'); + element.removeAttribute('aria-labelledby'); + element.removeAttribute('aria-describedby'); + } + + // Also hide all children + const children = element.querySelectorAll('*'); + children.forEach((child) => { + // Only add aria-hidden to child elements where it's valid + if (!this.isInvalidForAriaHidden(child)) { + child.setAttribute('aria-hidden', 'true'); + } + + // Only set role="presentation" on child elements where it's valid + if (!this.isInvalidForPresentationRole(child)) { + child.setAttribute('role', 'presentation'); + } + + // Remove ARIA attributes from children where appropriate + if (!this.isInvalidForAriaAttributes(child)) { + child.removeAttribute('aria-label'); + child.removeAttribute('aria-labelledby'); + child.removeAttribute('aria-describedby'); + } + }); + } + + /** + * Check if role="presentation" is invalid for this element + */ + isInvalidForPresentationRole(element) { + const invalidTags = ['title', 'desc', 'metadata']; + return invalidTags.includes(element.tagName?.toLowerCase()); + } + + /** + * Check if aria-hidden is invalid for this element + */ + isInvalidForAriaHidden(element) { + // aria-hidden is invalid on title elements when they have role="none" + const tagName = element.tagName?.toLowerCase(); + if (tagName === 'title' && element.getAttribute('role') === 'none') { + return true; + } + // aria-hidden should not be used on title, desc, metadata elements in general + const invalidTags = ['title', 'desc', 'metadata']; + return invalidTags.includes(tagName); + } + + /** + * Check if ARIA attributes should be removed from this element + */ + isInvalidForAriaAttributes(element) { + // Don't remove ARIA attributes from elements that might legitimately use them + const tagName = element.tagName?.toLowerCase(); + const protectedTags = ['title', 'desc', 'metadata']; + return protectedTags.includes(tagName); + } + + /** + * Fix tabbable group elements + */ + fixTabbableGroups() { + const groups = document.querySelectorAll('.vue-flow [role="group"][tabindex], [role="group"][tabindex]'); + + groups.forEach((group) => { + if (!this.processedElements.has(group)) { + // Remove tabindex from non-interactive groups + const hasInteractiveChildren = group.querySelector('button, [role="button"], [role="menuitem"], input, select, textarea, a[href]'); + + if (!hasInteractiveChildren) { + group.removeAttribute('tabindex'); + group.style.pointerEvents = 'none'; + } else { + // If it has interactive children, make the group non-focusable but keep children interactive + group.removeAttribute('tabindex'); + group.style.userSelect = 'none'; + group.style.webkitUserSelect = 'none'; + group.style.mozUserSelect = 'none'; + } + + this.processedElements.add(group); + } + }); + } + + /** + * Fix graphics-document elements + */ + fixGraphicsDocuments() { + const graphicsElements = document.querySelectorAll('[role="graphics-document"]'); + + graphicsElements.forEach((element) => { + if (!this.processedElements.has(element)) { + this.hideSVGFromScreenReaders(element); + this.processedElements.add(element); + } + }); + } + + /** + * Fix button accessible names to match visible text + */ + fixButtonAccessibleNames() { + const buttons = document.querySelectorAll('.stage-node[role="button"], .edge-label[role="button"]'); + + buttons.forEach((button) => { + if (!this.processedElements.has(button)) { + // Remove any conflicting aria-label that doesn't match visible text + const currentLabel = button.getAttribute('aria-label'); + const visibleText = this.getVisibleText(button); + + if (currentLabel && visibleText && !visibleText.includes(currentLabel) && !currentLabel.includes(visibleText)) { + // If aria-label doesn't match visible text, remove it to let the browser use visible text + button.removeAttribute('aria-label'); + } + + this.processedElements.add(button); + } + }); + } + + /** + * Fix duplicate SVG element IDs + */ + fixDuplicateSVGIds() { + const seenIds = new Set(); + const elementsWithIds = document.querySelectorAll('svg [id], .vue-flow [id]'); + + elementsWithIds.forEach((element) => { + const id = element.id; + if (id && seenIds.has(id)) { + // Generate a unique ID + const uniqueId = this.generateUniqueId(id, seenIds); + element.id = uniqueId; + seenIds.add(uniqueId); + } else if (id) { + seenIds.add(id); + } + }); + } + + /** + * Generate a unique ID based on the original ID + */ + generateUniqueId(originalId, seenIds) { + let counter = 1; + let newId = `${originalId}-${counter}`; + + while (seenIds.has(newId)) { + counter++; + newId = `${originalId}-${counter}`; + } + + return newId; + } + + /** + * Get the main visible text content of an element + */ + getVisibleText(element) { + // For stage nodes, get the title + const titleElement = element.querySelector('.card-title'); + if (titleElement) { + return titleElement.textContent.trim(); + } + + // For edge labels, get the header text + const headerElement = element.querySelector('header .card-title'); + if (headerElement) { + return headerElement.textContent.trim(); + } + + // Fallback to any text content + return element.textContent.trim().split('\n')[0].trim(); + } + + /** + * Setup mutation observer to handle dynamically added elements + */ + setupMutationObserver() { + this.observer = new MutationObserver((mutations) => { + let shouldProcess = false; + + mutations.forEach((mutation) => { + if (mutation.type === 'childList' && mutation.addedNodes.length > 0) { + mutation.addedNodes.forEach((node) => { + if (node.nodeType === Node.ELEMENT_NODE) { + // Check if the added node is a VueFlow element or contains VueFlow elements + if (node.matches && ( + node.matches('.vue-flow *') || + node.matches('svg') || + node.matches('[role="graphics-document"]') || + node.matches('[role="group"]') || + node.querySelector('.vue-flow *, svg, [role="graphics-document"], [role="group"]') + )) { + shouldProcess = true; + } + } + }); + } + }); + + if (shouldProcess) { + // Debounce the processing + clearTimeout(this.processTimeout); + this.processTimeout = setTimeout(() => { + this.fixVueFlowAccessibility(); + }, 100); + } + }); + + this.observer.observe(document.body, { + childList: true, + subtree: true, + }); + } + + /** + * Setup VueFlow-specific observer for when the flow updates + */ + setupVueFlowObserver() { + // Also listen for VueFlow specific events if available + const vueFlowElement = document.querySelector('.vue-flow'); + if (vueFlowElement) { + // Set up additional observer for VueFlow container changes + const vueFlowObserver = new MutationObserver(() => { + clearTimeout(this.vueFlowTimeout); + this.vueFlowTimeout = setTimeout(() => { + this.fixVueFlowAccessibility(); + }, 200); + }); + + vueFlowObserver.observe(vueFlowElement, { + childList: true, + subtree: true, + attributes: true, + attributeFilter: ['role', 'tabindex', 'aria-label', 'aria-hidden'], + }); + } + } + + /** + * Cleanup the fixer + */ + destroy() { + if (this.observer) { + this.observer.disconnect(); + } + clearTimeout(this.processTimeout); + clearTimeout(this.vueFlowTimeout); + } +} + +// Auto-initialize when DOM is ready +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', () => { + const fixer = new AccessibilityFixer(); + fixer.init(); + + // Make it globally available for cleanup + window.workflowAccessibilityFixer = fixer; + }); +} else { + const fixer = new AccessibilityFixer(); + fixer.init(); + window.workflowAccessibilityFixer = fixer; +} + +export default AccessibilityFixer; diff --git a/administrator/components/com_workflow/resources/scripts/utils/edges.es6.js b/administrator/components/com_workflow/resources/scripts/utils/edges.es6.js new file mode 100644 index 0000000000000..e52b2418f98f2 --- /dev/null +++ b/administrator/components/com_workflow/resources/scripts/utils/edges.es6.js @@ -0,0 +1,80 @@ +import { getEdgeColor } from './utils.es6.js'; + +/** + * Generate styled edges based on transition data. + * @param {Array} transitions - List of transitions. + * @param {Object} options - Optional configuration - contains selected transition id. + * @returns {Array} Styled edge definitions. + */ +export function generateStyledEdges(transitions, options = {}) { + const { + selectedId = null, + } = options; + + // Group transitions by source-target pair + const edgeGroups = {}; + transitions.forEach((transition) => { + const sourceId = transition.from_stage_id === -1 ? 'from_any' : String(transition.from_stage_id); + const targetId = String(transition.to_stage_id); + const key = `${sourceId}__${targetId}`; + if (!edgeGroups[key]) edgeGroups[key] = []; + edgeGroups[key].push(transition); + }); + + // Assign offsetIndex for each edge in a group + const edgeOffsetMap = new Map(); + Object.entries(edgeGroups).forEach(([key, group]) => { + group.forEach((transition, idx) => { + edgeOffsetMap.set(transition.id, idx - (group.length - 1) / 2); + }); + }); + + return transitions.map((transition) => { + const sourceId = transition.from_stage_id === -1 ? 'from_any' : String(transition.from_stage_id); + const targetId = String(transition.to_stage_id); + + const isSelected = transition.id === selectedId; + const isBiDirectional = transitions.some( + (t) => t.from_stage_id === transition.to_stage_id && t.to_stage_id === transition.from_stage_id, + ); + + // Offset index for multiple edges between same source-target + let offsetIndex = edgeOffsetMap.get(transition.id) || 0; + + // If bidirectional, add a small extra offset to separate further + if (isBiDirectional) { + offsetIndex += transition.from_stage_id > transition.to_stage_id ? 1 : -1; + } + + const edgeColor = getEdgeColor(transition, isSelected); + const strokeWidth = isSelected ? 5 : 3; + + return { + id: String(transition.id), + source: sourceId, + target: targetId, + type: 'custom', + animated: isSelected, + style: { + stroke: edgeColor, + strokeWidth, + strokeDasharray: transition.published ? undefined : '5,5', + zIndex: isSelected ? 1000 : 1, + }, + markerEnd: { + type: 'arrow', + width: 10, + height: 10, + color: edgeColor, + }, + data: { + ...transition, + isSelected, + isBiDirectional, + offsetIndex, + onEdit: () => {}, + onDelete: () => {}, + }, + }; + }); +} diff --git a/administrator/components/com_workflow/resources/scripts/utils/focus-utils.es6.js b/administrator/components/com_workflow/resources/scripts/utils/focus-utils.es6.js new file mode 100644 index 0000000000000..86fd0efddd48d --- /dev/null +++ b/administrator/components/com_workflow/resources/scripts/utils/focus-utils.es6.js @@ -0,0 +1,149 @@ +/** + * Announce a message via ARIA live region. + * @param {HTMLElement} liveRegionElement + * @param {string} message + */ +export function announce(liveRegionElement, message) { + if (!liveRegionElement || !message) return; + liveRegionElement.textContent = ''; + setTimeout(() => { + liveRegionElement.textContent = message; + }, 10); +} + +/** + * Focus a stage node by stageId. + * @param {string|number} stageId - The ID of the stage to focus + */ +export function focusNode(stageId) { + const el = document.querySelector(`.stage-node[data-stage-id='${stageId}']`); + if (el) el.focus(); +} + +/** + * Focus an edge label by transitionId. + * @param {string|number} transitionId - The ID of the transition to focus + */ +export function focusEdge(transitionId) { + const el = document.querySelector(`.edge-label[data-edge-id='${transitionId}']`); + if (el) el.focus(); +} + +/** + * Find and cycle focus among elements with a selector. + * @param {string} selector - The selector for the elements to focus + * @param {boolean} reverse - Whether to cycle focus in reverse order + */ +export function cycleFocus(selector, reverse = false) { + const elements = Array.from(document.querySelectorAll(selector)); + if (!elements.length) return; + const currentIndex = elements.indexOf(document.activeElement); + let nextIndex; + if (reverse) { + nextIndex = currentIndex <= 0 ? elements.length - 1 : currentIndex - 1; + } else { + nextIndex = currentIndex >= elements.length - 1 ? 0 : currentIndex + 1; + } + elements[nextIndex].focus(); +} + +/** + * Cycle between defined focus modes (e.g., stages → transitions → toolbar → actions). + * @param {string[]} focusModes - Array of focus mode strings. + * @param {Ref} currentModeRef - Vue ref holding the current mode. + * @param {HTMLElement} liveRegionElement - ARIA live region for screen reader feedback. + */ +export function cycleMode(focusModes, currentModeRef, liveRegionElement) { + const currentIndex = focusModes.indexOf(currentModeRef.value); + const nextIndex = (currentIndex + 1) % focusModes.length; + currentModeRef.value = focusModes[nextIndex]; +} + +/** + * Handle focus and keyboard events for dialog iframes. + * This function sets focus to the first input or body of the iframe, + * and adds an Escape key listener to close the dialog. + * + * @param {HTMLIFrameElement} iframe - The iframe element to handle. + * + */ +function handleDialogIframeLoad(iframe) { + try { + iframe.focus(); + const iframeDoc = iframe.contentDocument || iframe.contentWindow.document; + if (iframeDoc) { + const firstInput = iframeDoc.querySelector('input:not([type="hidden"]), select, textarea'); + if (firstInput) { + firstInput.focus(); + } else { + iframeDoc.body.focus(); + } + + iframeDoc.addEventListener('keydown', (e) => { + if (e.key === 'Escape') { + e.preventDefault(); + const parentDialog = document.querySelector('joomla-dialog dialog[open]'); + if (parentDialog && parentDialog.close) { + parentDialog.close(); + } + } + }); + } + } catch (error) { + iframe.focus(); + } +} + +/** + * Handle dialog close event. + * @param previouslyFocusedElement - The element that was focused before the dialog opened + * @param store - The Vuex store instance + */ +function handleDialogClose(previouslyFocusedElement, store) { + if (previouslyFocusedElement.value) { + previouslyFocusedElement.value.focus(); + previouslyFocusedElement.value = null; + } + store.dispatch('loadWorkflow', store.getters.workflowId); +} + +/** + * Handle Escape keydown event on dialog. + * @param e - The keyboard event + */ +function handleDialogKeydown(e) { + if (e.key === 'Escape') { + e.preventDefault(); + const dialog = e.currentTarget; + if (dialog && dialog.close) { + dialog.close(); + } + } +} + +/** + * Setup focus handlers for dialog iframes. + * This function will focus the dialog and handle iframe loading and closing. + * + * @param {Ref} previouslyFocusedElement - Ref to store the previously focused element. + * @param {Object} store - Vuex store instance. + */ +export function setupDialogFocusHandlers(previouslyFocusedElement, store) { + setTimeout(() => { + const dialog = document.querySelector('joomla-dialog dialog[open]'); + if (dialog) { + dialog.focus(); + const iframe = dialog.querySelector('iframe'); + if (iframe) { + iframe.addEventListener('load', () => { + handleDialogIframeLoad(iframe); + }); + } + + dialog.addEventListener('close', () => { + handleDialogClose(previouslyFocusedElement, store); + }); + dialog.addEventListener('keydown', handleDialogKeydown); + } + }, 100); +} diff --git a/administrator/components/com_workflow/resources/scripts/utils/keyboard-manager.es6.js b/administrator/components/com_workflow/resources/scripts/utils/keyboard-manager.es6.js new file mode 100644 index 0000000000000..c5e6f0d97a8fb --- /dev/null +++ b/administrator/components/com_workflow/resources/scripts/utils/keyboard-manager.es6.js @@ -0,0 +1,192 @@ +import { + announce, cycleFocus, cycleMode, +} from './focus-utils.es6'; + +/** + * Attach global keyboard listeners for workflow canvas. + * @param {Object} options + * @param {Function} addStage - Function to add a new stage + * @param {Function} addTransition - Function to add a new transition + * @param {Function} editItem - Function to edit an item + * @param {Function} deleteItem - Function to delete an item + * @param {Function} undo - Function to undo an action + * @param {Function} redo - Function to redo an action + * @param {Function} setSaveStatus - Function to set the save status of positions + * @param {Function} updateSaveMessage - Function to update the save message + * @param {Function} saveNodePosition - Function to save the node position + * @param {Function} clearSelection - Function to clear the selection + * @param {Function} zoomIn - Function to zoom in + * @param {Function} zoomOut - Function to zoom out + * @param {Function} fitView - Function to fit the view + * @param {Ref} viewport - Ref to the viewport object + * @param {Object} state - { selectedStage, selectedTransition, isTransitionMode, liveRegion } + * @param {Object} store - Vuex store instance + */ +export function setupGlobalShortcuts({ + addStage, addTransition, editItem, deleteItem, + undo, redo, setSaveStatus, updateSaveMessage, saveNodePosition, + clearSelection, zoomIn, zoomOut, fitView, + viewport, state, store, +}) { + function isModifierPressed(e, key) { + return (e.ctrlKey || e.metaKey) && [key.toLowerCase(), key.toUpperCase()].includes(e.key); + } + function handleKey(e) { + const iframe = document.querySelector('joomla-dialog dialog[open]'); + if (iframe) { + if (e.key === 'Escape') { + e.preventDefault(); + iframe.close(); + return; + } + return; + } + + const groupSelectors = { + buttons: 'button, button:not([tabindex="-1"])', + stages: '.stage-node', + transitions: '.edge-label', + toolbar: '.toolbar-button', + actions: '.action-button', + links: 'a[href], a[href]:not([tabindex="-1"])', + }; + + function moveNode(stageId, direction, fast = false) { + const el = document.querySelector(`.stage-node[data-stage-id='${stageId}']`); + if (!el) return; + + const moveBy = fast ? 20 : 5; + if (!store) return; + + const stageIndex = store.getters.stages.findIndex((s) => s.id === parseInt(stageId, 10)); + if (stageIndex === -1) return; + const currentPosition = store.getters.stages[stageIndex].position || { x: 0, y: 0 }; + if (!currentPosition) return; + + let newX = currentPosition.x; + let newY = currentPosition.y; + + switch (direction) { + case 'ArrowUp': newY -= moveBy; break; + case 'ArrowDown': newY += moveBy; break; + case 'ArrowLeft': newX -= moveBy; break; + case 'ArrowRight': newX += moveBy; break; + default: break; + } + + store.dispatch('updateStagePosition', { id: stageId, x: newX, y: newY }); + setSaveStatus('unsaved'); + updateSaveMessage(); + saveNodePosition(); + } + + switch (true) { + case e.altKey && ['n', 'N'].includes(e.key): + e.preventDefault(); + addStage(); + announce(state.liveRegion, 'Add stage'); + break; + + case e.altKey && ['m', 'M'].includes(e.key): + e.preventDefault(); + addTransition(); + announce(state.liveRegion, 'Add transition'); + break; + + case isModifierPressed(e, 'z'): + e.preventDefault(); + undo(); + break; + + case isModifierPressed(e, 'y'): + e.preventDefault(); + redo(); + break; + + case e.key === 'e' || e.key === 'E': + e.preventDefault(); + editItem(); + break; + + case e.key === 'Delete' || e.key === 'Backspace': + e.preventDefault(); + deleteItem(); + break; + + case e.key === 'Escape': + e.preventDefault(); + clearSelection(); + break; + + case ['+', '='].includes(e.key): + e.preventDefault(); + zoomIn(); + break; + + case ['-', '_'].includes(e.key): + e.preventDefault(); + zoomOut(); + break; + + case ['f', 'F'].includes(e.key): + e.preventDefault(); + fitView({ padding: 0.5, duration: 300 }); + break; + + case e.key === 'Tab': { + e.preventDefault(); + cycleMode(['buttons', 'stages', 'transitions', 'toolbar', 'actions', 'links'], state.currentFocusMode, state.liveRegion); + const tabSelector = groupSelectors[state.currentFocusMode.value]; + if (tabSelector) { + const first = document.querySelector(tabSelector); + if (first) first.focus(); + } + break; + } + + case ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key): + e.preventDefault(); + if (state.selectedStage.value) { + if (e.shiftKey) { + moveNode(state.selectedStage.value.toString(), e.key, e.shiftKey); + } else { + const buttonSelector = `.stage-node[data-stage-id='${state.selectedStage.value}'] button[tabindex="0"]`; + if (buttonSelector) { + cycleFocus(buttonSelector, 0); + } + } + } else if (state.selectedTransition.value) { + const buttonSelector = `.edge-label[data-edge-id='${state.selectedTransition.value}'] button[tabindex="0"]`; + if (buttonSelector) { + cycleFocus(buttonSelector, 0); + } + } else if (e.shiftKey) { + const panStep = 20; + switch (e.key) { + case 'ArrowUp': viewport.value.y += panStep; break; + case 'ArrowDown': viewport.value.y -= panStep; break; + case 'ArrowLeft': viewport.value.x += panStep; break; + case 'ArrowRight': viewport.value.x -= panStep; break; + default: break; + } + } else { + const reverse = ['ArrowLeft', 'ArrowUp'].includes(e.key); + const selector = groupSelectors[state.currentFocusMode.value]; + if (selector) { + cycleFocus(selector, reverse); + } + break; + } + break; + + default: + break; + } + } + + document.addEventListener('keydown', handleKey); + + return () => { + document.removeEventListener('keydown', handleKey); + }; +} diff --git a/administrator/components/com_workflow/resources/scripts/utils/positioning.es6.js b/administrator/components/com_workflow/resources/scripts/utils/positioning.es6.js new file mode 100644 index 0000000000000..740996f8304aa --- /dev/null +++ b/administrator/components/com_workflow/resources/scripts/utils/positioning.es6.js @@ -0,0 +1,73 @@ +import { getColorForStage } from './utils.es6.js'; + +/** + * Calculate and return positioned stage nodes in a grid layout. + * @param {Array} stages - Array of stage objects. + * @param {Object} options - Grid layout options (gapX, gapY, paddingX, paddingY). + * @returns {Array} Array of positioned node configs. + */ +export function generatePositionedNodes(stages, options = {}) { + const { + gapX = 400, + gapY = 300, + paddingX = 100, + paddingY = 100, + } = options; + + const columns = Math.min(4, Math.ceil(Math.sqrt(stages?.length || 0)) + 1); + + return stages.map((stage, index) => { + const col = index % columns; + const row = Math.floor(index / columns); + + const position = stage?.position || { + x: col * gapX + paddingX, + y: row * gapY + paddingY, + }; + + return { + id: String(stage.id), + type: 'stage', + position, + data: { + stage: { + ...stage, + color: stage?.color || getColorForStage(stage), + }, + isSelected: false, + onSelect: () => {}, + onEdit: () => {}, + onDelete: () => {}, + }, + draggable: true, + }; + }); +} + +/** + * Create special static nodes like "from_any" node. + * @param {String} id - The ID of the special node + * @param {Object} position - The position of the special node + * @param {String} color - The color of the special node + * @param {String} label - The label of the special node + * @param {Function} onSelect - The function to call when the special node is selected + * @param {Boolean} draggable - Whether the special node is draggable + */ +export function createSpecialNode(id, position, color, label, onSelect = () => {}, draggable = false) { + return { + id, + type: 'stage', + position, + data: { + stage: { + id, + title: label, + published: true, + color, + }, + isSpecial: true, + onSelect, + }, + draggable, + }; +} diff --git a/administrator/components/com_workflow/resources/scripts/utils/utils.es6.js b/administrator/components/com_workflow/resources/scripts/utils/utils.es6.js new file mode 100644 index 0000000000000..29f8e18b465a8 --- /dev/null +++ b/administrator/components/com_workflow/resources/scripts/utils/utils.es6.js @@ -0,0 +1,48 @@ +/** + * Utility function to compute color for a stage based on its ID. + * Uses a hue offset to ensure color uniqueness. + * @param {Object} stage - Stage object with an `id` field. + * @returns {string} HSL color string. + */ +export function getColorForStage(stage) { + const hue = (parseInt(stage?.id, 10) * 137) % 360; + return `hsl(${hue}, 70%, 85%)`; +} + +/** + * Utility function to compute color for a transition based on its ID. + * Uses a different hue offset than stages. + * @param {Object} transition - Transition object with an `id` field. + * @returns {string} HSL color string. + */ +export function getColorForTransition(transition) { + const hue = (parseInt(transition?.id, 10) * 199) % 360; + return `hsl(${hue}, 70%, 60%)`; +} + +/** + * Utility function to determine edge color for a transition. + * @param {Object} transition - Transition object. + * @param {boolean} isSelected - Whether the edge is currently selected. + * @returns {string} Hex or HSL color. + */ +export function getEdgeColor(transition, isSelected) { + if (isSelected) return getColorForTransition(transition); // Blue for selected + if (transition?.published) return '#3B82F6'; + return (transition.from_stage_id === -1 || transition.to_stage_id === -1) ? '#F97316' : '#10B981'; +} + +/** + * Utility function to debounce a function call by delay in milliseconds. + * Useful for rate-limiting input or UI updates. + * @param {Function} func - Function to debounce. + * @param {number} delay - Delay in milliseconds. + * @returns {Function} Debounced function. + */ +export function debounce(func, delay) { + let timer; + return function debounced(...args) { + clearTimeout(timer); + timer = setTimeout(() => func.apply(this, args), delay); + }; +} diff --git a/administrator/components/com_workflow/resources/scripts/workflowgraph.es6.js b/administrator/components/com_workflow/resources/scripts/workflowgraph.es6.js new file mode 100644 index 0000000000000..805987c532dd6 --- /dev/null +++ b/administrator/components/com_workflow/resources/scripts/workflowgraph.es6.js @@ -0,0 +1,24 @@ +import { createApp } from 'vue'; +import App from './components/App.vue'; +import EventBus from './app/Event.es6'; +import store from './store/store.es6'; +import translate from './plugins/translate.es6.js'; +import notifications from './plugins/Notifications.es6.js'; + +// Register WorkflowGraph namespace +window.WorkflowGraph = window.WorkflowGraph || {}; +// Register the WorkflowGraph event bus +window.WorkflowGraph.Event = EventBus; + +document.addEventListener('DOMContentLoaded', () => { + const mountElement = document.getElementById('workflow-graph-root'); + + if (mountElement) { + const app = createApp(App); + app.use(store); + app.use(translate); + app.mount(mountElement); + } else { + notifications.error('COM_WORKFLOW_GRAPH_API_NOT_SET'); + } +}); diff --git a/administrator/components/com_workflow/src/Controller/GraphController.php b/administrator/components/com_workflow/src/Controller/GraphController.php new file mode 100644 index 0000000000000..e21927697676f --- /dev/null +++ b/administrator/components/com_workflow/src/Controller/GraphController.php @@ -0,0 +1,399 @@ + + * @license GNU General Public License version 2 or later; see LICENSE.txt + */ + +namespace Joomla\Component\Workflow\Administrator\Controller; + +use Joomla\CMS\Helper\ContentHelper; +use Joomla\CMS\Language\Text; +use Joomla\CMS\MVC\Controller\AdminController; +use Joomla\CMS\MVC\Factory\MVCFactoryInterface; +use Joomla\CMS\Response\JsonResponse; +use Joomla\Utilities\ArrayHelper; + +// phpcs:disable PSR1.Files.SideEffects +\defined('_JEXEC') or die; +// phpcs:enable PSR1.Files.SideEffects + + +/** + * The workflow Graphical View and Api controller + * + * @since __DEPLOY_VERSION__ + */ +class GraphController extends AdminController +{ + /** + * Present workflow id + * + * @var integer + * @since __DEPLOY_VERSION__ + */ + protected $workflowId; + + /** + * The extension + * + * @var string + * @since __DEPLOY_VERSION__ + */ + protected $extension; + + /** + * The component name + * + * @var string + * @since __DEPLOY_VERSION__ + */ + protected $component; + + /** + * The section of the current extension + * + * @var string + * @since __DEPLOY_VERSION__ + */ + protected $section; + + /** + * The prefix to use with controller messages. + * + * @var string + * @since __DEPLOY_VERSION__ + */ + protected $text_prefix = 'COM_WORKFLOW_GRAPH'; + + + public function __construct($config = [], ?MVCFactoryInterface $factory = null, $app = null, $input = null) + { + parent::__construct($config, $factory, $app, $input); + + $this->registerTask('trash', 'publish'); + + // If workflow id is not set try to get it from input or throw an exception + if (empty($this->workflowId)) { + $this->workflowId = $this->input->getInt('id'); + if (empty($this->workflowId)) { + $this->workflowId = $this->input->getInt('workflow_id'); + } + + if (empty($this->workflowId)) { + throw new \InvalidArgumentException(Text::_('COM_WORKFLOW_GRAPH_ERROR_WORKFLOW_ID_NOT_SET')); + } + } + + // If extension is not set try to get it from input or throw an exception + if (empty($this->extension)) { + $extension = $this->input->getCmd('extension'); + + $parts = explode('.', $extension); + + $this->component = reset($parts); + + $this->extension = array_shift($parts); + + if (!empty($parts)) { + $this->section = array_shift($parts); + } + + if (empty($this->extension)) { + throw new \InvalidArgumentException(Text::_('COM_WORKFLOW_GRAPH_ERROR_EXTENSION_NOT_SET')); + } + } + } + + + /** + * Retrieves workflow data for graphical display in the workflow graph view. + * + * This method fetches the workflow details by ID, checks user permissions, + * and returns the workflow information as a JSON response for use in the + * graphical workflow editor or API consumers. + * + * @return void Outputs a JSON response with workflow data or error message. + * + * @since __DEPLOY_VERSION__ + */ + public function getWorkflow(): void + { + + try { + $id = $this->workflowId; + $model = $this->getModel('Workflow'); + + if (empty($id)) { + throw new \InvalidArgumentException(Text::_('COM_WORKFLOW_GRAPH_ERROR_INVALID_ID')); + } + + $workflow = $model->getItem($id); + + if (empty($workflow->id)) { + throw new \RuntimeException(Text::_('COM_WORKFLOW_GRAPH_ERROR_WORKFLOW_NOT_FOUND')); + } + + // Check permissions + if (!$this->app->getIdentity()->authorise('core.edit', $this->extension . '.workflow.' . $id)) { + throw new \RuntimeException(Text::_('JERROR_ALERTNOAUTHOR')); + } + + $canDo = ContentHelper::getActions($this->extension, 'workflow', $workflow->id); + $canCreate = $canDo->get('core.create'); + + $response = [ + 'id' => $workflow->id, + 'title' => Text::_($workflow->title), + 'description' => Text::_($workflow->description), + 'published' => (bool) $workflow->published, + 'default' => (bool) $workflow->default, + 'extension' => $workflow->extension, + 'canCreate' => $canCreate, + ]; + + echo new JsonResponse($response); + } catch (\Exception $e) { + $this->app->setHeader('status', 500); + echo new JsonResponse($e->getMessage(), 'error', true); + } + + $this->app->close(); + } + + /** + * Retrieves all stages for the specified workflow to be used in the workflow graph view. + * + * Fetches stages by workflow ID, decodes position data if available, and returns + * the result as a JSON response for graphical editors or API consumers. + * + * @return void Outputs a JSON response with stages data or error message. + * + * @since __DEPLOY_VERSION__ + */ + public function getStages() + { + try { + $workflowId = $this->workflowId; + $model = $this->getModel('Stages'); + + if (empty($workflowId)) { + throw new \InvalidArgumentException(Text::_('COM_WORKFLOW_GRAPH_ERROR_INVALID_ID')); + } + + $model->setState('filter.workflow_id', $workflowId); + $model->setState('list.limit', 0); // Get all stages + + $stages = $model->getItems(); + + if (empty($stages)) { + throw new \RuntimeException(Text::_('COM_WORKFLOW_GRAPH_ERROR_STAGES_NOT_FOUND')); + } + + $response = []; + $user = $this->app->getIdentity(); + + foreach ($stages as $stage) { + $canEdit = $user->authorise('core.edit', $this->extension . '.stage.' . $stage->id); + $canDelete = $user->authorise('core.delete', $this->extension . '.stage.' . $stage->id); + + $response[] = [ + 'id' => (int) $stage->id, + 'title' => Text::_($stage->title), + 'description' => Text::_($stage->description), + 'published' => (bool) $stage->published, + 'default' => (bool) $stage->default, + 'ordering' => (int) $stage->ordering, + 'position' => $stage->position ? json_decode($stage->position, true) : null, + 'workflow_id' => $stage->workflow_id, + 'permissions' => [ + 'edit' => $canEdit, + 'delete' => $canDelete, + ], + ]; + } + echo new JsonResponse($response); + } catch (\Exception $e) { + $this->app->setHeader('status', 500); + echo new JsonResponse($e->getMessage(), 'error', true); + } + + $this->app->close(); + } + + + /** + * Retrieves all transitions for the specified workflow to be used in the workflow graph view. + * + * Fetches transitions by workflow ID and returns the result as a JSON response + * for graphical editors or API consumers. + * + * @return void Outputs a JSON response with transitions data or error message. + * + * @since __DEPLOY_VERSION__ + */ + public function getTransitions() + { + + try { + $workflowId = $this->workflowId; + $model = $this->getModel('Transitions'); + + if (empty($workflowId)) { + throw new \InvalidArgumentException(Text::_('COM_WORKFLOW_GRAPH_ERROR_INVALID_ID')); + } + + $model->setState('filter.workflow_id', $workflowId); + $model->setState('list.limit', 0); + + $transitions = $model->getItems(); + + $response = []; + $user = $this->app->getIdentity(); + + foreach ($transitions as $transition) { + $canEdit = $user->authorise('core.edit', $this->extension . '.transition.' . (int) $transition->id); + $canDelete = $user->authorise('core.delete', $this->extension . '.transition.' . (int) $transition->id); + $canRun = $user->authorise('core.execute.transition', $this->extension . '.transition.' . (int) $transition->id); + + $response[] = [ + 'id' => (int) $transition->id, + 'title' => Text::_($transition->title), + 'description' => Text::_($transition->description), + 'published' => (bool) $transition->published, + 'from_stage_id' => (int) $transition->from_stage_id, + 'to_stage_id' => (int) $transition->to_stage_id, + 'ordering' => (int) $transition->ordering, + 'workflow_id' => (int) $transition->workflow_id, + 'permissions' => [ + 'edit' => $canEdit, + 'delete' => $canDelete, + 'run_transition' => $canRun, + ], + ]; + } + + echo new JsonResponse($response); + } catch (\Exception $e) { + $this->app->setHeader('status', 500); + echo new JsonResponse($e->getMessage(), 'error', true); + } + + $this->app->close(); + } + + + public function publish($type = 'stage') + { + + try { + // Check for request forgeries + if (!$this->checkToken('post', false)) { + throw new \RuntimeException(Text::_('JINVALID_TOKEN')); + } + + // Check if the user has permission to publish items + if (!$this->app->getIdentity()->authorise('core.edit.state', $this->extension . '.workflow.' . $this->workflowId)) { + throw new \RuntimeException(Text::_('JERROR_ALERTNOAUTHOR')); + } + + // Get items to publish from the request. + $cid = (array) $this->input->get('cid', [], 'int'); + $data = ['publish' => 1, 'unpublish' => 0, 'archive' => 2, 'trash' => -2, 'report' => -3]; + $task = $this->getTask(); + $type = $this->input->getCmd('type'); + $value = ArrayHelper::getValue($data, $task, 0, 'int'); + + if (empty($type)) { + throw new \RuntimeException(Text::_($this->text_prefix . '_' . strtoupper($type) . '_NO_ITEM_SELECTED')); + } + + // Remove zero values resulting from input filter + $cid = array_filter($cid); + + if (empty($cid)) { + throw new \RuntimeException(Text::_($this->text_prefix . '_' . strtoupper($type) . '_NO_ITEM_SELECTED')); + } + // Get the model. + $model = $this->getModel($type); + + + $model->publish($cid, $value); + $errors = $model->getErrors(); + $ntext = null; + + if ($value === 1) { + if ($errors) { + echo new JsonResponse(Text::plural($this->text_prefix . '_' . strtoupper($type) . '_N_ITEMS_FAILED_PUBLISHING', \count($cid)), 'error', true); + } else { + $ntext = $this->text_prefix . '_' . strtoupper($type) . '_N_ITEMS_PUBLISHED'; + } + } elseif ($value === 0) { + $ntext = $this->text_prefix . '_' . strtoupper($type) . '_N_ITEMS_UNPUBLISHED'; + } elseif ($value === 2) { + $ntext = $this->text_prefix . '_' . strtoupper($type) . '_N_ITEMS_ARCHIVED'; + } else { + $ntext = $this->text_prefix . '_' . strtoupper($type) . '_N_ITEMS_TRASHED'; + } + + $response = [ + 'success' => true, + 'message' => Text::plural($ntext, \count($cid)), + ]; + + if (\count($cid)) { + echo new JsonResponse($response); + } + } catch (\Exception $e) { + $this->app->setHeader('status', 500); + echo new JsonResponse($e->getMessage(), 'error', true); + } + $this->app->close(); + } + + public function delete($type = 'stage') + { + try { + // Check for request forgeries + if (!$this->checkToken('post', false)) { + throw new \RuntimeException(Text::_('JINVALID_TOKEN')); + } + + // Get items to remove from the request. + $cid = (array) $this->input->get('cid', [], 'int'); + $type = $this->input->getCmd('type'); + $cid = array_filter($cid); + + if (empty($cid)) { + throw new \RuntimeException(Text::_($this->text_prefix . '_' . strtoupper($type) . '_NO_ITEM_SELECTED')); + } + // Get the model. + $model = $this->getModel($type); + + // Remove the items. + if ($model->delete($cid)) { + $response = [ + 'success' => true, + 'message' => Text::plural($this->text_prefix . '_' . strtoupper($type) . '_N_ITEMS_DELETED', \count($cid)), + ]; + } else { + throw new \RuntimeException(Text::plural($this->text_prefix . '_' . strtoupper($type) . '_N_ITEMS_FAILED_DELETING', \count($cid))); + } + + if (isset($response)) { + echo new JsonResponse($response); + } + + // Invoke the postDelete method to allow for the child class to access the model. + $this->postDeleteHook($model, $cid); + } catch (\Exception $e) { + $this->app->setHeader('status', 500); + echo new JsonResponse($e->getMessage(), 'error', true); + } + + $this->app->close(); + } +} diff --git a/administrator/components/com_workflow/src/Controller/StageController.php b/administrator/components/com_workflow/src/Controller/StageController.php index b113d32619212..ad3c423d46ab4 100644 --- a/administrator/components/com_workflow/src/Controller/StageController.php +++ b/administrator/components/com_workflow/src/Controller/StageController.php @@ -14,6 +14,7 @@ use Joomla\CMS\Language\Text; use Joomla\CMS\MVC\Controller\FormController; use Joomla\CMS\MVC\Factory\MVCFactoryInterface; +use Joomla\CMS\Router\Route; use Joomla\Input\Input; // phpcs:disable PSR1.Files.SideEffects @@ -174,4 +175,56 @@ protected function getRedirectToListAppend() return $append; } + + /** + * Method to save a record. + * + * @param string $key The name of the primary key of the URL variable. + * @param string $urlVar The name of the URL variable if different from the primary key (sometimes required to avoid router collisions). + * + * @return boolean True if successful, false otherwise. + * + * @since __DEPLOY_VERSION__ + */ + public function save($key = null, $urlVar = null) + { + $result = parent::save($key, $urlVar); + $input = $this->input; + $isModal = $input->get('layout') === 'modal' || $input->get('tmpl') === 'component'; + $task = $this->getTask(); + + if ($isModal && $result && $task === 'save') { + $id = $this->input->get('id'); + $return = 'index.php?option=' . $this->option . '&view=' . $this->view_item . $this->getRedirectToItemAppend($id) + . '&layout=modalreturn&from-task=save'; + + $this->setRedirect(Route::_($return, false)); + } + return $result; + } + + /** + * Method to cancel an edit. + * + * @param string $key The name of the primary key of the URL variable. + * + * @return boolean True if access level checks pass, false otherwise. + * + * @since __DEPLOY_VERSION__ + */ + public function cancel($key = null) + { + $result = parent::cancel($key); + $input = $this->input; + $isModal = $input->get('layout') === 'modal' || $input->get('tmpl') === 'component'; + + if ($isModal) { + $id = $this->input->get('id'); + $return = 'index.php?option=' . $this->option . '&view=' . $this->view_item . $this->getRedirectToItemAppend($id) + . '&layout=modalreturn&from-task=cancel'; + + $this->setRedirect(Route::_($return, false)); + } + return $result; + } } diff --git a/administrator/components/com_workflow/src/Controller/StagesController.php b/administrator/components/com_workflow/src/Controller/StagesController.php index bd5c25f62b935..9997fff777042 100644 --- a/administrator/components/com_workflow/src/Controller/StagesController.php +++ b/administrator/components/com_workflow/src/Controller/StagesController.php @@ -14,6 +14,7 @@ use Joomla\CMS\Language\Text; use Joomla\CMS\MVC\Controller\AdminController; use Joomla\CMS\MVC\Factory\MVCFactoryInterface; +use Joomla\CMS\Response\JsonResponse; use Joomla\CMS\Router\Route; use Joomla\Input\Input; use Joomla\Utilities\ArrayHelper; @@ -195,4 +196,45 @@ protected function getRedirectToListAppend() { return '&extension=' . $this->extension . ($this->section ? '.' . $this->section : '') . '&workflow_id=' . $this->workflowId; } + + /** + * Method to save stage positions + * + * @return boolean True if successful, false otherwise. + * + * @since __DEPLOY_VERSION__ + */ + public function updateStagesPosition() + { + try { + // Check for request forgeries + if (!$this->checkToken('post', false)) { + throw new \RuntimeException(Text::_('JINVALID_TOKEN')); + } + + // Check if the user has permission to publish items + if (!$this->app->getIdentity()->authorise('core.edit.state', $this->extension . '.workflow.' . $this->workflowId)) { + throw new \RuntimeException(Text::_('JERROR_ALERTNOAUTHOR')); + } + + $app = $this->app; + $input = $app->getInput(); + $workflowId = $input->getInt('id'); + $positions = $input->get('positions', [], 'array'); + $model = $this->getModel('Stages', 'Administrator'); + + $response = []; + $success = $model->updatePositions($positions, $workflowId); + + $response = [ + 'success' => true, + 'message' => Text::_('COM_WORKFLOW_POSITIONS_SAVED'), + ]; + echo new JsonResponse($response); + } catch (\Exception $e) { + $this->app->setHeader('status', 500); + echo new JsonResponse($e->getMessage(), 'error', true); + } + $this->app->close(); + } } diff --git a/administrator/components/com_workflow/src/Controller/TransitionController.php b/administrator/components/com_workflow/src/Controller/TransitionController.php index e78227550dab3..9a803720d580f 100644 --- a/administrator/components/com_workflow/src/Controller/TransitionController.php +++ b/administrator/components/com_workflow/src/Controller/TransitionController.php @@ -14,6 +14,7 @@ use Joomla\CMS\Language\Text; use Joomla\CMS\MVC\Controller\FormController; use Joomla\CMS\MVC\Factory\MVCFactoryInterface; +use Joomla\CMS\Router\Route; use Joomla\Input\Input; // phpcs:disable PSR1.Files.SideEffects @@ -175,4 +176,56 @@ protected function getRedirectToListAppend() return $append; } + + /** + * Method to save a request. + * + * @param string $key The name of the primary key of the URL variable. + * @param string $urlVar The name of the URL variable if different from the primary key (sometimes required to avoid router collisions). + * + * @return boolean True if access level checks pass, false otherwise. + * + * @since __DEPLOY_VERSION__ + */ + public function save($key = null, $urlVar = null) + { + $result = parent::save($key, $urlVar); + $input = $this->input; + $isModal = $input->get('layout') === 'modal' || $input->get('tmpl') === 'component'; + $task = $this->getTask(); + + if ($isModal && $result && $task === 'save') { + $id = $this->input->get('id'); + $return = 'index.php?option=' . $this->option . '&view=' . $this->view_item . $this->getRedirectToItemAppend($id) + . '&layout=modalreturn&from-task=save'; + + $this->setRedirect(Route::_($return, false)); + } + return $result; + } + + /** + * Method to cancel an edit. + * + * @param string $key The name of the primary key of the URL variable. + * + * @return boolean True if access level checks pass, false otherwise. + * + * @since __DEPLOY_VERSION__ + */ + public function cancel($key = null) + { + $result = parent::cancel($key); + $input = $this->input; + $isModal = $input->get('layout') === 'modal' || $input->get('tmpl') === 'component'; + + if ($isModal) { + $id = $this->input->get('id'); + $return = 'index.php?option=' . $this->option . '&view=' . $this->view_item . $this->getRedirectToItemAppend($id) + . '&layout=modalreturn&from-task=cancel'; + + $this->setRedirect(Route::_($return, false)); + } + return $result; + } } diff --git a/administrator/components/com_workflow/src/Dispatcher/Dispatcher.php b/administrator/components/com_workflow/src/Dispatcher/Dispatcher.php index 5633363111185..2cf3bd475c4fa 100644 --- a/administrator/components/com_workflow/src/Dispatcher/Dispatcher.php +++ b/administrator/components/com_workflow/src/Dispatcher/Dispatcher.php @@ -31,9 +31,16 @@ class Dispatcher extends ComponentDispatcher */ protected function checkAccess() { - $extension = $this->getApplication()->getInput()->getCmd('extension'); + $input = $this->app->getInput(); + $view = $input->getCmd('view'); + $layout = $input->getCmd('layout'); + $extension = $input->getCmd('extension'); + $parts = explode('.', $extension); - $parts = explode('.', $extension); + // Allow access to the 'graph' view for all users with access + if ($this->app->isClient('administrator') && $view === 'graph' && $layout === 'modal') { + return; + } // Check the user has permission to access this component if in the backend if ($this->app->isClient('administrator') && !$this->app->getIdentity()->authorise('core.manage.workflow', $parts[0])) { diff --git a/administrator/components/com_workflow/src/Model/GraphModel.php b/administrator/components/com_workflow/src/Model/GraphModel.php new file mode 100644 index 0000000000000..05ef57afaa733 --- /dev/null +++ b/administrator/components/com_workflow/src/Model/GraphModel.php @@ -0,0 +1,74 @@ + + * @license GNU General Public License version 2 or later; see LICENSE.txt + * @since __DEPLOY_VERSION__ + */ + +namespace Joomla\Component\Workflow\Administrator\Model; + +use Joomla\CMS\Factory; +use Joomla\CMS\MVC\Model\AdminModel; + +// phpcs:disable PSR1.Files.SideEffects +\defined('_JEXEC') or die; +// phpcs:enable PSR1.Files.SideEffects + +/** + * Model class for Graphical View of the workflow + * + * @since __DEPLOY_VERSION__ + */ +class GraphModel extends AdminModel +{ + /** + * Auto-populate the model state. + * + * Note. Calling getState in this method will result in recursion. + * + * @return void + * + * @since __DEPLOY_VERSION__ + */ + public function populateState() + { + parent::populateState(); + + $app = Factory::getApplication(); + $context = $this->option . '.' . $this->name; + $extension = $app->getUserStateFromRequest($context . '.filter.extension', 'extension', null, 'cmd'); + + $this->setState('filter.extension', $extension); + } + + /** + * Method to get the name of the model. + * + * @return string The name of the model. + * + * @since __DEPLOY_VERSION__ + */ + public function getName() + { + return 'workflow'; // TODO: change it to to handle dynamically + } + + /** + * Method to get the form. + * + * @param array $data An optional array of data for the form to interrogate. + * @param bool $loadData True if the form is to load its own data (default case), false if not. + * + * @return mixed A \Joomla\CMS\Form\Form object on success, false on failure. + * + * @since __DEPLOY_VERSION__ + */ + public function getForm($data = [], $loadData = true) + { + return false; + } +} diff --git a/administrator/components/com_workflow/src/Model/StagesModel.php b/administrator/components/com_workflow/src/Model/StagesModel.php index 869a8e27f1e48..43ac20bec9725 100644 --- a/administrator/components/com_workflow/src/Model/StagesModel.php +++ b/administrator/components/com_workflow/src/Model/StagesModel.php @@ -12,6 +12,7 @@ namespace Joomla\Component\Workflow\Administrator\Model; use Joomla\CMS\Factory; +use Joomla\CMS\Language\Text; use Joomla\CMS\MVC\Factory\MVCFactoryInterface; use Joomla\CMS\MVC\Model\ListModel; use Joomla\Database\ParameterType; @@ -140,9 +141,11 @@ public function getListQuery() $db->quoteName('s.ordering'), $db->quoteName('s.default'), $db->quoteName('s.published'), + $db->quoteName('s.workflow_id'), $db->quoteName('s.checked_out'), $db->quoteName('s.checked_out_time'), $db->quoteName('s.description'), + $db->quoteName('s.position'), $db->quoteName('uc.name', 'editor'), ] ) @@ -200,4 +203,70 @@ public function getWorkflow() return (object) $table->getProperties(); } + + /** + * Update positions for multiple workflow stages + * + * @param array $stages Array of stage data, each with id, x, y values + * + * + * @return boolean True on success, false on failure + * + * @since __DEPLOY_VERSION__ + */ + public function updatePositions($stagePositions, $workflowId) + { + if (empty($stagePositions) || !\is_array($stagePositions)) { + throw new \InvalidArgumentException(Text::_('COM_WORKFLOW_GRAPH_ERROR_INVALID_STAGE_POSITIONS')); + } + + // Convert the stage positions to the expected format + $stages = []; + foreach ($stagePositions as $id => $position) { + if (isset($position['x'], $position['y'])) { + $stages[] = [ + 'id' => (int) $id, + 'x' => (float) $position['x'], + 'y' => (float) $position['y'], + ]; + } else { + throw new \InvalidArgumentException(Text::sprintf('COM_WORKFLOW_GRAPH_ERROR_INVALID_POSITION_DATA', $id)); + } + } + + $db = $this->getDatabase(); + + try { + $db->transactionStart(); + + foreach ($stages as $stage) { + if (!isset($stage['id']) || !isset($stage['x']) || !isset($stage['y'])) { + throw new \InvalidArgumentException(Text::_('COM_WORKFLOW_GRAPH_ERROR_INVALID_POSITION_DATA', $stage['id'])); + } + + $id = (int) $stage['id']; + $x = (float) $stage['x']; + $y = (float) $stage['y']; + + // Format the position as a text which can later converted to json + $point = '{"x":' . $x . ', "y":' . $y . '}'; + + $query = $db->getQuery(true) + ->update($db->quoteName('#__workflow_stages')) + ->set($db->quoteName('position') . ' = :position') + ->where($db->quoteName('id') . ' = :id') + ->bind(':position', $point, ParameterType::STRING) + ->bind(':id', $id, ParameterType::INTEGER); + $db->setQuery($query); + $db->execute(); + } + + $db->transactionCommit(); + } catch (\Exception $e) { + $db->transactionRollback(); + throw new \RuntimeException(Text::_('COM_WORKFLOW_GRAPH_ERROR_FAILED_TO_UPDATE_STAGE_POSITIONS') . ': ' . $e->getMessage()); + } + + return true; + } } diff --git a/administrator/components/com_workflow/src/Model/TransitionsModel.php b/administrator/components/com_workflow/src/Model/TransitionsModel.php index 28abaeffd5e89..68c8349e55c0a 100644 --- a/administrator/components/com_workflow/src/Model/TransitionsModel.php +++ b/administrator/components/com_workflow/src/Model/TransitionsModel.php @@ -141,6 +141,7 @@ public function getListQuery() $db->quoteName('t.from_stage_id'), $db->quoteName('t.to_stage_id'), $db->quoteName('t.published'), + $db->quoteName('t.workflow_id'), $db->quoteName('t.checked_out'), $db->quoteName('t.checked_out_time'), $db->quoteName('t.ordering'), diff --git a/administrator/components/com_workflow/src/View/Graph/HtmlView.php b/administrator/components/com_workflow/src/View/Graph/HtmlView.php new file mode 100644 index 0000000000000..e5dfb1e3cb5f6 --- /dev/null +++ b/administrator/components/com_workflow/src/View/Graph/HtmlView.php @@ -0,0 +1,169 @@ + + * @license GNU General Public License version 2 or later; see LICENSE.txt + */ + +namespace Joomla\Component\Workflow\Administrator\View\Graph; + +use Joomla\CMS\Factory; +use Joomla\CMS\Helper\ContentHelper; +use Joomla\CMS\Language\Text; +use Joomla\CMS\Layout\FileLayout; +use Joomla\CMS\MVC\View\GenericDataException; +use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView; +use Joomla\CMS\Router\Route; +use Joomla\CMS\Toolbar\ToolbarHelper; +use Joomla\Component\Workflow\Administrator\Model\GraphModel; + +// phpcs:disable PSR1.Files.SideEffects +\defined('_JEXEC') or die; +// phpcs:enable PSR1.Files.SideEffects + +/** + * View class to display the entire workflow graph + * + * @since __DEPLOY_VERSION__ + */ +class HtmlView extends BaseHtmlView +{ + /** + * The model state + * + * @var object + * @since __DEPLOY_VERSION__ + */ + protected $state; + + /** + * Items array + * + * @var object + * @since __DEPLOY_VERSION__ + */ + protected $item; + + /** + * The name of current extension + * + * @var string + * @since __DEPLOY_VERSION__ + */ + protected $extension; + + /** + * The ID of current workflow + * + * @var integer + * @since __DEPLOY_VERSION__ + */ + protected $workflow; + + /** + * The section of the current extension + * + * @var string + * @since __DEPLOY_VERSION__ + */ + protected $section; + + /** + * Display the view + * + * @param string $tpl The name of the template file to parse; automatically searches through the template paths. + * + * @return void + * + * @since __DEPLOY_VERSION__ + */ + public function display($tpl = null) + { + /** @var GraphModel $model */ + $model = $this->getModel(); + + // Get the data + try { + $this->state = $model->getState(); + $this->item = $model->getItem(); + } catch (\Exception $e) { + throw new GenericDataException(Text::_('COM_WORKFLOW_GRAPH_ERROR_FETCHING_MODEL') . $e->getMessage(), 500, $e); + } + + $extension = $this->state->get('filter.extension'); + + $parts = explode('.', $extension); + + $this->extension = array_shift($parts); + + if (!empty($parts)) { + $this->section = array_shift($parts); + } + + // Prepare workflow data for frontend + $options = [ + 'apiBaseUrl' => Route::_('index.php?option=com_workflow'), + 'extension' => $this->escape($this->extension), + 'workflowId' => $this->item->id, + ]; + + + // Set the toolbar + $this->addToolbar(); + + // Inject workflow data as JS options for frontend + $this->getDocument()->addScriptOptions('com_workflow', $options); + + // Display the template + parent::display($tpl); + } + + /** + * Add the page title and toolbar. + * + * @return void + * + * @since __DEPLOY_VERSION__ + */ + protected function addToolbar() + { + Factory::getApplication()->getInput()->set('hidemainmenu', true); + + $user = $this->getCurrentUser(); + $userId = $user->id; + $toolbar = $this->getDocument()->getToolbar(); + + $canDo = ContentHelper::getActions($this->extension, 'workflow', $this->item->id); + + ToolbarHelper::title(Text::_('COM_WORKFLOW_GRAPH_WORKFLOWS_EDIT'), 'file-alt contact'); + + // Since it's an existing record, check the edit permission, or fall back to edit own if the owner. + $itemEditable = $canDo->get('core.edit') || ($canDo->get('core.edit.own') && $this->item->created_by == $userId); + $arrow = $this->getLanguage()->isRtl() ? 'arrow-right' : 'arrow-left'; + + $toolbar->link( + 'JTOOLBAR_BACK', + Route::_('index.php?option=com_workflow&view=workflows&extension=' . $this->escape($this->item->extension)) + ) + ->icon('icon-' . $arrow); + + + if ($itemEditable) { + $undoLayout = new FileLayout('toolbar.undo', JPATH_ADMINISTRATOR . '/components/com_workflow/layouts'); + $toolbar->customButton('undo') + ->html($undoLayout->render([])); + + $redoLayout = new FileLayout('toolbar.redo', JPATH_ADMINISTRATOR . '/components/com_workflow/layouts'); + $toolbar->customButton('redo') + ->html($redoLayout->render([])); + + $toolbar->help('Workflow'); + $shortcutsLayout = new FileLayout('toolbar.shortcuts', JPATH_ADMINISTRATOR . '/components/com_workflow/layouts'); + $toolbar->customButton('Shortcuts') + ->html($shortcutsLayout->render([])); + } + } +} diff --git a/administrator/components/com_workflow/tmpl/graph/default.php b/administrator/components/com_workflow/tmpl/graph/default.php new file mode 100644 index 0000000000000..90dbd3bba1cfc --- /dev/null +++ b/administrator/components/com_workflow/tmpl/graph/default.php @@ -0,0 +1,79 @@ + + * @license GNU General Public License version 2 or later; see LICENSE.txt + * @since __DEPLOY_VERSION__ + */ + +// phpcs:disable PSR1.Files.SideEffects +\defined('_JEXEC') or die; +// phpcs:enable PSR1.Files.SideEffects + +use Joomla\CMS\Language\Text; + +/** @var Joomla\CMS\WebAsset\WebAssetManager $wa */ +$wa = $this->getDocument()->getWebAssetManager(); +$wa->useScript('keepalive') + ->useScript('form.validate') + ->useScript('joomla.dialog') + ->useScript('joomla.dialog-autocreate'); +$wa->useScript('com_workflow.workflowgraph') + ->useStyle('com_workflow.workflowgraph'); + +// Populate the language +$this->loadTemplate('texts'); + +$shortcuts = [ + ['key' => 'Alt + N', 'description' => Text::_('COM_WORKFLOW_GRAPH_ADD_STAGE')], + ['key' => 'Alt + M', 'description' => Text::_('COM_WORKFLOW_GRAPH_ADD_TRANSITION')], + ['key' => 'Enter / SpaceBar', 'description' => Text::_('COM_WORKFLOW_GRAPH_SELECT_ITEM')], + ['key' => 'Select + E', 'description' => Text::_('COM_WORKFLOW_GRAPH_EDIT_ITEM')], + ['key' => 'Select + Delete', 'description' => Text::_('COM_WORKFLOW_GRAPH_TRASH_ITEM')], + ['key' => 'Select + Backspace', 'description' => Text::_('COM_WORKFLOW_GRAPH_TRASH_ITEM')], + ['key' => 'Select + Shift + Arrows', 'description' => Text::_('COM_WORKFLOW_GRAPH_MOVE_STAGE')], + ['key' => 'Ctrl/Cmd + Z', 'description' => Text::_('COM_WORKFLOW_GRAPH_UNDO')], + ['key' => 'Ctrl/Cmd + Y', 'description' => Text::_('COM_WORKFLOW_GRAPH_REDO')], + ['key' => 'Escape', 'description' => Text::_('COM_WORKFLOW_GRAPH_CLEAR_SELECTION')], + ['key' => '+ / =', 'description' => Text::_('COM_WORKFLOW_GRAPH_ZOOM_IN')], + ['key' => '- / _', 'description' => Text::_('COM_WORKFLOW_GRAPH_ZOOM_OUT')], + ['key' => 'F', 'description' => Text::_('COM_WORKFLOW_GRAPH_FIT_VIEW')], + ['key' => 'Tab', 'description' => Text::_('COM_WORKFLOW_GRAPH_FOCUS_TYPE_CHANGE')], + ['key' => 'Arrows', 'description' => Text::_('COM_WORKFLOW_GRAPH_NAVIGATE_NODES')], + ['key' => 'Shift + Arrows', 'description' => Text::_('COM_WORKFLOW_GRAPH_MOVE_VIEW')], +]; + +$col1 = array_slice($shortcuts, 0, ceil(count($shortcuts) / 2)); +$col2 = array_slice($shortcuts, ceil(count($shortcuts) / 2)); + +$shortcutsHtml = []; +$shortcutsHtml[] = '
'; +$shortcutsHtml[] = '
'; +$renderColumn = function ($column) { + $html = '
'; + $html .= ''; + $html .= '' . Text::_('COM_WORKFLOW_GRAPH_SHORTCUTS_TITLE') . ''; + foreach ($column as $item) { + $html .= ''; + $html .= ''; + $html .= ''; + $html .= ''; + } + $html .= '
' . htmlspecialchars($item['key']) . '' . Text::_($item['description']) . '
'; + return $html; +}; + +$shortcutsHtml[] = $renderColumn($col1); +$shortcutsHtml[] = $renderColumn($col2); + +$shortcutsHtml[] = '
'; +$shortcutsHtml[] = '
'; +?> + + +
diff --git a/administrator/components/com_workflow/tmpl/graph/default_texts.php b/administrator/components/com_workflow/tmpl/graph/default_texts.php new file mode 100644 index 0000000000000..64eab24ceee0a --- /dev/null +++ b/administrator/components/com_workflow/tmpl/graph/default_texts.php @@ -0,0 +1,107 @@ + + * @license GNU General Public License version 2 or later; see LICENSE.txt + * @since __DEPLOY_VERSION__ + */ + +use Joomla\CMS\Language\Text; + +// phpcs:disable PSR1.Files.SideEffects +\defined('_JEXEC') or die; +// phpcs:enable PSR1.Files.SideEffects + +// Populate the language +$translationStrings = [ + 'COM_WORKFLOW_GRAPH', + 'COM_WORKFLOW_GRAPH_ADD_STAGE', + 'COM_WORKFLOW_GRAPH_ADD_STAGE_DIALOG_OPENED', + 'COM_WORKFLOW_GRAPH_ADD_TRANSITION_DIALOG_OPENED', + 'COM_WORKFLOW_GRAPH_ADD_TRANSITION', + 'COM_WORKFLOW_GRAPH_API_NOT_SET', + 'COM_WORKFLOW_GRAPH_BACKGROUND', + 'COM_WORKFLOW_GRAPH_CANVAS_DESCRIPTION', + 'COM_WORKFLOW_GRAPH_CANVAS_LABEL', + 'COM_WORKFLOW_GRAPH_CANVAS_VIEW_CONTROLS', + 'COM_WORKFLOW_GRAPH_CLEAR_SELECTION', + 'COM_WORKFLOW_GRAPH_CLOSE_ACTIONS_MENU', + 'COM_WORKFLOW_GRAPH_CONTROLS', + 'COM_WORKFLOW_GRAPH_CREATING_TRANSITION', + 'COM_WORKFLOW_GRAPH_DEFAULT', + 'COM_WORKFLOW_GRAPH_DISABLED', + 'COM_WORKFLOW_GRAPH_EDIT_ITEM', + 'COM_WORKFLOW_GRAPH_EDIT_STAGE', + 'COM_WORKFLOW_GRAPH_EDIT_TRANSITION', + 'COM_WORKFLOW_GRAPH_ENABLED', + 'COM_WORKFLOW_GRAPH_ERROR_CSRF_TOKEN_NOT_SET', + 'COM_WORKFLOW_GRAPH_ERROR_EXTENSION_NOT_SET', + 'COM_WORKFLOW_GRAPH_ERROR_FAILED_TO_UPDATE_STAGE_POSITIONS', + 'COM_WORKFLOW_GRAPH_ERROR_FETCHING_MODEL', + 'COM_WORKFLOW_GRAPH_ERROR_INVALID_ID', + 'COM_WORKFLOW_GRAPH_ERROR_INVALID_POSITION_DATA', + 'COM_WORKFLOW_GRAPH_ERROR_INVALID_STAGE_POSITIONS', + 'COM_WORKFLOW_GRAPH_ERROR_STAGES_NOT_FOUND', + 'COM_WORKFLOW_GRAPH_ERROR_STAGE_DEFAULT_CANT_DELETED', + 'COM_WORKFLOW_GRAPH_ERROR_STAGE_HAS_TRANSITIONS', + 'COM_WORKFLOW_GRAPH_ERROR_UNKNOWN', + 'COM_WORKFLOW_GRAPH_ERROR_WORKFLOW_ID_NOT_SET', + 'COM_WORKFLOW_GRAPH_ERROR_WORKFLOW_NOT_FOUND', + 'COM_WORKFLOW_GRAPH_FIT_VIEW', + 'COM_WORKFLOW_GRAPH_FOCUS_TYPE_CHANGE', + 'COM_WORKFLOW_GRAPH_LOADING', + 'COM_WORKFLOW_GRAPH_MINIMAP_HIDE', + 'COM_WORKFLOW_GRAPH_MINIMAP_LABEL', + 'COM_WORKFLOW_GRAPH_MINIMAP_SHOW', + 'COM_WORKFLOW_GRAPH_MOVE_STAGE', + 'COM_WORKFLOW_GRAPH_MOVE_VIEW', + 'COM_WORKFLOW_GRAPH_NAVIGATE_NODES', + 'COM_WORKFLOW_GRAPH_OPEN_ACTIONS_MENU', + 'COM_WORKFLOW_GRAPH_REDO', + 'COM_WORKFLOW_GRAPH_SELECTION_CLEARED', + 'COM_WORKFLOW_GRAPH_SELECT_ITEM', + 'COM_WORKFLOW_GRAPH_SHORTCUTS', + 'COM_WORKFLOW_GRAPH_SHORTCUTS_TITLE', + 'COM_WORKFLOW_GRAPH_STAGE', + 'COM_WORKFLOW_GRAPH_STAGES', + 'COM_WORKFLOW_GRAPH_STAGE_ACTIONS', + 'COM_WORKFLOW_GRAPH_STAGE_COUNT', + 'COM_WORKFLOW_GRAPH_STAGE_DESCRIPTION', + 'COM_WORKFLOW_GRAPH_STAGE_POSITIONS_UPDATED', + 'COM_WORKFLOW_GRAPH_STAGE_REF', + 'COM_WORKFLOW_GRAPH_STAGE_SELECTED', + 'COM_WORKFLOW_GRAPH_STAGE_STATUS_PUBLISHED', + 'COM_WORKFLOW_GRAPH_STAGE_STATUS_UNPUBLISHED', + 'COM_WORKFLOW_GRAPH_STATUS', + 'COM_WORKFLOW_GRAPH_TRANSITION', + 'COM_WORKFLOW_GRAPH_TRANSITIONS', + 'COM_WORKFLOW_GRAPH_TRANSITION_ACTIONS', + 'COM_WORKFLOW_GRAPH_TRANSITION_COUNT', + 'COM_WORKFLOW_GRAPH_TRANSITION_DESCRIPTION', + 'COM_WORKFLOW_GRAPH_TRANSITION_PATH', + 'COM_WORKFLOW_GRAPH_TRANSITION_REF', + 'COM_WORKFLOW_GRAPH_TRANSITION_SELECTED', + 'COM_WORKFLOW_GRAPH_TRANSITION_STATUS_PUBLISHED', + 'COM_WORKFLOW_GRAPH_TRANSITION_STATUS_UNPUBLISHED', + 'COM_WORKFLOW_GRAPH_TRASH_ITEM', + 'COM_WORKFLOW_GRAPH_TRASH_STAGE', + 'COM_WORKFLOW_GRAPH_TRASH_STAGE_CONFIRM', + 'COM_WORKFLOW_GRAPH_TRASH_STAGE_FAILED', + 'COM_WORKFLOW_GRAPH_TRASH_TRANSITION', + 'COM_WORKFLOW_GRAPH_TRASH_TRANSITION_CONFIRM', + 'COM_WORKFLOW_GRAPH_TRASH_TRANSITION_FAILED', + 'COM_WORKFLOW_GRAPH_UNSAVED_CHANGES', + 'COM_WORKFLOW_GRAPH_UNDO', + 'COM_WORKFLOW_GRAPH_UPDATE_STAGE_POSITION_FAILED', + 'COM_WORKFLOW_GRAPH_UP_TO_DATE', + 'COM_WORKFLOW_GRAPH_WORKFLOWS_EDIT', + 'COM_WORKFLOW_GRAPH_ZOOM_IN', + 'COM_WORKFLOW_GRAPH_ZOOM_OUT', +]; + +foreach ($translationStrings as $string) { + Text::script($string); +} diff --git a/administrator/components/com_workflow/tmpl/stage/modal.php b/administrator/components/com_workflow/tmpl/stage/modal.php new file mode 100644 index 0000000000000..31d6136f4849b --- /dev/null +++ b/administrator/components/com_workflow/tmpl/stage/modal.php @@ -0,0 +1,21 @@ + + * @license GNU General Public License version 2 or later; see LICENSE.txt + */ + +defined('_JEXEC') or die; + +/** @var \Joomla\Component\Workflow\Administrator\View\Stage\HtmlView $this */ +?> +
+ getDocument()->getToolbar('toolbar')->render(); ?> +
+
+ setLayout('edit'); ?> + loadTemplate(); ?> +
diff --git a/administrator/components/com_workflow/tmpl/stage/modalreturn.php b/administrator/components/com_workflow/tmpl/stage/modalreturn.php new file mode 100644 index 0000000000000..d7263e793e86e --- /dev/null +++ b/administrator/components/com_workflow/tmpl/stage/modalreturn.php @@ -0,0 +1,12 @@ + + diff --git a/administrator/components/com_workflow/tmpl/transition/edit.php b/administrator/components/com_workflow/tmpl/transition/edit.php index 10b3e7f6db664..e6894ba30c9f1 100644 --- a/administrator/components/com_workflow/tmpl/transition/edit.php +++ b/administrator/components/com_workflow/tmpl/transition/edit.php @@ -44,6 +44,16 @@
+ getInput()->get('from_stage_id'); + $toStage = $app->getInput()->get('to_stage_id'); + if ($fromStage && $toStage) { + $this->form->setFieldAttribute('from_stage_id', 'default', $fromStage); + $this->form->setFieldAttribute('to_stage_id', 'default', $toStage); + } + } + ?> form->renderField('from_stage_id'); ?> form->renderField('to_stage_id'); ?> form->renderField('description'); ?> diff --git a/administrator/components/com_workflow/tmpl/transition/modal.php b/administrator/components/com_workflow/tmpl/transition/modal.php new file mode 100644 index 0000000000000..228b3437e8403 --- /dev/null +++ b/administrator/components/com_workflow/tmpl/transition/modal.php @@ -0,0 +1,23 @@ + + * @license GNU General Public License version 2 or later; see LICENSE.txt + */ + +// phpcs:disable PSR1.Files.SideEffects +\defined('_JEXEC') or die; +// phpcs:enable PSR1.Files.SideEffects + +/** @var \Joomla\Component\Workflow\Administrator\View\Transition\HtmlView $this */ +?> +
+ getDocument()->getToolbar('toolbar')->render(); ?> +
+
+ setLayout('edit'); ?> + loadTemplate(); ?> +
diff --git a/administrator/components/com_workflow/tmpl/transition/modalreturn.php b/administrator/components/com_workflow/tmpl/transition/modalreturn.php new file mode 100644 index 0000000000000..d7263e793e86e --- /dev/null +++ b/administrator/components/com_workflow/tmpl/transition/modalreturn.php @@ -0,0 +1,12 @@ + + diff --git a/administrator/components/com_workflow/tmpl/workflows/default.php b/administrator/components/com_workflow/tmpl/workflows/default.php index 1412a90d2c9fb..4aef59dca7797 100644 --- a/administrator/components/com_workflow/tmpl/workflows/default.php +++ b/administrator/components/com_workflow/tmpl/workflows/default.php @@ -98,7 +98,10 @@ - + + + + @@ -110,6 +113,7 @@ $states = Route::_('index.php?option=com_workflow&view=stages&workflow_id=' . $item->id . '&extension=' . $extension); $transitions = Route::_('index.php?option=com_workflow&view=transitions&workflow_id=' . $item->id . '&extension=' . $extension); $edit = Route::_('index.php?option=com_workflow&task=workflow.edit&id=' . $item->id . '&extension=' . $extension); + $graph = Route::_('index.php?option=com_workflow&view=graph&id=' . $item->id . '&extension=' . $extension); $canEdit = $user->authorise('core.edit', $extension . '.workflow.' . $item->id); $canCheckin = $user->authorise('core.admin', 'com_workflow') || $item->checked_out == $userId || is_null($item->checked_out); @@ -174,11 +178,20 @@
- + + + + + + + id; ?> + pagination->getListFooter(); ?> diff --git a/administrator/components/com_workflow/workflow.xml b/administrator/components/com_workflow/workflow.xml index eab5374c25ca8..b4f782472383f 100644 --- a/administrator/components/com_workflow/workflow.xml +++ b/administrator/components/com_workflow/workflow.xml @@ -15,6 +15,8 @@ workflow.xml forms + layouts + resources services src tmpl diff --git a/administrator/language/en-GB/com_workflow.ini b/administrator/language/en-GB/com_workflow.ini index 6b5d01f1fcfa2..a471b81e291cd 100644 --- a/administrator/language/en-GB/com_workflow.ini +++ b/administrator/language/en-GB/com_workflow.ini @@ -46,7 +46,9 @@ COM_WORKFLOW_N_ITEMS_TRASHED="%d workflows trashed." 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_PUBLISHED_LABEL="Status" +COM_WORKFLOW_REDO="Redo" COM_WORKFLOW_RULES_TAB="Permissions" COM_WORKFLOW_SELECT_FROM_STAGE="- Select Current Stage -" COM_WORKFLOW_SELECT_TO_STAGE="- Select Target Stage -" @@ -97,6 +99,7 @@ COM_WORKFLOW_TRANSITION_EDIT="Edit Transition" COM_WORKFLOW_TRANSITION_FORM_EDIT="Edit Transition" 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_USE_DEFAULT_WORKFLOW="Use default (%s)" COM_WORKFLOW_WORKFLOWS_ADD="Add Workflow" @@ -106,3 +109,125 @@ COM_WORKFLOW_WORKFLOWS_TABLE_CAPTION="Workflows" COM_WORKFLOW_WORKFLOW_NOTE="Note" JLIB_HTML_PUBLISH_ITEM="Enable" JLIB_HTML_UNPUBLISH_ITEM="Disable" + +; Workflow Graph Editor +COM_WORKFLOW_GRAPH="Graph" +COM_WORKFLOW_GRAPH_FULL="Workflow Graph" +COM_WORKFLOW_GRAPH_ADD_STAGE="Add Stage" +COM_WORKFLOW_GRAPH_ADD_STAGE_DIALOG_OPENED="Add stage dialog opened." +COM_WORKFLOW_GRAPH_ADD_TRANSITION_DIALOG_OPENED="Add transition dialog opened." +COM_WORKFLOW_GRAPH_ADD_TRANSITION="Add Transition" +COM_WORKFLOW_GRAPH_API_NOT_SET="Workflow Graph is not initialized correctly." +COM_WORKFLOW_GRAPH_BACKGROUND="Workflow Graph Background" +COM_WORKFLOW_GRAPH_CANVAS_DESCRIPTION="Keyboard shortcuts: Alt+N to add stage, Alt+M to add transition, F to fit view, E to edit selected item, Delete to remove selected item." +COM_WORKFLOW_GRAPH_CANVAS_LABEL="Workflow Graph Canvas" +COM_WORKFLOW_GRAPH_CANVAS_VIEW_CONTROLS="Workflow Graph Canvas View Controls" +COM_WORKFLOW_GRAPH_CLEAR_SELECTION="Clear Selection" +COM_WORKFLOW_GRAPH_CLOSE_ACTIONS_MENU="Close actions menu for %s" +COM_WORKFLOW_GRAPH_CONTROLS="Workflow Graph Controls" +COM_WORKFLOW_GRAPH_CREATING_TRANSITION="Creating new transition from stage %s to %s." +COM_WORKFLOW_GRAPH_DEFAULT="Default" +COM_WORKFLOW_GRAPH_DISABLED="Disabled" +COM_WORKFLOW_GRAPH_EDIT_ITEM="Edit Item" +COM_WORKFLOW_GRAPH_EDIT_STAGE="Edit stage" +COM_WORKFLOW_GRAPH_EDIT_TRANSITION="Edit transition" +COM_WORKFLOW_GRAPH_ENABLED="Enabled" +COM_WORKFLOW_GRAPH_ERROR_API_RETURNED_ERROR="API returned an error." +COM_WORKFLOW_GRAPH_ERROR_CSRF_TOKEN_NOT_SET="Invalid token. Please try again." +COM_WORKFLOW_GRAPH_ERROR_EXTENSION_NOT_SET="Workflow extension is not set." +COM_WORKFLOW_GRAPH_ERROR_FAILED_TO_UPDATE_STAGE_POSITIONS="Failed to update stage positions." +COM_WORKFLOW_GRAPH_ERROR_FETCHING_MODEL="Error loading workflow." +COM_WORKFLOW_GRAPH_ERROR_INVALID_ID="The workflow is not set correctly." +COM_WORKFLOW_GRAPH_ERROR_INVALID_POSITION_DATA="Invalid position data for %s." +COM_WORKFLOW_GRAPH_ERROR_INVALID_STAGE_POSITIONS="Invalid stage positions." +COM_WORKFLOW_GRAPH_ERROR_NOT_AUTHENTICATED="You are not authenticated." +COM_WORKFLOW_GRAPH_ERROR_NO_PERMISSION="You do not have permission to access the workflows." +COM_WORKFLOW_GRAPH_ERROR_REQUEST_FAILED="Request failed with status %s" +COM_WORKFLOW_GRAPH_ERROR_STAGES_NOT_FOUND="Stages not found." +COM_WORKFLOW_GRAPH_ERROR_STAGE_DEFAULT_CANT_DELETED="The default stage cannot be deleted." +COM_WORKFLOW_GRAPH_ERROR_STAGE_HAS_TRANSITIONS="This stage has transitions and cannot be deleted." +COM_WORKFLOW_GRAPH_ERROR_UNKNOWN="An unknown error occurred." +COM_WORKFLOW_GRAPH_ERROR_WORKFLOW_ID_NOT_SET="Workflow ID is not set." +COM_WORKFLOW_GRAPH_ERROR_WORKFLOW_NOT_FOUND="Workflow not found." +COM_WORKFLOW_GRAPH_FIT_VIEW="Fit View" +COM_WORKFLOW_GRAPH_FOCUS_TYPE_CHANGE="Change Focus Type" +COM_WORKFLOW_GRAPH_LOADING="Loading..." +COM_WORKFLOW_GRAPH_MINIMAP_HIDE="Hide Minimap" +COM_WORKFLOW_GRAPH_MINIMAP_LABEL="Workflow Graph Minimap" +COM_WORKFLOW_GRAPH_MINIMAP_SHOW="Show Minimap" +COM_WORKFLOW_GRAPH_MOVE_STAGE="Move Stage" +COM_WORKFLOW_GRAPH_MOVE_VIEW="Move View" +COM_WORKFLOW_GRAPH_NAVIGATE_NODES="Navigate Nodes" +COM_WORKFLOW_GRAPH_N_ITEMS_ARCHIVED="%d items archived." +COM_WORKFLOW_GRAPH_N_ITEMS_ARCHIVED_1="Item archived." +COM_WORKFLOW_GRAPH_N_ITEMS_FAILED_PUBLISHING="%d items failed to publish." +COM_WORKFLOW_GRAPH_N_ITEMS_FAILED_PUBLISHING_1="Item failed to publish." +COM_WORKFLOW_GRAPH_N_ITEMS_PUBLISHED="%d items published." +COM_WORKFLOW_GRAPH_N_ITEMS_PUBLISHED_1="Item published." +COM_WORKFLOW_GRAPH_N_ITEMS_UNPUBLISHED="%d items unpublished." +COM_WORKFLOW_GRAPH_N_ITEMS_UNPUBLISHED_1="Item unpublished." +COM_WORKFLOW_GRAPH_OPEN_ACTIONS_MENU="Open actions menu for %s" +COM_WORKFLOW_GRAPH_REDO="Redo" +COM_WORKFLOW_GRAPH_SELECTION_CLEARED="Selection cleared." +COM_WORKFLOW_GRAPH_SELECT_ITEM="Select Item" +COM_WORKFLOW_GRAPH_SHORTCUTS="Shortcuts" +COM_WORKFLOW_GRAPH_SHORTCUTS_TITLE="Keyboard Shortcuts" +COM_WORKFLOW_GRAPH_STAGE="Stage" +COM_WORKFLOW_GRAPH_STAGES="Stages" +COM_WORKFLOW_GRAPH_STAGE_ACTIONS="Actions for stage %s." +COM_WORKFLOW_GRAPH_STAGE_COUNT="Number of Stages" +COM_WORKFLOW_GRAPH_STAGE_DESCRIPTION="Description of stage %s." +COM_WORKFLOW_GRAPH_STAGE_NO_ITEM_SELECTED="No stage selected." +COM_WORKFLOW_GRAPH_STAGE_N_ITEMS_CHECKED_IN="%d stages checked in." +COM_WORKFLOW_GRAPH_STAGE_N_ITEMS_CHECKED_IN_1="Stage checked in." +COM_WORKFLOW_GRAPH_STAGE_N_ITEMS_DELETED="%d stages deleted." +COM_WORKFLOW_GRAPH_STAGE_N_ITEMS_DELETED_1="Stage deleted." +COM_WORKFLOW_GRAPH_STAGE_N_ITEMS_FAILED_DELETING="%d stages failed to delete." +COM_WORKFLOW_GRAPH_STAGE_N_ITEMS_FAILED_DELETING_1="Stage failed to delete." +COM_WORKFLOW_GRAPH_STAGE_N_ITEMS_TRASHED="%d stages trashed." +COM_WORKFLOW_GRAPH_STAGE_N_ITEMS_TRASHED_1="Stage trashed." +COM_WORKFLOW_GRAPH_STAGE_N_ITEMS_UNPUBLISHED="%d stages unpublished." +COM_WORKFLOW_GRAPH_STAGE_N_ITEMS_UNPUBLISHED_1="Stage unpublished." +COM_WORKFLOW_GRAPH_STAGE_POSITIONS_UPDATED="Stage positions updated." +COM_WORKFLOW_GRAPH_STAGE_REF="Stage: %s" +COM_WORKFLOW_GRAPH_STAGE_SELECTED="Stage selected: %s" +COM_WORKFLOW_GRAPH_STAGE_STATUS_PUBLISHED="Status of stage %s: Published." +COM_WORKFLOW_GRAPH_STAGE_STATUS_UNPUBLISHED="Status of stage %s: Unpublished." +COM_WORKFLOW_GRAPH_STATUS="Status" +COM_WORKFLOW_GRAPH_TRANSITION="Transition" +COM_WORKFLOW_GRAPH_TRANSITIONS="Transitions" +COM_WORKFLOW_GRAPH_TRANSITION_ACTIONS="Actions for transition %s." +COM_WORKFLOW_GRAPH_TRANSITION_COUNT="Number of Transitions" +COM_WORKFLOW_GRAPH_TRANSITION_DESCRIPTION="Description of transition %s." +COM_WORKFLOW_GRAPH_TRANSITION_NO_ITEM_SELECTED="No transition selected." +COM_WORKFLOW_GRAPH_TRANSITION_N_ITEMS_CHECKED_IN="%d transitions checked in." +COM_WORKFLOW_GRAPH_TRANSITION_N_ITEMS_CHECKED_IN_1="Transition checked in." +COM_WORKFLOW_GRAPH_TRANSITION_N_ITEMS_DELETED="%d transitions deleted." +COM_WORKFLOW_GRAPH_TRANSITION_N_ITEMS_DELETED_1="Transition deleted." +COM_WORKFLOW_GRAPH_TRANSITION_N_ITEMS_FAILED_DELETING="%d transitions failed to delete." +COM_WORKFLOW_GRAPH_TRANSITION_N_ITEMS_FAILED_DELETING_1="Transition failed to delete." +COM_WORKFLOW_GRAPH_TRANSITION_N_ITEMS_PUBLISHED="%d transitions published." +COM_WORKFLOW_GRAPH_TRANSITION_N_ITEMS_PUBLISHED_1="Transition published." +COM_WORKFLOW_GRAPH_TRANSITION_N_ITEMS_TRASHED="%d transitions trashed." +COM_WORKFLOW_GRAPH_TRANSITION_N_ITEMS_TRASHED_1="Transition trashed." +COM_WORKFLOW_GRAPH_TRANSITION_N_ITEMS_UNPUBLISHED="%d transitions unpublished." +COM_WORKFLOW_GRAPH_TRANSITION_N_ITEMS_UNPUBLISHED_1="Transition unpublished." +COM_WORKFLOW_GRAPH_TRANSITION_PATH="Transition path for %s." +COM_WORKFLOW_GRAPH_TRANSITION_REF="Transition: %s from stage %s to %s." +COM_WORKFLOW_GRAPH_TRANSITION_SELECTED="Transition selected: %s" +COM_WORKFLOW_GRAPH_TRANSITION_STATUS_PUBLISHED="Status of transition %s: Published." +COM_WORKFLOW_GRAPH_TRANSITION_STATUS_UNPUBLISHED="Status of transition %s: Unpublished." +COM_WORKFLOW_GRAPH_TRASH_ITEM="Trash Item" +COM_WORKFLOW_GRAPH_TRASH_STAGE="Trash stage" +COM_WORKFLOW_GRAPH_TRASH_STAGE_CONFIRM="Are you sure you want to trash the stage %s?" +COM_WORKFLOW_GRAPH_TRASH_STAGE_FAILED="Failed to trash stage." +COM_WORKFLOW_GRAPH_TRASH_TRANSITION="Trash transition" +COM_WORKFLOW_GRAPH_TRASH_TRANSITION_CONFIRM="Are you sure you want to trash the transition %s?" +COM_WORKFLOW_GRAPH_TRASH_TRANSITION_FAILED="Failed to trash transition." +COM_WORKFLOW_GRAPH_UNSAVED_CHANGES="You have unsaved changes." +COM_WORKFLOW_GRAPH_UNDO="Undo" +COM_WORKFLOW_GRAPH_UPDATE_STAGE_POSITION_FAILED="Failed to update stage position." +COM_WORKFLOW_GRAPH_UP_TO_DATE="All changes saved." +COM_WORKFLOW_GRAPH_WORKFLOWS_EDIT="Workflow Editor" +COM_WORKFLOW_GRAPH_ZOOM_IN="Zoom In" +COM_WORKFLOW_GRAPH_ZOOM_OUT="Zoom Out" diff --git a/build/build-modules-js/javascript/build-com_workflow-js.mjs b/build/build-modules-js/javascript/build-com_workflow-js.mjs new file mode 100644 index 0000000000000..cf6c4b8fc53b1 --- /dev/null +++ b/build/build-modules-js/javascript/build-com_workflow-js.mjs @@ -0,0 +1,170 @@ +import { writeFile, copyFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; + +import { rollup, watch } from 'rollup'; +import { nodeResolve } from '@rollup/plugin-node-resolve'; +import replace from '@rollup/plugin-replace'; +import { babel } from '@rollup/plugin-babel'; +import VuePlugin from 'rollup-plugin-vue'; +import commonjs from '@rollup/plugin-commonjs'; +import dotenv from 'dotenv'; + +import { minifyCode } from './minify.mjs'; + +dotenv.config(); + +const inputJS = 'administrator/components/com_workflow/resources/scripts/workflowgraph.es6.js'; +const isProduction = process.env.NODE_ENV !== 'DEVELOPMENT'; + +export const workflowGraph = async () => { + // eslint-disable-next-line no-console + console.log('Building Workflow Graph ES Module...'); + + const bundle = await rollup({ + input: resolve(inputJS), + external: ['joomla.dialog'], + plugins: [ + VuePlugin({ + target: 'browser', + css: false, + compileTemplate: true, + template: { + isProduction, + }, + }), + nodeResolve(), + commonjs(), + replace({ + 'process.env.NODE_ENV': JSON.stringify((process.env.NODE_ENV && process.env.NODE_ENV.toLocaleLowerCase()) || 'production'), + __VUE_OPTIONS_API__: true, + __VUE_PROD_DEVTOOLS__: !isProduction, + preventAssignment: true, + }), + babel({ + exclude: 'node_modules/core-js/**', + babelHelpers: 'bundled', + babelrc: false, + presets: [ + [ + '@babel/preset-env', + { + targets: { + browsers: [ + '> 1%', + 'not op_mini all', + /** https://caniuse.com/es6-module */ + 'chrome >= 61', + 'safari >= 11', + 'edge >= 16', + 'Firefox >= 60', + ], + }, + loose: true, + bugfixes: false, + ignoreBrowserslistConfig: true, + }, + ], + ], + }), + ], + }); + + bundle + .write({ + format: 'es', + sourcemap: !isProduction ? 'inline' : false, + file: 'media/com_workflow/js/workflow-graph.js', + }) + .then((value) => (isProduction ? minifyCode(value.output[0].code) : value.output[0])) + .then((content) => { + if (isProduction) { + // eslint-disable-next-line no-console + console.log('✅ ES2017 Workflow Graph ready'); + return writeFile(resolve('media/com_workflow/js/workflow-graph.min.js'), content.code, { encoding: 'utf8', mode: 0o644 }); + } + // eslint-disable-next-line no-console + console.log('✅ ES2017 Workflow Graph ready'); + return copyFile(resolve('media/com_workflow/js/workflow-graph.js'), resolve('media/com_workflow/js/workflow-graph.min.js')); + }) + .catch((error) => { + // eslint-disable-next-line no-console + console.error(error); + }); + + // closes the bundle + await bundle.close(); +}; + +export const watchWorkflowGraph = async () => { + // eslint-disable-next-line no-console + console.log('Watching Workflow Graph js+vue files...'); + // eslint-disable-next-line no-console + console.log('========='); + const watcher = watch({ + input: resolve(inputJS), + plugins: [ + VuePlugin({ + target: 'browser', + css: false, + compileTemplate: true, + template: { + isProduction: true, + }, + }), + nodeResolve(), + commonjs(), + replace({ + 'process.env.NODE_ENV': JSON.stringify('development'), + __VUE_OPTIONS_API__: true, + __VUE_PROD_DEVTOOLS__: true, + preventAssignment: true, + }), + babel({ + exclude: 'node_modules/core-js/**', + babelHelpers: 'bundled', + babelrc: false, + presets: [ + [ + '@babel/preset-env', + { + targets: { + browsers: [ + '> 1%', + 'not op_mini all', + /** https://caniuse.com/es6-module */ + 'chrome 61', + 'safari 11', + 'edge 16', + 'Firefox 60', + ], + }, + loose: true, + bugfixes: false, + ignoreBrowserslistConfig: true, + }, + ], + ], + }), + ], + output: [ + { + format: 'es', + sourcemap: 'inline', + file: 'media/com_workflow/js/workflow-graph.js', + }, + { + format: 'es', + sourcemap: 'inline', + file: 'media/com_workflow/js/workflow-graph.min.js', + }, + ], + }); + + watcher.on('event', ({ code, result, error }) => { + if (result) result.close(); + // eslint-disable-next-line no-console + if (error) console.log(error); + // eslint-disable-next-line no-console + if (code === 'BUNDLE_END') console.log('Files updated ✅'); + }); +}; diff --git a/build/build.mjs b/build/build.mjs index a82f28620594d..37185ff1d58e0 100644 --- a/build/build.mjs +++ b/build/build.mjs @@ -32,6 +32,7 @@ import { cleanVendors } from './build-modules-js/init/cleanup-media.mjs'; import { recreateMediaFolder } from './build-modules-js/init/recreate-media.mjs'; import { watching } from './build-modules-js/watch.mjs'; import { mediaManager, watchMediaManager } from './build-modules-js/javascript/build-com_media-js.mjs'; +import { workflowGraph, watchWorkflowGraph } from './build-modules-js/javascript/build-com_workflow-js.mjs'; import { compressFiles } from './build-modules-js/compress.mjs'; import { versioning } from './build-modules-js/versioning.mjs'; import { Timer } from './build-modules-js/utils/timer.mjs'; @@ -100,6 +101,11 @@ Program.allowUnknownOption() '--watch-com-media', 'Watch and Compile the Media Manager client side App.', ) + .option('--com-workflow', 'Compile the Workflow Graph client side App.') + .option( + '--watch-com-workflow', + 'Watch and Compile the Workflow Graph client side App.', + ) .option('--gzip', 'Compress all the minified stylesheets and scripts.') .option('--prepare', 'Run all the needed tasks to initialise the repo') .option( @@ -176,6 +182,17 @@ if (cliOptions.watchComMedia) { watchMediaManager(true); } +// Compile the Workflow Graph +if (cliOptions.comWorkflow) { + // false indicates "no watch" + workflowGraph(false); +} + +// Watch & Compile the Workflow Graph +if (cliOptions.watchComWorkflow) { + watchWorkflowGraph(true); +} + // Update the .js/.css versions if (cliOptions.versioning) { versioning().catch((err) => handleError(err, 1)); @@ -195,6 +212,7 @@ if (cliOptions.prepare) { .then(() => cssVersioningVendor()) .then(() => scripts(options, Program.args[0])) .then(() => mediaManager()) + .then(() => workflowGraph()) .then(() => bootstrapJs()) .then(() => compileCodemirror()) .then(() => bench.stop('Build')) diff --git a/build/media_source/com_workflow/joomla.asset.json b/build/media_source/com_workflow/joomla.asset.json index ed1cbf269248f..99b29813026a9 100644 --- a/build/media_source/com_workflow/joomla.asset.json +++ b/build/media_source/com_workflow/joomla.asset.json @@ -15,6 +15,38 @@ "attributes": { "type": "module" } + }, + { + "name": "com_workflow.workflowgraph", + "type": "style", + "uri": "com_workflow/workflow-graph.min.css" + }, + { + "name": "com_workflow.workflowgraph", + "type": "script", + "uri": "com_workflow/workflow-graph.min.js", + "dependencies": [ + "core" + ], + "attributes": { + "type": "module" + } + }, + { + "name" : "com_workflow.workflowgraphclient", + "type": "style", + "uri": "com_workflow/workflow-graph-client.min.css" + }, + { + "name": "com_workflow.workflowgraphclient", + "type": "script", + "uri": "com_workflow/workflow-graph-client.min.js", + "dependencies": [ + "core" + ], + "attributes": { + "type": "module" + } } ] } diff --git a/build/media_source/com_workflow/js/workflow-graph-client.es6.js b/build/media_source/com_workflow/js/workflow-graph-client.es6.js new file mode 100644 index 0000000000000..52317e8e6fa71 --- /dev/null +++ b/build/media_source/com_workflow/js/workflow-graph-client.es6.js @@ -0,0 +1,586 @@ +/** + * @copyright (C) 2025 Open Source Matters + * @license GNU GPL v2 or later; see LICENSE.txt + */ + +Joomla = window.Joomla || {}; +(() => { + // --- Constants --- + const STAGE_WIDTH = 200; + const STAGE_HEIGHT = 100; + const MIN_ZOOM = 0.5; + const MAX_ZOOM = 2; + const ZOOM_SENSITIVITY = 0.1; + + // This central state object holds all data needed for rendering. + const state = { + workflow: null, + stages: [], + transitions: [], + scale: 1, + panX: 0, + panY: 0, + isDraggingStage: false, + highlightedEdge: null, + }; + + // --- API Communication & Error Handling --- + const translate = (string) => { + return Joomla.Text._(string); + }; + + const sprintf = (string, ...args) => { + const base = Joomla.Text._(string, string); + let i = 0; + return base.replace(/%((%)|s|d)/g, (m) => { + let val = args[i]; + + if (m === '%d') { + val = parseFloat(val); + if (Number.isNaN(val)) { + val = 0; + } + } + i += 1; + return val; + }); + }; + + function showMessageInModal(message, type) { + const messages = {}; + messages[type] = [Joomla.Text._(message)]; + Joomla.renderMessages(messages); + if (type === 'error') { + const dialog = document.querySelector('joomla-dialog'); + if (dialog) { + dialog.close(); + } + } + } + + async function makeRequest(url) { + try { + const paths = Joomla.getOptions('system.paths'); + const baseUri = `${paths ? `${paths.rootFull}/administrator/index.php` : window.location.pathname}`; + const uri = `${baseUri}?option=com_workflow&extension=com_content&layout=modal&view=graph${url}`; + const response = await fetch(uri, { credentials: 'same-origin' }); + + if (!response.ok) { + let message = 'COM_WORKFLOW_GRAPH_ERROR_UNKNOWN'; + if (response.status === 401) message = 'COM_WORKFLOW_GRAPH_ERROR_NOT_AUTHENTICATED'; + else if (response.status >= 403) message = 'COM_WORKFLOW_GRAPH_ERROR_NO_PERMISSION'; + else message = sprintf('COM_WORKFLOW_GRAPH_ERROR_REQUEST_FAILED', response.status); + throw new Error(message); + } + const responseData = await response.json(); + if (responseData.success === false) { + throw new Error(responseData.message || 'COM_WORKFLOW_GRAPH_ERROR_API_RETURNED_ERROR'); + } + return responseData; + } catch (err) { + showMessageInModal(err.message, "error"); + return false; + } + } + + // --- Layout Calculation --- + function calculateAutoLayout(stages) { + const withNoPosition = stages.filter(stage => !stage.position || isNaN(stage.position.x) || isNaN(stage.position.y)); + if (withNoPosition.length === 0) return stages; + + // special node + const fromAnyStage = stages.find(s => s.id === 'From Any'); + const transitionStages = stages.filter(s => s.id !== 'From Any'); + + const verticalSpacing = 80; + const horizontalOffset = 350; + const startY = 50; + + transitionStages.forEach((stage, index) => { + if (withNoPosition.some(s => s.id === stage.id)) { + stage.position = { + x: horizontalOffset, + y: startY + (index * verticalSpacing) + }; + } + }); + + if (fromAnyStage && withNoPosition.some(s => s.id === fromAnyStage.id)) { + if (!fromAnyStage.position || isNaN(fromAnyStage.position.x) || isNaN(fromAnyStage.position.y)) { + fromAnyStage.position = { + x: 600, + y: -200 + }; + } + } + + return stages; + } + + function getSmoothStepPath(sourceX, sourceY, targetX, targetY, sourcePosition, targetPosition, centerX, centerY) { + let path = `M ${sourceX},${sourceY}`; + const midX = centerX || (sourceX + targetX) / 2; + const midY = centerY || (sourceY + targetY) / 2; + const branchDistance = 30; + + if (sourcePosition === 'left' || sourcePosition === 'right') { + const branchX = sourcePosition === 'left' ? sourceX - branchDistance : sourceX + branchDistance; + path += ` L ${branchX},${sourceY} L ${branchX},${midY} L ${midX},${midY}`; + const mergeX = targetPosition === 'left' ? targetX - branchDistance : targetX + branchDistance; + path += ` L ${mergeX},${midY} L ${mergeX},${targetY} L ${targetX},${targetY}`; + } else if (sourcePosition === 'top' || sourcePosition === 'bottom') { + const branchY = sourcePosition === 'top' ? sourceY - branchDistance : sourceY + branchDistance; + path += ` L ${sourceX},${branchY} L ${midX},${branchY} L ${midX},${midY}`; + const mergeY = targetPosition === 'top' ? targetY - branchDistance : targetY + branchDistance; + path += ` L ${midX},${mergeY} L ${midX},${targetY} L ${targetX},${targetY}`; + } else { + const branchX = sourceX + (targetX > sourceX ? branchDistance : -branchDistance); + path += ` L ${branchX},${sourceY} L ${branchX},${midY} L ${midX},${midY}`; + const mergeX = targetX + (targetX > sourceX ? -branchDistance : branchDistance); + path += ` L ${mergeX},${midY} L ${mergeX},${targetY} L ${targetX},${targetY}`; + } + return [path, midX, midY]; + } + + function generateEdges(transitions, stages) { + const stageMap = new Map(stages.map(s => [s.id, s])); + const edgeGroups = {}; + + transitions.forEach(tr => { + const fromId = tr.from_stage_id === -1 ? 'From Any' : tr.from_stage_id; + const toId = tr.to_stage_id; + const key = `${fromId}->${toId}`; + if (!edgeGroups[key]) edgeGroups[key] = []; + edgeGroups[key].push(tr); + }); + + const edgePairs = new Set(transitions.map(tr => `${tr.from_stage_id}->${tr.to_stage_id}`)); + return transitions.flatMap(tr => { + const fromId = tr.from_stage_id === -1 ? 'From Any' : tr.from_stage_id; + const toId = tr.to_stage_id; + const fromStage = stageMap.get(fromId); + const toStage = stageMap.get(toId); + + if (!fromStage?.position || !toStage?.position) return []; + + // Calculate source and target positions for step-wise edges + let sourceX = fromStage.position.x + STAGE_WIDTH / 2; + let sourceY = fromStage.position.y + STAGE_HEIGHT / 2; + let targetX = toStage.position.x + STAGE_WIDTH / 2; + let targetY = toStage.position.y + STAGE_HEIGHT / 2; + let sourcePosition = 'center', targetPosition = 'center'; + + // If target is to the left/right, connect to left/right edge + if (Math.abs(toStage.position.x - fromStage.position.x) > Math.abs(toStage.position.y - fromStage.position.y)) { + if (toStage.position.x < fromStage.position.x) { + sourceX = fromStage.position.x; + targetX = toStage.position.x + STAGE_WIDTH; + sourcePosition = 'left'; + targetPosition = 'right'; + } else { + sourceX = fromStage.position.x + STAGE_WIDTH; + targetX = toStage.position.x; + sourcePosition = 'right'; + targetPosition = 'left'; + } + } else { + // If target is above/below, connect to top/bottom edge + if (toStage.position.y < fromStage.position.y) { + sourceY = fromStage.position.y; + targetY = toStage.position.y + STAGE_HEIGHT; + sourcePosition = 'top'; + targetPosition = 'bottom'; + } else { + sourceY = fromStage.position.y + STAGE_HEIGHT; + targetY = toStage.position.y; + sourcePosition = 'bottom'; + targetPosition = 'top'; + } + } + + const groupKey = `${fromId}->${toStage.id}`; + const group = edgeGroups[groupKey] || [tr]; + const transitionIndex = group.findIndex(t => t.id === tr.id); + const offsetIndex = transitionIndex - (group.length - 1) / 2; + + // Calculate perpendicular offset + const dx = targetX - sourceX; + const dy = targetY - sourceY; + const length = Math.sqrt(dx * dx + dy * dy) || 1; + const perpX = -dy / length; + const perpY = dx / length; + const curveMagnitude = 40 * offsetIndex; + const centerX = (sourceX + targetX) / 2 + perpX * curveMagnitude; + const centerY = (sourceY + targetY) / 2 + perpY * curveMagnitude; + + // Generate sharp step path + const [pathData, labelX, labelY] = getSmoothStepPath( + sourceX, + sourceY, + targetX, + targetY, + sourcePosition, + targetPosition, + centerX, + centerY, + ); + + // Determine arrowhead direction based on final segment + let arrowDirection = 'right'; + if (Math.abs(targetX - centerX) > Math.abs(targetY - centerY)) { + arrowDirection = (targetX > centerX) ? 'right' : 'left'; + } else { + arrowDirection = (targetY > centerY) ? 'down' : 'up'; + } + + // Mark as bidirectional if the reverse exists + const isBidirectional = edgePairs.has(`${toId}->${tr.from_stage_id}`); + + return { + id: `transition-${tr.id}`, + pathData, + label: tr.title, + labelPosition: { x: labelX, y: labelY }, + fromId, + toId, + isBidirectional, + arrowDirection + }; + }).filter(Boolean); + } + + function renderGraph(modal) { + const graph = modal.querySelector('#graph'); + const stageContainer = modal.querySelector('#stages'); + const svg = modal.querySelector('#connections'); + if (!graph || !stageContainer || !svg) return; + + // Remove all existing stage elements before rendering to avoid duplicates + stageContainer.querySelectorAll('[id^="stage-"]').forEach(el => el.remove()); + state.stages.forEach(stage => { + let stageEl = document.createElement('div'); + stageEl.id = `stage-${stage.id}`; + stageEl.addEventListener('mousedown', e => { if (e.button === 0) handleNodeDrag(e, stage); }); + const isVirtual = stage.id === 'From Any'; + stageEl.className = `stage ${stage.default ? 'default' : ''} ${isVirtual ? 'virtual' : ''}`; + stageEl.style.left = `${stage.position.x}px`; + stageEl.style.top = `${stage.position.y}px`; + let newHTML; + if (isVirtual) { + newHTML = ` +
${stage.title}
+
+
+
+
+
+
+ `; + } else { + newHTML = ` +
${stage.title}
+
+ ${stage.description ? `
${stage?.description}
` : ''} +
+
+ ${typeof stage.published !== 'undefined' ? `
${stage.published === 1 ? translate('COM_WORKFLOW_GRAPH_ENABLED') : translate('COM_WORKFLOW_GRAPH_DISABLED')}
` : ''} + ${stage.default ? `
${translate('COM_WORKFLOW_GRAPH_DEFAULT')}
` : ''} +
`; + } + stageEl.innerHTML = newHTML; + stageContainer.appendChild(stageEl); + }); + + const edges = generateEdges(state.transitions, state.stages); + svg.querySelectorAll('g.edge-group').forEach(group => { + if (!edges.find(e => e.id === group.dataset.edgeId)) group.remove(); + }); + + edges.forEach(edge => { + let group = svg.querySelector(`g.edge-group[data-edge-id="${edge.id}"]`); + if (!group) { + group = document.createElementNS('http://www.w3.org/2000/svg', 'g'); + group.setAttribute('class', 'edge-group'); + group.dataset.edgeId = edge.id; + + const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + path.setAttribute('class', 'transition-path'); + path.setAttribute('marker-end', 'url(#arrowhead)'); + + const foreignObject = document.createElementNS('http://www.w3.org/2000/svg', 'foreignObject'); + foreignObject.setAttribute('width', '1'); + foreignObject.setAttribute('height', '1'); + foreignObject.style.overflow = 'visible'; + + const labelDiv = document.createElement('div'); + labelDiv.className = 'transition-label-content'; + labelDiv.addEventListener('click', e => { + e.stopPropagation(); + state.highlightedEdge = state.highlightedEdge === edge.id ? null : edge.id; + renderGraph(modal); + }); + + foreignObject.appendChild(labelDiv); + group.appendChild(path); + group.appendChild(foreignObject); + svg.appendChild(group); + } + + const path = group.querySelector('path'); + const foreignObject = group.querySelector('foreignObject'); + const labelDiv = foreignObject.querySelector('div'); + + path.setAttribute('d', edge.pathData); + path.classList.toggle('highlighted', state.highlightedEdge === edge.id); + // Update marker for existing path as well + let markerId = 'arrowhead'; + if (edge.arrowDirection === 'up') markerId = 'arrowhead-up'; + else if (edge.arrowDirection === 'down') markerId = 'arrowhead-down'; + else if (edge.arrowDirection === 'left') markerId = 'arrowhead-left'; + path.setAttribute('marker-end', `url(#${markerId})`); + + labelDiv.textContent = edge.label; + labelDiv.classList.toggle('highlighted', state.highlightedEdge === edge.id); + + requestAnimationFrame(() => { + // Use max-content for label width + labelDiv.style.width = 'max-content'; + const measuredWidth = labelDiv.getBoundingClientRect().width; + foreignObject.setAttribute('width', measuredWidth); + foreignObject.setAttribute('height', '32'); + // Center the label at the control point + let labelY = edge.labelPosition.y - 16; + if (edge.isBidirectional && typeof edge.fromId !== 'undefined' && typeof edge.toId !== 'undefined') { + labelY += (edge.fromId < edge.toId ? -18 : 18); + } + foreignObject.setAttribute('x', edge.labelPosition.x - measuredWidth / 2); + foreignObject.setAttribute('y', labelY); + }); + }); + + // Apply transform to graph + graph.style.transform = `translate(${state.panX}px, ${state.panY}px) scale(${state.scale})`; + + // Apply transforms to background pattern if it exists + const workflowGraph = modal.querySelector('#workflow-graph'); + if (workflowGraph) { + // Create a dynamic radial gradient where both dot size and spacing scale with zoom + const dotSize = Math.max(0.5, Math.min(1, state.scale)) * 1; // Dot size scales but has limits + const spacing = 15 * state.scale; // Grid spacing scales with zoom + workflowGraph.style.backgroundImage = `radial-gradient(circle at 1px 1px, var(--wf-dot-color) ${dotSize}px, transparent ${dotSize}px)`; + workflowGraph.style.backgroundSize = `${spacing}px ${spacing}px`; + workflowGraph.style.backgroundPosition = `${state.panX}px ${state.panY}px`; + } + } + + function handleNodeDrag(startEvent, draggedStage) { + if (draggedStage.id === 'From Any') return; + const stageElement = document.getElementById(`stage-${draggedStage.id}`); + state.isDraggingStage = true; + const dragStart = { x: startEvent.clientX, y: startEvent.clientY, stageX: draggedStage.position.x, stageY: draggedStage.position.y }; + stageElement.classList.add('dragging'); + + const onMouseMove = moveEvent => { + const newX = dragStart.stageX + (moveEvent.clientX - dragStart.x) / state.scale; + const newY = dragStart.stageY + (moveEvent.clientY - dragStart.y) / state.scale; + const stageToUpdate = state.stages.find(s => s.id === draggedStage.id); + if (stageToUpdate) { + stageToUpdate.position.x = newX; + stageToUpdate.position.y = newY; + } + renderGraph(document.querySelector('#workflow-graph')); + }; + + const onMouseUp = () => { + document.removeEventListener('mousemove', onMouseMove); + document.removeEventListener('mouseup', onMouseUp); + stageElement.classList.remove('dragging'); + state.isDraggingStage = false; + }; + document.addEventListener('mousemove', onMouseMove); + document.addEventListener('mouseup', onMouseUp); + } + + async function init(modal) { + const container = modal.querySelector('#workflow-graph'); + + if (!container || container.dataset.initialized) { + return; + } + container.dataset.initialized = 'true'; + + const workflowContainer = container.querySelector('#workflow-container'); + + const workflowId = parseInt(workflowContainer.dataset.workflowId, 10); + + if (!workflowId) return showMessageInModal('COM_WORKFLOW_GRAPH_ERROR_INVALID_ID', 'error'); + + const graph = modal.querySelector('#graph'); + const svg = modal.querySelector('#connections'); + + // Vue Flow style arrowhead markers + svg.innerHTML = ` + + + + + + + + + + + + + `; + + try { + const workflowData = await makeRequest(`&task=graph.getWorkflow&workflow_id=${workflowId}&format=json`); + if (!workflowData) return; + const stagesData = await makeRequest(`&task=graph.getStages&workflow_id=${workflowId}&format=json`); + if (!stagesData) return; + const transitionsData = await makeRequest(`&task=graph.getTransitions&workflow_id=${workflowId}&format=json`); + if (!transitionsData) return; + + state.workflow = workflowData?.data || {}; + let stages = stagesData?.data || []; + let transitions = transitionsData?.data || []; + + if (!stages.length) { + showMessageInModal('COM_WORKFLOW_GRAPH_ERROR_STAGES_NOT_FOUND', 'error'); + return; + } + + const hasStart = transitions.some(tr => tr.from_stage_id === -1); + // Only add 'From Any' once + if (hasStart && !stages.some(s => s.id === 'From Any')) { + stages.unshift({ id: 'From Any', title: 'From Any', position: null }); + } + + state.stages = stages.map(s => ({ ...s, position: s.position || { x: NaN, y: NaN } })); + state.transitions = transitions; + state.stages = calculateAutoLayout(state.stages, state.transitions); + + modal.querySelector('#workflow-main-title').textContent = state.workflow.title || translate('COM_WORKFLOW_GRAPH_WORKFLOW'); + const statusBadge = modal.querySelector('.badge[role="status"]'); + if (statusBadge) { + const isPublished = state.workflow.published == 1; + statusBadge.className = `badge ${isPublished ? 'bg-success' : 'bg-warning'}`; + statusBadge.textContent = isPublished ? translate('COM_WORKFLOW_GRAPH_ENABLED') : translate('COM_WORKFLOW_GRAPH_DISABLED'); + } + const stageCount = modal.querySelector('#workflow-stage-count'); + if (stageCount) { + const realStagesCount = state.stages.filter(s => s.id !== 'From Any').length; + stageCount.textContent = `${realStagesCount} ${realStagesCount === 1 ? translate('COM_WORKFLOW_GRAPH_STAGE') : translate('COM_WORKFLOW_GRAPH_STAGES')}`; + } + const transitionCount = modal.querySelector('#workflow-transition-count'); + if (transitionCount) { + transitionCount.textContent = `${state.transitions.length} ${state.transitions.length === 1 ? translate('COM_WORKFLOW_GRAPH_TRANSITION') : translate('COM_WORKFLOW_GRAPH_TRANSITIONS')}`; + } + + renderGraph(modal); + setTimeout(() => fitToScreen(modal), 150); + + } catch (error) { + showMessageInModal(error.message, 'error'); + return; + } + + let isPanning = false, panStart = {}; + container.addEventListener("mousedown", e => { + if (e.target.closest('.stage') || e.target.closest('.zoom-controls') || e.button !== 0) return; + isPanning = true; + panStart = { x: e.clientX - state.panX, y: e.clientY - state.panY }; + graph.classList.add('dragging'); + }); + + document.addEventListener("mousemove", e => { + if (!isPanning) return; + state.panX = e.clientX - panStart.x; + state.panY = e.clientY - panStart.y; + renderGraph(modal); + }); + + const stopPanning = () => { isPanning = false; graph.classList.remove('dragging'); }; + document.addEventListener("mouseup", stopPanning); + container.addEventListener("mouseleave", stopPanning); + + container.addEventListener("wheel", e => { + e.preventDefault(); + const rect = container.getBoundingClientRect(); + const mouseX = e.clientX - rect.left; + const mouseY = e.clientY - rect.top; + const oldScale = state.scale; + const zoomDirection = e.deltaY < 0 ? 1 : -1; + state.scale = Math.max(MIN_ZOOM, Math.min(state.scale * (1 + zoomDirection * ZOOM_SENSITIVITY), MAX_ZOOM)); + const factor = state.scale / oldScale; + state.panX = mouseX - (mouseX - state.panX) * factor; + state.panY = mouseY - (mouseY - state.panY) * factor; + renderGraph(modal); + }); + + const zoomControls = container.querySelector('.zoom-controls'); + zoomControls.querySelector('.zoom-in').addEventListener('click', () => applyZoom(1.2, modal)); + zoomControls.querySelector('.zoom-out').addEventListener('click', () => applyZoom(1 / 1.2, modal)); + zoomControls.querySelector('.fit-screen').addEventListener('click', () => fitToScreen(modal)); + + function applyZoom(factor, modalContext) { + const rect = modalContext.querySelector('#workflow-graph').getBoundingClientRect(); + const centerX = rect.width / 2; + const centerY = rect.height / 2; + const oldScale = state.scale; + state.scale = Math.max(MIN_ZOOM, Math.min(state.scale * factor, MAX_ZOOM)); + const scaleRatio = state.scale / oldScale; + state.panX = centerX - (centerX - state.panX) * scaleRatio; + state.panY = centerY - (centerY - state.panY) * scaleRatio; + renderGraph(modalContext); + } + + function getBoundingBox() { + if (!state.stages.length) return null; + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + state.stages.forEach(stage => { + if (stage.position) { + minX = Math.min(minX, stage.position.x); + minY = Math.min(minY, stage.position.y); + maxX = Math.max(maxX, stage.position.x + STAGE_WIDTH); + maxY = Math.max(maxY, stage.position.y + STAGE_HEIGHT); + } + }); + if (minX === Infinity) return null; + return { minX, minY, maxX, maxY, width: maxX - minX, height: maxY - minY }; + } + + function fitToScreen(modalContext) { + const bounds = getBoundingBox(); + if (!bounds) return; + const containerRect = modalContext.querySelector('#workflow-graph').getBoundingClientRect(); + const padding = 50; + const scaleX = (containerRect.width - padding) / bounds.width; + const scaleY = (containerRect.height - padding) / bounds.height; + state.scale = Math.max(MIN_ZOOM, Math.min(scaleX, scaleY, MAX_ZOOM)); + state.panX = (containerRect.width - bounds.width * state.scale) / 2 - bounds.minX * state.scale; + state.panY = (containerRect.height - bounds.height * state.scale) / 2 - bounds.minY * state.scale; + renderGraph(modalContext); + } + } + + // Listen for dialog open events + document.addEventListener('joomla-dialog:open', (event) => { + const dialog = event.target; + const workflowContainer = dialog.querySelector('#workflow-container'); + + if (workflowContainer) { + init(dialog); + } + }); + +})(); \ No newline at end of file diff --git a/build/media_source/com_workflow/scss/workflow-graph-client.scss b/build/media_source/com_workflow/scss/workflow-graph-client.scss new file mode 100644 index 0000000000000..51d7185962b88 --- /dev/null +++ b/build/media_source/com_workflow/scss/workflow-graph-client.scss @@ -0,0 +1,236 @@ +/* ================================ + Workflow Graph Client Styles + ================================ */ +/* Color Variables */ +:root { + --wf-bg: var(--bg-primary); + --wf-border: var(--border-color); + --wf-border-hover: var(--border-color-translucent); + --wf-dot-color: var(--body-color); + --stage-bg: rgb(var(--primary-rgb)); + --stage-border: var(--border-color); + --stage-text: var(--white); + --stage-desc: var(--ext-secondary); + --stage-virtual-bg: #800080; + --transition-hover: var(--border-color-hover); + --transition-label-bg: #2071c6; + --transition-stroke: var(--transition-label-bg); + --transition-label-color: var(--white); + --transition-highlight: #ff6868; + --zoom-btn-bg: transparent; + --zoom-btn-hover: var(--border-color); + --zoom-btn-color: var(--text-primary); + --vf-custom-controls-bgcolor: var(--secondary); +} + +#workflow-graph { + position: relative; + width: 100%; + height: 80vh; + overflow: hidden; + cursor: grab; + background-image: radial-gradient(circle at 1px 1px, var(--wf-dot-color) 1px, transparent 1px); + background-size: 38px 38px; + border: 1px solid var(--stage-border); + border-radius: 8px; +} + +#workflow-graph:active { + cursor: grabbing; +} + +#workflow-container { + position: relative; + width: 100%; + height: 100%; + overflow: hidden; +} + +#graph { + position: relative; + width: fit-content; + min-width: 100%; + min-height: 100%; + transition: transform .15s ease-out; + transform-origin: 0 0; +} + +#graph.dragging { + transition: none; +} + +#stages { + position: relative; + width: 100%; + height: 100%; +} + +#connections { + position: absolute; + top: 0; + left: 0; + z-index: 1; + width: 100%; + height: 100%; + overflow: visible; + pointer-events: none; +} + +/* ================================ + Stage Styling + ================================ */ +.stage { + position: absolute; + z-index: 10; + display: flex; + flex-direction: column; + justify-content: center; + width: 200px; + min-height: 80px; + padding: 12px 16px; + cursor: move; + user-select: none; + background: var(--stage-bg); + border: 1px solid var(--stage-border); + border-radius: 6px; + box-shadow: 0 4px 10px rgba(0, 0, 0, .6); +} + +.stage:hover { + transform: translateY(-1px); +} + +.stage.dragging { + z-index: 1000; + transform: rotate(1deg) scale(1.02); +} + +.stage.virtual { + background: var(--stage-virtual-bg); + border-style: dashed; + border-width: 2px; +} + +/* Stage content */ +.stage-title { + margin: 0 0 6px; + font-size: 14px; + font-weight: 600; + line-height: 1.3; + color: var(--stage-text); + word-wrap: break-word; +} + +.stage-description { + margin: 0 0 8px; + font-size: 12px; + line-height: 1.4; + color: var(--stage-desc); + word-wrap: break-word; +} + +.stage-badge { + display: inline-block; + align-self: flex-start; + padding: 2px 6px; + font-size: 10px; + font-weight: 500; + color: var(--stage-text); + text-transform: uppercase; + letter-spacing: .5px; + background: var(--stage-border); + border-radius: 3px; +} + +/* ================================ + Transitions + ================================ */ +.transition-path { + stroke: var(--transition-stroke); + stroke-width: 3; + fill: none; + opacity: .8; + transition: stroke .2s ease, stroke-width .2s ease; +} + +.transition-path:hover { + stroke-width: 3; + opacity: 1; + stroke: var(--transition-hover); +} + +.arrow-marker { + fill: var(--transition-stroke); + transition: fill .2s ease; +} + +.transition-path:hover+.arrow-marker { + fill: var(--transition-hover); +} + +/* Highlighted transitions */ +.transition-path.highlighted { + stroke: var(--transition-highlight); + stroke-width: 5; + stroke-dasharray: 8 6; + animation: dashmove 1s linear infinite; +} + +/* ================================ + Transition Labels + ================================ */ +.transition-label-content { + position: absolute; + z-index: 20; + min-width: 80px; + max-width: 300px; + padding: 2px 12px; + font-size: 1rem; + font-weight: 600; + line-height: 1.2; + color: var(--transition-label-color); + text-align: center; + white-space: nowrap; + pointer-events: auto; + user-select: none; + background: var(--transition-label-bg) !important; + border: 2px solid var(--transition-stroke); + border-radius: 6px; + box-shadow: 0 2px 8px rgba(0, 0, 0, .15); + transition: background .2s, border .2s, box-shadow .2s; +} + +.transition-label-content.highlighted { + font-weight: 700; + transition: transform .2s ease; + transform: scale(1.1); +} + +/* ================================ + Zoom Controls + ================================ */ + +.zoom-controls { + position: absolute; + right: 20px; + bottom: 20px; + z-index: 1000; + display: flex; + flex-direction: column; + gap: 2px; + padding: 4px; + background: var(--vf-custom-controls-bgcolor); + border: 1px solid var(--wf-border); + border-radius: 6px; + backdrop-filter: blur(4px); +} + +.zoom-btn:hover { + background: var(--zoom-btn-hover); + transform: scale(1.05); +} + +.zoom-btn:active { + transition: transform .05s ease; + transform: scale(.95); +} diff --git a/build/media_source/com_workflow/scss/workflow-graph.scss b/build/media_source/com_workflow/scss/workflow-graph.scss new file mode 100644 index 0000000000000..e3ed28834048d --- /dev/null +++ b/build/media_source/com_workflow/scss/workflow-graph.scss @@ -0,0 +1,216 @@ +/* ================================ + Workflow Graph Styles + ================================ */ + +/* Color Variables */ +:root { + --vf-black: #000; + --vf-custom-controls-bgcolor: var(--secondary); + --vf-edge-color: #2071c6; + --vf-white: #fff; + --vf-opacity-less: rgba(0, 0, 0, .1); + --vf-opacity-more: rgba(0, 0, 0, .6); +} + +/* ================================ + Vue Flow Core Components + ================================ */ +.vue-flow { + position: relative; + z-index: 0; + width: 100%; + height: 100%; + overflow: hidden; + direction: ltr; +} + +.vue-flow__container { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; +} + +.vue-flow__pane.draggable { + cursor: grab; +} + +.vue-flow__pane.selection { + cursor: pointer; +} + +.vue-flow__pane.dragging { + cursor: grabbing; +} + +.vue-flow__transformationpane { + z-index: 2; + pointer-events: none; + transform-origin: 0 0; +} + +.vue-flow__viewport { + z-index: 4; + overflow: clip; +} + +.vue-flow__nodesselection-rect:focus, +.vue-flow__nodesselection-rect:focus-visible, +.vue-flow__edge.selected, +.vue-flow__edge:focus, +.vue-flow__edge:focus-visible { + outline: none; +} + +.vue-flow .vue-flow__edges { + overflow: visible; + pointer-events: none; +} + +.vue-flow__edge-path, +.vue-flow__connection-path { + stroke: var(--vf-white); + stroke-width: 1; + fill: none; +} + +.vue-flow__edge.animated path { + stroke-dasharray: 5; + animation: dashdraw .5s linear infinite; +} + +.vue-flow__edge.animated path.vue-flow__edge-interaction { + stroke-dasharray: none; + animation: none; +} + +.vue-flow__edge.inactive { + pointer-events: none; +} + +.vue-flow__connection { + pointer-events: none; +} + +.vue-flow__connection .animated { + stroke-dasharray: 5; + animation: dashdraw .5s linear infinite; +} + +.vue-flow__connectionline { + z-index: 1001; +} + +.vue-flow__nodes { + pointer-events: none; + transform-origin: 0 0; +} + +.vue-flow__node { + position: absolute; + box-sizing: border-box; + pointer-events: all; + cursor: default; + user-select: none; + transform-origin: 0 0; +} + + +.vue-flow__handle.connectable { + pointer-events: all; + cursor: crosshair; +} + +.vue-flow__panel { + position: absolute; + z-index: 5; + margin: 15px; +} + +.vue-flow__panel.top { + top: 0; +} + +.vue-flow__panel.bottom { + bottom: 0; +} + +.vue-flow__panel.left { + left: 0; +} + +.vue-flow__panel.right { + right: 0; +} + +.vue-flow__panel.center { + left: 50%; + transform: translateX(-50%); +} + +@keyframes dashdraw { + from { + stroke-dashoffset: 10; + } +} + +/* ================================ + Vue Flow Custom Components + ================================ */ +.min-vh-80 { + height: 80vh; +} + +.end-20-px { + right: 20px !important; +} + +.top-25-px { + top: 25px !important; +} + +.z-10 { + z-index: 10; +} + +.z-20 { + z-index: 20; +} + +.custom-edge { + background: var(--vf-edge-color) !important; +} + +.vue-flow__minimap { + box-shadow: 0 10px 15px -3px var(--vf-opacity-less); +} + +.custom-controls { + right: 10px; + bottom: 10px; + background: var(--vf-custom-controls-bgcolor); +} + +.vue-flow__minimap.pannable { + cursor: grab; +} + +.stage-node .edge-handler { + width: 12px !important; + height: 12px !important; +} + +.stage-node:hover .vue-flow__handle { + opacity: 1; +} + +.vue-flow__node-stage { + max-width: 250px !important; +} + +.workflow-browser-actions-list { + background-color: var(--vf-black); + box-shadow: 0 4px 10px var(--vf-opacity-more); +} + diff --git a/installation/sql/mysql/base.sql b/installation/sql/mysql/base.sql index 99bf988897243..87d72bcea8e59 100644 --- a/installation/sql/mysql/base.sql +++ b/installation/sql/mysql/base.sql @@ -1206,6 +1206,7 @@ CREATE TABLE IF NOT EXISTS `#__workflow_stages` ( `title` varchar(255) NOT NULL, `description` text NOT NULL, `default` tinyint NOT NULL DEFAULT 0, + `position` text, `checked_out_time` datetime, `checked_out` int unsigned, PRIMARY KEY (`id`), diff --git a/installation/sql/postgresql/base.sql b/installation/sql/postgresql/base.sql index 8694854991876..d06ea000f58f8 100644 --- a/installation/sql/postgresql/base.sql +++ b/installation/sql/postgresql/base.sql @@ -1231,6 +1231,7 @@ CREATE TABLE IF NOT EXISTS "#__workflow_stages" ( "title" varchar(255) NOT NULL, "description" text NOT NULL, "default" smallint NOT NULL DEFAULT 0, + "position" text, "checked_out_time" timestamp without time zone, "checked_out" integer, PRIMARY KEY ("id") diff --git a/layouts/joomla/form/field/groupedlist-transition.php b/layouts/joomla/form/field/groupedlist-transition.php new file mode 100644 index 0000000000000..720132fcdb248 --- /dev/null +++ b/layouts/joomla/form/field/groupedlist-transition.php @@ -0,0 +1,213 @@ + + * @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; +use Joomla\CMS\Layout\LayoutHelper; + +extract($displayData); +$wa = Factory::getApplication()->getDocument()->getWebAssetManager(); +$wa->getRegistry()->addExtensionRegistryFile('com_workflow'); + +$wa->useScript('joomla.dialog-autocreate'); +$wa->useScript('com_workflow.workflowgraphclient') + ->useStyle('com_workflow.workflowgraphclient'); + + +// Populate the language +$translationStrings = [ + 'COM_WORKFLOW_GRAPH', + 'COM_WORKFLOW_GRAPH_ADD_STAGE', + 'COM_WORKFLOW_GRAPH_ADD_STAGE_DIALOG_OPENED', + 'COM_WORKFLOW_GRAPH_ADD_TRANSITION_DIALOG_OPENED', + 'COM_WORKFLOW_GRAPH_ADD_TRANSITION', + 'COM_WORKFLOW_GRAPH_API_NOT_SET', + 'COM_WORKFLOW_GRAPH_BACKGROUND', + 'COM_WORKFLOW_GRAPH_CANVAS_DESCRIPTION', + 'COM_WORKFLOW_GRAPH_CANVAS_LABEL', + 'COM_WORKFLOW_GRAPH_CANVAS_VIEW_CONTROLS', + 'COM_WORKFLOW_GRAPH_CLEAR_SELECTION', + 'COM_WORKFLOW_GRAPH_CLOSE_ACTIONS_MENU', + 'COM_WORKFLOW_GRAPH_CONTROLS', + 'COM_WORKFLOW_GRAPH_CREATING_TRANSITION', + 'COM_WORKFLOW_GRAPH_DEFAULT', + 'COM_WORKFLOW_GRAPH_DISABLED', + 'COM_WORKFLOW_GRAPH_EDIT_ITEM', + 'COM_WORKFLOW_GRAPH_EDIT_STAGE', + 'COM_WORKFLOW_GRAPH_EDIT_TRANSITION', + 'COM_WORKFLOW_GRAPH_ENABLED', + 'COM_WORKFLOW_GRAPH_ERROR_API_RETURNED_ERROR', + 'COM_WORKFLOW_GRAPH_ERROR_CSRF_TOKEN_NOT_SET', + 'COM_WORKFLOW_GRAPH_ERROR_EXTENSION_NOT_SET', + 'COM_WORKFLOW_GRAPH_ERROR_FAILED_TO_UPDATE_STAGE_POSITIONS', + 'COM_WORKFLOW_GRAPH_ERROR_FETCHING_MODEL', + 'COM_WORKFLOW_GRAPH_ERROR_INVALID_ID', + 'COM_WORKFLOW_GRAPH_ERROR_INVALID_POSITION_DATA', + 'COM_WORKFLOW_GRAPH_ERROR_INVALID_STAGE_POSITIONS', + 'COM_WORKFLOW_GRAPH_ERROR_NOT_AUTHENTICATED', + 'COM_WORKFLOW_GRAPH_ERROR_NO_PERMISSION', + 'COM_WORKFLOW_GRAPH_ERROR_REQUEST_FAILED', + 'COM_WORKFLOW_GRAPH_ERROR_STAGES_NOT_FOUND', + 'COM_WORKFLOW_GRAPH_ERROR_STAGE_DEFAULT_CANT_DELETED', + 'COM_WORKFLOW_GRAPH_ERROR_STAGE_HAS_TRANSITIONS', + 'COM_WORKFLOW_GRAPH_ERROR_UNKNOWN', + 'COM_WORKFLOW_GRAPH_ERROR_WORKFLOW_ID_NOT_SET', + 'COM_WORKFLOW_GRAPH_ERROR_WORKFLOW_NOT_FOUND', + 'COM_WORKFLOW_GRAPH_FIT_VIEW', + 'COM_WORKFLOW_GRAPH_FOCUS_TYPE_CHANGE', + 'COM_WORKFLOW_GRAPH_LOADING', + 'COM_WORKFLOW_GRAPH_MINIMAP_HIDE', + 'COM_WORKFLOW_GRAPH_MINIMAP_LABEL', + 'COM_WORKFLOW_GRAPH_MINIMAP_SHOW', + 'COM_WORKFLOW_GRAPH_MOVE_STAGE', + 'COM_WORKFLOW_GRAPH_MOVE_VIEW', + 'COM_WORKFLOW_GRAPH_NAVIGATE_NODES', + 'COM_WORKFLOW_GRAPH_OPEN_ACTIONS_MENU', + 'COM_WORKFLOW_GRAPH_REDO', + 'COM_WORKFLOW_GRAPH_SELECTION_CLEARED', + 'COM_WORKFLOW_GRAPH_SELECT_ITEM', + 'COM_WORKFLOW_GRAPH_SHORTCUTS', + 'COM_WORKFLOW_GRAPH_SHORTCUTS_TITLE', + 'COM_WORKFLOW_GRAPH_STAGE', + 'COM_WORKFLOW_GRAPH_STAGES', + 'COM_WORKFLOW_GRAPH_STAGE_ACTIONS', + 'COM_WORKFLOW_GRAPH_STAGE_COUNT', + 'COM_WORKFLOW_GRAPH_STAGE_DESCRIPTION', + 'COM_WORKFLOW_GRAPH_STAGE_POSITIONS_UPDATED', + 'COM_WORKFLOW_GRAPH_STAGE_REF', + 'COM_WORKFLOW_GRAPH_STAGE_SELECTED', + 'COM_WORKFLOW_GRAPH_STAGE_STATUS_PUBLISHED', + 'COM_WORKFLOW_GRAPH_STAGE_STATUS_UNPUBLISHED', + 'COM_WORKFLOW_GRAPH_STATUS', + 'COM_WORKFLOW_GRAPH_TRANSITION', + 'COM_WORKFLOW_GRAPH_TRANSITIONS', + 'COM_WORKFLOW_GRAPH_TRANSITION_ACTIONS', + 'COM_WORKFLOW_GRAPH_TRANSITION_COUNT', + 'COM_WORKFLOW_GRAPH_TRANSITION_DESCRIPTION', + 'COM_WORKFLOW_GRAPH_TRANSITION_PATH', + 'COM_WORKFLOW_GRAPH_TRANSITION_REF', + 'COM_WORKFLOW_GRAPH_TRANSITION_SELECTED', + 'COM_WORKFLOW_GRAPH_TRANSITION_STATUS_PUBLISHED', + 'COM_WORKFLOW_GRAPH_TRANSITION_STATUS_UNPUBLISHED', + 'COM_WORKFLOW_GRAPH_TRASH_ITEM', + 'COM_WORKFLOW_GRAPH_TRASH_STAGE', + 'COM_WORKFLOW_GRAPH_TRASH_STAGE_CONFIRM', + 'COM_WORKFLOW_GRAPH_TRASH_STAGE_FAILED', + 'COM_WORKFLOW_GRAPH_TRASH_TRANSITION', + 'COM_WORKFLOW_GRAPH_TRASH_TRANSITION_CONFIRM', + 'COM_WORKFLOW_GRAPH_TRASH_TRANSITION_FAILED', + 'COM_WORKFLOW_GRAPH_UNSAVED_CHANGES', + 'COM_WORKFLOW_GRAPH_UNDO', + 'COM_WORKFLOW_GRAPH_UPDATE_STAGE_POSITION_FAILED', + 'COM_WORKFLOW_GRAPH_UP_TO_DATE', + 'COM_WORKFLOW_GRAPH_WORKFLOWS_EDIT', + 'COM_WORKFLOW_GRAPH_ZOOM_IN', + 'COM_WORKFLOW_GRAPH_ZOOM_OUT', +]; + +foreach ($translationStrings as $string) { + Text::script($string); +} + +$workflowId = $field ? $field->getAttribute('workflow_id') : null; +if (!$workflowId) { + return; +} +$popupId = 'workflow-graph-modal-content'; +$popupOptions = json_encode([ + 'src' => '#' . $popupId, + 'height' => 'fit-content', + 'textHeader' => Text::_('COM_WORKFLOW_GRAPH_FULL'), + 'preferredParent' => 'body', + 'modal' => true, +]); + +?> +
+
+ +
+
+
+ + +
+
+
+ + diff --git a/libraries/src/Form/Field/TransitionField.php b/libraries/src/Form/Field/TransitionField.php index 4b10c86dc98bd..a9291fa9165f0 100644 --- a/libraries/src/Form/Field/TransitionField.php +++ b/libraries/src/Form/Field/TransitionField.php @@ -79,6 +79,15 @@ public function setup(\SimpleXMLElement $element, $value, $group = null) } else { $this->workflowStage = $input->getInt('id'); } + + $db = $this->getDatabase(); + + $query = $db->getQuery(true) + ->select($db->quoteName('workflow_id')) + ->from($db->quoteName('#__workflow_stages')) + ->where($db->quoteName('id') . ' = ' . (int) $this->workflowStage); + + $this->form->setFieldAttribute('transition', 'workflow_id', (int) $db->setQuery($query)->loadResult()); } return $result; diff --git a/package-lock.json b/package-lock.json index ce065fe62cff1..384518a72599a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,6 +28,11 @@ "@codemirror/view": "^6.38.3", "@fortawesome/fontawesome-free": "^6.7.2", "@popperjs/core": "^2.11.8", + "@vue-flow/background": "^1.3.2", + "@vue-flow/controls": "^1.1.2", + "@vue-flow/core": "^1.45.0", + "@vue-flow/minimap": "^1.5.3", + "@vue-flow/node-resizer": "^1.5.0", "accessibility": "^3.0.17", "awesomplete": "^1.1.7", "bootstrap": "^5.3.8", @@ -5317,6 +5322,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/web-bluetooth": { + "version": "0.0.20", + "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.20.tgz", + "integrity": "sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==", + "license": "MIT" + }, "node_modules/@types/yauzl": { "version": "2.10.3", "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", @@ -5328,6 +5339,70 @@ "@types/node": "*" } }, + "node_modules/@vue-flow/background": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@vue-flow/background/-/background-1.3.2.tgz", + "integrity": "sha512-eJPhDcLj1wEo45bBoqTXw1uhl0yK2RaQGnEINqvvBsAFKh/camHJd5NPmOdS1w+M9lggc9igUewxaEd3iCQX2w==", + "license": "MIT", + "peerDependencies": { + "@vue-flow/core": "^1.23.0", + "vue": "^3.3.0" + } + }, + "node_modules/@vue-flow/controls": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@vue-flow/controls/-/controls-1.1.2.tgz", + "integrity": "sha512-6dtl/JnwDBNau5h3pDBdOCK6tdxiVAOL3cyruRL61gItwq5E97Hmjmj2BIIqX2p7gU1ENg3z80Z4zlu58fGlsg==", + "license": "MIT", + "peerDependencies": { + "@vue-flow/core": "^1.23.0", + "vue": "^3.3.0" + } + }, + "node_modules/@vue-flow/core": { + "version": "1.45.0", + "resolved": "https://registry.npmjs.org/@vue-flow/core/-/core-1.45.0.tgz", + "integrity": "sha512-+Qd4fTnCfrhfYQzlHyf5Jt7rNE4PlDnEJEJZH9v6hDZoTOeOy1RhS85cSxKYxdsJ31Ttj2v3yabhoVfBf+bmJA==", + "license": "MIT", + "dependencies": { + "@vueuse/core": "^10.5.0", + "d3-drag": "^3.0.0", + "d3-interpolate": "^3.0.1", + "d3-selection": "^3.0.0", + "d3-zoom": "^3.0.0" + }, + "peerDependencies": { + "vue": "^3.3.0" + } + }, + "node_modules/@vue-flow/minimap": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/@vue-flow/minimap/-/minimap-1.5.3.tgz", + "integrity": "sha512-w8VQc8orPdzfstIPI4/u6H7qlc/uVM1W6b5Upd5NQi0+S9seYl3CiUrzO9liW/f8Fuvr5oHVQg0X6nn2K083rA==", + "license": "MIT", + "dependencies": { + "d3-selection": "^3.0.0", + "d3-zoom": "^3.0.0" + }, + "peerDependencies": { + "@vue-flow/core": "^1.23.0", + "vue": "^3.3.0" + } + }, + "node_modules/@vue-flow/node-resizer": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@vue-flow/node-resizer/-/node-resizer-1.5.0.tgz", + "integrity": "sha512-FmvOZ6+yVrBEf+8oJcCU20PUZ105QsyM01iiP4vTKHGJ01hzoh9d0/wP9iJkxkIpvBU59CyOHyTKQZlDr4qDhA==", + "license": "MIT", + "dependencies": { + "d3-drag": "^3.0.0", + "d3-selection": "^3.0.0" + }, + "peerDependencies": { + "@vue-flow/core": "^1.23.0", + "vue": "^3.3.0" + } + }, "node_modules/@vue/compiler-core": { "version": "3.5.22", "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.22.tgz", @@ -5434,6 +5509,94 @@ "integrity": "sha512-F4yc6palwq3TT0u+FYf0Ns4Tfl9GRFURDN2gWG7L1ecIaS/4fCIuFOjMTnCyjsu/OK6vaDKLCrGAa+KvvH+h4w==", "license": "MIT" }, + "node_modules/@vueuse/core": { + "version": "10.11.1", + "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-10.11.1.tgz", + "integrity": "sha512-guoy26JQktXPcz+0n3GukWIy/JDNKti9v6VEMu6kV2sYBsWuGiTU8OWdg+ADfUbHg3/3DlqySDe7JmdHrktiww==", + "license": "MIT", + "dependencies": { + "@types/web-bluetooth": "^0.0.20", + "@vueuse/metadata": "10.11.1", + "@vueuse/shared": "10.11.1", + "vue-demi": ">=0.14.8" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/core/node_modules/vue-demi": { + "version": "0.14.10", + "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz", + "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, + "node_modules/@vueuse/metadata": { + "version": "10.11.1", + "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-10.11.1.tgz", + "integrity": "sha512-IGa5FXd003Ug1qAZmyE8wF3sJ81xGLSqTqtQ6jaVfkeZ4i5kS2mwQF61yhVqojRnenVew5PldLyRgvdl4YYuSw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/shared": { + "version": "10.11.1", + "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-10.11.1.tgz", + "integrity": "sha512-LHpC8711VFZlDaYUXEBbFBCQ7GS3dVU9mjOhhMhXP6txTV4EhYQg/KGnQuvt/sPAtoUKq7VVUnL6mVtFoL42sA==", + "license": "MIT", + "dependencies": { + "vue-demi": ">=0.14.8" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/shared/node_modules/vue-demi": { + "version": "0.14.10", + "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz", + "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, "node_modules/accessibility": { "version": "3.0.17", "resolved": "https://registry.npmjs.org/accessibility/-/accessibility-3.0.17.tgz", @@ -6724,6 +6887,111 @@ "node": ">=10" } }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/dashdash": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", diff --git a/package.json b/package.json index 2389979a9dea6..e8119fb7fa5b1 100644 --- a/package.json +++ b/package.json @@ -17,8 +17,11 @@ "build:bs5": "node build/build.mjs --compile-bs", "build:com_media": "node --env-file=./build/production.env build/build.mjs --com-media", "build:com_media:dev": "node --env-file=./build/development.env build/build.mjs --com-media", + "build:com_workflow": "node --env-file=./build/production.env build/build.mjs --com-workflow", + "build:com_workflow:dev": "node --env-file=./build/development.env build/build.mjs --com-workflow", "watch": "node build/build.mjs --watch", "watch:com_media": "node build/build.mjs --watch-com-media", + "watch:com_Workflow": "node build/build.mjs --watch-com-workflow", "lint:js": "eslint --config build/eslint.config.mjs build administrator/components/com_media/resources/scripts", "lint:testjs": "eslint --config build/eslint-tests.mjs tests/System", "lint:css": "stylelint --config build/.stylelintrc.json \"administrator/components/com_media/resources/**/*.scss\" \"administrator/templates/**/*.scss\" \"build/media_source/**/*.scss\" \"build/media_source/**/*.css\" \"templates/**/*.scss\" \"installation/template/**/*.scss\"", @@ -55,6 +58,11 @@ "@codemirror/view": "^6.38.3", "@fortawesome/fontawesome-free": "^6.7.2", "@popperjs/core": "^2.11.8", + "@vue-flow/background": "^1.3.2", + "@vue-flow/controls": "^1.1.2", + "@vue-flow/core": "^1.45.0", + "@vue-flow/minimap": "^1.5.3", + "@vue-flow/node-resizer": "^1.5.0", "accessibility": "^3.0.17", "awesomplete": "^1.1.7", "bootstrap": "^5.3.8",