Skip to content
19 changes: 16 additions & 3 deletions packages/ui/client/components/FilterStatus.vue
Original file line number Diff line number Diff line change
@@ -1,13 +1,25 @@
<script setup lang="ts">
defineProps<{ label: string }>()
const { disabled = false } = defineProps<{
label: string
disabled?: boolean
}>()
const modelValue = defineModel<boolean | null>()

function toggle() {
if (disabled) {
return
}

modelValue.value = !modelValue.value
}
</script>

<template>
<label
class="font-light text-sm checkbox flex items-center cursor-pointer py-1 text-sm w-full gap-y-1 mb-1px"
class="font-light text-sm checkbox flex items-center py-1 text-sm w-full gap-y-1 mb-1px"
:class="disabled ? 'cursor-not-allowed op50' : 'cursor-pointer'"
v-bind="$attrs"
@click.prevent="modelValue = !modelValue"
@click.prevent="toggle"
>
<span
:class="[
Expand All @@ -19,6 +31,7 @@ const modelValue = defineModel<boolean | null>()
<input
v-model="modelValue"
type="checkbox"
:disabled="disabled"
sr-only
>
<span flex-1 ms-2 select-none>{{ label }}</span>
Expand Down
120 changes: 113 additions & 7 deletions packages/ui/client/components/explorer/Explorer.vue
Original file line number Diff line number Diff line change
@@ -1,16 +1,14 @@
<script setup lang="ts">
import type { File, Task } from '@vitest/runner'
import { useResizeObserver } from '@vueuse/core'

import { hideAllPoppers } from 'floating-vue'

import { computed, ref } from 'vue'

// @ts-expect-error missing types
import { RecycleScroller } from 'vue-virtual-scroller'

import { config } from '~/composables/client'

