diff --git a/changes.d/2370.feat.md b/changes.d/2370.feat.md new file mode 100644 index 000000000..88ebe2743 --- /dev/null +++ b/changes.d/2370.feat.md @@ -0,0 +1,4 @@ +Task filtering improvements: +* Task ID filtering now supports globs. +* Task state filtering now supports queued, runahead, wallclock, xtriggered, retry, held and skip selectors. +* Task filtering controls have been redesigned. 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..faf7f7c13 100644 --- a/src/components/cylc/ViewToolbar.vue +++ b/src/components/cylc/ViewToolbar.vue @@ -30,24 +30,90 @@ along with this program. If not, see . class="control" :data-cy="`control-${iControl.key}`" > - + - {{ iControl.icon }} - {{ iControl.title }} - + + + + + + Reset + + + + + + + + + @@ -204,11 +363,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..92eb9768c 100644 --- a/src/components/cylc/common/filter.js +++ b/src/components/cylc/common/filter.js @@ -17,6 +17,44 @@ /* Logic for filtering tasks. */ +import { + GenericModifierNames, + TaskState, + TaskStateNames, + WaitingStateModifierNames, +} from '@/model/TaskState.model' +import { + escapeRegExp +} from 'lodash-es' + +/* 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 + escapeRegExp(glob.trim()) + .replace(/\\\*/, '.*') + // `?` -> `.` + .replace(/\\\?/, '.') + // `[!X]` -> `[^X]` + .replace(/\\\[!([^]*)\\\]/, '[^$1]') + // `[X]` -> `[X]` + .replace(/\\\[([^]*)\\\]/, '[$1]') + ) +} + /** * Return true if the node ID matches the given ID, or if no ID is given. * @@ -24,8 +62,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 +74,29 @@ 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]) || + ( + genericModifiers.includes('isSkip') && + node.node.runtime?.runMode === 'Skip' + ) + ) + ) } /** @@ -49,6 +108,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..258b0ff28 100644 --- a/src/components/cylc/gscan/GScan.vue +++ b/src/components/cylc/gscan/GScan.vue @@ -99,7 +99,7 @@ along with this program. If not, see . :workflows="workflows" :node-filter-func="filterNode" tree-item-component="GScanTreeItem" - class="c-gscan-workflow" + class="c-gscan-workflow pa-0" ref="tree" v-bind="{ filterState }" /> @@ -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/components/cylc/table/Table.vue b/src/components/cylc/table/Table.vue index 87df61e4a..d4a3cb726 100644 --- a/src/components/cylc/table/Table.vue +++ b/src/components/cylc/table/Table.vue @@ -26,6 +26,7 @@ along with this program. If not, see . density="compact" v-model:page="page" v-model:items-per-page="itemsPerPage" + fixed-header >