feat(filters): add UI for marking saved filters as favorites (#2055)

This PR adds UI support for marking saved filters as favorites. The backend already supports the `is_favorite` field for saved filters, but the frontend didn't expose this functionality. Users can now favorite/unfavorite saved filters just like regular projects.
This commit is contained in:
kolaente
2026-01-07 17:21:41 +01:00
committed by GitHub
parent 59f203298f
commit 49af08d3f6
7 changed files with 167 additions and 8 deletions

View File

@@ -84,7 +84,7 @@
class="menu"
>
<ProjectsNavigation
:model-value="favoriteProjects"
:model-value="favoriteProjects"
:can-edit-order="false"
:can-collapse="false"
/>

View File

@@ -52,7 +52,7 @@
<span class="project-menu-title">{{ getProjectTitle(project) }}</span>
</BaseButton>
<BaseButton
v-if="project.id > 0 && project.maxPermission !== null && project.maxPermission > PERMISSIONS.READ"
v-if="canToggleFavorite"
class="favorite"
:class="{'is-favorite': project.isFavorite}"
@click="projectStore.toggleProjectFavorite(project)"
@@ -104,6 +104,7 @@ import {getProjectTitle} from '@/helpers/getProjectTitle'
import ColorBubble from '@/components/misc/ColorBubble.vue'
import ProjectsNavigation from '@/components/home/ProjectsNavigation.vue'
import {PERMISSIONS} from '@/constants/permissions'
import {isSavedFilter} from '@/services/savedFilter'
const props = defineProps<{
project: IProject,
@@ -183,6 +184,18 @@ const childProjects = computed(() => {
.filter(p => !p.isArchived)
.sort((a, b) => a.position - b.position)
})
const canToggleFavorite = computed(() => {
// Allow favorite toggle for:
// 1. Regular projects (id > 0) with write permission
// 2. Saved filters (id < -1) - user owns their own filters
if (props.project.id === -1) return false // Favorites pseudo-project
if (props.project.id > 0) {
return props.project.maxPermission !== null && props.project.maxPermission > PERMISSIONS.READ
}
// Saved filters (negative IDs except -1)
return isSavedFilter(props.project)
})
</script>
<style lang="scss" scoped>
@@ -190,11 +203,6 @@ const childProjects = computed(() => {
transition: background-color $transition;
}
.list-setting-spacer {
inline-size: 5rem;
flex-shrink: 0;
}
.project-is-collapsed {
transform: rotate(-90deg);
}

View File

@@ -15,6 +15,7 @@ export interface ISavedFilter extends IAbstract {
title: string
description: string
filters: IFilters
isFavorite: boolean
owner: IUser
created: Date

View File

@@ -16,6 +16,7 @@ export default class SavedFilterModel extends AbstractModel<ISavedFilter> implem
filter_include_nulls: true,
s: '',
}
isFavorite = false
owner: IUser = {}
created: Date = null

View File

@@ -15,7 +15,9 @@ import type {IProject} from '@/modelTypes/IProject'
import ProjectModel from '@/models/project'
import {success} from '@/message'
import {useBaseStore} from '@/stores/base'
import {getSavedFilterIdFromProjectId} from '@/services/savedFilter'
import SavedFilterService from '@/services/savedFilter'
import {getSavedFilterIdFromProjectId, isSavedFilter} from '@/services/savedFilter'
import SavedFilterModel from '@/models/savedFilter'
import type {IProjectView} from '@/modelTypes/IProjectView'
import {PERMISSIONS} from '@/constants/permissions.ts'
@@ -145,12 +147,45 @@ export const useProjectStore = defineStore('project', () => {
if (project.id === -1 || project.isArchived) {
return
}
if (isSavedFilter(project)) {
return toggleSavedFilterFavorite(project)
}
return updateProject({
...project,
isFavorite: !project.isFavorite,
})
}
async function toggleSavedFilterFavorite(project: IProject) {
if (!isSavedFilter(project)) {
return
}
const wasFavorite = project.isFavorite
const filterId = getSavedFilterIdFromProjectId(project.id)
const savedFilterService = new SavedFilterService()
// Optimistically update the UI
setProject({
...project,
isFavorite: !wasFavorite,
})
try {
const savedFilter = await savedFilterService.get(new SavedFilterModel({id: filterId}))
savedFilter.isFavorite = !wasFavorite
await savedFilterService.update(savedFilter)
} catch (e) {
setProject({
...project,
isFavorite: wasFavorite,
})
throw e
}
}
async function createProject(project: IProject) {
const cancel = setModuleLoading(setIsLoading)
const projectService = new ProjectService()

View File

@@ -0,0 +1,112 @@
import {test, expect} from '../../support/fixtures'
import {SavedFilterFactory} from '../../factories/saved_filter'
import {ProjectFactory} from '../../factories/project'
test.describe('Saved Filter Favorites', () => {
test('Can mark a saved filter as favorite', async ({authenticatedPage: page}) => {
// Create a project (required for saved filters to work)
await ProjectFactory.create(1)
// Create a saved filter (not favorite initially)
await SavedFilterFactory.create(1, {
title: 'My Test Filter',
is_favorite: false,
filters: '{"filter":"done = false","filter_include_nulls":false,"s":""}',
})
await page.goto('/')
await page.waitForLoadState('networkidle')
// The saved filter should appear in the sidebar (as a pseudo-project with negative ID)
// Saved filters section shows filters that aren't favorites
const filterItem = page.locator('.list-menu .navigation-item').filter({hasText: 'My Test Filter'})
await expect(filterItem).toBeVisible({timeout: 10000})
// Hover to reveal the favorite button
await filterItem.hover()
const favoriteButton = filterItem.locator('.favorite')
await expect(favoriteButton).toBeVisible()
// Click to mark as favorite
const favoritePromise = page.waitForResponse(response =>
response.url().includes('/filters/') && response.request().method() === 'POST',
)
await favoriteButton.click()
await favoritePromise
await expect(filterItem.locator('.favorite.is-favorite').nth(0)).toBeVisible()
await expect(filterItem.locator('.favorite.is-favorite').nth(1)).toBeVisible()
})
test('Can remove a saved filter from favorites', async ({authenticatedPage: page}) => {
// Create a project (required for saved filters to work)
await ProjectFactory.create(1)
// Create a saved filter that is already a favorite
await SavedFilterFactory.create(1, {
title: 'Favorited Filter',
is_favorite: true,
filters: '{"filter":"done = false","filter_include_nulls":false,"s":""}',
})
await page.goto('/')
await page.waitForLoadState('networkidle')
// The saved filter appears twice (favorites section + saved filters section)
// Get the first instance (in favorites section) which should have a filled star
const filterItem = page.locator('.menu-container').getByRole('listitem').filter({hasText: 'Favorited Filter'}).first()
await expect(filterItem).toBeVisible()
// Hover to reveal the favorite button (should be filled star)
await filterItem.hover()
const favoriteButton = filterItem.locator('.favorite.is-favorite')
await expect(favoriteButton).toBeVisible()
// Click to remove from favorites
const unfavoritePromise = page.waitForResponse(response =>
response.url().includes('/filters/') && response.request().method() === 'POST',
)
await favoriteButton.click()
await unfavoritePromise
// After unfavoriting, the star should be outline (not filled)
// Wait for UI to update with longer timeout
await expect(filterItem.locator('.favorite:not(.is-favorite)')).toBeVisible({timeout: 10000})
})
test('Saved filter favorite status persists after page reload', async ({authenticatedPage: page}) => {
// Create a project
await ProjectFactory.create(1)
// Create a non-favorite saved filter
await SavedFilterFactory.create(1, {
title: 'Persistent Filter',
is_favorite: false,
filters: '{"filter":"done = false","filter_include_nulls":false,"s":""}',
})
await page.goto('/')
await page.waitForLoadState('networkidle')
// Find and favorite the filter
const filterItem = page.locator('.menu-container').getByRole('listitem').filter({hasText: 'Persistent Filter'})
await filterItem.hover()
const favoritePromise = page.waitForResponse(response =>
response.url().includes('/filters/') && response.request().method() === 'POST',
)
await filterItem.locator('.favorite').click()
await favoritePromise
// Wait for UI to update before reloading
await page.waitForTimeout(500)
// Reload the page
await page.reload()
await page.waitForLoadState('networkidle')
// The filter should still be favorited after reload (filled star)
await expect(filterItem.locator('.favorite.is-favorite').nth(0)).toBeVisible()
await expect(filterItem.locator('.favorite.is-favorite').nth(1)).toBeVisible()
})
})

View File

@@ -195,6 +195,8 @@ func (sf *SavedFilter) Update(s *xorm.Session, _ web.Auth) error {
return err
}
sf.OwnerID = origFilter.OwnerID
if sf.Filters == nil {
sf.Filters = origFilter.Filters
}