mirror of
https://github.com/go-vikunja/vikunja.git
synced 2026-02-01 22:47:40 +00:00
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:
@@ -84,7 +84,7 @@
|
||||
class="menu"
|
||||
>
|
||||
<ProjectsNavigation
|
||||
:model-value="favoriteProjects"
|
||||
:model-value="favoriteProjects"
|
||||
:can-edit-order="false"
|
||||
:can-collapse="false"
|
||||
/>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ export interface ISavedFilter extends IAbstract {
|
||||
title: string
|
||||
description: string
|
||||
filters: IFilters
|
||||
isFavorite: boolean
|
||||
|
||||
owner: IUser
|
||||
created: Date
|
||||
|
||||
@@ -16,6 +16,7 @@ export default class SavedFilterModel extends AbstractModel<ISavedFilter> implem
|
||||
filter_include_nulls: true,
|
||||
s: '',
|
||||
}
|
||||
isFavorite = false
|
||||
|
||||
owner: IUser = {}
|
||||
created: Date = null
|
||||
|
||||
@@ -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()
|
||||
|
||||
112
frontend/tests/e2e/project/saved-filter-favorite.spec.ts
Normal file
112
frontend/tests/e2e/project/saved-filter-favorite.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user