diff --git a/src/components/cylc/common/deltas.js b/src/components/cylc/common/deltas.js index e1170f42e..994ecee42 100644 --- a/src/components/cylc/common/deltas.js +++ b/src/components/cylc/common/deltas.js @@ -94,7 +94,7 @@ import { mergeWithCustomizer } from '@/components/cylc/common/merge' const KEYS = ['workflow', 'cyclePoints', 'familyProxies', 'taskProxies', 'jobs'] /** - * @param {DeltasAdded|Object} added + * @param {DeltasAdded} added * @param {Object.} lookup * @return {Result} */ @@ -126,7 +126,7 @@ function applyDeltasAdded (added, lookup) { /** * Deltas updated. * - * @param updated {DeltasUpdated|Object} updated + * @param updated {DeltasUpdated} updated * @param {Object.} lookup * @return {Result} */ @@ -164,7 +164,7 @@ function applyDeltasUpdated (updated, lookup) { /** * Deltas pruned. * - * @param {DeltasPruned|Object} pruned - deltas pruned + * @param {DeltasPruned} pruned - deltas pruned * @param {Object.} lookup * @return {Result} */ diff --git a/src/components/cylc/gscan/GScan.vue b/src/components/cylc/gscan/GScan.vue index 853de79d4..474f1f988 100644 --- a/src/components/cylc/gscan/GScan.vue +++ b/src/components/cylc/gscan/GScan.vue @@ -143,42 +143,14 @@ along with this program. If not, see . - - - - - - - {{ countTasksInState(scope.node.node, state) }} {{ state }}. Recent {{ state }} tasks: -
- - {{ task }}
-
-
-
-
+
@@ -204,10 +176,10 @@ import subscriptionComponentMixin from '@/mixins/subscriptionComponent' import TaskState from '@/model/TaskState.model' import SubscriptionQuery from '@/model/SubscriptionQuery.model' import { WorkflowState } from '@/model/WorkflowState.model' -import Job from '@/components/cylc/Job' import Tree from '@/components/cylc/tree/Tree' import WorkflowIcon from '@/components/cylc/gscan/WorkflowIcon' -import { addNodeToTree, createWorkflowNode } from '@/components/cylc/gscan/nodes' +import WorkflowStateSummary from '@/components/cylc/gscan/WorkflowStateSummary' +// import { addNodeToTree, createWorkflowNode } from '@/components/cylc/gscan/nodes' import { filterHierarchically } from '@/components/cylc/gscan/filters' import GScanCallback from '@/components/cylc/gscan/callbacks' import { GSCAN_DELTAS_SUBSCRIPTION } from '@/graphql/queries' @@ -215,9 +187,9 @@ import { GSCAN_DELTAS_SUBSCRIPTION } from '@/graphql/queries' export default { name: 'GScan', components: { - Job, Tree, - WorkflowIcon + WorkflowIcon, + WorkflowStateSummary }, mixins: [ subscriptionComponentMixin @@ -313,16 +285,16 @@ export default { } }, computed: { - ...mapState('workflows', ['workflows']), - workflowNodes () { - // NOTE: In case we decide to allow the user to switch between hierarchical and flat - // gscan view, then all we need to do is just pass a boolean data-property to - // the `createWorkflowNode` function below. Then reactivity will take care of - // the rest. - const reducer = (acc, workflow) => addNodeToTree(createWorkflowNode(workflow, /* hierarchy */true), acc) - return Object.values(this.workflows) - .reduce(reducer, []) - }, + ...mapState('gscan', ['gscan']), + // workflowNodes () { + // // NOTE: In case we decide to allow the user to switch between hierarchical and flat + // // gscan view, then all we need to do is just pass a boolean data-property to + // // the `createWorkflowNode` function below. Then reactivity will take care of + // // the rest. + // const reducer = (acc, workflow) => addNodeToTree(createWorkflowNode(workflow, /* hierarchy */true), acc) + // return Object.values(this.workflows) + // .reduce(reducer, []) + // }, /** * @return {Array} */ @@ -351,7 +323,7 @@ export default { deep: true, immediate: false, handler: function (newVal) { - this.filteredWorkflows = this.filterHierarchically(this.workflowNodes, this.searchWorkflows, this.workflowStates, this.taskStates) + this.filteredWorkflows = this.filterHierarchically(this.gscan.tree, this.searchWorkflows, this.workflowStates, this.taskStates) } }, /** @@ -361,13 +333,14 @@ export default { searchWorkflows: { immediate: false, handler: function (newVal) { - this.filteredWorkflows = this.filterHierarchically(this.workflowNodes, newVal, this.workflowStates, this.taskStates) + this.filteredWorkflows = this.filterHierarchically(this.gscan.tree, newVal, this.workflowStates, this.taskStates) } }, - workflowNodes: { + gscan: { immediate: true, + deep: true, handler: function () { - this.filteredWorkflows = this.filterHierarchically(this.workflowNodes, this.searchWorkflows, this.workflowStates, this.taskStates) + this.filteredWorkflows = this.filterHierarchically(this.gscan.tree, this.searchWorkflows, this.workflowStates, this.taskStates) } } }, @@ -415,42 +388,6 @@ export default { return `/workflows/${ node.node.name }` } return '' - }, - - /** - * Get number of tasks we have in a given state. The states are retrieved - * from `latestStateTasks`, and the number of tasks in each state is from - * the `stateTotals`. (`latestStateTasks` includes old tasks). - * - * @param {WorkflowGraphQLData} workflow - the workflow object retrieved from GraphQL - * @param {string} state - a workflow state - * @returns {number|*} - the number of tasks in the given state - */ - countTasksInState (workflow, state) { - if (Object.hasOwnProperty.call(workflow.stateTotals, state)) { - return workflow.stateTotals[state] - } - return 0 - }, - - getTaskStateClasses (workflow, state) { - const tasksInState = this.countTasksInState(workflow, state) - return tasksInState === 0 ? ['empty-state'] : [] - }, - - // TODO: temporary filter, remove after b0 - https://github.com/cylc/cylc-ui/pull/617#issuecomment-805343847 - getLatestStateTasks (latestStateTasks) { - // Values found in: https://github.com/cylc/cylc-flow/blob/9c542f9f3082d3c3d9839cf4330c41cfb2738ba1/cylc/flow/data_store_mgr.py#L143-L149 - const validValues = [ - TaskState.SUBMITTED.name, - TaskState.SUBMIT_FAILED.name, - TaskState.RUNNING.name, - TaskState.SUCCEEDED.name, - TaskState.FAILED.name - ] - return latestStateTasks.filter(entry => { - return validValues.includes(entry[0]) - }) } } } diff --git a/src/components/cylc/gscan/WorkflowStateSummary.vue b/src/components/cylc/gscan/WorkflowStateSummary.vue new file mode 100644 index 000000000..4d641d674 --- /dev/null +++ b/src/components/cylc/gscan/WorkflowStateSummary.vue @@ -0,0 +1,133 @@ + + + + + diff --git a/src/components/cylc/gscan/callbacks.js b/src/components/cylc/gscan/callbacks.js index 7281f0659..9678de171 100644 --- a/src/components/cylc/gscan/callbacks.js +++ b/src/components/cylc/gscan/callbacks.js @@ -21,6 +21,7 @@ import { applyDeltasPruned } from '@/components/cylc/gscan/deltas' import DeltasCallback from '@/services/callbacks' +import { clear } from '@/components/cylc/gscan/index' /** * Provisional GScan callback until https://github.com/cylc/cylc-ui/pull/736 @@ -29,37 +30,41 @@ import DeltasCallback from '@/services/callbacks' class GScanCallback extends DeltasCallback { constructor () { super() - this.workflows = null + this.lookup = null + this.gscan = null } before (deltas, store, errors) { - this.workflows = Object.assign({}, store.state.workflows.workflows) + // If it were TS, we would use a ReadOnly type here... + this.lookup = store.state.workflows.lookup + const gscan = store.state.gscan.gscan + this.gscan = Object.assign({}, gscan) } tearDown (store, errors) { - store.commit('workflows/SET_WORKFLOWS', {}) - this.workflows = null + clear(this.gscan) + store.commit('gscan/SET_GSCAN', this.gscan) + this.lookup = null + this.gscan = null } onAdded (added, store, errors) { - this.workflows = Object.assign(this.workflows, store.state.workflows.workflows) - const results = applyDeltasAdded(added, this.workflows) + const results = applyDeltasAdded(added, this.gscan, {}) errors.push(...results.errors) } onUpdated (updated, store, errors) { - const results = applyDeltasUpdated(updated, this.workflows) + const results = applyDeltasUpdated(updated, this.gscan, {}) errors.push(...results.errors) } onPruned (pruned, store, errors) { - this.workflows = Object.assign(this.workflows, store.state.workflows.workflows) - const results = applyDeltasPruned(pruned, this.workflows) + const results = applyDeltasPruned(pruned, this.gscan, {}) errors.push(...results.errors) } commit (store, errors) { - store.commit('workflows/SET_WORKFLOWS', this.workflows) + store.commit('gscan/SET_GSCAN', this.gscan) } } diff --git a/src/components/cylc/gscan/deltas.js b/src/components/cylc/gscan/deltas.js index 4785d1ec3..1e67bd978 100644 --- a/src/components/cylc/gscan/deltas.js +++ b/src/components/cylc/gscan/deltas.js @@ -14,28 +14,31 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -import Vue from 'vue' -import { mergeWith } from 'lodash' -import { mergeWithCustomizer } from '@/components/cylc/common/merge' +import * as GScanTree from '@/components/cylc/gscan/index' /** - * @param {DeltasAdded|Object} added - * @param {Array} workflows - * @return {Result} + * Deltas added. + * + * @param {DeltasAdded} added + * @param {GScan} gscan + * @param {*} options + * @returns {Result} */ -function applyDeltasAdded (added, workflows) { +function applyDeltasAdded (added, gscan, options) { const result = { errors: [] } - if (added && added.workflow && added.workflow.status) { + if (added.workflow) { + const workflow = added.workflow try { - Vue.set(workflows, added.workflow.id, added.workflow) + GScanTree.addWorkflow(workflow, gscan, options) } catch (error) { result.errors.push([ 'Error applying GScan added-delta, see browser console logs for more. Please reload your browser tab to retrieve the full flow state', error, - added, - workflows + added.workflow, + gscan, + options ]) } } @@ -43,49 +46,61 @@ function applyDeltasAdded (added, workflows) { } /** - * @param {DeltasUpdated|Object} updated - * @param {Array} workflows - * @return {Result} + * Deltas updated. + * + * @param {DeltasUpdated} updated + * @param {GScan} gscan + * @param {*} options + * @returns {Result} */ -function applyDeltasUpdated (updated, workflows) { +function applyDeltasUpdated (updated, gscan, options) { const result = { errors: [] } - try { - if (updated && updated.workflow && workflows[updated.workflow.id]) { - mergeWith(workflows[updated.workflow.id], updated.workflow, mergeWithCustomizer) + if (updated.workflow) { + const workflow = updated.workflow + try { + GScanTree.updateWorkflow(workflow, gscan, options) + } catch (error) { + result.errors.push([ + 'Error applying updated-delta, see browser console logs for more. Please reload your browser tab to retrieve the full flow state', + error, + workflow, + gscan, + options + ]) } - } catch (error) { - result.errors.push([ - 'Error applying GScan updated-delta, see browser console logs for more. Please reload your browser tab to retrieve the full flow state', - error, - updated, - workflows - ]) } return result } /** - * @param {DeltasPruned|Object} pruned - * @param {Array} workflows - * @return {Result} + * Deltas pruned. + * + * @param {DeltasPruned} pruned + * @param {GScan} gscan + * @param {*} options + * @returns {Result} */ -function applyDeltasPruned (pruned, workflows) { +function applyDeltasPruned (pruned, gscan, options) { const result = { errors: [] } - try { - if (pruned && pruned.workflow) { - Vue.delete(workflows, pruned.workflow) + // TBD: why not workflows? We have that in the jsdoc for DeltasPruned, and I believe + // that's how other queries return the data. + if (pruned.workflow) { + const workflowId = pruned.workflow + try { + GScanTree.removeWorkflow(workflowId, gscan, options) + } catch (error) { + result.errors.push([ + 'Error applying pruned-delta, see browser console logs for more. Please reload your browser tab to retrieve the full flow state', + error, + workflowId, + gscan, + options + ]) } - } catch (error) { - result.errors.push([ - 'Error applying GScan pruned-delta, see browser console logs for more. Please reload your browser tab to retrieve the full flow state', - error, - pruned, - workflows - ]) } return result } diff --git a/src/components/cylc/gscan/index.js b/src/components/cylc/gscan/index.js new file mode 100644 index 000000000..27e3ebceb --- /dev/null +++ b/src/components/cylc/gscan/index.js @@ -0,0 +1,256 @@ +/** + * Copyright (C) NIWA & British Crown (Met Office) & Contributors. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +import Vue from 'vue' +import { mergeWith } from 'lodash' +import { sortedIndexBy } from '@/components/cylc/common/sort' +import { mergeWithCustomizer } from '@/components/cylc/common/merge' +import { sortWorkflowNamePartNodeOrWorkflowNode } from '@/components/cylc/gscan/sort' +import { + createWorkflowNode, + getWorkflowNamePartsNodesIds, + parseWorkflowNameParts +} from '@/components/cylc/gscan/nodes' + +/** + * @typedef {Object} GScan + * @property {Array} tree + * @property {Lookup} lookup + */ + +/** + * @typedef {Object} Lookup + */ + +/** + * @param {GScan} gscan + */ +function clear (gscan) { + ['tree', 'lookup'].forEach(each => { + Object.keys(gscan[each]).forEach(key => { + Vue.delete(gscan[each], key) + }) + }) +} + +// --- Added + +/** + * Add a new workflow to the GScan data structure. + * + * @param {TreeNode} workflow + * @param {GScan} gscan + * @param {*} options + */ +function addWorkflow (workflow, gscan, options) { + const hierarchical = options.hierarchical || true + if (hierarchical) { + const workflowNode = createWorkflowNode(workflow, hierarchical) + addHierarchicalWorkflow(workflowNode, gscan.lookup, gscan.tree, options) + } else { + gscan.lookup[workflow.id] = workflow + gscan.tree.push(workflow) + } +} + +/** + * This function is private. It receives a lookup and tree instead of a GScan object (as in other + * functions of this module). This is required as we apply recursion for adding nodes into the tree, + * but we replace the tree and pass only a sub-tree. + * + * @param workflow + * @param {Lookup} lookup + * @param {Array} tree + * @param {*} options + * @private + */ +function addHierarchicalWorkflow (workflow, lookup, tree, options) { + if (!lookup[workflow.id]) { + // a new node, let's add this node and its descendants to the lookup + lookup[workflow.id] = workflow + if (workflow.children) { + const stack = [...workflow.children] + while (stack.length) { + const currentNode = stack.shift() + lookup[currentNode.id] = currentNode + if (currentNode.children) { + stack.push(...currentNode.children) + } + } + } + // and now add the top-level node to the tree + // Here we calculate what is the index for this element. If we decide to have ASC and DESC, + // then we just need to invert the location of the element, something like + // `sortedIndex = (array.length - sortedIndex)`. + const sortedIndex = sortedIndexBy( + tree, + workflow, + (n) => n.name, + sortWorkflowNamePartNodeOrWorkflowNode + ) + tree.splice(sortedIndex, 0, workflow) + } else { + // we will have to merge the hierarchies + const existingNode = lookup[workflow.id] + // TODO: combine states summaries? + if (existingNode.children) { + // Copy array since we will iterate it, and modify existingNode.children + // (see the tree.splice above.) + const children = [...workflow.children] + for (const child of children) { + // Recursion + addHierarchicalWorkflow(child, lookup, existingNode.children, options) + } + } else { + // Here we have an existing workflow node. Let's merge it. + mergeWith(existingNode, workflow, mergeWithCustomizer) + } + } +} + +// --- Updated + +/** + * Update a workflow in the GScan data structure. + * + * @param {WorkflowGraphQLData} workflow + * @param {GScan} gscan + * @param {Object} options + */ +function updateWorkflow (workflow, gscan, options) { + // We don't care whether it is hierarchical or not here, since we can quickly + // access the node via the GScan lookup. + const existingData = gscan.lookup[workflow.id] + if (!existingData) { + throw new Error(`Updated node [${workflow.id}] not found in workflow lookup`) + } + mergeWith(existingData.node, workflow, mergeWithCustomizer) + const hierarchical = options.hierarchical || true + if (hierarchical) { + updateHierarchicalWorkflow(existingData, gscan.lookup, gscan.tree, options) + } + // TODO: create workflow hierarchy (from workflow object), then iterate + // it and use lookup to fetch the existing node. Finally, combine + // the gscan states (latestStateTasks & stateTotals). + Vue.set(gscan.lookup, existingData.id, existingData) +} + +function updateHierarchicalWorkflow (existingData, lookup, tree, options) { + // We need to sort its parent again. + const workflowNameParts = parseWorkflowNameParts(existingData.id) + const nodesIds = getWorkflowNamePartsNodesIds(workflowNameParts) + // Discard the last since it's the workflow ID that we already have + // in the `existingData` object. Now if not empty, we have our parent. + nodesIds.pop() + const parentId = nodesIds.length > 0 ? nodesIds.pop() : null + const parent = parentId ? lookup[parentId] : tree + if (!parent) { + throw new Error(`Invalid orphan hierarchical node: ${existingData.id}`) + } + const siblings = parent.children + // Where is this node at the moment? + const currentIndex = siblings.findIndex(node => node.id === existingData.id) + // Where should it be now? + const sortedIndex = sortedIndexBy( + parent.children, + existingData, + (n) => n.name, + sortWorkflowNamePartNodeOrWorkflowNode + ) + // If it is not where it is, we need to add it to its correct location. + if (currentIndex !== sortedIndex) { + // siblings.splice(currentIndex, 1) + // siblings.splice(sortedIndex, 0, existingData) + Vue.delete(siblings, currentIndex) + Vue.set(siblings, sortedIndex, existingData) + } +} + +// -- Pruned + +/** + * Remove the workflow with ID equals to the given `workflowId` from the GScan data structure. + * + * @param {String} workflowId + * @param {GScan} gscan + * @param {*} options + */ +function removeWorkflow (workflowId, gscan, options) { + const workflow = gscan.lookup[workflowId] + if (!workflow) { + throw new Error(`Pruned node [${workflow.id}] not found in workflow lookup`) + } + const hierarchical = options.hierarchical || true + if (hierarchical) { + removeHierarchicalWorkflow(workflowId, gscan.lookup, gscan.tree, options) + } else { + removeNode(workflowId, gscan.lookup, gscan.tree) + } +} + +/** + * This function is private. It removes the workflow associated with the given `workflowId` from the + * lookup, and also proceeds to remove the leaf-node with the workflow node, and all of its parents that + * do not have any other descendants. + * + * @param {String} workflowId - Existing workflow ID + * @param {Lookup} lookup + * @param {Array} tree + * @param {Object} options + * @private + */ +function removeHierarchicalWorkflow (workflowId, lookup, tree, options) { + const workflowNameParts = parseWorkflowNameParts(workflowId) + const nodesIds = getWorkflowNamePartsNodesIds(workflowNameParts) + // We start from the leaf-node, going upward to make sure we don't leave nodes with no children. + for (let i = nodesIds.length - 1; i >= 0; i--) { + const nodeId = nodesIds[i] + const node = lookup[nodeId] + if (node.children && node.children.length > 0) { + // We stop as soon as we find a node that still has children. + break + } + // Now we can remove the node from the lookup, and from its parents children array. + const previousIndex = i - 1 + const parentId = previousIndex >= 0 ? nodesIds[previousIndex] : null + if (parentId && !lookup[parentId]) { + throw new Error(`Failed to locate parent ${parentId} in GScan lookup`) + } + const parentChildren = parentId ? lookup[parentId].children : tree + removeNode(nodeId, lookup, parentChildren) + } +} + +/** + * @param {String} id - ID of the tree node + * @param {Array} tree + * @param {Lookup} lookup + * @private + */ +function removeNode (id, lookup, tree) { + Vue.delete(lookup, id) + const treeNode = tree.find(node => node.id === id) + if (treeNode) { + Vue.delete(tree, tree.indexOf(treeNode)) + } +} + +export { + clear, + addWorkflow, + updateWorkflow, + removeWorkflow +} diff --git a/src/components/cylc/gscan/nodes.js b/src/components/cylc/gscan/nodes.js index 524257d42..a74e47997 100644 --- a/src/components/cylc/gscan/nodes.js +++ b/src/components/cylc/gscan/nodes.js @@ -15,32 +15,81 @@ * along with this program. If not, see . */ -import { sortedIndexBy } from '@/components/cylc/common/sort' -import { sortWorkflowNamePartNodeOrWorkflowNode } from '@/components/cylc/gscan/sort' +// TODO: move to the `options` parameter that is passed to deltas; ideally it would be stored in DB or localstorage. +const DEFAULT_PARTS_SEPARATOR = '|' +const DEFAULT_NAMES_SEPARATOR = '/' /** - * @typedef {Object} WorkflowGScanNode + * @typedef {Object} TreeNode * @property {String} id - * @property {String} name + * @property {String|null} name * @property {String} type * @property {WorkflowGraphQLData} node */ /** - * @typedef {Object} WorkflowNamePartGScanNode - * @property {String} id - * @property {String} name - * @property {String} type - * @property {WorkflowGraphQLData} node + * @typedef {TreeNode} WorkflowGScanNode + */ + +/** + * @typedef {TreeNode} WorkflowNamePartGScanNode * @property {Array} children */ +/** + * Create a workflow node for GScan component. + * + * If the `hierarchy` parameter is `true`, then the workflow name will be split by + * `/`'s. For each part, a new `WorkflowNamePart` will be added in the hierarchy. + * With the final node being the last part of the name. + * + * The last part of a workflow name may be the workflow name (e.g. `five`), or its + * run ID (e.g. `run1`, if workflow name is `five/run1`). + * + * @param {WorkflowGraphQLData} workflow + * @param {boolean} hierarchy - whether to parse the Workflow name and create a hierarchy or not + * @param {String} partsSeparator - separator for workflow name parts (e.g. '|' as in 'part1|part2|...') + * @param {String} namesSeparator - separator used for workflow and run names (e.g. '/' as in 'workflow/run1') + * @returns {TreeNode|null} + */ +function createWorkflowNode (workflow, hierarchy, partsSeparator = DEFAULT_PARTS_SEPARATOR, namesSeparator = DEFAULT_NAMES_SEPARATOR) { + if (!hierarchy) { + return newWorkflowNode(workflow, null) + } + const workflowNameParts = parseWorkflowNameParts(workflow.id, partsSeparator, namesSeparator) + let prefix = workflowNameParts.user + // The root node, returned in this function. + let rootNode = null + // And a helper used when iterating the array... + let currentNode = null + for (const part of workflowNameParts.parts) { + prefix = prefix === null ? part : `${prefix}${partsSeparator}${part}` + const partNode = newWorkflowPartNode(prefix, part) + if (rootNode === null) { + rootNode = currentNode = partNode + } else { + currentNode.children.push(partNode) + currentNode = partNode + } + } + const workflowNode = newWorkflowNode(workflow, workflowNameParts.name) + + if (currentNode === null) { + // We will return the workflow node only. It will be appended directly to the tree as a new leaf. + rootNode = workflowNode + } else { + // Add the workflow node to the end of the branch as a leaf. Note that the top of the branch is returned in this case. + currentNode.children.push(workflowNode) + } + return rootNode +} + /** * Create a new Workflow Node. * * @private * @param {WorkflowGraphQLData} workflow - * @param {string|null} part + * @param {String|null} part * @returns {WorkflowGScanNode} */ function newWorkflowNode (workflow, part) { @@ -61,11 +110,11 @@ function newWorkflowNode (workflow, part) { */ function newWorkflowPartNode (id, part) { return { - id: `workflow-name-part-${id}`, + id, name: part, type: 'workflow-name-part', node: { - id: id, + id, name: part, status: '' }, @@ -74,90 +123,75 @@ function newWorkflowPartNode (id, part) { } /** - * Create a workflow node for GScan component. - * - * If the `hierarchy` parameter is `true`, then the workflow name will be split by - * `/`'s. For each part, a new `WorkflowNamePart` will be added in the hierarchy. - * With the final node being the last part of the name. - * - * The last part of a workflow name may be the workflow name (e.g. `five`), or its - * run ID (e.g. `run1`, if workflow name is `five/run1`). - * - * @param {WorkflowGraphQLData} workflow - * @param {boolean} hierarchy - whether to parse the Workflow name and create a hierarchy or not - * @returns {WorkflowGScanNode|WorkflowNamePartGScanNode|null} + * @typedef {Object} ParsedWorkflowNameParts + * @property {String} workflowId - workflow ID + * @property {String} partsSeparator - parts separator parameter used to parse the name + * @property {String} namesSeparator - names separator parameter used to parse the name + * @property {String} user - parsed workflow user/owner + * @property {String} workflowName - original workflow name + * @property {Array} parts - workflow name parts + * @property {String} name - workflow name (last part, used to display nodes in the GScan tree) */ -function createWorkflowNode (workflow, hierarchy) { - if (!hierarchy) { - return newWorkflowNode(workflow, null) - } - const workflowIdParts = workflow.id.split('|') - // The prefix contains all the ID parts, except for the workflow name. - let prefix = workflowIdParts.slice(0, workflowIdParts.length - 1) - // The name is here. - const workflowName = workflow.name - const parts = workflowName.split('/') - // Returned node... - let rootNode = null - // And a helper used when iterating the array... - let currentNode = null - while (parts.length > 0) { - const part = parts.shift() - // For the first part, we need to add an ID separator `|`, but for the other parts - // we actually want to use the name parts separator `/`. - prefix = prefix.includes('/') ? `${prefix}/${part}` : `${prefix}|${part}` - const partNode = parts.length !== 0 - ? newWorkflowPartNode(prefix, part) - : newWorkflowNode(workflow, part) - if (rootNode === null) { - rootNode = currentNode = partNode - } else { - currentNode.children.push(partNode) - currentNode = partNode - } - } - return rootNode +/** + * Return the workflow name parts as an array of node IDs. The first node in the array is the top of the + * branch, with the workflow node ID at its other end, as a leaf-node. + * + * @param {ParsedWorkflowNameParts} workflowNameParts + * @return {Array} + */ +function getWorkflowNamePartsNodesIds (workflowNameParts) { + let prefix = workflowNameParts.user + const nodesIds = workflowNameParts.parts + .map(part => { + prefix = `${prefix}${workflowNameParts.partsSeparator}${part}` + return prefix + }) + nodesIds.push(workflowNameParts.workflowId) + return nodesIds } /** - * Add the new hierarchical node to the list of existing nodes. + * Parses the workflow name parts. A simple name such as `user|workflow-name` will return a structure + * with each part of the name, including the given parameters of this function (to simplify sending + * the data to other methods). * - * New nodes are added in order. + * More complicated names such as `user|top/level/other/leaf` return the structure with an array of + * each name part too. This is useful for functions that need to manipulate the tree of GScan nodes, + * and necessary as we don't have this information from the server (only the name which doesn't + * split the name parts). * - * @param {WorkflowGScanNode|WorkflowNamePartGScanNode} node - * @param {Array} nodes - * @return {Array} + * @param {String} workflowId - Workflow ID + * @param {String} partsSeparator - separator for workflow name parts (e.g. '|' as in 'user|research/workflow/run1') + * @param {String} namesSeparator - separator used for workflow and run names (e.g. '/' as in 'research/workflow/run1') + * @return {ParsedWorkflowNameParts} */ -function addNodeToTree (node, nodes) { - // N.B.: We must compare nodes by ID, not only by part-name, - // since we can have research/nwp/run1 workflow, and research workflow; - // in this case we do not want to confuse the research part-name with - // the research workflow. - const existingNode = nodes.find((existingNode) => existingNode.id === node.id) - if (!existingNode) { - // Here we calculate what is the index for this element. If we decide to have ASC and DESC, - // then we just need to invert the location of the element, something like - // `sortedIndex = (array.length - sortedIndex)`. - const sortedIndex = sortedIndexBy( - nodes, - node, - (n) => n.name, - sortWorkflowNamePartNodeOrWorkflowNode - ) - nodes.splice(sortedIndex, 0, node) - } else { - if (node.children) { - for (const child of node.children) { - // Recursion. Note that we are changing the `nodes` to the children of the existing node. - addNodeToTree(child, existingNode.children) - } - } +function parseWorkflowNameParts (workflowId, partsSeparator = DEFAULT_PARTS_SEPARATOR, namesSeparator = DEFAULT_NAMES_SEPARATOR) { + if (!workflowId || workflowId.trim() === '') { + throw new Error('Missing ID for workflow name parts') + } + const idParts = workflowId.split(partsSeparator) + if (idParts.length !== 2) { + throw new Error(`Invalid parts found, expected at least 2 parts in ${workflowId}`) + } + const user = idParts[0] + const workflowName = idParts[1] + const parts = workflowName.split(namesSeparator) + // The name, used for display in the tree. Can be a workflow name like 'd', or a runN like 'run1'. + const name = parts.pop() + return { + workflowId, + partsSeparator, + namesSeparator, + user, // user + workflowName, // a/b/c/d/run1 + parts, // [a, b, c, d] + name // run1 } - return nodes } export { - addNodeToTree, - createWorkflowNode + createWorkflowNode, + getWorkflowNamePartsNodesIds, + parseWorkflowNameParts } diff --git a/src/components/cylc/tree/Tree.vue b/src/components/cylc/tree/Tree.vue index dedc28157..ceb3250bc 100644 --- a/src/components/cylc/tree/Tree.vue +++ b/src/components/cylc/tree/Tree.vue @@ -218,6 +218,9 @@ export default { }) }, tasksFilterStates: function () { + if (!this.activeFilters) { + return [] + } return this.activeFilters.states.map(selectedTaskState => { return selectedTaskState }) diff --git a/src/components/cylc/tree/TreeItem.vue b/src/components/cylc/tree/TreeItem.vue index df49fd1c3..60a0c423b 100644 --- a/src/components/cylc/tree/TreeItem.vue +++ b/src/components/cylc/tree/TreeItem.vue @@ -194,7 +194,6 @@ along with this program. If not, see . . v-on:tree-item-expanded="$listeners['tree-item-expanded']" v-on:tree-item-collapsed="$listeners['tree-item-collapsed']" v-on:tree-item-clicked="$listeners['tree-item-clicked']" + > @@ -249,7 +249,7 @@ export default { active: false, selected: false, isExpanded: this.initialExpanded, - leafProperties: [ + leafProperties: Object.freeze([ { title: 'platform', property: 'platform' @@ -274,7 +274,7 @@ export default { title: 'finish time', property: 'finishedTime' } - ], + ]), filtered: true } }, diff --git a/src/components/cylc/tree/callbacks.js b/src/components/cylc/tree/callbacks.js index d1d4a9950..f11dc88c6 100644 --- a/src/components/cylc/tree/callbacks.js +++ b/src/components/cylc/tree/callbacks.js @@ -16,6 +16,7 @@ */ import DeltasCallback from '@/services/callbacks' +import { clear } from '@/components/cylc/tree' import { before, after, @@ -45,10 +46,10 @@ class TreeCallback extends DeltasCallback { } before (deltas, store, errors) { - const lookup = store.state.workflows.lookup - const workflow = store.state.workflows.workflow + // If it were TS, we would use a ReadOnly type here... + this.lookup = store.state.workflows.lookup + const workflow = store.state.tree.workflow this.workflow = Object.assign({}, workflow) - this.lookup = Object.assign({}, lookup) const results = before(deltas, this.workflow, this.lookup) errors.push(...results.errors) } @@ -59,9 +60,10 @@ class TreeCallback extends DeltasCallback { } tearDown (store, errors) { - store.commit('workflows/CLEAR_WORKFLOW') - this.workflow = null + clear(this.workflow) + store.commit('tree/SET_WORKFLOW', this.workflow) this.lookup = null + this.workflow = null } onAdded (added, store, errors) { @@ -80,7 +82,7 @@ class TreeCallback extends DeltasCallback { } commit (store, errors) { - store.commit('workflows/SET_WORKFLOW', this.workflow) + store.commit('tree/SET_WORKFLOW', this.workflow) } } diff --git a/src/components/cylc/tree/index.js b/src/components/cylc/tree/index.js index 0b726b77a..e1c934d8f 100644 --- a/src/components/cylc/tree/index.js +++ b/src/components/cylc/tree/index.js @@ -430,6 +430,7 @@ function removeJob (jobId, workflow, options) { parent.children.splice(parent.children.indexOf(job), 1) } } + // TODO: for later, I believe this has already been deleted above? In recursively... Vue.delete(workflow.lookup, jobId) } } diff --git a/src/components/cylc/tree/nodes.js b/src/components/cylc/tree/nodes.js index e52ea692e..063b30e0c 100644 --- a/src/components/cylc/tree/nodes.js +++ b/src/components/cylc/tree/nodes.js @@ -38,7 +38,7 @@ import Vue from 'vue' * Create a workflow node. Uses the same properties (by reference) as the given workflow, * only adding new properties such as type, children, etc. * - * @param workflow {WorkflowGraphQLData} workflow + * @param {WorkflowGraphQLData} workflow workflow * @return {WorkflowNode} */ function createWorkflowNode (workflow) { @@ -70,7 +70,7 @@ function createWorkflowNode (workflow) { * - 'a|b' results in a cycle point node ID 'a|b' * - '' results in a cycle point node ID '' * - * @param node {GraphQLData} a tree node + * @param {GraphQLData} node a tree node * @throws {Error} - if there was an error extracting the cycle point ID * @return {string} - the cycle point ID */ diff --git a/src/mixins/graphql.js b/src/mixins/graphql.js index 6b50f567a..38e6ae601 100644 --- a/src/mixins/graphql.js +++ b/src/mixins/graphql.js @@ -56,7 +56,7 @@ export default { /** * GraphQL query variables. * - * @returns {{workflowId: string}} + * @returns {Object.} */ variables () { return { diff --git a/src/mixins/index.js b/src/mixins/index.js index 85add48d2..7048be7b8 100644 --- a/src/mixins/index.js +++ b/src/mixins/index.js @@ -19,7 +19,6 @@ import i18n from '@/i18n' /** * Here we can define the operations that are common to components/views. - * @type {{methods: {setPageTitle(*=, *=): string}}} */ export default { /** diff --git a/src/model/WorkflowState.model.js b/src/model/WorkflowState.model.js index c151e3df3..44d02dd00 100644 --- a/src/model/WorkflowState.model.js +++ b/src/model/WorkflowState.model.js @@ -50,7 +50,7 @@ export class WorkflowState extends Enumify { /** * Workflow states ordered for display purposes. - * @type {Map} - Using a map to prevent more unexpected sorting issues + * @type {Map} * @see https://stackoverflow.com/questions/5525795/does-javascript-guarantee-object-property-order/38218582#38218582 */ export const WorkflowStateOrder = new Map([ diff --git a/src/services/callbacks.js b/src/services/callbacks.js index 29b421a86..6737230a7 100644 --- a/src/services/callbacks.js +++ b/src/services/callbacks.js @@ -37,21 +37,21 @@ class DeltasCallback { tearDown (store, errors) {} /** - * @param {DeltasAdded|Object} added + * @param {DeltasAdded} added * @param {Vuex} store * @param {Array} errors */ onAdded (added, store, errors) {} /** - * @param {DeltasUpdated|Object} updated + * @param {DeltasUpdated} updated * @param {Vuex} store * @param {Array} errors */ onUpdated (updated, store, errors) {} /** - * @param {DeltasPruned|Object} pruned - + * @param {DeltasPruned} pruned - * @param {Vuex} store - Vuex store * @param {Array} errors */ diff --git a/src/store/gscan.module.js b/src/store/gscan.module.js new file mode 100644 index 000000000..744825395 --- /dev/null +++ b/src/store/gscan.module.js @@ -0,0 +1,39 @@ +/** + * Copyright (C) NIWA & British Crown (Met Office) & Contributors. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +const state = { + /** + * This is the data structure used by GScan component. The tree holds the hierarchical GScan, + * and the lookup is a helper structure for quick access to nodes in the tree. + */ + gscan: { + tree: [], + lookup: {} + } +} + +const mutations = { + SET_GSCAN (state, data) { + state.gscan = data + } +} + +export const gscan = { + namespaced: true, + state, + mutations +} diff --git a/src/store/options.js b/src/store/options.js index b8346b556..e36265100 100644 --- a/src/store/options.js +++ b/src/store/options.js @@ -19,6 +19,8 @@ import { app } from './app.module' import { workflows } from './workflows.module' import { user } from './user.module' +import { tree } from './tree.module' +import { gscan } from './gscan.module' // State const state = { @@ -80,7 +82,9 @@ export default { modules: { app, workflows, - user + user, + tree, + gscan }, actions, mutations, diff --git a/src/store/tree.module.js b/src/store/tree.module.js new file mode 100644 index 000000000..07a2f3d1b --- /dev/null +++ b/src/store/tree.module.js @@ -0,0 +1,45 @@ +/** + * Copyright (C) NIWA & British Crown (Met Office) & Contributors. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +const state = { + /** + * This is the CylcTree, which contains the hierarchical tree data structure. + * It is created from the GraphQL data, with the only difference that this one + * contains hierarchy, while the lookup (not workflow.lookup) is flat-ish. + * + * The nodes in the .tree property have a reference or pointer (.node) to the + * data in the lookup map above, to avoid data duplication. + * + * @type {Workflow} + */ + workflow: { + tree: {}, + lookup: {} + } +} + +const mutations = { + SET_WORKFLOW (state, data) { + state.workflow = data + } +} + +export const tree = { + namespaced: true, + state, + mutations +} diff --git a/src/store/workflows.module.js b/src/store/workflows.module.js index 49a4e6cbb..5f87aa8db 100644 --- a/src/store/workflows.module.js +++ b/src/store/workflows.module.js @@ -14,7 +14,6 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -import { clear } from '@/components/cylc/tree/index' const state = { /** @@ -29,27 +28,6 @@ const state = { * @type {Object.} */ lookup: {}, - /** - * This is the CylcTree, which contains the hierarchical tree data structure. - * It is created from the GraphQL data, with the only difference that this one - * contains hierarchy, while the lookup (not workflow.lookup) is flat-ish. - * - * The nodes in the .tree property have a reference or pointer (.node) to the - * data in the lookup map above, to avoid data duplication. - * - * @type {Workflow} - */ - workflow: { - tree: {}, - lookup: {} - }, - /** - * This contains a list of workflows returned from GraphQL and is used by components - * such as GScan, Dashboard, and WorkflowsTable. - * - * @type {Object.} - */ - workflows: {}, /** * This holds the name of the current workflow. This is set by VueRouter * and is used to decide what's the current workflow. It is used in conjunction @@ -67,43 +45,23 @@ const getters = { if (state.workflowName === null) { return null } - return Object.values(state.workflows) + return Object.values(state.lookup) .find(workflow => workflow.name === state.workflowName) } } const mutations = { - SET_WORKFLOW_NAME (state, data) { - state.workflowName = data - }, - SET_WORKFLOWS (state, data) { - state.workflows = data - }, - SET_WORKFLOW (state, data) { - state.workflow = data - }, SET_LOOKUP (state, data) { state.lookup = data }, - CLEAR_WORKFLOW (state) { - clear(state.workflow) - state.workflow = { - tree: { - id: '', - type: 'workflow', - children: [] - }, - lookup: {} - } + SET_WORKFLOW_NAME (state, data) { + state.workflowName = data } } -const actions = {} - export const workflows = { namespaced: true, state, getters, - mutations, - actions + mutations } diff --git a/src/views/Dashboard.vue b/src/views/Dashboard.vue index eeb518b08..edabe8954 100644 --- a/src/views/Dashboard.vue +++ b/src/views/Dashboard.vue @@ -241,9 +241,9 @@ export default { } }, computed: { - ...mapState('workflows', ['workflows']), + ...mapState('workflows', ['lookup']), workflowsTable () { - const count = Object.values(this.workflows) + const count = Object.values(this.lookup) .map(workflow => workflow.status) .reduce((acc, state) => { acc[state] = (acc[state] || 0) + 1 diff --git a/src/views/Tree.vue b/src/views/Tree.vue index 06adb21f2..0d64638c2 100644 --- a/src/views/Tree.vue +++ b/src/views/Tree.vue @@ -72,7 +72,7 @@ export default { } }, computed: { - ...mapState('workflows', ['workflow']), + ...mapState('tree', ['workflow']), workflows () { return this.workflow && this.workflow.tree && diff --git a/src/views/WorkflowsTable.vue b/src/views/WorkflowsTable.vue index fa9ca6eee..4d1762f4f 100644 --- a/src/views/WorkflowsTable.vue +++ b/src/views/WorkflowsTable.vue @@ -132,9 +132,9 @@ export default { } }), computed: { - ...mapState('workflows', ['workflows']), + ...mapState('workflows', ['lookup']), workflowsTable () { - return Object.values(this.workflows) + return Object.values(this.lookup) } }, methods: { diff --git a/tests/e2e/specs/workflowservice.js b/tests/e2e/specs/workflowservice.js index 94c600577..9b1eded51 100644 --- a/tests/e2e/specs/workflowservice.js +++ b/tests/e2e/specs/workflowservice.js @@ -30,7 +30,7 @@ describe('WorkflowService subscriptions', () => { expect(Object.keys(subscriptions).length).to.equal(1) }) }) - it('-> Dashboard -> User Profile, should contain 1 subscription (GScan)', () => { + it('-> Dashboard -> User Profile, should contain 1 subscription ("root" = GScan)', () => { cy.visit('/#/') cy.get('[href="#/user-profile"]').click({ force: true }) cy.contains('h3', 'Your Profile') @@ -39,11 +39,11 @@ describe('WorkflowService subscriptions', () => { expect(Object.keys(subscriptions).length).to.equal(1) }) }) - it('-> Dashboard -> Workflows, should contain 2 subscriptions (GScan + Tree)', () => { + it('-> Dashboard -> Workflows, should contain 2 subscriptions ("root" = GScan + Dashboard, and "workflow" = Tree)', () => { cy.visit('/#/') cy.get('[href="#/workflows/one"]').click({ force: true }) //
is used by Lumino, and its initial tab contains the text tree - cy.get('div#main').find('.c-tree') + cy.get('div#main').find('.c-task') getSubscriptions().then(subscriptions => { // GScan subscription "root" and the subscription "workflow" used by the Tree view expect(Object.keys(subscriptions).length).to.equal(2) @@ -51,18 +51,18 @@ describe('WorkflowService subscriptions', () => { expect(subscriptions.workflow.observable.closed).to.equal(false) }) }) - it('-> Dashboard -> Workflows -> Dashboard, should contain 2 subscriptions (GScan + Dashboard)', () => { + it('-> Dashboard -> Workflows -> Dashboard, should contain 1 subscriptions ("root" = GScan + Dashboard)', () => { cy.visit('/#/') - cy.get('[href="#/workflows/one"]').click() + cy.get('[href="#/workflows/one"]').click({ force: true }) //
is used by Lumino, and its initial tab contains the text tree - cy.get('div#main').find('.c-tree') + cy.get('div#main').find('.c-task') cy.get('[href="#/"]').click({ force: true }) cy.get('div.c-dashboard') getSubscriptions().then(subscriptions => { expect(Object.keys(subscriptions).length).to.equal(1) }) }) - it('-> Tree, should contain 2 subscriptions (GScan + Tree)', () => { + it('-> Tree, should contain 2 subscriptions ("root" = GScan + Dashboard, and "workflow" = Tree)', () => { cy.visit('/#/tree/one') cy.get('.c-header').should('exist') getSubscriptions().then(subscriptions => { diff --git a/tests/unit/components/cylc/gscan/deltas.spec.js b/tests/unit/components/cylc/gscan/deltas.spec.js index 8b9ca92f4..e79df7e6b 100644 --- a/tests/unit/components/cylc/gscan/deltas.spec.js +++ b/tests/unit/components/cylc/gscan/deltas.spec.js @@ -17,17 +17,16 @@ import { expect } from 'chai' import WorkflowState from '@/model/WorkflowState.model' -import { - applyDeltasAdded, - applyDeltasUpdated, - applyDeltasPruned -} from '@/components/cylc/gscan/deltas' +import { applyDeltasAdded, applyDeltasUpdated, applyDeltasPruned } from '@/components/cylc/gscan/deltas' describe('GScan component', () => { - let workflows + let gscan let newWorkflow beforeEach(() => { - workflows = {} + gscan = { + lookup: {}, + tree: [] + } newWorkflow = { id: 'cylc|test', status: WorkflowState.PAUSED @@ -36,50 +35,41 @@ describe('GScan component', () => { describe('Deltas', () => { describe('Added', () => { it('should apply added deltas', () => { - const deltas = { - added: { - workflow: newWorkflow - } + const added = { + workflow: newWorkflow } - applyDeltasAdded(deltas.added, workflows) - expect(workflows[newWorkflow.id]).to.not.equal(undefined) + applyDeltasAdded(added, gscan, {}) + expect(gscan.lookup[newWorkflow.id]).to.not.equal(undefined) + expect(gscan.tree[0]).to.not.equal(undefined) }) }) describe('Updated', () => { it('should apply updated deltas', () => { - const deltasAdded = { - added: { - workflow: newWorkflow - } + const added = { + workflow: newWorkflow } - applyDeltasAdded(deltasAdded.added, workflows) - expect(workflows[newWorkflow.id].status).to.equal(WorkflowState.PAUSED) + applyDeltasAdded(added, gscan, {}) + expect(gscan.lookup[newWorkflow.id].node.status).to.equal(WorkflowState.PAUSED) newWorkflow.status = WorkflowState.STOPPED - const deltasUpdated = { - updated: { - workflow: newWorkflow - } + const updated = { + workflow: newWorkflow } - applyDeltasUpdated(deltasUpdated.updated, workflows) - expect(workflows[newWorkflow.id].status).to.equal(WorkflowState.STOPPED) + applyDeltasUpdated(updated, gscan, {}) + expect(gscan.lookup[newWorkflow.id].node.status).to.equal(WorkflowState.STOPPED) }) }) describe('Pruned', () => { it('should apply pruned deltas', () => { - const deltasAdded = { - added: { - workflow: newWorkflow - } + const added = { + workflow: newWorkflow } - applyDeltasAdded(deltasAdded.added, workflows) - expect(workflows[newWorkflow.id]).to.not.equal(undefined) - const deltasPruned = { - pruned: { - workflow: newWorkflow.id - } + applyDeltasAdded(added, gscan, {}) + expect(gscan.lookup[newWorkflow.id]).to.not.equal(undefined) + const pruned = { + workflow: newWorkflow.id } - applyDeltasPruned(deltasPruned.pruned, workflows) - expect(workflows[newWorkflow.id]).to.equal(undefined) + applyDeltasPruned(pruned, gscan, {}) + expect(gscan.lookup[newWorkflow.id]).to.equal(undefined) }) }) }) diff --git a/tests/unit/components/cylc/gscan/gscan.vue.spec.js b/tests/unit/components/cylc/gscan/gscan.vue.spec.js index bf0d74ee6..a9ab6ed52 100644 --- a/tests/unit/components/cylc/gscan/gscan.vue.spec.js +++ b/tests/unit/components/cylc/gscan/gscan.vue.spec.js @@ -27,6 +27,7 @@ import TaskState from '@/model/TaskState.model' import GScan from '@/components/cylc/gscan/GScan' import TreeItem from '@/components/cylc/tree/TreeItem' import { createWorkflowNode } from '@/components/cylc/gscan/nodes' +import { applyDeltasAdded } from '@/components/cylc/gscan/deltas' global.requestAnimationFrame = cb => cb() @@ -60,7 +61,7 @@ Vue.use(Vuex) describe('GScan component', () => { const store = new Vuex.Store(storeOptions) const resetState = () => { - store.commit('workflows/SET_WORKFLOWS', []) + store.commit('workflows/SET_LOOKUP', []) store.commit('workflows/SET_WORKFLOW_NAME', null) } beforeEach(resetState) @@ -82,6 +83,16 @@ describe('GScan component', () => { }) } it('should display a skeleton loader if loading data', () => { + const node = createWorkflowNode(simpleWorkflowGscanNodes[0], true) + const gscan = { + lookup: { + [node.id]: node + }, + tree: [ + node + ] + } + store.commit('gscan/SET_GSCAN', gscan) const wrapper = mountFunction({ computed: { isLoading () { @@ -94,9 +105,18 @@ describe('GScan component', () => { expect(isBusy).to.equal('true') }) it('should display the GScan with valid data', () => { - store.commit('workflows/SET_WORKFLOWS', simpleWorkflowGscanNodes) + const node = createWorkflowNode(simpleWorkflowGscanNodes[0], true) + const gscan = { + lookup: { + [node.id]: node + }, + tree: [ + node + ] + } + store.commit('gscan/SET_GSCAN', gscan) const wrapper = mountFunction({}) - expect(wrapper.vm.workflows[0].name).to.equal('five') + expect(wrapper.vm.gscan.tree[0].name).to.equal('five') expect(wrapper.find('div')).to.not.equal(null) expect(wrapper.html()).to.contain('five') }) @@ -121,10 +141,11 @@ describe('GScan component', () => { const createWorkflows = (namesAndStatuses) => { return namesAndStatuses.map(nameAndStatus => { return { - id: `user|${nameAndStatus.name}`, + id: nameAndStatus.id, name: nameAndStatus.name, status: nameAndStatus.status.name, - latestStateTasks: [] + stateTotals: {}, + latestStateTasks: {} } }) } @@ -133,111 +154,123 @@ describe('GScan component', () => { // already sorted by name { workflows: createWorkflows([ - { name: 'a', status: WorkflowState.RUNNING }, - { name: 'b', status: WorkflowState.RUNNING }, - { name: 'c', status: WorkflowState.RUNNING }, - { name: 'd', status: WorkflowState.RUNNING }, - { name: 'e', status: WorkflowState.RUNNING } + { id: 'cylc|a', name: 'a', status: WorkflowState.RUNNING }, + { id: 'cylc|b', name: 'b', status: WorkflowState.RUNNING }, + { id: 'cylc|c', name: 'c', status: WorkflowState.RUNNING }, + { id: 'cylc|d', name: 'd', status: WorkflowState.RUNNING }, + { id: 'cylc|e', name: 'e', status: WorkflowState.RUNNING } ]), expected: ['a', 'b', 'c', 'd', 'e'] }, // already sorted by status { workflows: createWorkflows([ - { name: 'a', status: WorkflowState.RUNNING }, - { name: 'b', status: WorkflowState.RUNNING }, - { name: 'c', status: WorkflowState.PAUSED }, - { name: 'd', status: WorkflowState.PAUSED }, - { name: 'e', status: WorkflowState.STOPPED } + { id: 'cylc|a', name: 'a', status: WorkflowState.RUNNING }, + { id: 'cylc|b', name: 'b', status: WorkflowState.RUNNING }, + { id: 'cylc|c', name: 'c', status: WorkflowState.PAUSED }, + { id: 'cylc|d', name: 'd', status: WorkflowState.PAUSED }, + { id: 'cylc|e', name: 'e', status: WorkflowState.STOPPED } ]), expected: ['a', 'b', 'c', 'd', 'e'] }, // sort in alphabetical order { workflows: createWorkflows([ - { name: 'a', status: WorkflowState.RUNNING }, - { name: 'e', status: WorkflowState.RUNNING }, - { name: 'c', status: WorkflowState.RUNNING }, - { name: 'b', status: WorkflowState.RUNNING }, - { name: 'd', status: WorkflowState.RUNNING } + { id: 'cylc|a', name: 'a', status: WorkflowState.RUNNING }, + { id: 'cylc|e', name: 'e', status: WorkflowState.RUNNING }, + { id: 'cylc|c', name: 'c', status: WorkflowState.RUNNING }, + { id: 'cylc|b', name: 'b', status: WorkflowState.RUNNING }, + { id: 'cylc|d', name: 'd', status: WorkflowState.RUNNING } ]), expected: ['a', 'b', 'c', 'd', 'e'] }, // running/paused/stopping grouped together and sorted, then the rest... { workflows: createWorkflows([ - { name: 'a', status: WorkflowState.RUNNING }, - { name: 'e', status: WorkflowState.PAUSED }, - { name: 'c', status: WorkflowState.STOPPED }, - { name: 'b', status: WorkflowState.STOPPED }, - { name: 'd', status: WorkflowState.STOPPED } + { id: 'cylc|a', name: 'a', status: WorkflowState.RUNNING }, + { id: 'cylc|e', name: 'e', status: WorkflowState.PAUSED }, + { id: 'cylc|c', name: 'c', status: WorkflowState.STOPPED }, + { id: 'cylc|b', name: 'b', status: WorkflowState.STOPPED }, + { id: 'cylc|d', name: 'd', status: WorkflowState.STOPPED } ]), expected: ['a', 'e', 'b', 'c', 'd'] }, // sorted alphabetically within statuses (running/paused/stopping are grouped together) { workflows: createWorkflows([ - { name: 'e', status: WorkflowState.PAUSED }, - { name: 'a', status: WorkflowState.PAUSED }, - { name: 'c', status: WorkflowState.STOPPED }, - { name: 'b', status: WorkflowState.RUNNING }, - { name: 'd', status: WorkflowState.STOPPED } + { id: 'cylc|e', name: 'e', status: WorkflowState.PAUSED }, + { id: 'cylc|a', name: 'a', status: WorkflowState.PAUSED }, + { id: 'cylc|c', name: 'c', status: WorkflowState.STOPPED }, + { id: 'cylc|b', name: 'b', status: WorkflowState.RUNNING }, + { id: 'cylc|d', name: 'd', status: WorkflowState.STOPPED } ]), expected: ['a', 'b', 'e', 'c', 'd'] }, { workflows: createWorkflows([ - { name: 'a', status: WorkflowState.PAUSED }, - { name: 'c', status: WorkflowState.STOPPED }, - { name: 'b', status: WorkflowState.RUNNING }, - { name: 'd', status: WorkflowState.STOPPED }, - { name: 'e', status: WorkflowState.PAUSED } + { id: 'cylc|a', name: 'a', status: WorkflowState.PAUSED }, + { id: 'cylc|c', name: 'c', status: WorkflowState.STOPPED }, + { id: 'cylc|b', name: 'b', status: WorkflowState.RUNNING }, + { id: 'cylc|d', name: 'd', status: WorkflowState.STOPPED }, + { id: 'cylc|e', name: 'e', status: WorkflowState.PAUSED } ]), expected: ['a', 'b', 'e', 'c', 'd'] }, // new statuses (stopping, error) { workflows: createWorkflows([ - { name: 'a', status: WorkflowState.PAUSED }, - { name: 'c', status: WorkflowState.STOPPED }, - { name: 'b', status: WorkflowState.RUNNING }, - { name: 'd', status: WorkflowState.STOPPED }, - { name: 'e', status: WorkflowState.PAUSED }, - { name: 'f', status: WorkflowState.PAUSED }, - { name: 'h', status: WorkflowState.STOPPING }, - { name: 'g', status: WorkflowState.ERROR }, - { name: 'j', status: WorkflowState.STOPPING }, - { name: 'i', status: WorkflowState.STOPPED }, - { name: 'k', status: WorkflowState.RUNNING }, - { name: 'l', status: WorkflowState.PAUSED } + { id: 'cylc|a', name: 'a', status: WorkflowState.PAUSED }, + { id: 'cylc|c', name: 'c', status: WorkflowState.STOPPED }, + { id: 'cylc|b', name: 'b', status: WorkflowState.RUNNING }, + { id: 'cylc|d', name: 'd', status: WorkflowState.STOPPED }, + { id: 'cylc|e', name: 'e', status: WorkflowState.PAUSED }, + { id: 'cylc|f', name: 'f', status: WorkflowState.PAUSED }, + { id: 'cylc|h', name: 'h', status: WorkflowState.STOPPING }, + { id: 'cylc|g', name: 'g', status: WorkflowState.ERROR }, + { id: 'cylc|j', name: 'j', status: WorkflowState.STOPPING }, + { id: 'cylc|i', name: 'i', status: WorkflowState.STOPPED }, + { id: 'cylc|k', name: 'k', status: WorkflowState.RUNNING }, + { id: 'cylc|l', name: 'l', status: WorkflowState.PAUSED } ]), expected: ['a', 'b', 'e', 'f', 'h', 'j', 'k', 'l', 'c', 'd', 'i', 'g'] }, // sorting by type too { workflows: createWorkflows([ - { name: 'a', status: WorkflowState.RUNNING }, - { name: 'a/b', status: WorkflowState.RUNNING } + { id: 'cylc|a/run2', name: 'run2', status: WorkflowState.RUNNING }, + { id: 'cylc|a/b/run1', name: 'run1', status: WorkflowState.RUNNING } ]), - expected: ['b', 'a'] + expected: ['run1', 'run2'] }, { workflows: createWorkflows([ - { name: 'a/b', status: WorkflowState.RUNNING }, - { name: 'a', status: WorkflowState.RUNNING } + { id: 'cylc|a/b/run1', name: 'run1', status: WorkflowState.RUNNING }, + { id: 'cylc|a/run2', name: 'run2', status: WorkflowState.RUNNING } ]), - expected: ['b', 'a'] + expected: ['run1', 'run2'] } ] tests.forEach(test => { - store.commit('workflows/SET_WORKFLOWS', test.workflows) - const wrapper = mountFunction() + const gscan = { + lookup: {}, + tree: [] + } + for (const workflow of test.workflows) { + const added = { + workflow + } + applyDeltasAdded(added, gscan, {}) + } + store.commit('gscan/SET_GSCAN', gscan) + const wrapper = mountFunction({}) // We will have all TreeItem elements, workflow-name-part's, and workflow's. const workflowsElements = wrapper.findAllComponents(TreeItem) .filter((treeItem) => { return treeItem.vm.node.type === 'workflow' }) - expect(workflowsElements.length).to.equal(test.expected.length) + expect(workflowsElements.length).to.equal( + test.expected.length, + `Length of sorted and expected not matching for given ${JSON.stringify(test.workflows)} using ${test.expected}`) for (let i = 0; i < test.expected.length; i++) { expect(test.expected[i]).to.equal( workflowsElements.at(i).element.textContent, @@ -250,7 +283,7 @@ describe('GScan component', () => { describe('Filters', () => { const workflows = [ { - id: 'user|1', + id: 'user|new_zealand', name: 'new zealand', status: WorkflowState.PAUSED.name, stateTotals: { @@ -258,7 +291,7 @@ describe('GScan component', () => { } }, { - id: 'user|2', + id: 'user|zeeland', name: 'zeeland', status: WorkflowState.RUNNING.name, stateTotals: { @@ -278,7 +311,7 @@ describe('GScan component', () => { stateTotals: {} }, { - id: 'user|research', + id: 'user|research/run1', name: 'research', status: WorkflowState.STOPPED.name, stateTotals: {} @@ -327,8 +360,27 @@ describe('GScan component', () => { } ] } + let gscan + beforeEach(() => { + gscan = { + lookup: {}, + tree: [] + } + for (const workflow of workflows) { + const added = { + workflow + } + applyDeltasAdded(added, gscan, {}) + } + }) + afterEach(() => { + store.commit('gscan/SET_GSCAN', { + lookup: {}, + tree: [] + }) + }) it('should have a default state of no name filter, and all states enabled', () => { - store.commit('workflows/SET_WORKFLOWS', workflows) + store.commit('gscan/SET_GSCAN', gscan) const wrapper = mountFunction({}) // read: give me all the workflows in RUNNING/PAUSED/STOPPED, no // matter their names or their tasks' states. @@ -347,17 +399,17 @@ describe('GScan component', () => { expect(filtered.length).to.equal(5) }) it('should not filter by name, nor by tasks state by default, but should include all workflow states', () => { - store.commit('workflows/SET_WORKFLOWS', workflows) + store.commit('gscan/SET_GSCAN', gscan) const wrapper = mountFunction({}) wrapper.vm.filteredWorkflows = wrapper.vm.filterHierarchically( - wrapper.vm.workflowNodes, + wrapper.vm.gscan.tree, wrapper.vm.searchWorkflows, wrapper.vm.workflowStates, wrapper.vm.taskStates ) // we will have the two items being displayed too const filtered = getWorkflows(wrapper.vm.filteredWorkflows) - const raw = getWorkflows(wrapper.vm.workflowNodes) + const raw = getWorkflows(wrapper.vm.gscan.tree) expect(filtered.length).to.equal(raw.length) }) describe('Filter by workflow name', () => { @@ -389,13 +441,13 @@ describe('GScan component', () => { } ] tests.forEach(test => { - store.commit('workflows/SET_WORKFLOWS', workflows) + store.commit('gscan/SET_GSCAN', gscan) const wrapper = mountFunction({}) let filtered = getWorkflows(wrapper.vm.filteredWorkflows) - const raw = getWorkflows(wrapper.vm.workflowNodes) + const raw = getWorkflows(wrapper.vm.gscan.tree) expect(filtered.length).to.equal(raw.length) wrapper.vm.filteredWorkflows = wrapper.vm.filterHierarchically( - wrapper.vm.workflowNodes, + wrapper.vm.gscan.tree, test.searchWorkflow, wrapper.vm.workflowStates, wrapper.vm.taskStates) @@ -436,11 +488,11 @@ describe('GScan component', () => { } return Object.assign({}, state, { model: false }) }) - store.commit('workflows/SET_WORKFLOWS', workflows) + store.commit('gscan/SET_GSCAN', gscan) const wrapper = mountFunction({}) const filters = createStatesFilters(workflowStates, initialWorkflowTaskStates) wrapper.vm.filteredWorkflows = wrapper.vm.filterHierarchically( - wrapper.vm.workflowNodes, + wrapper.vm.gscan.tree, '', filters[0] .items @@ -487,11 +539,11 @@ describe('GScan component', () => { } return Object.assign({}, state, { model: false }) }) - store.commit('workflows/SET_WORKFLOWS', workflows) + store.commit('gscan/SET_GSCAN', gscan) const wrapper = mountFunction({}) const filters = createStatesFilters(initialWorkflowStates, workflowTaskStates) wrapper.vm.filteredWorkflows = wrapper.vm.filterHierarchically( - wrapper.vm.workflowNodes, + wrapper.vm.gscan.tree, '', filters[0] .items diff --git a/tests/unit/store/gscan.spec.js b/tests/unit/store/gscan.spec.js new file mode 100644 index 000000000..477854d42 --- /dev/null +++ b/tests/unit/store/gscan.spec.js @@ -0,0 +1,60 @@ +/** + * Copyright (C) NIWA & British Crown (Met Office) & Contributors. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import { expect } from 'chai' +import Vue from 'vue' +import Vuex from 'vuex' +import storeOptions from '@/store/options' + +Vue.use(Vuex) + +/** + * Tests for the store/tree module. + */ +describe('gscan', () => { + const store = new Vuex.Store(storeOptions) + if (!global.localStorage) { + global.localStorage = {} + } + const resetState = () => { + store.state.gscan.gscan = {} + store.state.workflows.lookup = {} + } + beforeEach(resetState) + afterEach(resetState) + describe('State', () => { + it('should start with empty gscan', () => { + expect(Object.keys(store.state.gscan.gscan).length).to.deep.equal(0) + }) + }) + describe('Mutations', () => { + it('should set gscan', () => { + const gscan = { + tree: [ + { + test: 1 + } + ], + lookup: { + test: 1 + } + } + store.commit('gscan/SET_GSCAN', gscan) + expect(store.state.gscan.gscan).to.deep.equal(gscan) + }) + }) +}) diff --git a/tests/unit/store/tree.spec.js b/tests/unit/store/tree.spec.js new file mode 100644 index 000000000..30b1266f5 --- /dev/null +++ b/tests/unit/store/tree.spec.js @@ -0,0 +1,58 @@ +/** + * Copyright (C) NIWA & British Crown (Met Office) & Contributors. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import { expect } from 'chai' +import Vue from 'vue' +import Vuex from 'vuex' +import storeOptions from '@/store/options' + +Vue.use(Vuex) + +/** + * Tests for the store/tree module. + */ +describe('tree', () => { + const store = new Vuex.Store(storeOptions) + if (!global.localStorage) { + global.localStorage = {} + } + const resetState = () => { + store.state.tree.lookup = {} + store.state.tree.workflow = { + tree: {}, + lookup: {} + } + } + beforeEach(resetState) + afterEach(resetState) + describe('Mutations', () => { + it('should set tree workflow', () => { + const tree = { + tree: [ + { + test: 1 + } + ], + lookup: { + test: 1 + } + } + store.commit('tree/SET_WORKFLOW', tree) + expect(store.state.tree.workflow).to.deep.equal(tree) + }) + }) +}) diff --git a/tests/unit/store/workflows.spec.js b/tests/unit/store/workflows.spec.js index d8646a1e3..b67b963f9 100644 --- a/tests/unit/store/workflows.spec.js +++ b/tests/unit/store/workflows.spec.js @@ -32,85 +32,45 @@ describe('workflows', () => { } const resetState = () => { store.state.workflows.lookup = {} - store.state.workflows.workflow = { - tree: {}, - lookup: {} - } - store.state.workflows.workflows = [] store.state.workflows.workflowName = null } beforeEach(resetState) afterEach(resetState) describe('State', () => { - it('should start with empty lookup, empty workflow, no workflows, and no workflow name', () => { + it('should start with empty lookup and no workflow name', () => { expect(Object.keys(store.state.workflows.lookup).length).to.deep.equal(0) - expect(store.state.workflows.workflow).to.deep.equal({ tree: {}, lookup: {} }) - expect(store.state.workflows.workflows.length).to.equal(0) expect(store.state.workflows.workflowName).to.equal(null) }) }) describe('Getters', () => { it('should get the current workflow', () => { expect(store.getters['workflows/currentWorkflow']).to.equal(null) - const workflows = { + const lookup = { 'cylc|cylc': { id: 'cylc|cylc', name: 'cylc' } } - store.commit('workflows/SET_WORKFLOWS', workflows) - store.commit('workflows/SET_WORKFLOW_NAME', workflows['cylc|cylc'].name) - expect(store.getters['workflows/currentWorkflow']).to.deep.equal(workflows['cylc|cylc']) + store.commit('workflows/SET_LOOKUP', lookup) + store.commit('workflows/SET_WORKFLOW_NAME', lookup['cylc|cylc'].name) + expect(store.getters['workflows/currentWorkflow']).to.deep.equal(lookup['cylc|cylc']) }) }) describe('Mutations', () => { it('should set workflows', () => { - const workflows = { + const lookup = { 'cylc|cylc': { id: 'cylc|cylc', name: 'cylc' } } - store.commit('workflows/SET_WORKFLOWS', workflows) - expect(store.state.workflows.workflows).to.deep.equal(workflows) + store.commit('workflows/SET_LOOKUP', lookup) + expect(store.state.workflows.lookup).to.deep.equal(lookup) }) it('should set workflow name', () => { const workflowName = 'cylc' store.commit('workflows/SET_WORKFLOW_NAME', workflowName) expect(store.state.workflows.workflowName).to.equal(workflowName) }) - it('should set workflow', () => { - const workflow = { - tree: { - test: 1 - }, - lookup: { - test: 1 - } - } - store.commit('workflows/SET_WORKFLOW', workflow) - expect(store.state.workflows.workflow).to.deep.equal(workflow) - }) - it('should set lookup', () => { - const lookup = { - test: 1 - } - store.commit('workflows/SET_LOOKUP', lookup) - expect(store.state.workflows.lookup).to.deep.equal(lookup) - }) - it('should clear workflow', () => { - const workflow = { - tree: { - test: 1 - }, - lookup: { - test: 1 - } - } - store.commit('workflows/SET_WORKFLOW', workflow) - expect(store.state.workflows.workflow).to.deep.equal(workflow) - store.commit('workflows/CLEAR_WORKFLOW', workflow) - expect(store.state.workflows.workflow).to.not.deep.equal(workflow) - }) }) })