diff --git a/administrator/components/com_admin/sql/updates/mysql/6.0.0-2025-08-26.sql b/administrator/components/com_admin/sql/updates/mysql/6.0.0-2025-08-26.sql new file mode 100644 index 00000000000..d82d0777d5a --- /dev/null +++ b/administrator/components/com_admin/sql/updates/mysql/6.0.0-2025-08-26.sql @@ -0,0 +1,5 @@ +-- +-- Add position column to workflow stages table +-- + +ALTER TABLE `#__workflow_stages` ADD COLUMN `position` text DEFAULT NULL AFTER `default`; diff --git a/administrator/components/com_admin/sql/updates/postgresql/6.0.0-2025-08-26.sql b/administrator/components/com_admin/sql/updates/postgresql/6.0.0-2025-08-26.sql new file mode 100644 index 00000000000..f98b81da0b6 --- /dev/null +++ b/administrator/components/com_admin/sql/updates/postgresql/6.0.0-2025-08-26.sql @@ -0,0 +1,5 @@ +-- +-- Add position column to workflow stages table +-- + +ALTER TABLE "#__workflow_stages" ADD COLUMN "position" text DEFAULT NULL; diff --git a/administrator/components/com_content/src/Model/ArticleModel.php b/administrator/components/com_content/src/Model/ArticleModel.php index 106a7139a76..180d6199311 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 00000000000..fb63baf3f21 --- /dev/null +++ b/administrator/components/com_workflow/layouts/toolbar/redo.php @@ -0,0 +1,31 @@ + + * @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'); + +?> + + + + + 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 00000000000..41788338ccd --- /dev/null +++ b/administrator/components/com_workflow/layouts/toolbar/shortcuts.php @@ -0,0 +1,37 @@ + + * @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 00000000000..2e16cfc5901 --- /dev/null +++ b/administrator/components/com_workflow/layouts/toolbar/undo.php @@ -0,0 +1,33 @@ + + * @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'); + +?> + + + + + + + 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 00000000000..3fddff6f7f9 --- /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 00000000000..05be12e9785 --- /dev/null +++ b/administrator/components/com_workflow/resources/scripts/app/WorkflowGraphApi.es6.js @@ -0,0 +1,206 @@ +/** + * Handles API communication for the workflow graph. + */ +class WorkflowGraphApi { + /** + * Initializes the WorkflowGraphApi instance. + * + * @throws {TypeError} If required options are missing. + */ + constructor() { + const { + apiBaseUrl, + extension, + } = Joomla.getOptions('com_workflow', {}); + + if (!apiBaseUrl) { + throw new TypeError(Joomla.Text._('COM_WORKFLOW_GRAPH_API_BASEURL_NOT_SET', 'Workflow API baseUrl is not defined')); + } + + if (!extension) { + throw new TypeError(Joomla.Text._('COM_WORKFLOW_GRAPH_ERROR_EXTENSION_NOT_SET', 'Workflow extension is not set')); + } + + this.baseUrl = apiBaseUrl; + this.extension = extension; + this.csrfToken = Joomla.getOptions('csrf.token', null); + + if (!this.csrfToken) { + throw new TypeError(Joomla.Text._('COM_WORKFLOW_GRAPH_ERROR_CSRF_TOKEN_NOT_SET', 'CSRF token is not set')); + } + } + + /** + * Makes a request using Joomla.request with better error handling. + * + * @param {string} url - The endpoint relative to baseUrl. + * @param {Object} [options={}] - Request config (method, data, headers). + * @returns {Promise} The parsed response or error. + */ + async makeRequest(url, options = {}) { + const headers = options.headers || {}; + headers['X-Requested-With'] = 'XMLHttpRequest'; + options.headers = headers; + options[this.csrfToken] = 1; + + return new Promise((resolve, reject) => { + Joomla.request({ + url: `${this.baseUrl}${url}&extension=${this.extension}`, + ...options, + onSuccess: (response) => { + const data = JSON.parse(response); + resolve(data); + }, + onError: (xhr) => { + let message = 'Network error'; + try { + const errorData = JSON.parse(xhr.responseText); + message = errorData.data || errorData.message || message; + } catch (e) { + message = xhr.statusText || message; + } + if (window.Joomla && window.Joomla.renderMessages) { + window.Joomla.renderMessages({ error: [message] }); + } + reject(new Error(message)); + }, + }); + }); + } + + /** + * Fetches workflow data by ID. + * + * @param {number} id - Workflow ID. + * @returns {Promise} + */ + 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) { + if (window.Joomla && window.Joomla.renderMessages) { + window.Joomla.renderMessages({ + success: [response?.data?.message || response?.message], + }); + } + } + } catch (error) { + window.WorkflowGraph.Event.fire('Error', { error: error.message }); + throw error; + } + } + + /** + * Deletes a transition from a workflow. + * + * @param {number} id - Transition ID. + * @param {number} workflowId - Workflow ID. + * @param {boolean} [transitionDelete=false] - Optional flag to indicate if the transition should be deleted or just trashed. + * + * @returns {Promise} + */ + async deleteTransition(id, workflowId, transitionDelete = false) { + try { + const formData = new FormData(); + formData.append('cid[]', id); + formData.append('workflow_id', workflowId); + formData.append('type', 'transition'); + formData.append(this.csrfToken, '1'); + + const response = await this.makeRequest(`&task=${transitionDelete ? 'graph.delete' : 'graph.trash'}&workflow_id=${workflowId}&format=json`, { + method: 'POST', + data: formData, + }); + + if (response && response.success) { + if (window.Joomla && window.Joomla.renderMessages) { + window.Joomla.renderMessages({ + success: [response?.data?.message || response?.message], + }); + } + } + } catch (error) { + window.WorkflowGraph.Event.fire('Error', { error: error.message }); + throw error; + } + } + + /** + * Updates the position of a stage. + * + * @param {number} workflowId - Workflow ID. + * @param {Object} positions - Position objects {x, y} of updated stages. + * @returns {Promise} + */ + async updateStagePosition(workflowId, positions) { + try { + const formData = new FormData(); + formData.append('workflow_id', workflowId); + formData.append(this.csrfToken, '1'); + + if (positions === null || Object.keys(positions).length === 0) { + return true; + } + + Object.entries(positions).forEach(([id, position]) => { + formData.append(`positions[${id}][x]`, position.x); + formData.append(`positions[${id}][y]`, position.y); + }); + + const response = await this.makeRequest('&task=stages.updateStagesPosition&format=json', { + method: 'POST', + data: formData, + }); + + return !!(response && response.success); + } catch (error) { + window.WorkflowGraph.Event.fire('Error', { error }); + throw error; + } + } +} + +export default new WorkflowGraphApi(); 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 00000000000..b748f88cd5e --- /dev/null +++ b/administrator/components/com_workflow/resources/scripts/components/App.vue @@ -0,0 +1,75 @@ + + + + + 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 00000000000..982c60785ee --- /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 00000000000..18f49dada7a --- /dev/null +++ b/administrator/components/com_workflow/resources/scripts/components/canvas/ControlsPanel.vue @@ -0,0 +1,54 @@ + + + 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 00000000000..a989c28f76b --- /dev/null +++ b/administrator/components/com_workflow/resources/scripts/components/canvas/CustomControls.vue @@ -0,0 +1,84 @@ + + +clear 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 00000000000..1e8789c708b --- /dev/null +++ b/administrator/components/com_workflow/resources/scripts/components/canvas/WorkflowCanvas.vue @@ -0,0 +1,618 @@ + + + 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 00000000000..7f32908519b --- /dev/null +++ b/administrator/components/com_workflow/resources/scripts/components/edges/CustomEdge.vue @@ -0,0 +1,305 @@ + + + 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 00000000000..a936897e6ea --- /dev/null +++ b/administrator/components/com_workflow/resources/scripts/components/nodes/StageNode.vue @@ -0,0 +1,303 @@ + + + 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 00000000000..d12b27ee9cc --- /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 00000000000..42d8c31b38a --- /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 00000000000..2b062060586 --- /dev/null +++ b/administrator/components/com_workflow/resources/scripts/store/actions.es6.js @@ -0,0 +1,215 @@ +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 + * @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) { + commit('SET_ERROR', error.response?.data?.message || error.message || 'UNEXPECTED_ERROR'); + } finally { + commit('SET_LOADING', false); + } + }, + + /** + * Delete a stage from the workflow. + * @param commit + * @param dispatch + * @param state + * @param id + * @param workflowId + * @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 + ) { + const errorMessage = 'COM_WORKFLOW_ERROR_STAGE_DEFAULT_CANT_DELETED'; + commit('SET_ERROR', errorMessage); + notifications.error(errorMessage); + return; + } + + if (transitions.length > 0) { + const errorMessage = 'COM_WORKFLOW_ERROR_STAGE_HAS_TRANSITIONS'; + commit('SET_ERROR', errorMessage); + if (window.Joomla && window.Joomla.renderMessages) { + window.Joomla.renderMessages({ error: [errorMessage] }); + } + return; + } + + const stageDelete = state.stages.find( + (s) => s.id.toString() === id, + ).published === -1; + + await workflowGraphApi.deleteStage(id, workflowId, stageDelete); + } catch (error) { + const errorMessage = error.message; + commit('SET_ERROR', errorMessage); + + if (window.Joomla && window.Joomla.renderMessages) { + window.Joomla.renderMessages({ + error: [errorMessage], + }); + } + } finally { + commit('SET_LOADING', false); + await dispatch('loadWorkflow', workflowId); + } + }, + + /** + * Delete a transition from the workflow. + * @param commit + * @param dispatch + * @param id + * @param workflowId + * @param transitionDelete + * @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) { + const errorMessage = error.message || 'COM_WORKFLOW_GRAPH_DELETE_TRANSITION_FAILED'; + commit('SET_ERROR', errorMessage); + + if (window.Joomla && window.Joomla.renderMessages) { + window.Joomla.renderMessages({ + error: [errorMessage], + }); + } + } 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 + * @param x + * @param y + */ + updateStagePosition({ commit, dispatch }, { id, x, y }) { + commit('UPDATE_STAGE_POSITION', { id, x, y }); + dispatch('saveToHistory'); + }, + + 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; + } + + commit('SET_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 + * @param panX + * @param panY + */ + 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 00000000000..b74ee9e509d --- /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 00000000000..3aac8365da9 --- /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 00000000000..29ee02fef5b --- /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 00000000000..6d84a575da1 --- /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, stages, 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 00000000000..45d7407a1ad --- /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, 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 00000000000..c8c4a53373e --- /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 00000000000..61ca2975a68 --- /dev/null +++ b/administrator/components/com_workflow/resources/scripts/utils/edges.es6.js @@ -0,0 +1,60 @@ +import { getEdgeColor } from './utils.es6.js'; + +/** + * Generate styled edges based on transition data. + * @param {Array} transitions - List of transitions. + * @param {Object} options - Optional configuration. + * @param {Number|String|null} options.selectedId - Currently selected transition id. + * @returns {Array} Styled edge definitions. + */ +export function generateStyledEdges(transitions, options = {}) { + const { + selectedId = null, + } = options; + + 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, + ); + + let offsetIndex = 0; + 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 00000000000..c428e17079f --- /dev/null +++ b/administrator/components/com_workflow/resources/scripts/utils/focus-utils.es6.js @@ -0,0 +1,150 @@ +/** + * 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 + */ +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 + */ +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 + * @param {boolean} reverse + */ +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]; + announce(liveRegionElement, `Focus mode: ${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 + * @param store + */ +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 + */ +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 00000000000..007c3b39630 --- /dev/null +++ b/administrator/components/com_workflow/resources/scripts/utils/keyboard-manager.es6.js @@ -0,0 +1,188 @@ +import { + announce, cycleFocus, cycleMode, +} from './focus-utils.es6'; + +/** + * Attach global keyboard listeners for workflow canvas. + * @param {Object} options + * @param {Function} addStage + * @param {Function} addTransition + * @param {Function} editItem + * @param {Function} deleteItem + * @param {Function} toggleMode + * @param {Function} undo + * @param {Function} redo + * @param {Function} clearSelection + * @param {Function} zoomIn + * @param {Function} zoomOut + * + * @param {Object} state - { selectedStage, selectedTransition, isTransitionMode, liveRegion } + */ +export function setupGlobalShortcuts({ + addStage, addTransition, editItem, deleteItem, + undo, redo, updateSaveMessage, saveNodePosition, + clearSelection, zoomIn, zoomOut, fitView, + viewport, state, setSaveStatus, 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 00000000000..a888109706a --- /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, padding). + * @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 + * @param {Object} position + * @param {String} color + * @param {String} label + * @param {Function} onSelect + * @param {Boolean} 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 00000000000..29f8e18b465 --- /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 00000000000..2adcd31aaa2 --- /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('Can\'t start the page, the root is not found'); + } +}); 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 00000000000..6bbf5d54ba2 --- /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); + + $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 . '_N_ITEMS_FAILED_PUBLISHING', \count($cid)), 'error', true); + } else { + $ntext = $this->text_prefix . '_N_ITEMS_PUBLISHED'; + } + } elseif ($value === 0) { + $ntext = $this->text_prefix . '_N_ITEMS_UNPUBLISHED'; + } elseif ($value === 2) { + $ntext = $this->text_prefix . '_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 b113d326192..ad3c423d46a 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 bd5c25f62b9..9997fff7770 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 e78227550da..9a803720d58 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 56333631111..2cf3bd475c4 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 00000000000..05ef57afaa7 --- /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 bd26a985bc8..1a9a4835919 100644 --- a/administrator/components/com_workflow/src/Model/StagesModel.php +++ b/administrator/components/com_workflow/src/Model/StagesModel.php @@ -140,9 +140,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 +202,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('Invalid stage positions data provided'); + } + + // 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('Invalid position data for stage ID: ' . $id); + } + } + + $db = $this->getDatabase(); + + try { + $db->transactionStart(); + + foreach ($stages as $stage) { + if (!isset($stage['id']) || !isset($stage['x']) || !isset($stage['y'])) { + throw new \InvalidArgumentException('Invalid stage data structure'); + } + + $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('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 7a3171c9cc6..ef4441249ee 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 00000000000..e5dfb1e3cb5 --- /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/src/View/Stages/HtmlView.php b/administrator/components/com_workflow/src/View/Stages/HtmlView.php index b3ca38f5dfa..adee2bba0e3 100644 --- a/administrator/components/com_workflow/src/View/Stages/HtmlView.php +++ b/administrator/components/com_workflow/src/View/Stages/HtmlView.php @@ -140,6 +140,12 @@ public function display($tpl = null) $this->activeFilters = $model->getActiveFilters(); $this->workflow = $model->getWorkflow(); + if ($this->getLayout() === 'modalreturn') { + parent::display($tpl); + + return; + } + // Check for errors. if (\count($errors = $model->getErrors())) { throw new GenericDataException(implode("\n", $errors), 500); diff --git a/administrator/components/com_workflow/src/View/Transitions/HtmlView.php b/administrator/components/com_workflow/src/View/Transitions/HtmlView.php index 785f61c21d1..d8b6efa1b0d 100644 --- a/administrator/components/com_workflow/src/View/Transitions/HtmlView.php +++ b/administrator/components/com_workflow/src/View/Transitions/HtmlView.php @@ -133,6 +133,12 @@ public function display($tpl = null) $this->activeFilters = $model->getActiveFilters(); $this->workflow = $model->getWorkflow(); + if ($this->getLayout() === 'modalreturn') { + parent::display($tpl); + + return; + } + // Check for errors. if (\count($errors = $model->getErrors())) { throw new GenericDataException(implode("\n", $errors), 500); 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 00000000000..9b8fa623df1 --- /dev/null +++ b/administrator/components/com_workflow/tmpl/graph/default.php @@ -0,0 +1,82 @@ + + * @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'); +$wa->useScript('form.validate'); +$wa->useScript('joomla.dialog'); +$wa->useScript('joomla.dialog-autocreate'); +$wa->useStyle('com_workflow.workflowgraph'); + +// Populate the language +$this->loadTemplate('texts'); + +// Get the URI for the JavaScript module +$script = $wa->getAsset('script', name: 'com_workflow.workflowgraph')->getUri(true); + +$shortcuts = [ + ['key' => 'Alt + N', 'description' => 'Add Stage'], + ['key' => 'Alt + M', 'description' => 'Add Transition'], + ['key' => 'Enter / SpaceBar', 'description' => 'Select Item'], + ['key' => 'Select + E', 'description' => 'Edit Item'], + ['key' => 'Select + Delete', 'description' => 'Delete Item'], + ['key' => 'Select + Backspace', 'description' => 'Delete Item'], + ['key' => 'Select + Shift + Arrows', 'description' => 'Move Stage'], + ['key' => 'Ctrl/Cmd + Z', 'description' => 'Undo'], + ['key' => 'Ctrl/Cmd + Y', 'description' => 'Redo'], + ['key' => 'Escape', 'description' => 'Clear Selection'], + ['key' => '+ / =', 'description' => 'Zoom In'], + ['key' => '- / _', 'description' => 'Zoom Out'], + ['key' => 'F', 'description' => 'Fit View'], + ['key' => 'Tab', 'description' => 'Focus Type Change'], + ['key' => 'Arrows', 'description' => 'Navigate Nodes'], + ['key' => 'Shift + Arrows', 'description' => '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 .= ''; + 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 00000000000..c3e539b0914 --- /dev/null +++ b/administrator/components/com_workflow/tmpl/graph/default_texts.php @@ -0,0 +1,68 @@ + + * @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 = [ + 'UNEXPECTED_ERROR', + 'COM_WORKFLOW_GRAPH', + 'COM_WORKFLOW_GRAPH_LOADING', + 'COM_WORKFLOW_GRAPH_STATUS', + 'COM_WORKFLOW_GRAPH_ENABLED', + 'COM_WORKFLOW_GRAPH_DISABLED', + 'COM_WORKFLOW_GRAPH_STAGE', + 'COM_WORKFLOW_GRAPH_STAGES', + 'COM_WORKFLOW_GRAPH_STAGE_COUNT', + 'COM_WORKFLOW_GRAPH_TRANSITION', + 'COM_WORKFLOW_GRAPH_TRANSITIONS', + 'COM_WORKFLOW_GRAPH_TRANSITION_COUNT', + 'COM_WORKFLOW_GRAPH_ADD_STAGE', + 'COM_WORKFLOW_GRAPH_ADD_TRANSITION', + 'COM_WORKFLOW_GRAPH_EDIT_STAGE', + 'COM_WORKFLOW_GRAPH_EDIT_TRANSITION', + 'COM_WORKFLOW_GRAPH_ENTER_TRANSITION_MODE', + 'COM_WORKFLOW_GRAPH_EXIT_TRANSITION_MODE', + 'COM_WORKFLOW_GRAPH_UP_TO_DATE', + 'COM_WORKFLOW_GRAPH_UNSAVED_CHANGES', + 'COM_WORKFLOW_GRAPH_DEFAULT', + 'COM_WORKFLOW_GRAPH_CANVAS_REGION', + 'COM_WORKFLOW_GRAPH_MINIMAP', + 'COM_WORKFLOW_GRAPH_ADD_STAGE_DIALOG_OPENED', + 'COM_WORKFLOW_GRAPH_ADD_TRANSITION_DIALOG_OPENED', + 'COM_WORKFLOW_GRAPH_TRANSITION_MODE_ON', + 'COM_WORKFLOW_GRAPH_TRANSITION_MODE_OFF', + 'COM_WORKFLOW_GRAPH_ERROR_WORKFLOW_ID_NOT_SET', + 'COM_WORKFLOW_GRAPH_ERROR_EXTENSION_NOT_SET', + 'COM_WORKFLOW_GRAPH_API_BASEURL_NOT_SET', + 'COM_WORKFLOW_GRAPH_ERROR_CSRF_TOKEN_NOT_SET', + 'COM_WORKFLOW_GRAPH_ERROR_INVALID_ID', + 'COM_WORKFLOW_GRAPH_ERROR_WORKFLOW_NOT_FOUND', + 'COM_WORKFLOW_ERROR_STAGE_DEFAULT_CANT_DELETED', + 'COM_WORKFLOW_ERROR_STAGE_HAS_TRANSITIONS', + 'COM_WORKFLOW_GRAPH_DELETE_TRANSITION_FAILED', + 'COM_WORKFLOW_GRAPH_UPDATE_STAGE_POSITION_FAILED', + 'COM_WORKFLOW_GRAPH_WORKFLOWS_EDIT', + 'COM_WORKFLOW_GRAPH_SHORTCUTS_TITLE', + 'COM_WORKFLOW_GRAPH_SHORTCUTS', + 'COM_WORKFLOW_GRAPH_DELETE_STAGE_TITLE', + 'COM_WORKFLOW_GRAPH_DELETE_STAGE_CONFIRM', + 'COM_WORKFLOW_GRAPH_DELETE_TRANSITION_TITLE', + 'COM_WORKFLOW_GRAPH_DELETE_TRANSITION_CONFIRM' +]; + +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 00000000000..31d6136f484 --- /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 00000000000..d7263e793e8 --- /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 10b3e7f6db6..e6894ba30c9 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 00000000000..228b3437e84 --- /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 00000000000..d7263e793e8 --- /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 1412a90d2c9..4aef59dca77 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 eab5374c25c..b4f78247238 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 6b5d01f1fcf..aaaee7c0224 100644 --- a/administrator/language/en-GB/com_workflow.ini +++ b/administrator/language/en-GB/com_workflow.ini @@ -46,6 +46,7 @@ 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_RULES_TAB="Permissions" COM_WORKFLOW_SELECT_FROM_STAGE="- Select Current Stage -" @@ -97,6 +98,8 @@ 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_REDO="Redo" 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,61 @@ COM_WORKFLOW_WORKFLOWS_TABLE_CAPTION="Workflows" COM_WORKFLOW_WORKFLOW_NOTE="Note" JLIB_HTML_PUBLISH_ITEM="Enable" JLIB_HTML_UNPUBLISH_ITEM="Disable" + +;Workflow Graph Editor +UNEXPECTED_ERROR="An unexpected error occurred" +COM_WORKFLOW_GRAPH="Graph" +COM_WORKFLOW_GRAPH_LOADING=Loading... +COM_WORKFLOW_GRAPH_STATUS=Workflow status +COM_WORKFLOW_GRAPH_ENABLED=Enabled +COM_WORKFLOW_GRAPH_DEFAULT=Default +COM_WORKFLOW_GRAPH_DISABLED=Disabled +COM_WORKFLOW_GRAPH_STAGE=Stage +COM_WORKFLOW_GRAPH_STAGES=Stages +COM_WORKFLOW_GRAPH_STAGE_COUNT=Number of stages +COM_WORKFLOW_GRAPH_TRANSITION=Transition +COM_WORKFLOW_GRAPH_TRANSITIONS=Transitions +COM_WORKFLOW_GRAPH_TRANSITION_COUNT=Number of transitions +COM_WORKFLOW_GRAPH_ENTER_TRANSITION_MODE=Enter transition mode +COM_WORKFLOW_GRAPH_EXIT_TRANSITION_MODE=Exit transition mode +COM_WORKFLOW_GRAPH_UP_TO_DATE=All changes saved +COM_WORKFLOW_GRAPH_UNSAVED_CHANGES=You have unsaved changes +COM_WORKFLOW_GRAPH_CANVAS_REGION=Workflow canvas +COM_WORKFLOW_GRAPH_MINIMAP=Minimap +COM_WORKFLOW_GRAPH_ADD_STAGE=Add Stage +COM_WORKFLOW_GRAPH_ADD_STAGE_DIALOG_OPENED=Add stage dialog opened +COM_WORKFLOW_GRAPH_ADD_TRANSITION=Add Transition +COM_WORKFLOW_GRAPH_ADD_TRANSITION_DIALOG_OPENED=Add transition dialog opened +COM_WORKFLOW_GRAPH_EDIT_STAGE=Edit stage +COM_WORKFLOW_GRAPH_EDIT_TRANSITION=Edit transition +COM_WORKFLOW_GRAPH_TRANSITION_MODE_ON=Transition mode enabled +COM_WORKFLOW_GRAPH_TRANSITION_MODE_OFF=Transition mode disabled +COM_WORKFLOW_GRAPH_ERROR_WORKFLOW_ID_NOT_SET=Workflow ID not set +COM_WORKFLOW_GRAPH_ERROR_EXTENSION_NOT_SET=Extension not set +COM_WORKFLOW_GRAPH_API_BASEURL_NOT_SET=API base URL not set +COM_WORKFLOW_GRAPH_ERROR_CSRF_TOKEN_NOT_SET=CSRF token not set +COM_WORKFLOW_GRAPH_ERROR_INVALID_ID=Invalid workflow ID +COM_WORKFLOW_GRAPH_ERROR_WORKFLOW_NOT_FOUND=Workflow not found +COM_WORKFLOW_GRAPH_WORKFLOWS_EDIT=Edit Workflow Graph +COM_WORKFLOW_GRAPH_SHORTCUTS=Shortcuts +COM_WORKFLOW_GRAPH_SHORTCUTS_TITLE=Keyboard Shortcuts +COM_WORKFLOW_GRAPH_DELETE_STAGE_TITLE=Delete Stage +COM_WORKFLOW_GRAPH_DELETE_STAGE_CONFIRM=Are you sure you want to delete this stage? This action cannot be undone. +COM_WORKFLOW_GRAPH_DELETE_TRANSITION_TITLE=Delete Transition +COM_WORKFLOW_GRAPH_DELETE_TRANSITION_CONFIRM=Are you sure you want to delete this transition? This action cannot be undone. +COM_WORKFLOW_ERROR_STAGE_DEFAULT_CANT_DELETED="You cannot delete the default stage." +COM_WORKFLOW_ERROR_STAGE_HAS_TRANSITIONS="This stage has transitions and cannot be deleted." +COM_WORKFLOW_GRAPH_DELETE_TRANSITION_FAILED="Failed to delete transition." +COM_WORKFLOW_GRAPH_UPDATE_STAGE_POSITION_FAILED="Failed to update stage position." +COM_WORKFLOW_GRAPH_STAGE_NO_ITEM_SELECTED="No stage selected." +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_DELETED="%d stages deleted." +COM_WORKFLOW_GRAPH_STAGE_N_ITEMS_DELETED_1="Stage deleted." +COM_WORKFLOW_GRAPH_TRANSITION_NO_ITEM_SELECTED="No transition selected." +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_DELETED="%d transitions deleted." +COM_WORKFLOW_GRAPH_TRANSITION_N_ITEMS_DELETED_1="Transition deleted." +COM_WORKFLOW_GRAPH_TRANSITION_N_ITEMS_FAILED_DELETING="Failed to delete %d transitions." +COM_WORKFLOW_GRAPH_STAGE_N_ITEMS_FAILED_DELETING="%d stages failed to delete." 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 00000000000..cf6c4b8fc53 --- /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 6b7217bf8f8..de340e20548 100644 --- a/build/build.mjs +++ b/build/build.mjs @@ -33,6 +33,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 { cssVersioning } from './build-modules-js/css-versioning.mjs'; import { versioning } from './build-modules-js/versioning.mjs'; @@ -101,6 +102,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( @@ -180,6 +186,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)); @@ -203,6 +220,7 @@ if (cliOptions.prepare) { .then(() => stylesheets(options, Program.args[0])) .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 ed1cbf26924..99b29813026 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 00000000000..875acdef86c --- /dev/null +++ b/build/media_source/com_workflow/js/workflow-graph-client.es6.js @@ -0,0 +1,626 @@ +/** + * @copyright (C) 2025 Open Source Matters + * @license GNU GPL v2 or later; see LICENSE.txt + */ + +Joomla = window.Joomla || {}; +(() => { + async function makeRequest(url) { + try { + const paths = Joomla.getOptions('system.paths'); + const uri = `${paths ? `${paths.rootFull}administrator/index.php` : window.location.pathname + }?option=com_workflow&extension=com_content&layout=modal&view=graph${url}`; + + const response = await fetch(uri, { credentials: 'same-origin' }); + + if (!response.ok) { + // Normalize message based on status + let message = 'An unexpected error occurred.'; + if (response.status === 401) { + message = 'Not authenticated.'; + } else if (response.status === 403 || response.status === 404) { + message = 'You do not have permission to access the workflows.'; + } else { + message = `Request failed with status ${response.status}`; + } + throw new Error(message); + } + + return await response.json(); + + } catch (err) { + showErrorInModal(err.message); + throw err; + } + } + + function showErrorInModal(errorMessage) { + const container = document.getElementById("workflow-container"); + const stageContainer = document.getElementById("stages"); + + if (container) { + // Clear the main container and show error + container.innerHTML = ` + + `; + } else if (stageContainer) { + // Fallback: show in stages container + stageContainer.innerHTML = ` + + `; + } + } + + + async function getWorkflow(id) { + return makeRequest(`&task=graph.getWorkflow&workflow_id=${id}&format=json`); + } + async function getStages(workflowId) { + return makeRequest(`&task=graph.getStages&workflow_id=${workflowId}&format=json`); + } + async function getTransitions(workflowId) { + return makeRequest(`&task=graph.getTransitions&workflow_id=${workflowId}&format=json`); + } + function filterWorkflow(stages, transitions) { + let filteredTransitions = transitions.filter(tr => { + return tr.permissions == null ? void 0 : tr.permissions.run_transition; + }); + const connectedStageIds = new Set(); + filteredTransitions.forEach(tr => { + if (tr.from_stage_id !== -1) connectedStageIds.add(tr.from_stage_id); + connectedStageIds.add(tr.to_stage_id); + }); + let filteredStages = stages.filter(st => { + const editable = (st.permissions == null ? void 0 : st.permissions.edit) || (st.permissions == null ? void 0 : st.permissions.delete); + const connected = connectedStageIds.has(st.id); + return editable || connected; + }); + const validStageIds = new Set(filteredStages.map(st => st.id)); + filteredTransitions = filteredTransitions.filter(tr => (tr.from_stage_id === -1 || validStageIds.has(tr.from_stage_id)) && validStageIds.has(tr.to_stage_id)); + return { + stages: filteredStages, + transitions: filteredTransitions + }; + } + function calculateAutoLayout(stages, transitions) { + const needsPosition = stages.filter(stage => !stage.position || isNaN(stage.position.x) || isNaN(stage.position.y)); + if (needsPosition.length === 0) return stages; + const fromAnyStage = stages.find(s => s.id === 'From Any'); + if (fromAnyStage && (!fromAnyStage.position || isNaN(fromAnyStage.position.x) || isNaN(fromAnyStage.position.y))) { + fromAnyStage.position = { + x: 600, + y: -200 + }; + } + const outgoing = new Map(stages.map(s => [s.id, []])); + const inDegree = new Map(stages.map(s => [s.id, 0])); + transitions.forEach(tr => { + const fromId = tr.from_stage_id === -1 ? 'From Any' : tr.from_stage_id; + const toId = tr.to_stage_id; + if (outgoing.has(fromId) && inDegree.has(toId)) { + outgoing.get(fromId).push(toId); + inDegree.set(toId, inDegree.get(toId) + 1); + } + }); + const levels = []; + let queue = stages.filter(s => inDegree.get(s.id) === 0 && s.id !== 'From Any'); + while (queue.length > 0) { + levels.push(queue); + const nextQueue = []; + for (const stage of queue) { + for (const targetId of outgoing.get(stage.id) || []) { + inDegree.set(targetId, inDegree.get(targetId) - 1); + if (inDegree.get(targetId) === 0 && targetId !== 'From Any') { + nextQueue.push(stages.find(s => s.id === targetId)); + } + } + } + queue = nextQueue; + } + const levelSpacing = 300; + const stageSpacing = 120; + levels.forEach((levelStages, level) => { + const levelHeight = (levelStages.length - 1) * stageSpacing; + const startY = -levelHeight / 2; + levelStages.forEach((stage, index) => { + if (needsPosition.some(s => s.id === stage.id)) { + stage.position = { + x: level * levelSpacing + 50, + y: startY + index * stageSpacing + 300 + }; + } + }); + }); + return stages; + } + function generateNodes(stages, transitions) { + // Ensure every stage has a position object + stages.forEach(stage => { + if (!stage.position || isNaN(stage.position.x) || isNaN(stage.position.y)) { + stage.position = { x: 0, y: 0 }; + } else { + stage.position.x = parseFloat(stage.position.x); + stage.position.y = parseFloat(stage.position.y); + } + }); + const hasStart = transitions.some(tr => tr.from_stage_id === -1); + if (hasStart && !stages.find(s => s.id === 'From Any')) stages.unshift({ + id: 'From Any', + title: 'From Any', + position: { x: 600, y: -200 } + }); + const positionedStages = calculateAutoLayout(stages, transitions); + return positionedStages.map(stage => { + const isVirtual = stage.id === 'From Any'; + return { + id: `stage-${stage.id}`, + position: stage.position, + data: stage, + className: `stage ${stage.default ? 'default' : ''} ${isVirtual ? 'virtual' : ''}`, + innerHTML: ` +
${stage.title}
+ ${stage.description ? `
${stage.description}
` : ''} +
+ ${stage.default ? '
DEFAULT
' : ''} + ${typeof stage.published !== 'undefined' ? `
${stage.published == 1 ? 'ENABLED' : 'DISABLED'}
` : ''} +
+ ` + }; + }); + } + + /** + * Generates edge objects with robust pathing and centered labels. + */ + function generateEdges(transitions, stages) { + const STAGE_WIDTH = 200; + const STAGE_HEIGHT = 100; + const getConnectionPoint = (fromStage, toStage, isSource) => { + const node = isSource ? fromStage : toStage; + const center = { + x: node.position.x + STAGE_WIDTH / 2, + y: node.position.y + STAGE_HEIGHT / 2 + }; + const otherCenter = { + x: (isSource ? toStage : fromStage).position.x + STAGE_WIDTH / 2, + y: (isSource ? toStage : fromStage).position.y + STAGE_HEIGHT / 2 + }; + const dx = otherCenter.x - center.x; + const dy = otherCenter.y - center.y; + if (Math.abs(dx) > Math.abs(dy)) { + return { + x: dx > 0 ? node.position.x + STAGE_WIDTH : node.position.x, + y: center.y + }; + } + return { + x: center.x, + y: dy > 0 ? node.position.y + STAGE_HEIGHT : node.position.y + }; + }; + return transitions.map(tr => { + const fromStage = stages.find(s => s.id === (tr.from_stage_id === -1 ? 'From Any' : tr.from_stage_id)); + const toStage = stages.find(s => s.id === tr.to_stage_id); + if (!(fromStage != null && fromStage.position) || !(toStage != null && toStage.position)) return null; + const sourcePoint = getConnectionPoint(fromStage, toStage, true); + const targetPoint = getConnectionPoint(fromStage, toStage, false); + const toCenter = { + x: toStage.position.x + STAGE_WIDTH / 2, + y: toStage.position.y + STAGE_HEIGHT / 2 + }; + let pathData; + let labelPosition; + + // Determine if the entry to the target node is vertical (top/bottom) or horizontal (left/right) + const isVerticalEntry = Math.abs(targetPoint.x - toCenter.x) < 1; + if (isVerticalEntry) { + // Entry is top/bottom. Path must end with a vertical line for the arrow. + const midY = (sourcePoint.y + targetPoint.y) / 2; + pathData = `M ${sourcePoint.x},${sourcePoint.y} L ${sourcePoint.x},${midY} L ${targetPoint.x},${midY} L ${targetPoint.x},${targetPoint.y}`; + labelPosition = { + x: (sourcePoint.x + targetPoint.x) / 2, + y: midY + }; + } else { + // Entry is left/right. Path must end with a horizontal line. + const midX = (sourcePoint.x + targetPoint.x) / 2; + pathData = `M ${sourcePoint.x},${sourcePoint.y} L ${midX},${sourcePoint.y} L ${midX},${targetPoint.y} L ${targetPoint.x},${targetPoint.y}`; + labelPosition = { + x: midX, + y: (sourcePoint.y + targetPoint.y) / 2 + }; + } + return { + id: `transition-${tr.id}`, + pathData, + label: tr.title || 'Transition', + labelPosition + }; + }).filter(Boolean); + } + function renderNodes(nodes, container, onDrag) { + container.innerHTML = ''; + nodes.forEach(node => { + const div = document.createElement('div'); + div.id = node.id; + div.className = node.className; + div.innerHTML = node.innerHTML; + div.style.left = `${node.position.x}px`; + div.style.top = `${node.position.y}px`; + div.addEventListener('mousedown', e => { + if (e.button !== 0) return; + e.stopPropagation(); + onDrag(e, node.data); + }); + container.appendChild(div); + }); + } + function highlightTransition(edgeId) { + // Reset all first + document.querySelectorAll('.transition-path').forEach(p => { + p.classList.remove('highlighted'); + }); + document.querySelectorAll('.transition-label-content').forEach(l => { + l.classList.remove('highlighted'); + }); + + // Highlight selected + const path = document.querySelector(`.transition-path[data-edge-id="${edgeId}"]`); + const label = document.querySelector(`.transition-label-content[data-edge-id="${edgeId}"]`); + if (path) path.classList.add('highlighted'); + if (label) label.classList.add('highlighted'); + } + function renderEdges(edges, svg) { + svg.querySelectorAll('path, foreignObject').forEach(el => el.remove()); + edges.forEach(edge => { + const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + path.setAttribute('d', edge.pathData); + path.setAttribute('class', 'transition-path'); + path.setAttribute('data-edge-id', edge.id); // track edge + path.setAttribute('marker-end', 'url(#arrowhead)'); + const foreignObject = document.createElementNS('http://www.w3.org/2000/svg', 'foreignObject'); + foreignObject.setAttribute('class', 'transition-label'); + foreignObject.setAttribute('width', '120'); + foreignObject.setAttribute('height', '24'); + foreignObject.setAttribute('x', edge.labelPosition.x - 60); + foreignObject.setAttribute('y', edge.labelPosition.y - 12); + const labelDiv = document.createElement('div'); + labelDiv.className = 'transition-label-content'; + labelDiv.textContent = edge.label; + labelDiv.dataset.edgeId = edge.id; + labelDiv.addEventListener('click', e => { + e.stopPropagation(); + highlightTransition(edge.id); + }); + foreignObject.appendChild(labelDiv); + svg.appendChild(path); + svg.appendChild(foreignObject); + }); + } + function initWorkflowGraph() { + const container = document.getElementById("workflow-container"); + const containerTitle = document.getElementById("workflow-main-title"); + const graph = document.getElementById("graph"); + const stageContainer = document.getElementById("stages"); + const svg = document.getElementById("connections"); + const statusBadge = document.querySelector(".badge[role='status']"); + if (!container || !graph || !stageContainer || !svg) { + console.warn("[Workflow Graph] Missing required DOM elements."); + return; + } + + // Check if already initialized + if (container.hasAttribute('data-workflow-initialized')) { + console.log('[Workflow Graph] Already initialized, skipping...'); + return; + } + + // Mark as initialized + container.setAttribute('data-workflow-initialized', 'true'); + const workflowId = parseInt(container.dataset.workflowId, 10); + if (!workflowId) { + console.warn("[Workflow Graph] Invalid workflow ID."); + return; + } + + + // Pan & Zoom state + svg.innerHTML = ` + + + + + `; + let state = { + stages: [], + transitions: [], + scale: 1, + panX: 0, + panY: 0, + isDraggingStage: false + }; + + // Stage dimensions + const STAGE_WIDTH = 200; + const STAGE_HEIGHT = 80; + function updateTransform() { + graph.style.transform = `translate(${state.panX}px, ${state.panY}px) scale(${state.scale})`; + } + const handleNodeDrag = (startEvent, draggedStage) => { + state.isDraggingStage = true; + const stageElement = document.getElementById(`stage-${draggedStage.id}`); + const dragStart = { + x: startEvent.clientX, + y: startEvent.clientY, + stageX: draggedStage.position.x, + stageY: draggedStage.position.y + }; + stageElement.classList.add('dragging'); + const onMouseMove = moveEvent => { + draggedStage.position.x = dragStart.stageX + (moveEvent.clientX - dragStart.x) / state.scale; + draggedStage.position.y = dragStart.stageY + (moveEvent.clientY - dragStart.y) / state.scale; + stageElement.style.left = `${draggedStage.position.x}px`; + stageElement.style.top = `${draggedStage.position.y}px`; + const edges = generateEdges(state.transitions, state.stages); + renderEdges(edges, svg); + }; + const onMouseUp = () => { + document.removeEventListener('mousemove', onMouseMove); + document.removeEventListener('mouseup', onMouseUp); + stageElement.classList.remove('dragging'); + setTimeout(() => { + state.isDraggingStage = false; + }, 0); + }; + document.addEventListener('mousemove', onMouseMove); + document.addEventListener('mouseup', onMouseUp); + }; + 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() { + const bounds = getBoundingBox(); + if (!bounds) return; + const containerRect = container.getBoundingClientRect(); + const padding = 100; + const availableWidth = containerRect.width - padding; + const availableHeight = containerRect.height - padding; + + // Calculate scale to fit content + const scaleX = availableWidth / bounds.width; + const scaleY = availableHeight / bounds.height; + state.scale = Math.min(scaleX, scaleY, 1.5); // Allow some zoom in + state.scale = Math.max(state.scale, 0.1); // Minimum scale + + // Center the content + 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; + updateTransform(); + } + Promise.all([getWorkflow(workflowId), getStages(workflowId), getTransitions(workflowId)]).then(([workflowData, stagesData, transitionsData]) => { + const workflow = (workflowData == null ? void 0 : workflowData.data) || {}; + let stages = (stagesData == null ? void 0 : stagesData.data) || []; + let transitions = (transitionsData == null ? void 0 : transitionsData.data) || []; + ({ + stages, + transitions + } = filterWorkflow(stages, transitions)); + console.log('stages:', stages); + console.log('transitions:', transitions); + state.stages = stages; + state.transitions = transitions; + if (!state.stages.length) { + showErrorInModal('No stages found.'); + return; + } + + // Update header info + if (containerTitle) { + containerTitle.innerHTML = workflow.title || 'Workflow'; + } + if (statusBadge) { + const isPublished = workflow.published || workflow.state === 1; + statusBadge.className = `badge ${isPublished ? 'bg-success' : 'bg-warning'}`; + statusBadge.textContent = isPublished ? 'Enabled' : 'Disabled'; + } + const stageCountSpan = document.querySelectorAll('dd span')[1]; + if (stageCountSpan) { + stageCountSpan.textContent = `${state.stages.length} ${state.stages.length === 1 ? 'Stage' : 'Stages'}`; + } + const transitionCountSpan = document.querySelectorAll('dd span')[2]; + if (transitionCountSpan) { + transitionCountSpan.textContent = `${state.transitions.length} ${state.transitions.length === 1 ? 'Transition' : 'Transitions'}`; + } + const nodes = generateNodes(state.stages, state.transitions); + renderNodes(nodes, stageContainer, handleNodeDrag); + const edges = generateEdges(state.transitions, state.stages); + renderEdges(edges, svg); + + // Add zoom controls + const zoomControls = container.querySelector('.zoom-controls'); + const zoomInBtn = zoomControls.querySelector('.zoom-in'); + const zoomOutBtn = zoomControls.querySelector('.zoom-out'); + const fitBtn = zoomControls.querySelector('.fit-screen'); + zoomInBtn.addEventListener('click', e => { + e.preventDefault(); + e.stopPropagation(); + const rect = container.getBoundingClientRect(); + const centerX = rect.width / 2; + const centerY = rect.height / 2; + const oldScale = state.scale; + state.scale = Math.min(state.scale * 1.2, 3); + + // Zoom towards center + const factor = state.scale / oldScale; + state.panX = centerX - (centerX - state.panX) * factor; + state.panY = centerY - (centerY - state.panY) * factor; + updateTransform(); + }); + zoomOutBtn.addEventListener('click', e => { + e.preventDefault(); + e.stopPropagation(); + const rect = container.getBoundingClientRect(); + const centerX = rect.width / 2; + const centerY = rect.height / 2; + const oldScale = state.scale; + state.scale = Math.max(state.scale / 1.2, 0.1); + + // Zoom from center + const factor = state.scale / oldScale; + state.panX = centerX - (centerX - state.panX) * factor; + state.panY = centerY - (centerY - state.panY) * factor; + updateTransform(); + }); + fitBtn.addEventListener('click', e => { + e.preventDefault(); + e.stopPropagation(); + fitToScreen(); + }); + + // Pan functionality - improved to not conflict with stage dragging + let isPanning = false, + panStart = {}; + container.addEventListener("mousedown", e => { + if (e.target.closest('.stage') || e.target.closest('.zoom-controls') || state.isDraggingStage) return; + isPanning = true; + panStart = { + x: e.clientX - state.panX, + y: e.clientY - state.panY + }; + container.style.cursor = 'grabbing'; + e.preventDefault(); + }); + container.addEventListener("mousemove", e => { + if (!isPanning || state.isDraggingStage) return; + state.panX = e.clientX - panStart.x; + state.panY = e.clientY - panStart.y; + updateTransform(); + }); + container.addEventListener("mouseup", () => { + isPanning = false; + container.style.cursor = 'default'; + }); + container.addEventListener("mouseleave", () => { + isPanning = false; + container.style.cursor = 'default'; + }); + + // Zoom with wheel - fixed to zoom towards mouse position + 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 zoomFactor = 0.1; + if (e.deltaY < 0) { + state.scale = Math.min(state.scale * (1 + zoomFactor), 3); + } else { + state.scale = Math.max(state.scale * (1 - zoomFactor), 0.1); + } + + // Zoom towards mouse position + const factor = state.scale / oldScale; + state.panX = mouseX - (mouseX - state.panX) * factor; + state.panY = mouseY - (mouseY - state.panY) * factor; + updateTransform(); + }); + + // Keyboard shortcuts + const handleKeyDown = e => { + if (document.activeElement.tagName === 'INPUT' || document.activeElement.tagName === 'TEXTAREA' || document.activeElement.isContentEditable) return; + switch (e.key.toLowerCase()) { + case '+': + case '=': + e.preventDefault(); + zoomControls.querySelector('.zoom-in').click(); + break; + case '-': + e.preventDefault(); + zoomControls.querySelector('.zoom-out').click(); + break; + case 'f': + e.preventDefault(); + fitToScreen(); + break; + } + }; + document.addEventListener('keydown', handleKeyDown); + + // Auto-fit initially with proper delay + setTimeout(() => { + requestAnimationFrame(() => { + fitToScreen(); + }); + }, 100); // Reduced delay for faster fitting + }).catch(error => { + console.error('[Workflow Graph] Failed to initialize:', error); + showErrorInModal(`Failed to load workflow data: ${error.message}`); + }); + } + + // Observer for dynamic content + const observer = new MutationObserver(mutationsList => { + for (const mutation of mutationsList) { + if (mutation.type === 'childList') { + mutation.addedNodes.forEach(node => { + if (node.nodeType === Node.ELEMENT_NODE) { + const workflowContainer = node.querySelector ? node.querySelector('#workflow-container') : node.id === 'workflow-container' ? node : null; + if (workflowContainer && !workflowContainer.hasAttribute('data-workflow-initialized')) { + setTimeout(initWorkflowGraph, 100); + } + } + }); + } + } + }); + function init() { + const existingContainer = document.getElementById('workflow-container'); + if (existingContainer && !existingContainer.hasAttribute('data-workflow-initialized')) { + setTimeout(initWorkflowGraph, 100); + } + observer.observe(document.body, { + childList: true, + subtree: true + }); + } + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); + } else { + init(); + } +})(); diff --git a/build/media_source/com_workflow/scss/_variables.scss b/build/media_source/com_workflow/scss/_variables.scss new file mode 100644 index 00000000000..e69de29bb2d diff --git a/build/media_source/com_workflow/scss/components/_workflow-graph-custom.scss b/build/media_source/com_workflow/scss/components/_workflow-graph-custom.scss new file mode 100644 index 00000000000..e8a8db33590 --- /dev/null +++ b/build/media_source/com_workflow/scss/components/_workflow-graph-custom.scss @@ -0,0 +1,199 @@ +/* ----- Layout ----- */ +.workflow-canvas { + background-color: var(--primary-rgb); + background-size: 20px 20px; +} + +.min-vh-80 { height: 80vh; } +.min-width-0 { min-width: 0; } +.h-40 { height: 40px; } +.end-20-px { right: 20px !important; } +.top-25-px { top: 25px !important; } +.z-10 { z-index: 10; } +.z-20 { z-index: 20; } + +/* ----- Edges ----- */ +.vue-flow__edge-path { + stroke-width: 2; + transition: stroke .2s ease, stroke-width .2s ease; +} +.vue-flow__edge.selected .vue-flow__edge-path, +.vue-flow__edge:hover .vue-flow__edge-path { + stroke: var(--primary); + stroke-width: 3; +} + +.custom-edge { + background: rgb(32, 113, 198) !important; +} + +/* ----- Edge Labels ----- */ +.vue-flow__edge-label { + padding: 4px 8px; + font-size: 12px; + font-weight: 500; + pointer-events: all; + cursor: pointer; + background: var(--white); + border: 1px solid var(--border); + border-radius: 3px; +} +.vue-flow__edge-label:hover { + background: var(--gray-100); + border-color: var(--gray-400); +} + +/* ----- Controls & Minimap ----- */ +.vue-flow__controls, +.vue-flow__minimap { + box-shadow: 0 10px 15px -3px rgba(0, 0, 0, .1); +} + +.custom-controls { + position: absolute; + right: 10px; + bottom: 10px; + z-index: 10; + padding: 4px; + background: var(--secondary); +} + +/* ----- Nodes ----- */ +.stage-node { position: relative; } +.stage-node .edge-handler { + width: 12px !important; + height: 12px !important; + background: var(--vf-node-text); +} +.stage-node:hover .vue-flow__handle { opacity: 1; } +.vue-flow__node-stage { max-width: 250px !important; } + +/* ----- Handle ----- */ +.vue-flow__handle { + opacity: 0; + transition: opacity .2s; +} + +/* ----- Card Actions ----- */ +.workflow-browser-actions-list { + background-color: #000; + box-shadow: 0 4px 10px rgba(0, 0, 0, .6); +} +.stage-card-actions button { + --btn-padding-x: 2px !important; + --btn-padding-y: 0 !important; +} + +/* ----- Text Truncation ----- */ +.line-clamp-2 { + display: block; + overflow: hidden; + text-overflow: ellipsis; + line-clamp: 2; +} + +/* ----- Accessibility Utilities ----- */ +.visually-hidden { + position: absolute !important; + width: 1px !important; + height: 1px !important; + padding: 0 !important; + margin: -1px !important; + overflow: hidden !important; + clip: rect(0, 0, 0, 0) !important; + white-space: nowrap !important; + border: 0 !important; +} + +/* Focus indicators */ +:focus { + border-radius: 0 !important; + outline: 2px solid #005fcc !important; + outline-offset: 2px !important; + box-shadow: 0 0 0 3px rgba(0, 95, 204, .4) !important; +} + +/* Skip links */ +.skip-link { + position: absolute; + top: -40px; + left: 6px; + z-index: 9999; + padding: 8px; + color: #fff; + text-decoration: none; + background: #0d6efd; + border-radius: 3px; + transition: top .3s; +} +.skip-link:focus { top: 6px; } +/* Ensure child elements inside groups remain usable */ +.vue-flow [role="group"] { + pointer-events: none !important; + user-select: none; +} +.vue-flow [role="group"] > * { + pointer-events: auto; +} + +/* ----- High Contrast Mode ----- */ +@media (prefers-contrast: high), (forced-colors: active), screen and (-ms-high-contrast: active) { + .stage-node, + .edge-label, + .workflow-browser-actions-list, + .custom-controls-button, + .badge { + color: ButtonText !important; + background: ButtonFace !important; + border: 2px solid ButtonText !important; + forced-color-adjust: none !important; + } + :focus { + color: HighlightText !important; + background: Highlight !important; + outline: 3px solid Highlight !important; + } +} + +/* ----- Reduced Motion ----- */ +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { + transition: none !important; + animation: none !important; + scroll-behavior: auto !important; + } +} + +/* ----- Touch Targets ----- */ +@media (pointer: coarse) { + button, + [role="button"], + [role="menuitem"], + .stage-node, + .edge-label, + .custom-controls-button { + min-width: 48px !important; + min-height: 48px !important; + padding: 14px 18px !important; + } +} + +/* ----- Print Styles ----- */ +@media print { + .stage-node, + .edge-label { + color: #000 !important; + background: #fff !important; + border: 2px solid #000 !important; + box-shadow: none !important; + } + .workflow-browser-actions-list, + .stage-card-actions, + .vue-flow__controls, + .custom-controls { display: none !important; } + .badge::after { + font-size: 11px !important; + font-style: italic !important; + content: " (" attr(title) ")" !important; + } +} diff --git a/build/media_source/com_workflow/scss/components/_workflow-vue-controls.scss b/build/media_source/com_workflow/scss/components/_workflow-vue-controls.scss new file mode 100644 index 00000000000..18b3ead4634 --- /dev/null +++ b/build/media_source/com_workflow/scss/components/_workflow-vue-controls.scss @@ -0,0 +1,41 @@ +/* ----- Controls Container ----- */ +.vue-flow__controls { + box-shadow: 0 0 2px 1px var(--shadow-subtle); +} + +/* ----- Controls Buttons ----- */ +.vue-flow__controls-button { + box-sizing: content-box; + display: flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + padding: 5px; + cursor: pointer; + user-select: none; + background: var(--button-bg); + border: none; + border-bottom: 1px solid var(--border-light); +} + +/* ----- SVG Icon Styling ----- */ +.vue-flow__controls-button svg { + width: 100%; + max-width: 12px; + max-height: 12px; +} + +/* ----- Button Hover ----- */ +.vue-flow__controls-button:hover { + background: var(--button-hover-bg); +} + +/* ----- Button Disabled ----- */ +.vue-flow__controls-button:disabled { + pointer-events: none; +} + +.vue-flow__controls-button:disabled svg { + fill-opacity: .4; +} diff --git a/build/media_source/com_workflow/scss/components/_workflow-vue-core.scss b/build/media_source/com_workflow/scss/components/_workflow-vue-core.scss new file mode 100644 index 00000000000..a4bc541c3ea --- /dev/null +++ b/build/media_source/com_workflow/scss/components/_workflow-vue-core.scss @@ -0,0 +1,261 @@ +/* Vue Flow Base Container */ +.vue-flow { + position: relative; + z-index: 0; + width: 100%; + height: 100%; + overflow: hidden; + direction: ltr; +} + +/* Flow Container */ +.vue-flow__container { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; +} + +.vue-flow__pane { + z-index: 1; +} + +.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__selection { + z-index: 6; +} + +.vue-flow__edge-labels { + position: absolute; + width: 100%; + height: 100%; + pointer-events: none; + user-select: none; +} + +.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(--edge-color); + stroke-width: 1; + fill: none; +} + +.vue-flow__edge { + pointer-events: visibleStroke; + cursor: pointer; +} + +.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__edge.selected .vue-flow__edge-path, +.vue-flow__edge:focus .vue-flow__edge-path, +.vue-flow__edge:focus-visible .vue-flow__edge-path { + stroke: var(--edge-selected-color); +} + +.vue-flow__edge-textwrapper { + pointer-events: all; +} + +.vue-flow__edge-textbg { + fill: var(--edge-label-bg); +} + +.vue-flow__edge-text { + pointer-events: none; + user-select: 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-default, +.vue-flow__node-input, +.vue-flow__node-output { + border-color: var(--node-border-color); + border-style: solid; + border-width: 1px; +} + +.vue-flow__node-default.selected, +.vue-flow__node-default:focus, +.vue-flow__node-default:focus-visible, +.vue-flow__node-input.selected, +.vue-flow__node-input:focus, +.vue-flow__node-input:focus-visible, +.vue-flow__node-output.selected, +.vue-flow__node-output:focus, +.vue-flow__node-output:focus-visible { + border: 1px solid var(--node-selected-border-color); + outline: none; +} + +.vue-flow__node { + position: absolute; + box-sizing: border-box; + pointer-events: all; + cursor: default; + user-select: none; + transform-origin: 0 0; +} + +.vue-flow__node.draggable { + cursor: grab; +} + +.vue-flow__node.draggable.dragging { + cursor: grabbing; +} + +.vue-flow__nodesselection { + z-index: 3; + pointer-events: none; + transform-origin: left top; +} + +.vue-flow__nodesselection-rect { + position: absolute; + pointer-events: all; + cursor: grab; +} + +.vue-flow__nodesselection-rect.dragging { + cursor: grabbing; +} + +.vue-flow__handle { + position: absolute; + min-width: 5px; + min-height: 5px; + pointer-events: none; +} + +.vue-flow__handle.connectable { + pointer-events: all; + cursor: crosshair; +} + +.vue-flow__handle-bottom { + bottom: 0; + left: 50%; + transform: translate(-50%, 50%); +} + +.vue-flow__handle-top { + top: 0; + left: 50%; + transform: translate(-50%, -50%); +} + +.vue-flow__handle-left { + top: 50%; + left: 0; + transform: translate(-50%, -50%); +} + +.vue-flow__handle-right { + top: 50%; + right: 0; + transform: translate(50%, -50%); +} + +.vue-flow__edgeupdater { + pointer-events: all; + cursor: move; +} + +.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; + } +} diff --git a/build/media_source/com_workflow/scss/components/_workflow-vue-minimap.scss b/build/media_source/com_workflow/scss/components/_workflow-vue-minimap.scss new file mode 100644 index 00000000000..64cc162f19f --- /dev/null +++ b/build/media_source/com_workflow/scss/components/_workflow-vue-minimap.scss @@ -0,0 +1,15 @@ +.vue-flow__minimap { + background-color: var(--bg-normal); +} + +.vue-flow__minimap.pannable { + cursor: grab; +} + +.vue-flow__minimap.dragging { + cursor: grabbing; +} + +.vue-flow__minimap-mask.pannable { + cursor: grab; +} diff --git a/build/media_source/com_workflow/scss/components/_workflow-vue-theme.scss b/build/media_source/com_workflow/scss/components/_workflow-vue-theme.scss new file mode 100644 index 00000000000..7966e0ceab1 --- /dev/null +++ b/build/media_source/com_workflow/scss/components/_workflow-vue-theme.scss @@ -0,0 +1,129 @@ +:root { + --vf-node-bg: #fff; + --vf-node-text: #222; + --vf-connection-path: #b1b1b7; + --vf-handle: #555; +} + +.vue-flow__edge.updating .vue-flow__edge-path { + stroke: #777; +} + +.vue-flow__edge-text { + font-size: 10px; +} + +.vue-flow__edge-textbg { + fill: #fff; +} + +.vue-flow__connection-path { + stroke: var(--vf-connection-path); +} + +.vue-flow__node { + cursor: grab; +} + +.vue-flow__node.selectable:focus, +.vue-flow__node.selectable:focus-visible { + outline: none; +} + +.vue-flow__node-default, +.vue-flow__node-input, +.vue-flow__node-output { + width: 150px; + padding: 10px; + font-size: 12px; + color: var(--vf-node-text); + text-align: center; + background-color: var(--vf-node-bg); + border-color: var(--vf-node-color); + border-style: solid; + border-width: 1px; + border-radius: 3px; +} + +.vue-flow__node-default.selected, +.vue-flow__node-default.selected:hover, +.vue-flow__node-input.selected, +.vue-flow__node-input.selected:hover, +.vue-flow__node-output.selected, +.vue-flow__node-output.selected:hover { + box-shadow: 0 0 0 .5px var(--vf-box-shadow); +} + +.vue-flow__node-default .vue-flow__handle, .vue-flow__node-input .vue-flow__handle, .vue-flow__node-output .vue-flow__handle { + background: var(--vf-handle); +} + +.vue-flow__node-default.selectable:hover, .vue-flow__node-input.selectable:hover, .vue-flow__node-output.selectable:hover { + box-shadow: 0 1px 4px 1px rgba(0, 0, 0, .08); +} + +.vue-flow__node-input { + --vf-node-color: var(--vf-node-color, #0041d0); + --vf-handle: var(--vf-node-color, #0041d0); + --vf-box-shadow: var(--vf-node-color, #0041d0); + + background: var(--vf-node-bg); + border-color: var(--vf-node-color, #0041d0); +} + +.vue-flow__node-input.selected, +.vue-flow__node-input:focus, +.vue-flow__node-input:focus-visible { + border: 1px solid var(--vf-node-color, #0041d0); + outline: none; +} + +.vue-flow__node-default { + --vf-handle: var(--vf-node-color, #1a192b); + --vf-box-shadow: var(--vf-node-color, #1a192b); + background: var(--vf-node-bg); + border-color: var(--vf-node-color, #1a192b); +} + +.vue-flow__node-default.selected, +.vue-flow__node-default:focus, +.vue-flow__node-default:focus-visible { + border: 1px solid var(--vf-node-color, #1a192b); + outline: none; +} + +.vue-flow__node-output { + --vf-handle: var(--vf-node-color, #ff0072); + --vf-box-shadow: var(--vf-node-color, #ff0072); + + background: var(--vf-node-bg); + border-color: var(--vf-node-color, #ff0072); +} + +.vue-flow__node-output.selected, +.vue-flow__node-output:focus, +.vue-flow__node-output:focus-visible { + border: 1px solid var(--vf-node-color, #ff0072); + outline: none; +} + +.vue-flow__nodesselection-rect, +.vue-flow__selection { + background: rgba(0, 89, 220, .08); + border: 1px dotted rgba(0, 89, 220, .8); +} + +.vue-flow__nodesselection-rect:focus, +.vue-flow__nodesselection-rect:focus-visible, +.vue-flow__selection:focus, +.vue-flow__selection:focus-visible { + outline: none; +} + +.vue-flow__handle { + width: 6px; + height: 6px; + background: var(--vf-handle); + border: 1px solid #fff; + border-radius: 100%; +} 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 00000000000..f230ba2bf1c --- /dev/null +++ b/build/media_source/com_workflow/scss/workflow-graph-client.scss @@ -0,0 +1,298 @@ +/* ================================ + Workflow Graph 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(--primary); + + --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-stroke: var(--primary); + --transition-hover: var(--border-color-hover); + --transition-label-bg: rgb(49, 135, 226); + --transition-label-color: var(--white); + --transition-highlight: #ff6868; + + --zoom-btn-bg: transparent; + --zoom-btn-hover: var(--border-color); + --zoom-btn-color: var(--text-primary); +} + +/* ================================ + Containers + ================================ */ + +#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: 18px 18px; + 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); + transition: all .2s cubic-bezier(.4, 0, .2, 1); + animation: slideIn .3s ease-out forwards; +} + +.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: relative; + z-index: 10; + padding: 4px 8px; + font-size: 14px; + 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: 1px solid var(--stage-border); + border-radius: 4px; + box-shadow: 0 4px 10px rgba(0, 0, 0, .6); +} + +.transition-label-content.highlighted { + font-weight: 700; + background: var(--transition-highlight) !important; + transition: transform .2s ease, background .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; + border: 1px solid var(--wf-border); + border-radius: 6px; + backdrop-filter: blur(4px); +} + +.zoom-btn { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + font-size: 14px; + font-weight: 600; + color: var(--zoom-btn-color); + cursor: pointer; + user-select: none; + background: var(--zoom-btn-bg); + border: none; + border-radius: 4px; + transition: all .15s ease; +} + +.zoom-btn:hover { + background: var(--zoom-btn-hover); + transform: scale(1.05); +} + +.zoom-btn:active { + transition: transform .05s ease; + transform: scale(.95); +} + +/* ================================ + Accessibility & Preferences + ================================ */ + +.stage:focus-visible, +.zoom-btn:focus-visible { + outline: 2px solid var(--stage-border); + outline-offset: 2px; +} + +@media (prefers-contrast: high) { + .stage { border-width: 2px; } + .transition-path { stroke-width: 3; } + .transition-label-content { + font-weight: 600; + border-width: 2px; + } +} + +@media (prefers-reduced-motion: reduce) { + .stage, .transition-path, .zoom-btn, #graph { transition: none; } + .stage:hover, .zoom-btn:hover, .stage.dragging { transform: none; } +} + +/* ================================ + Animations + ================================ */ + +@keyframes slideIn { + from { opacity: 0; transform: translateY(20px) scale(.9); } + to { opacity: 1; transform: translateY(0) scale(1); } +} + +@keyframes dashmove { + to { stroke-dashoffset: -50; } +} 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 00000000000..eaffcdd6e10 --- /dev/null +++ b/build/media_source/com_workflow/scss/workflow-graph.scss @@ -0,0 +1,9 @@ +// Imports +@import "variables"; + +// Components +@import "components/workflow-vue-core"; +@import "components/workflow-vue-theme"; +@import "components/workflow-vue-controls"; +@import "components/workflow-vue-minimap"; +@import "components/workflow-graph-custom"; diff --git a/installation/sql/mysql/base.sql b/installation/sql/mysql/base.sql index 14c23485d5c..7d4d4682e40 100644 --- a/installation/sql/mysql/base.sql +++ b/installation/sql/mysql/base.sql @@ -1204,6 +1204,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 DEFAULT NULL, `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 3a4aa25934a..b0c6f0f898f 100644 --- a/installation/sql/postgresql/base.sql +++ b/installation/sql/postgresql/base.sql @@ -1229,6 +1229,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 DEFAULT NULL, "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 00000000000..d0ba29c4cfa --- /dev/null +++ b/layouts/joomla/form/field/groupedlist-transition.php @@ -0,0 +1,144 @@ + + * @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->useStyle('com_workflow.workflowgraphclient'); + +$script = $wa->getAsset('script', name: 'com_workflow.workflowgraphclient')->getUri(true); +$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'), + 'preferredParent' => 'body', + 'modal' => true, +]); + +?> +
+
+ +
+
+
+ + +
+
+
+ + diff --git a/libraries/src/Form/Field/TransitionField.php b/libraries/src/Form/Field/TransitionField.php index 0ffdccec554..528e40dff88 100644 --- a/libraries/src/Form/Field/TransitionField.php +++ b/libraries/src/Form/Field/TransitionField.php @@ -79,6 +79,16 @@ public function setup(\SimpleXMLElement $element, $value, $group = null) } else { $this->workflowStage = $input->getInt('id'); } + + $db = $this->getDatabase(); + $workflowStage = (int) $this->workflowStage; + + $query = $db->getQuery(true) + ->select($db->quoteName('workflow_id')) + ->from($db->quoteName('#__workflow_stages')) + ->where($db->quoteName('id') . ' = ' . (int) $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 83a0d2842a4..55270ef442c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,6 +7,7 @@ "": { "name": "joomla", "version": "6.0.0", + "version": "6.0.0", "hasInstallScript": true, "license": "GPL-2.0-or-later", "dependencies": { @@ -28,6 +29,12 @@ "@codemirror/view": "^6.38.1", "@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", + "@webcomponents/webcomponentsjs": "^2.8.0", "accessibility": "^3.0.17", "awesomplete": "^1.1.7", "bootstrap": "^5.3.7", @@ -3584,6 +3591,12 @@ "@types/nodemailer": "*" } }, + "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", @@ -3595,6 +3608,77 @@ "@types/node": "*" } }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "dev": true, + "license": "ISC" + }, + "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.18", "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.18.tgz", @@ -3701,6 +3785,100 @@ "integrity": "sha512-cZy8Dq+uuIXbxCZpuLd2GJdeSO/lIzIspC2WtkqIpje5QyFbvLaI5wZtdUjLHjGZrlVX6GilejatWwVYYRc8tA==", "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/@webcomponents/webcomponentsjs": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@webcomponents/webcomponentsjs/-/webcomponentsjs-2.8.0.tgz", + "integrity": "sha512-loGD63sacRzOzSJgQnB9ZAhaQGkN7wl2Zuw7tsphI5Isa0irijrRo6EnJii/GgjGefIFO8AIO7UivzRhFaEk9w==", + "license": "BSD-3-Clause" + }, "node_modules/accessibility": { "version": "3.0.17", "resolved": "https://registry.npmjs.org/accessibility/-/accessibility-3.0.17.tgz", @@ -4990,6 +5168,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 283cf3e812c..25a8f5526ee 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,12 @@ "@codemirror/view": "^6.38.1", "@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", + "@webcomponents/webcomponentsjs": "^2.8.0", "accessibility": "^3.0.17", "awesomplete": "^1.1.7", "bootstrap": "^5.3.7",