diff --git a/src/components/cylc/TaskFilter.vue b/src/components/cylc/TaskFilter.vue deleted file mode 100644 index a8fc1547f..000000000 --- a/src/components/cylc/TaskFilter.vue +++ /dev/null @@ -1,61 +0,0 @@ - - - - - - - diff --git a/src/components/cylc/ViewToolbar.vue b/src/components/cylc/ViewToolbar.vue index 25a024835..f902263de 100644 --- a/src/components/cylc/ViewToolbar.vue +++ b/src/components/cylc/ViewToolbar.vue @@ -30,24 +30,83 @@ along with this program. If not, see . class="control" :data-cy="`control-${iControl.key}`" > - + - {{ iControl.icon }} - {{ iControl.title }} - + + + + + + + + + + @@ -204,11 +345,25 @@ export default { // place a divider between groups content: ''; height: 70%; - width: 2px; - background: rgb(0, 0, 0, 0.22); + width: 0.15em; + border-radius: 0.15em; + background: rgb(0, 0, 0, 0.18); // put a bit of space between the groups margin: 0 $spacing; } + + // pack buttons more tightly than the vuetify default + .control-btn { + margin: 0.4em 0.25em 0.4em 0.25em; + } + + // auto expand/collapse the search bar + .input { + width: 8em; + } + .input:has(input.expanded) { + width: 20em; + } } } diff --git a/src/components/cylc/common/filter.js b/src/components/cylc/common/filter.js index b39715cef..31496c4b6 100644 --- a/src/components/cylc/common/filter.js +++ b/src/components/cylc/common/filter.js @@ -17,6 +17,46 @@ /* Logic for filtering tasks. */ +import { + GenericModifierNames, + TaskState, + TaskStateNames, + WaitingStateModifierNames, +} from '@/model/TaskState.model' + +/* Convert a glob to a Regex. + * + * Returns null if a blank string is provided as input. + * + * Supports the same globs as Python's "fnmatch" module: + * `*` - match everything. + * `?` - match a single character. + * `[seq]` - match any character in seq (same as regex). + * `[!seq]` - match any character *not* in seq. + */ +export function globToRegex (glob) { + if (!glob || !glob.trim()) { + // no glob provided + return null + } + return new RegExp( + // escape any regex characters in the glob + // NOTE: the first character is escaped using the `\x` syntax, so we + // prefix a space then subtract the first four characters (`\x20`) + // from the result + RegExp.escape(' ' + glob.trim()) + .substr(4) + // `*` -> `.*` + .replace(/\\\*/, '.*') + // `?` -> `.` + .replace(/\\\?/, '.') + // `[X]` -> `[X]` + .replace(/\\\[\\x21([^]*)\\\]/, '[^$1]') + // `[^X]` -> `[^X]` + .replace(/\\\[([^]*)\\\]/, '[$1]') + ) +} + /** * Return true if the node ID matches the given ID, or if no ID is given. * @@ -24,8 +64,8 @@ * @param {?string} id * @return {boolean} */ -export function matchID (node, id) { - return !id?.trim() || node.tokens.relativeID.includes(id) +export function matchID (node, regex) { + return !regex || Boolean(node.tokens.relativeID.match(regex)) } /** @@ -36,8 +76,25 @@ export function matchID (node, id) { * @param {?string[]} states * @returns {boolean} */ -export function matchState (node, states) { - return !states?.length || states.includes(node.node.state) +export function matchState ( + node, + states = [], + waitingStateModifiers = [], + genericModifiers = [], +) { + return ( + (!states?.length || states.includes(node.node.state)) && + ( + node.node.state !== 'waiting' || + !states.includes(TaskState.WAITING.name) || + !waitingStateModifiers.length || + waitingStateModifiers.some((modifier) => node.node[modifier]) + ) && + ( + !genericModifiers.length || + genericModifiers.some((modifier) => node.node[modifier]) + ) + ) } /** @@ -49,6 +106,14 @@ export function matchState (node, states) { * @param {?string[]} states * @return {boolean} */ -export function matchNode (node, id, states) { - return matchID(node, id) && matchState(node, states) +export function matchNode (node, regex, states, waitingStateModifiers, genericModifiers) { + return matchID(node, regex) && matchState(node, states, waitingStateModifiers, genericModifiers) +} + +export function groupStateFilters (states) { + return [ + states.filter(x => TaskStateNames.includes(x)), + states.filter(x => WaitingStateModifierNames.includes(x)), + states.filter(x => GenericModifierNames.includes(x)), + ] } diff --git a/src/components/cylc/gscan/GScan.vue b/src/components/cylc/gscan/GScan.vue index 74cbb1446..301e3619a 100644 --- a/src/components/cylc/gscan/GScan.vue +++ b/src/components/cylc/gscan/GScan.vue @@ -123,7 +123,7 @@ import Tree from '@/components/cylc/tree/Tree.vue' import { filterByName, filterByState } from '@/components/cylc/gscan/filters' import { sortedWorkflowTree } from '@/components/cylc/gscan/sort.js' import { mutate } from '@/utils/aotf' -import TaskFilterSelect from '@/components/cylc/TaskFilterSelect.vue' +import TaskFilterSelect from '@/components/cylc/gscan/TaskFilterSelect.vue' export default { name: 'GScan', diff --git a/src/components/cylc/TaskFilterSelect.vue b/src/components/cylc/gscan/TaskFilterSelect.vue similarity index 100% rename from src/components/cylc/TaskFilterSelect.vue rename to src/components/cylc/gscan/TaskFilterSelect.vue diff --git a/src/model/TaskState.model.js b/src/model/TaskState.model.js index 63501409a..87614438e 100644 --- a/src/model/TaskState.model.js +++ b/src/model/TaskState.model.js @@ -43,6 +43,7 @@ export class TaskState extends Enumify { /** * Task states ordered for display purposes. + * TODO: replace this by iterating TaskState.enumKeys? */ export const TaskStateUserOrder = [ TaskState.WAITING, @@ -57,4 +58,38 @@ export const TaskStateUserOrder = [ export const TaskStateNames = TaskStateUserOrder.map(({ name }) => name) +export class TaskModifier extends Enumify { + static isRetry = new TaskModifier('isRetry', 'Retry') + static isRunahead = new TaskModifier('isRunahead', 'Runahead') + static isQueued = new TaskModifier('isQueued', 'Queued') + static isWallclock = new TaskModifier('isWallclock', 'Wallclock') + static isXtrigger = new TaskModifier('isXtriggered', 'Xtrigger') + static isHeld = new TaskModifier('isHeld', 'Held') + static isSkip = new TaskModifier('isSkip', 'Skip') + static _ = this.closeEnum() + + constructor (field, title) { + super() + this.field = field + this.title = title + } +} + +export const WaitingStateModifiers = [ + TaskModifier.isRetry, + TaskModifier.isRunahead, + TaskModifier.isQueued, + TaskModifier.isWallclock, + TaskModifier.isXtrigger, +] + +export const WaitingStateModifierNames = WaitingStateModifiers.map(({ field }) => field) + +export const GenericModifiers = [ + TaskModifier.isSkip, + TaskModifier.isHeld, +] + +export const GenericModifierNames = GenericModifiers.map(({ field }) => field) + export default TaskState diff --git a/src/services/mock/json/workflows/one.json b/src/services/mock/json/workflows/one.json index 784b2ac56..824529307 100644 --- a/src/services/mock/json/workflows/one.json +++ b/src/services/mock/json/workflows/one.json @@ -53,6 +53,12 @@ "id": "~user/one//20000102T0000Z/BAD", "name": "BAD", "state": "failed", + "isHeld": true, + "isQueued": false, + "isRunahead": false, + "isRetry": false, + "isWallclock": false, + "isXtriggered": false, "cyclePoint": "20000102T0000Z", "firstParent": { "id": "~user/one//20000102T0000Z/root", @@ -115,6 +121,9 @@ "isHeld": false, "isQueued": false, "isRunahead": false, + "isRetry": false, + "isWallclock": false, + "isXtriggered": false, "cyclePoint": "20000102T0000Z", "flowNums": "[1]", "firstParent": { @@ -135,6 +144,9 @@ "isHeld": false, "isQueued": false, "isRunahead": false, + "isRetry": false, + "isWallclock": false, + "isXtriggered": false, "cyclePoint": "20000102T0000Z", "flowNums": "[1]", "firstParent": { @@ -152,9 +164,12 @@ "id": "~user/one//20000102T0000Z/failed", "name": "failed", "state": "failed", - "isHeld": false, + "isHeld": true, "isQueued": false, "isRunahead": false, + "isRetry": false, + "isWallclock": false, + "isXtriggered": false, "cyclePoint": "20000102T0000Z", "flowNums": "[1,2]", "firstParent": { @@ -174,6 +189,9 @@ "isHeld": false, "isQueued": false, "isRunahead": false, + "isRetry": true, + "isWallclock": false, + "isXtriggered": false, "cyclePoint": "20000102T0000Z", "flowNums": "[1]", "firstParent": { @@ -193,6 +211,9 @@ "isHeld": false, "isQueued": false, "isRunahead": false, + "isRetry": false, + "isWallclock": false, + "isXtriggered": false, "cyclePoint": "20000102T0000Z", "flowNums": "[]", "firstParent": { @@ -212,6 +233,9 @@ "isHeld": false, "isQueued": false, "isRunahead": false, + "isRetry": false, + "isWallclock": false, + "isXtriggered": false, "cyclePoint": "20000102T0000Z", "flowNums": "[1]", "firstParent": { @@ -232,6 +256,9 @@ "isHeld": false, "isQueued": false, "isRunahead": false, + "isRetry": false, + "isWallclock": true, + "isXtriggered": true, "cyclePoint": "20000102T0000Z", "flowNums": "[1]", "firstParent": { diff --git a/src/utils/viewToolbar.js b/src/utils/viewToolbar.js index 54626a56b..7ecf65474 100644 --- a/src/utils/viewToolbar.js +++ b/src/utils/viewToolbar.js @@ -14,6 +14,14 @@ * along with this program. If not, see . */ +import { + GenericModifiers, + TaskModifier, + TaskState, + TaskStateNames, + WaitingStateModifiers, +} from '@/model/TaskState.model' + /** * Scale icon size to button size. * https://github.com/vuetifyjs/vuetify/issues/16288 @@ -39,3 +47,47 @@ export const btnProps = (size) => ({ fontSize: btnIconFontSize(size) }, }) + +function getProps (modifier) { + const ret = {} + if (modifier === TaskModifier.isSkip) { + ret.runtime = { runMode: 'Skip' } + } else { + ret[modifier.field] = true + } + return ret +} + +export const taskStateItems = [ + { + title: TaskState.WAITING.name, + value: TaskState.WAITING.name, + props: { state: TaskState.WAITING.name }, + children: WaitingStateModifiers + .map((modifier) => { + return { + title: modifier.title, + value: modifier.field, + props: getProps(modifier) + } + }) + }, + ...TaskStateNames + .filter((name) => name !== TaskState.WAITING.name) + .map((name) => { + return { + title: name, + value: name, + props: { state: name } + } + }), + { type: 'divider' }, + ...GenericModifiers + .map((modifier) => { + return { + title: modifier.title, + value: modifier.field, + props: getProps(modifier) + } + }), +] diff --git a/src/views/Table.vue b/src/views/Table.vue index e57008936..66dc0cfd4 100644 --- a/src/views/Table.vue +++ b/src/views/Table.vue @@ -21,16 +21,18 @@ along with this program. If not, see . fluid class="c-table ma-0 pa-2 h-100 flex-column d-flex" > - + - - - - + + + . diff --git a/src/views/Tree.vue b/src/views/Tree.vue index 984999dd9..ecd2ffb4b 100644 --- a/src/views/Tree.vue +++ b/src/views/Tree.vue @@ -16,89 +16,33 @@ along with this program. If not, see . --> + + diff --git a/tests/e2e/specs/table.cy.js b/tests/e2e/specs/table.cy.js index 26df1dee7..e1c583572 100644 --- a/tests/e2e/specs/table.cy.js +++ b/tests/e2e/specs/table.cy.js @@ -43,14 +43,12 @@ describe('Table view', () => { it('Should filter by ID', () => { cy.get('.c-table table > tbody > tr') .should('have.length', initialNumRows) - cy.get('[data-cy=filter-id] input') + cy.get('[data-cy=control-taskIDFilter] input') .should('be.empty') - cy.get('[data-cy="filter task state"] input') - .should('have.value', '') cy.get('td [data-cy-task-name=sleepy]') .should('be.visible') for (const id of ['eep', '/sle']) { - cy.get('[data-cy=filter-id] input') + cy.get('[data-cy=control-taskIDFilter] input') .clear() .type(id) cy.get('td [data-cy-task-name=sleepy]') @@ -69,7 +67,7 @@ describe('Table view', () => { .get('td [data-cy-task-name=failed]') .should('be.visible') cy - .get('[data-cy="filter task state"]') + .get('[data-cy=control-taskStateFilter]') .click() cy .get('.v-list-item') @@ -89,7 +87,7 @@ describe('Table view', () => { .get('.c-table table > tbody > tr') .should('have.length', initialNumRows) cy - .get('[data-cy="filter task state"]') + .get('[data-cy=control-taskStateFilter]') .click() cy .get('.v-list-item') @@ -100,7 +98,7 @@ describe('Table view', () => { .should('have.length', 2) .should('be.visible') cy - .get('[data-cy=filter-id] input') + .get('[data-cy=control-taskIDFilter] input') .type('eventually') cy .get('td [data-cy-task-name=eventually_succeeded]') @@ -182,18 +180,17 @@ describe('State saving', () => { .get('.c-table table > tbody > tr') .should('have.length', initialNumRows) cy - .get('[data-cy="filter task state"]:last') + .get('[data-cy=control-taskStateFilter]:last') .click() cy - .get('.v-list-item') - .contains(TaskState.SUCCEEDED.name) - .click({ force: true }) + .get('.v-list-item[state=succeeded]') + .click({ force: true, multiple: true }) cy .get('.c-table table > tbody > tr') .should('have.length', 2) .should('be.visible') cy - .get('[data-cy=filter-id] input:last') + .get('[data-cy=control-taskIDFilter] input:last') .type('eventually') cy .get('td [data-cy-task-name=eventually_succeeded]') diff --git a/tests/e2e/specs/tree.cy.js b/tests/e2e/specs/tree.cy.js index 525cb733d..9ea18b958 100644 --- a/tests/e2e/specs/tree.cy.js +++ b/tests/e2e/specs/tree.cy.js @@ -174,7 +174,7 @@ describe('Tree view', () => { .should('have.length', initialNumTasks) .contains('waiting') for (const id of ['eed', '/suc', 'GOOD', 'SUC']) { - cy.get('[data-cy=filter-id] input') + cy.get('.c-view-toolbar input') .clear() .type(id) cy.get('.node-data-task:visible') @@ -184,12 +184,12 @@ describe('Tree view', () => { .should('not.be.visible') } // It should stop filtering when input is cleared - cy.get('[data-cy=filter-id] input') + cy.get('.c-view-toolbar input') .clear() .get('.node-data-task:visible') .should('have.length', initialNumTasks) // It should filter by cycle point - cy.get('[data-cy=filter-id] input') + cy.get('.c-view-toolbar input') .type('2000') // (matches all tasks) .get('.node-data-task:visible') .should('have.length', initialNumTasks) @@ -202,7 +202,7 @@ describe('Tree view', () => { .contains(name) .should('be.visible') } - cy.get('[data-cy="filter task state"]') + cy.get('[data-cy="control-taskStateFilter"]') .click() .get('.v-list-item') .contains(new RegExp(`^${TaskState.FAILED.name}$`)) @@ -226,10 +226,10 @@ describe('Tree view', () => { .contains('failed') .should('be.visible') cy - .get('[data-cy=filter-id]') + .get('[data-cy="control-taskIDFilter"]') .type('i') cy - .get('[data-cy="filter task state"]') + .get('[data-cy="control-taskStateFilter"]') .click() .get('.v-list-item') .contains(TaskState.WAITING.name) @@ -247,10 +247,10 @@ describe('Tree view', () => { .contains('failed') .should('be.visible') cy - .get('[data-cy=filter-id]') + .get('[data-cy="control-taskIDFilter"]') .type('i') cy - .get('[data-cy="filter task state"]') + .get('[data-cy="control-taskStateFilter"]') .click() .get('.v-list-item') .contains(TaskState.WAITING.name) @@ -265,19 +265,19 @@ describe('Tree view', () => { .contains('retrying') }) - it('Provides a select all functionality', () => { - cy.visit('/#/tree/one') - cy.get('[data-cy="filter task state"]') - .get('.v-list-item--active') - .should('have.length', 0) - cy.get('[data-cy="filter task state"]') - .click() - .get('[data-cy=task-filter-select-all]') - .click() - cy.get('[data-cy="filter task state"]') - .get('.v-list-item--active') - .should('have.length', 8) - }) + // it('Provides a select all functionality', () => { + // cy.visit('/#/tree/one') + // cy.get('[data-cy="control-taskStateFilter"]') + // .get('.v-list-item--active') + // .should('have.length', 0) + // cy.get('[data-cy="control-taskStateFilter"]') + // .click() + // .get('[data-cy=task-filter-select-all]') + // .click() + // cy.get('[data-cy="control-taskStateFilter"]') + // .get('.v-list-item--active') + // .should('have.length', 8) + // }) }) describe('Expand/collapse all buttons', () => { @@ -287,11 +287,11 @@ describe('Tree view', () => { .contains('sleepy') .as('sleepyTask') .should('be.visible') - cy.get('[data-cy=collapse-all]') + cy.get('[data-cy=control-CollapseAll]') .click() .get('@sleepyTask') .should('not.be.visible') - .get('[data-cy=expand-all]') + .get('[data-cy=control-ExpandAll]') .click() .get('@sleepyTask') .should('be.visible') @@ -299,7 +299,7 @@ describe('Tree view', () => { it('Does not expand jobs but can collapse them', () => { cy.visit('/#/tree/one') - .get('[data-cy=expand-all]') + .get('[data-cy=control-ExpandAll]') .click() .get('.node-data-job:first') .should('not.exist') @@ -308,14 +308,14 @@ describe('Tree view', () => { .click() .get('.node-data-job:first') .should('be.visible') - cy.get('[data-cy=expand-all]') + cy.get('[data-cy=control-ExpandAll]') .click() // The job should remain expanded .get('.node-data-job:first') .should('be.visible') - cy.get('[data-cy=collapse-all]') + cy.get('[data-cy=control-CollapseAll]') .click() - .get('[data-cy=expand-all]') + .get('[data-cy=control-ExpandAll]') .click() // The job should be collapsed now .get('.node-data-job:first') @@ -328,41 +328,24 @@ describe('Tree view', () => { .contains('sleepy') .as('sleepyTask') .should('be.visible') - cy.get('[data-cy=filter-id]') + cy.get('[data-cy="control-taskIDFilter"]') .type('sleep') - cy.get('[data-cy=collapse-all]') + cy.get('[data-cy=control-CollapseAll]') .click() .get('@sleepyTask') .should('not.be.visible') - .get('[data-cy=expand-all]') + .get('[data-cy=control-ExpandAll]') .click() .get('@sleepyTask') .should('be.visible') }) }) - it('should show a summary of tasks if the number of selected items is greater than the maximum limit', () => { - cy.visit('/#/tree/one') - cy.get('[data-cy="filter task state"]') - .click() - // eslint-disable-next-line no-lone-blocks - TaskState.enumValues.forEach(state => { - cy.get('.v-list-item') - .contains(state.name) - .click({ force: true }) - }) - // Click outside to close dropdown - cy.get('noscript') - .click({ force: true }) - cy.get('[data-cy="filter task state"]') - .contains('.v-select__selection', '(+') - }) - describe('Toggle families', () => { it('Toggles between flat and hierarchical modes', () => { cy.visit('/#/tree/one') cy.get('.node-data-family').should('have.length', 3) - cy.get('[data-cy=toggle-families]').click() + cy.get('[data-cy=control-flat]').click() cy.get('.node-data-family').should('have.length', 0) }) }) diff --git a/tests/unit/components/cylc/common/filter.spec.js b/tests/unit/components/cylc/common/filter.spec.js index cc965ad74..f9ffa14ee 100644 --- a/tests/unit/components/cylc/common/filter.spec.js +++ b/tests/unit/components/cylc/common/filter.spec.js @@ -15,7 +15,12 @@ * along with this program. If not, see . */ -import { matchID, matchNode, matchState } from '@/components/cylc/common/filter' +import { + globToRegex, + matchID, + matchNode, + matchState, +} from '@/components/cylc/common/filter' const taskNode = { id: '~user/one//20000102T0000Z/succeeded', @@ -35,9 +40,12 @@ const taskNode = { id: '~user/one//20000102T0000Z/succeeded', name: 'succeeded', state: 'succeeded', - isHeld: false, + isHeld: true, isQueued: false, isRunahead: false, + isRetry: true, + isWallclock: false, + isXtriggered: false, cyclePoint: '20000102T0000Z', firstParent: { id: '~user/one//20000102T0000Z/SUCCEEDED', @@ -53,19 +61,21 @@ const taskNode = { describe('task filtering', () => { describe('matchID', () => { it.each([ - { node: taskNode, id: '', expected: true }, - { node: taskNode, id: ' ', expected: true }, - { node: taskNode, id: '2000', expected: true }, - { node: taskNode, id: 'succeeded', expected: true }, - { node: taskNode, id: '20000102T0000Z/suc', expected: true }, - { node: taskNode, id: '2001', expected: false }, - { node: taskNode, id: 'darmok', expected: false }, + { node: taskNode, regex: null, expected: true }, + { node: taskNode, regex: /2000/, expected: true }, + { node: taskNode, regex: /succeeded/, expected: true }, + { node: taskNode, regex: /20000102T0000Z\/suc/, expected: true }, + { node: taskNode, regex: /2001/, expected: false }, + { node: taskNode, regex: globToRegex('*'), expected: true }, + { node: taskNode, regex: globToRegex('suc*'), expected: true }, + { node: taskNode, regex: /darmok/, expected: false }, + { node: taskNode, regex: globToRegex('darmok*'), expected: false }, // Only matches relative ID: - { node: taskNode, id: 'user/one', expected: false }, + { node: taskNode, regex: /user\/one/, expected: false }, // Case sensitive: - { node: taskNode, id: 'SUC', expected: false }, - ])('matchID(<$node.id>, $id)', ({ node, id, expected }) => { - expect(matchID(node, id)).toBe(expected) + { node: taskNode, regex: /SUC/, expected: false }, + ])('matchID(<$node.id>, $regex)', ({ node, regex, expected }) => { + expect(matchID(node, regex)).toBe(expected) }) }) @@ -76,21 +86,63 @@ describe('task filtering', () => { { node: taskNode, states: ['succeeded'], expected: true }, { node: taskNode, states: ['succeeded', 'failed'], expected: true }, { node: taskNode, states: ['failed'], expected: false }, - ])('matchState(<$node.node.state>, $states)', ({ node, states, expected }) => { - expect(matchState(node, states)).toBe(expected) + { node: taskNode, states: ['failed'], expected: false }, + { node: taskNode, states: ['waiting'], waitingStateModifiers: ['isRetry'], expected: false }, + { node: taskNode, genericModifiers: ['isHeld'], expected: true }, + { node: taskNode, genericModifiers: ['isHeld', 'isRunahead'], expected: true }, + { node: taskNode, genericModifiers: ['isRunahead'], expected: false }, + { node: taskNode, states: ['succeeded'], genericModifiers: ['isHeld'], expected: true }, + { node: taskNode, states: ['waiting'], genericModifiers: ['isHeld'], expected: false }, + ])('matchState(<$node.node.state>, $states)', ({ + node, + states, + waitingStateModifiers, + genericModifiers, + expected, + }) => { + expect(matchState( + node, + states, + waitingStateModifiers, + genericModifiers, + )).toBe(expected) }) }) describe('matchNode', () => { it.each([ - { node: taskNode, id: '', states: [], expected: true }, - { node: taskNode, id: '2000', states: [], expected: true }, - { node: taskNode, id: '', states: ['succeeded', 'failed'], expected: true }, - { node: taskNode, id: '2000', states: ['succeeded', 'failed'], expected: true }, - { node: taskNode, id: 'darmok', states: ['succeeded', 'failed'], expected: false }, - { node: taskNode, id: '2000', states: ['failed'], expected: false }, - ])('matchNode(<$node.id>, $id, $states)', ({ node, id, states, expected }) => { - expect(matchNode(node, id, states)).toBe(expected) + { node: taskNode, regex: null, states: [], expected: true }, + { node: taskNode, regex: /2000/, states: [], expected: true }, + { node: taskNode, regex: /2000/, states: ['succeeded', 'failed'], expected: true }, + { node: taskNode, regex: /darmok/, states: ['succeeded', 'failed'], expected: false }, + { node: taskNode, regex: /2000/, states: ['failed'], expected: false }, + ])('matchNode(<$node.id>, $regex, $states)', ({ node, regex, states, expected }) => { + expect(matchNode(node, regex, states)).toBe(expected) + }) + }) + + describe('globToRegex', () => { + // NOTE: The functionality tested here requires Node 24+ + it.each([ + // no pattern specified + { glob: '', regex: null }, + { glob: ' ', regex: null }, + + // plain text + { glob: 'foo', regex: /foo/ }, + + // globs + { glob: 'f*o', regex: /f.*o/ }, + { glob: 'f?o', regex: /f.o/ }, + { glob: 'f[o]o', regex: /f[o]o/ }, + { glob: 'f[!o]o', regex: /f[^o]o/ }, + + // regex escapes + { glob: '.*', regex: /\..*/ }, + { glob: '(x)', regex: /\(x\)/ }, + { glob: '\\w\\d\\s', regex: /\\w\\d\\s/ }, + ])('globToRegex($glob) => $regex', ({ glob, regex }) => { + expect(String(globToRegex(glob))).toBe(String(regex)) }) }) }) diff --git a/tests/unit/views/table.vue.spec.js b/tests/unit/views/table.vue.spec.js index 8314cc4ea..14022cae6 100644 --- a/tests/unit/views/table.vue.spec.js +++ b/tests/unit/views/table.vue.spec.js @@ -124,11 +124,19 @@ describe('Table view', () => { }) it('should filter by ID', async () => { + // plain ID wrapper.vm.tasksFilter = { id: 'taskA' } await nextTick() expect(wrapper.vm.filteredTasks.length).to.equal(1) + + // glob ID + wrapper.vm.tasksFilter = { + id: 'task[A]' + } + await nextTick() + expect(wrapper.vm.filteredTasks.length).to.equal(1) }) it('should filter by task state', async () => { diff --git a/tests/unit/views/tree.vue.spec.js b/tests/unit/views/tree.vue.spec.js index a8bd5a38f..421ec7abf 100644 --- a/tests/unit/views/tree.vue.spec.js +++ b/tests/unit/views/tree.vue.spec.js @@ -55,7 +55,7 @@ const workflowNode = { { ...expandID('~user/workflow1//1/foo'), type: 'task', - node: { state: 'failed' }, + node: { state: 'failed', isHeld: true }, children: [ { ...expandID('~user/workflow1//1/foo/1'), @@ -114,19 +114,33 @@ describe('Tree view', () => { }) it.each([ + // the task should be displayed { tasksFilter: { id: 'foo' }, filteredOut: false }, { tasksFilter: { states: ['failed'] }, filteredOut: false }, { tasksFilter: { id: 'foo', states: ['failed'] }, filteredOut: false }, + { tasksFilter: { id: 'foo', states: ['isHeld'] }, filteredOut: false }, + { tasksFilter: { id: 'foo', states: ['failed', 'isHeld'] }, filteredOut: false }, + { tasksFilter: { id: 'f*', states: ['failed', 'isHeld'] }, filteredOut: false }, + { tasksFilter: { id: 'f?', states: ['failed', 'isHeld'] }, filteredOut: false }, + { tasksFilter: { id: 'f[o]o', states: ['failed', 'isHeld'] }, filteredOut: false }, + { tasksFilter: { id: 'f[!z]o', states: ['failed', 'isHeld'] }, filteredOut: false }, + // the task should *not* be displayed { tasksFilter: { id: 'asdf' }, filteredOut: true }, { tasksFilter: { states: ['running'] }, filteredOut: true }, { tasksFilter: { id: 'foo', states: ['running'] }, filteredOut: true }, { tasksFilter: { id: 'asdf', states: ['failed'] }, filteredOut: true }, + { tasksFilter: { id: 'asdf', states: ['failed', 'isRunahead'] }, filteredOut: true }, + { tasksFilter: { id: 'asdf*' }, filteredOut: true }, + { tasksFilter: { id: 'asdf?' }, filteredOut: true }, + { tasksFilter: { id: 'asd[f]' }, filteredOut: true }, + { tasksFilter: { id: 'asd[!f]' }, filteredOut: true }, ])('filters by $tasksFilter', async ({ tasksFilter, filteredOut }) => { const wrapper = mountFunction() wrapper.vm.tasksFilter = tasksFilter await nextTick() - expect(wrapper.vm.filterState).toMatchObject(tasksFilter) + expect(wrapper.vm.filterState[0]).toMatchObject(tasksFilter.id) + expect(wrapper.vm.filterState[1]).toMatchObject(tasksFilter.states) const filteredOutNodesCache = new Map() expect(wrapper.vm.filterNode(workflowNode, filteredOutNodesCache)).toEqual(!filteredOut) expect(getIDMap(filteredOutNodesCache)).toEqual({ diff --git a/vite.config.js b/vite.config.js index f7aec3560..b552c78e4 100644 --- a/vite.config.js +++ b/vite.config.js @@ -88,7 +88,8 @@ export default defineConfig(({ mode }) => { }, watch: { ignored: [ - path.resolve(__dirname, './coverage') + path.resolve(__dirname, './coverage'), + path.resolve(__dirname, '**/.nfs*'), ] }, warmup: { diff --git a/yarn.lock b/yarn.lock index 81d16c381..daa1a4754 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3957,9 +3957,9 @@ __metadata: linkType: hard "caniuse-lite@npm:^1.0.30001524, caniuse-lite@npm:^1.0.30001718": - version: 1.0.30001723 - resolution: "caniuse-lite@npm:1.0.30001723" - checksum: 10c0/e019503061759b96017c4d27ddd7ca1b48533eabcd0431b51d2e3156f99f6b031075e46c279c0db63424cdfc874bba992caec2db51b922a0f945e686246886f6 + version: 1.0.30001757 + resolution: "caniuse-lite@npm:1.0.30001757" + checksum: 10c0/3ccb71fa2bf1f8c96ff1bf9b918b08806fed33307e20a3ce3259155fda131eaf96cfcd88d3d309c8fd7f8285cc71d89a3b93648a1c04814da31c301f98508d42 languageName: node linkType: hard