Skip to content

Commit 5fe0eee

Browse files
view toolbar: support globs in task ID search
* Closes #1264
1 parent 3e653fa commit 5fe0eee

File tree

5 files changed

+106
-26
lines changed

5 files changed

+106
-26
lines changed

src/components/cylc/common/filter.js

Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,15 +24,48 @@ import {
2424
WaitingStateModifierNames,
2525
} from '@/model/TaskState.model'
2626

27+
/* Convert a glob to a Regex.
28+
*
29+
* Returns null if a blank string is provided as input.
30+
*
31+
* Supports the same globs as Python's "fnmatch" module:
32+
* `*` - match everything.
33+
* `?` - match a single character.
34+
* `[seq]` - match any character in seq (same as regex).
35+
* `[!seq]` - match any character *not* in seq.
36+
*/
37+
export function globToRegex (glob) {
38+
if (!glob || !glob.trim()) {
39+
// no glob provided
40+
return null
41+
}
42+
return new RegExp(
43+
// escape any regex characters in the glob
44+
// NOTE: the first character is escaped using the `\x` syntax, so we
45+
// prefix a space then subtract the first four characters (`\x20`)
46+
// from the result
47+
RegExp.escape(' ' + glob.trim())
48+
.substr(4)
49+
// `*` -> `.*`
50+
.replace(/\\\*/, '.*')
51+
// `?` -> `.`
52+
.replace(/\\\?/, '.')
53+
// `[X]` -> `[X]`
54+
.replace(/\\\[\\x21([^]*)\\\]/, '[^$1]')
55+
// `[^X]` -> `[^X]`
56+
.replace(/\\\[([^]*)\\\]/, '[$1]')
57+
)
58+
}
59+
2760
/**
2861
* Return true if the node ID matches the given ID, or if no ID is given.
2962
*
3063
* @param {Object} node
3164
* @param {?string} id
3265
* @return {boolean}
3366
*/
34-
export function matchID (node, id) {
35-
return !id?.trim() || node.tokens.relativeID.includes(id)
67+
export function matchID (node, regex) {
68+
return !regex || Boolean(node.tokens.relativeID.match(regex))
3669
}
3770

3871
/**
@@ -73,8 +106,8 @@ export function matchState (
73106
* @param {?string[]} states
74107
* @return {boolean}
75108
*/
76-
export function matchNode (node, id, states, waitingStateModifiers, genericModifiers) {
77-
return matchID(node, id) && matchState(node, states, waitingStateModifiers, genericModifiers)
109+
export function matchNode (node, regex, states, waitingStateModifiers, genericModifiers) {
110+
return matchID(node, regex) && matchState(node, states, waitingStateModifiers, genericModifiers)
78111
}
79112

80113
export function groupStateFilters (states) {

src/views/Tree.vue

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ import {
5353
import SubscriptionQuery from '@/model/SubscriptionQuery.model'
5454
import TreeComponent from '@/components/cylc/tree/Tree.vue'
5555
import ViewToolbar from '@/components/cylc/ViewToolbar.vue'
56-
import { matchID, matchState, groupStateFilters } from '@/components/cylc/common/filter'
56+
import { matchID, matchState, groupStateFilters, globToRegex } from '@/components/cylc/common/filter'
5757
5858
const QUERY = gql`
5959
subscription Workflow ($workflowId: ID) {
@@ -324,7 +324,7 @@ export default {
324324
325325
const stateMatch = matchState(node, states, waitingStateModifiers, genericModifiers)
326326
// This node should be included if any parent matches the ID filter
327-
const idMatch = parentsIDMatch || matchID(node, this.tasksFilter.id)
327+
const idMatch = parentsIDMatch || matchID(node, globToRegex(this.tasksFilter.id))
328328
let isMatch = stateMatch && idMatch
329329
330330
let { children } = node

tests/unit/components/cylc/common/filter.spec.js

Lines changed: 51 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,12 @@
1515
* along with this program. If not, see <http://www.gnu.org/licenses/>.
1616
*/
1717

18-
import { matchID, matchNode, matchState } from '@/components/cylc/common/filter'
18+
import {
19+
globToRegex,
20+
matchID,
21+
matchNode,
22+
matchState,
23+
} from '@/components/cylc/common/filter'
1924

2025
const taskNode = {
2126
id: '~user/one//20000102T0000Z/succeeded',
@@ -56,19 +61,21 @@ const taskNode = {
5661
describe('task filtering', () => {
5762
describe('matchID', () => {
5863
it.each([
59-
{ node: taskNode, id: '', expected: true },
60-
{ node: taskNode, id: ' ', expected: true },
61-
{ node: taskNode, id: '2000', expected: true },
62-
{ node: taskNode, id: 'succeeded', expected: true },
63-
{ node: taskNode, id: '20000102T0000Z/suc', expected: true },
64-
{ node: taskNode, id: '2001', expected: false },
65-
{ node: taskNode, id: 'darmok', expected: false },
64+
{ node: taskNode, regex: null, expected: true },
65+
{ node: taskNode, regex: /2000/, expected: true },
66+
{ node: taskNode, regex: /succeeded/, expected: true },
67+
{ node: taskNode, regex: /20000102T0000Z\/suc/, expected: true },
68+
{ node: taskNode, regex: /2001/, expected: false },
69+
{ node: taskNode, regex: globToRegex('*'), expected: true },
70+
{ node: taskNode, regex: globToRegex('suc*'), expected: true },
71+
{ node: taskNode, regex: /darmok/, expected: false },
72+
{ node: taskNode, regex: globToRegex('darmok*'), expected: false },
6673
// Only matches relative ID:
67-
{ node: taskNode, id: 'user/one', expected: false },
74+
{ node: taskNode, regex: /user\/one/, expected: false },
6875
// Case sensitive:
69-
{ node: taskNode, id: 'SUC', expected: false },
70-
])('matchID(<$node.id>, $id)', ({ node, id, expected }) => {
71-
expect(matchID(node, id)).toBe(expected)
76+
{ node: taskNode, regex: /SUC/, expected: false },
77+
])('matchID(<$node.id>, $regex)', ({ node, regex, expected }) => {
78+
expect(matchID(node, regex)).toBe(expected)
7279
})
7380
})
7481

@@ -104,14 +111,38 @@ describe('task filtering', () => {
104111

105112
describe('matchNode', () => {
106113
it.each([
107-
{ node: taskNode, id: '', states: [], expected: true },
108-
{ node: taskNode, id: '2000', states: [], expected: true },
109-
{ node: taskNode, id: '', states: ['succeeded', 'failed'], expected: true },
110-
{ node: taskNode, id: '2000', states: ['succeeded', 'failed'], expected: true },
111-
{ node: taskNode, id: 'darmok', states: ['succeeded', 'failed'], expected: false },
112-
{ node: taskNode, id: '2000', states: ['failed'], expected: false },
113-
])('matchNode(<$node.id>, $id, $states)', ({ node, id, states, expected }) => {
114-
expect(matchNode(node, id, states)).toBe(expected)
114+
{ node: taskNode, regex: null, states: [], expected: true },
115+
{ node: taskNode, regex: /2000/, states: [], expected: true },
116+
{ node: taskNode, regex: /2000/, states: ['succeeded', 'failed'], expected: true },
117+
{ node: taskNode, regex: /darmok/, states: ['succeeded', 'failed'], expected: false },
118+
{ node: taskNode, regex: /2000/, states: ['failed'], expected: false },
119+
])('matchNode(<$node.id>, $regex, $states)', ({ node, regex, states, expected }) => {
120+
expect(matchNode(node, regex, states)).toBe(expected)
121+
})
122+
})
123+
124+
describe('globToRegex', () => {
125+
// NOTE: The functionality tested here requires Node 24+
126+
it.each([
127+
// no pattern specified
128+
{ glob: '', regex: null },
129+
{ glob: ' ', regex: null },
130+
131+
// plain text
132+
{ glob: 'foo', regex: /foo/ },
133+
134+
// globs
135+
{ glob: 'f*o', regex: /f.*o/ },
136+
{ glob: 'f?o', regex: /f.o/ },
137+
{ glob: 'f[o]o', regex: /f[o]o/ },
138+
{ glob: 'f[!o]o', regex: /f[^o]o/ },
139+
140+
// regex escapes
141+
{ glob: '.*', regex: /\..*/ },
142+
{ glob: '(x)', regex: /\(x\)/ },
143+
{ glob: '\\w\\d\\s', regex: /\\w\\d\\s/ },
144+
])('globToRegex($glob) => $regex', ({ glob, regex }) => {
145+
expect(String(globToRegex(glob))).toBe(String(regex))
115146
})
116147
})
117148
})

tests/unit/views/table.vue.spec.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,11 +124,19 @@ describe('Table view', () => {
124124
})
125125

126126
it('should filter by ID', async () => {
127+
// plain ID
127128
wrapper.vm.tasksFilter = {
128129
id: 'taskA'
129130
}
130131
await nextTick()
131132
expect(wrapper.vm.filteredTasks.length).to.equal(1)
133+
134+
// glob ID
135+
wrapper.vm.tasksFilter = {
136+
id: 'task[A]'
137+
}
138+
await nextTick()
139+
expect(wrapper.vm.filteredTasks.length).to.equal(1)
132140
})
133141

134142
it('should filter by task state', async () => {

tests/unit/views/tree.vue.spec.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,13 +120,21 @@ describe('Tree view', () => {
120120
{ tasksFilter: { id: 'foo', states: ['failed'] }, filteredOut: false },
121121
{ tasksFilter: { id: 'foo', states: ['isHeld'] }, filteredOut: false },
122122
{ tasksFilter: { id: 'foo', states: ['failed', 'isHeld'] }, filteredOut: false },
123+
{ tasksFilter: { id: 'f*', states: ['failed', 'isHeld'] }, filteredOut: false },
124+
{ tasksFilter: { id: 'f?', states: ['failed', 'isHeld'] }, filteredOut: false },
125+
{ tasksFilter: { id: 'f[o]o', states: ['failed', 'isHeld'] }, filteredOut: false },
126+
{ tasksFilter: { id: 'f[!z]o', states: ['failed', 'isHeld'] }, filteredOut: false },
123127

124128
// the task should *not* be displayed
125129
{ tasksFilter: { id: 'asdf' }, filteredOut: true },
126130
{ tasksFilter: { states: ['running'] }, filteredOut: true },
127131
{ tasksFilter: { id: 'foo', states: ['running'] }, filteredOut: true },
128132
{ tasksFilter: { id: 'asdf', states: ['failed'] }, filteredOut: true },
129133
{ tasksFilter: { id: 'asdf', states: ['failed', 'isRunahead'] }, filteredOut: true },
134+
{ tasksFilter: { id: 'asdf*' }, filteredOut: true },
135+
{ tasksFilter: { id: 'asdf?' }, filteredOut: true },
136+
{ tasksFilter: { id: 'asd[f]' }, filteredOut: true },
137+
{ tasksFilter: { id: 'asd[!f]' }, filteredOut: true },
130138
])('filters by $tasksFilter', async ({ tasksFilter, filteredOut }) => {
131139
const wrapper = mountFunction()
132140
wrapper.vm.tasksFilter = tasksFilter

0 commit comments

Comments
 (0)