import { availableProjects, config } from '~/composables/client'
import { useSearch } from '~/composables/explorer/search'
import { ALL_PROJECTS, projectSort } from '~/composables/explorer/state'
import { activeFileId } from '~/composables/params'
import DetailsPanel from '../DetailsPanel.vue'
import FilterStatus from '../FilterStatus.vue'
Expand All @@ -32,6 +30,8 @@ const emit = defineEmits<{
const includeTaskLocation = computed(() => config.value.includeTaskLocation)

const searchBox = ref<HTMLInputElement | undefined>()
const selectProjectRef = ref<HTMLSelectElement | undefined>()
const sortProjectRef = ref<HTMLSelectElement | undefined>()

const {
initialized,
Expand All @@ -47,7 +47,15 @@ const {
filteredFiles,
testsTotal,
uiEntries,
} = useSearch(searchBox)
enableProjects,
disableClearProjects,
currentProject,
currentProjectName,
clearProject,
disableProjectSort,
clearProjectSort,
disableClearProjectSort,
} = useSearch(searchBox, selectProjectRef, sortProjectRef)

const filterClass = ref<string>('grid-cols-2')
const filterHeaderClass = ref<string>('grid-col-span-2')
Expand All @@ -71,6 +79,104 @@ useResizeObserver(() => testExplorerRef.value, ([{ contentRect }]) => {
<div p="2" h-10 flex="~ gap-2" items-center bg-header border="b base">
<slot name="header" :filtered-files="isFiltered || isFilteredByStatus ? filteredFiles : undefined" />
</div>
<div
v-if="enableProjects"
p="l3 y2 r2"
bg-header
border="b-2 base"
grid="~ cols-[auto_auto_minmax(0,1fr)_auto] gap-x-2 gap-y-1"
items-center
>
<!-- Row 1 -->
<div class="i-carbon:workspace" flex-shrink-0 />
<label for="project-select" text-sm>
Projects
</label>
<div class="relative flex-1">
<select
id="project-select"
ref="selectProjectRef"
v-model="currentProject"
w-full
appearance-none
bg-base
text-base
border="~ base rounded"
pl-2
pr-8
py-1
text-sm
cursor-pointer
hover:bg-active
class="outline-none"
>
<option :value="ALL_PROJECTS" class="text-base bg-base">
All Projects
</option>
<option
v-for="project in availableProjects"
:key="project"
:value="project"
class="text-base bg-base"
>
{{ project }}
</option>
</select>
<div class="i-carbon:chevron-down absolute right-2 top-1/2 op50 -translate-y-1/2 pointer-events-none" />
</div>

<IconButton
v-tooltip.bottom="'Clear project filter'"
:disabled="disableClearProjects"
title="Clear project filter"
icon="i-carbon:filter-remove"
@click.passive="clearProject(true)"
/>

<!-- Row 2 -->
<div class="i-carbon:arrows-vertical" flex-shrink-0 />
<label for="project-sort" text-sm>
Sort by
</label>
<div class="relative flex-1" :class="{ 'op-50 cursor-not-allowed': disableProjectSort }">
<select
id="project-sort"
ref="sortProjectRef"
v-model="projectSort"
w-full
appearance-none
bg-base
text-base
border="~ base rounded"
pl-2
pr-8
py-1
text-sm
cursor-pointer
hover:bg-active
class="outline-none"
:disabled="disableProjectSort"
>
<option value="default" class="text-base bg-base">
Default
</option>
<option value="asc" class="text-base bg-base">
Project A-Z
</option>
<option value="desc" class="text-base bg-base">
Project Z-A
</option>
</select>
<div class="i-carbon:chevron-down absolute right-2 top-1/2 op50 -translate-y-1/2 pointer-events-none" />
</div>
<IconButton
v-tooltip.bottom="'Reset sort'"
:disabled="disableClearProjectSort"
title="Reset sort"
icon="i-carbon:filter-reset"
@click.passive="clearProjectSort(true)"
/>
</div>
<div
p="l3 y2 r2"
flex="~ gap-2"
Expand Down Expand Up @@ -149,7 +255,7 @@ useResizeObserver(() => testExplorerRef.value, ([{ contentRect }]) => {
</div>
</template>
<!-- empty-state -->
<template v-if="(isFiltered || isFilteredByStatus) && uiEntries.length === 0">
<template v-if="(isFiltered || isFilteredByStatus || !!currentProjectName) && uiEntries.length === 0">
<div v-if="initialized" flex="~ col" items-center p="x4 y4" font-light>
<div op30>
No matched test
Expand Down
2 changes: 2 additions & 0 deletions packages/ui/client/composables/client/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ export const client = (function createVitestClient() {

export const config = shallowRef<SerializedConfig>({} as any)
export const status = ref<WebSocketStatus>('CONNECTING')
export const availableProjects = shallowRef<string[]>([])

export const current = computed(() => {
const currentFileId = activeFileId.value
Expand Down Expand Up @@ -184,6 +185,7 @@ watch(
return file
})
}
availableProjects.value = projects.map(p => p.name)
explorerTree.loadFiles(files, projects)
client.state.collectFiles(files)
explorerTree.startRun()
Expand Down
12 changes: 9 additions & 3 deletions packages/ui/client/composables/explorer/filter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,12 @@ import type { Task } from '@vitest/runner'
import type { FileTreeNode, Filter, FilterResult, ParentTreeNode, UITaskTreeNode } from '~/composables/explorer/types'
import { client, findById } from '~/composables/client'
import { explorerTree } from '~/composables/explorer/index'
import { filteredFiles, uiEntries } from '~/composables/explorer/state'
import { currentProjectName, filteredFiles, projectSort, uiEntries } from '~/composables/explorer/state'
import {
getSortedRootTasks,
isFileNode,
isParentNode,
isTestNode,
sortedRootTasks,
} from '~/composables/explorer/utils'
import { caseInsensitiveMatch } from '~/utils/task'

Expand Down Expand Up @@ -36,7 +36,13 @@ export function* filterAll(
search: string,
filter: Filter,
) {
for (const node of sortedRootTasks()) {
const project = currentProjectName.value
const tasks = getSortedRootTasks(projectSort.value)

for (const node of tasks) {
if (project && node.projectName !== project) {
continue
}
yield* filterNode(node, search, filter)
}
}
Expand Down
59 changes: 56 additions & 3 deletions packages/ui/client/composables/explorer/search.ts
Original file line number Diff line number Diff line change
@@ -1,30 +1,44 @@
import type { Ref } from 'vue'
import type { ProjectSortUIType } from '~/composables/explorer/types'
import { debouncedWatch } from '@vueuse/core'
import { computed, ref, watch } from 'vue'
import { explorerTree } from '~/composables/explorer'
import {
ALL_PROJECTS,
currentProject,
currentProjectName,
disableClearProjects,
enableProjects,
filter,
filteredFiles,
initialized,
isFiltered,
isFilteredByStatus,
openedTreeItems,
projectSort,
search,
testsTotal,
treeFilter,
uiEntries,
} from './state'

export function useSearch(searchBox: Ref<HTMLDivElement | undefined>) {
export function useSearch(
searchBox: Ref<HTMLInputElement | undefined>,
selectProject: Ref<HTMLSelectElement | undefined>,
sortProject: Ref<HTMLSelectElement | undefined>,
) {
const disableFilter = computed(() => {
if (isFilteredByStatus.value) {
return false
}

return !filter.onlyTests
})

const disableClearSearch = computed(() => search.value === '')
const debouncedSearch = ref(search.value)
const disableProjectSort = computed(() => currentProject.value !== ALL_PROJECTS)
const disableClearProjectSort = computed(() => disableProjectSort.value || projectSort.value === 'default')

debouncedWatch(() => search.value, (value) => {
debouncedSearch.value = value?.trim() ?? ''
Expand All @@ -47,9 +61,25 @@ export function useSearch(searchBox: Ref<HTMLDivElement | undefined>) {
}
}

function clearProject(focus: boolean) {
currentProject.value = ALL_PROJECTS
if (focus) {
selectProject.value?.focus()
}
}

function clearProjectSort(focus: boolean) {
projectSort.value = 'default'
if (focus) {
sortProject.value?.focus()
}
}

function clearAll() {
clearFilter(false)
clearSearch(true)
clearProject(false)
clearProjectSort(false)
}

function updateFilterStorage(
Expand All @@ -58,6 +88,8 @@ export function useSearch(searchBox: Ref<HTMLDivElement | undefined>) {
successValue: boolean,
skippedValue: boolean,
onlyTestsValue: boolean,
projectValue: string,
projectSortValue: ProjectSortUIType,
) {
if (!initialized.value) {
return
Expand All @@ -68,6 +100,8 @@ export function useSearch(searchBox: Ref<HTMLDivElement | undefined>) {
treeFilter.value.success = successValue
treeFilter.value.skipped = skippedValue
treeFilter.value.onlyTests = onlyTestsValue
treeFilter.value.project = projectValue
treeFilter.value.projectSort = projectSortValue === 'default' ? undefined : projectSortValue
}

watch(
Expand All @@ -77,9 +111,19 @@ export function useSearch(searchBox: Ref<HTMLDivElement | undefined>) {
filter.success,
filter.skipped,
filter.onlyTests,
currentProject.value,
projectSort.value,
] as const,
([search, failed, success, skipped, onlyTests]) => {
updateFilterStorage(search, failed, success, skipped, onlyTests)
([search, failed, success, skipped, onlyTests, project, projectSort]) => {
updateFilterStorage(
search,
failed,
success,
skipped,
onlyTests,
project,
projectSort,
)
explorerTree.filterNodes()
},
{ flush: 'post' },
Expand All @@ -105,5 +149,14 @@ export function useSearch(searchBox: Ref<HTMLDivElement | undefined>) {
filteredFiles,
testsTotal,
uiEntries,
enableProjects,
disableClearProjects,
currentProject,
currentProjectName,
clearProject,
projectSort,
disableProjectSort,
clearProjectSort,
disableClearProjectSort,
}
}
Loading
Loading