Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions changes.d/2370.feat.md
Original file line number Diff line number Diff line change
@@ -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.
61 changes: 0 additions & 61 deletions src/components/cylc/TaskFilter.vue

This file was deleted.

207 changes: 190 additions & 17 deletions src/components/cylc/ViewToolbar.vue
Copy link
Member

@MetRonnie MetRonnie Dec 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(Discussion to possibly take into another issue, not hold up this PR)

IMO this is becoming increasingly difficult to use. This single component controls the presentation and business logic of several input types, making it difficult to reason about how they are working.

In future, I think it would be worth extracting out the input types that are used here into their own SFCs, leaving this as a component that controls presentation and vuetify defaults, with a slot for allowing each view to set its controls (the more conventional way of using Vue components).

While I understand the idea behind this existing implementation (#1809 (comment)), the main advantage being having an object that contains the configuration that can be re-used in the settings page, this can still be achieved under what I'm proposing.

What do you think?

Copy link
Member Author

@oliver-sanders oliver-sanders Dec 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For another issue if you are sufficiently comfortable with this as it is here.

As it stands this is right at the edge of suitability for the current approach, agreed. Happy to look towards restructuring this code going forwards, e.g, something along the lines of the GraphQL form generator. Also if we end up taking this further in the future, a more model-orientated approach (hopefully removing the setOption callback) would be great.

I wouldn't like to see the complexity being decentralised back into the views (which one interpretation of the slots suggestion may result in), but there are defo other ways to do this.

With this PR I think the toolbar should do everything we need from it now and for the foreseeable (just need to add search & de-select all buttons for the graph view PR). So I don't think we should look into refactoring in the short/mid term (time better spent elsewhere). But if / when requirements start to become more advanced...

Original file line number Diff line number Diff line change
Expand Up @@ -30,24 +30,90 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
class="control"
:data-cy="`control-${iControl.key}`"
>
<v-btn
@click="iControl.callback"
v-bind="btnProps"
:disabled="iControl.disabled"
:aria-checked="iControl.value"
:color="iControl.value ? 'blue' : undefined"
role="switch"
<!-- menu component (to support dropdowns) -->
<v-menu
eager
:close-on-content-click="false"
>
<v-icon>{{ iControl.icon }}</v-icon>
<v-tooltip>{{ iControl.title }}</v-tooltip>
</v-btn>
<template v-slot:activator="{ props }">
<!-- inputs -->
<v-text-field
v-if="iControl.action === 'input'"
v-model="iControl.value"
class="input"
v-bind="iControl.props"
clearable
:prepend-inner-icon="iControl.icon"
@update:modelValue="iControl.callback"
@focus="autoResizeInput"
@blur="autoResizeInput"
/>

<!-- buttons -->
<v-btn
v-else
class="control-btn"
v-bind="{...$attrs, ...props, ...btnProps}"
@click="(e) => {iControl.action === 'menu' ? null : iControl.callback(e)}"
:disabled="iControl.disabled"
:aria-checked="iControl.value"
:color="isSet(iControl.value) ? 'blue' : undefined"
role="switch"
density="compact"
>
<v-icon>{{ iControl.icon[iControl.value] || iControl.icon }}</v-icon>
<v-tooltip>{{ iControl.title }}</v-tooltip>
</v-btn>
</template>

<!-- dropdowns -->
<v-card
v-if="iControl.action === 'menu'"
>
<v-btn
:prepend-icon="$options.icons.mdiUndo"
variant="plain"
@click="iControl.callback([])"
block
spaced="end"
:data-cy="`control-${iControl.key}-reset`"
>
Reset
</v-btn>
<v-divider></v-divider>

<v-treeview
v-bind="iControl.props"
v-model:activated="iControl.value"
@update:activated="iControl.callback"
color="blue"
density="compact"
style="padding-top: 0;"
>
<!-- task icons (for task state filters -->
<template
v-slot:prepend="{ item }"
v-if="iControl.props['task-state-icons']"
>
<Task :task="item.taskProps" />
</template>
</v-treeview>
</v-card>
</v-menu>
</div>
</div>
</div>
</template>

<script>
import { btnProps } from '@/utils/viewToolbar'
import { btnProps, taskStateItems } from '@/utils/viewToolbar'
import Task from '@/components/cylc/Task.vue'
import {
mdiFilter,
mdiMagnify,
mdiUndo,
} from '@mdi/js'
import { TaskState, WaitingStateModifierNames } from '@/model/TaskState.model'

export default {
name: 'ViewToolbar',
Expand All @@ -56,10 +122,18 @@ export default {
'setOption'
],

components: {
Task
},

icons: {
mdiUndo,
},

props: {
groups: {
required: true,
type: Array
type: Array,
/*
groups: [
{
Expand All @@ -70,21 +144,40 @@ export default {
{
// display name
title: String,

// unique key:
// * Provided with "setOption" events.
// * Used by enableIf/disableIf
// * Added to the control's class list for testing.
key: String

// icon for the control:
// * Either an icon.
// * Or a mapping of state to an icon.
// NOTE: this is autopopulated for action="taskStateFilter | taskIDFilter"
icon: Icon | Object[key, Icon]

// action to perform when clicked:
// Generic actions:
// * toggle - toggle true/false
// * callback - call the provided callback
// * menu - open a menu (provide props: {items} in v-treeview format)
// Specialised actions:
// * taskIDFilter - Search box for task IDs
// * taskStateFilter - open a task state filter menu
action: String

// for use with action='callback'
callback: Fuction

// props to be set on the control
props: Object

// list of keys
// only enable this control if all of the listed controls have
// truthy values
enableIf

// list of keys
// disable this control if any of the listed controls have
// truthy values
Expand All @@ -111,6 +204,8 @@ export default {
let iControl
let callback // callback to fire when control is activated
let disabled // true if control should not be enabled
let props
let action
const values = this.getValues()
for (const group of this.groups) {
iGroup = {
Expand All @@ -120,15 +215,43 @@ export default {
for (const control of group.controls) {
callback = null
disabled = false
props = control.props || {}
action = control.action

// set callback
switch (control.action) {
case 'toggle':
switch (action) {
case 'toggle': // toggle button
callback = (e) => this.toggle(control, e)
break
case 'callback':
case 'callback': // button which actions a callback
callback = (e) => this.call(control, e)
break
case 'taskIDFilter': // specialised "input" for filtering tasks
callback = (value) => this.set(control, value)
control.icon = mdiMagnify
action = 'input'
props = {
placeholder: 'Search (globs supported)',
...props,
}
break
case 'input': // text input
callback = (value) => this.set(control, value)
break
case 'taskStateFilter': // specialised "menu" for filtering tasks
action = 'menu'
control.icon = mdiFilter
props = {
items: taskStateItems,
'indent-lines': true,
activatable: true,
'active-strategy': 'independent',
'item-value': 'value',
'task-state-icons': true, // flag to enable special slots
...props,
}
callback = (value) => this.set(control, value)
break
}

// set disabled
Expand All @@ -147,6 +270,8 @@ export default {

iControl = {
...control,
action,
props,
callback,
disabled
}
Expand Down Expand Up @@ -186,6 +311,40 @@ export default {
}
return vars
},
set (control, value) {
// update the value
if ( // special logic for the taskStateFilter
control.action === 'taskStateFilter' &&
// if a waiting state modifier is selected
value.some((modifier) => WaitingStateModifierNames.includes(modifier)) &&
// but the waiting state is not
!value.includes(TaskState.WAITING.name)
) {
// then add the waiting state (i.e, don't allow the user to de-select
// waiting whilst a modifier is in play)
value.push(TaskState.WAITING.name)
}
this.$emit('setOption', control.key, value)
},
autoResizeInput (e) {
// enlarge a text input when focused or containing text
if (e.type === 'focus') {
e.target.classList.add('expanded')
} else {
if (e.target.value) {
e.target.classList.add('expanded')
} else {
e.target.classList.remove('expanded')
}
}
},
isSet (value) {
// determine if a control is active or not
if (Array.isArray(value)) {
return value.length
}
return value
}
}
}
</script>
Expand All @@ -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;
}
}
}
</style>
Loading
Loading