fix(smart-scope): preserve draft filters while editing (#54)

Keep incomplete root filters visible in the SmartScope editor.

This lets users choose a field before setting an operator or value.

Fixes #52
This commit is contained in:
neon
2026-05-19 10:26:29 -06:00
committed by GitHub
parent 9d9461640b
commit 402d1cde5d
4 changed files with 60 additions and 34 deletions
@@ -12,6 +12,7 @@ import FilterTextTypeahead from './FilterTextTypeahead.vue'
const props = defineProps<{
modelValue: GroupRule | undefined
depth?: number
preserveIncompleteRoot?: boolean
}>()
const emit = defineEmits<{
@@ -166,7 +167,7 @@ function emitUpdate() {
}
})
const isSubGroup = (props.depth ?? 0) > 0
if (!isSubGroup && rules.length === 0) {
if (!isSubGroup && rules.length === 0 && !props.preserveIncompleteRoot) {
// Top-level: no complete rules means no active filter — don't send an empty group to the API.
emit('update:modelValue', undefined)
return
@@ -0,0 +1,51 @@
import { mount } from '@vue/test-utils'
import { ref } from 'vue'
import { describe, expect, it, vi } from 'vitest'
import type { GroupRule } from '@bookorbit/types'
import BookFilterBuilder from '../BookFilterBuilder.vue'
vi.mock('@/features/library/composables/useLibraries', () => ({
useLibraries: () => ({
libraries: ref([]),
loading: ref(false),
fetchLibraries: vi.fn<() => Promise<void>>(),
}),
}))
const incompleteFilter: GroupRule = {
type: 'group',
join: 'AND',
rules: [{ type: 'rule', field: 'title', operator: 'contains' }],
}
function lastUpdate(wrapper: ReturnType<typeof mount<typeof BookFilterBuilder>>) {
const events = wrapper.emitted('update:modelValue')
return events?.[events.length - 1]?.[0]
}
describe('BookFilterBuilder', () => {
it('emits undefined for an incomplete root filter by default', async () => {
const wrapper = mount(BookFilterBuilder, {
props: {
modelValue: incompleteFilter,
},
})
await wrapper.get('select').setValue('collection')
expect(lastUpdate(wrapper)).toBeUndefined()
})
it('preserves an incomplete root group when requested', async () => {
const wrapper = mount(BookFilterBuilder, {
props: {
modelValue: incompleteFilter,
preserveIncompleteRoot: true,
},
})
await wrapper.get('select').setValue('collection')
expect(lastUpdate(wrapper)).toEqual({ type: 'group', join: 'AND', rules: [] })
})
})
@@ -105,6 +105,10 @@ function initFilter() {
draftFilter.value = { type: 'group', join: 'AND', rules: [{ type: 'rule', field: 'title', operator: 'contains' }] }
}
function hasCompleteRules(group: GroupRule | undefined): boolean {
return !!group?.rules.some((rule) => rule.type === 'rule' || hasCompleteRules(rule as GroupRule))
}
async function save() {
if (!props.smartScope) return
if (!trimmedDraftName.value) {
@@ -121,7 +125,7 @@ async function save() {
await updateSmartScope(props.smartScope.id, {
name: trimmedDraftName.value,
icon: trimmedDraftIcon.value,
filter: draftFilter.value,
filter: hasCompleteRules(draftFilter.value) ? draftFilter.value : undefined,
defaultSort: draftSort.value,
})
emit('saved')
@@ -218,7 +222,7 @@ async function save() {
<!-- Filter builder -->
<template v-else>
<BookFilterBuilder v-model="draftFilter" />
<BookFilterBuilder v-model="draftFilter" preserve-incomplete-root />
<div class="flex flex-wrap items-center gap-1.5 pt-3 border-t border-border/50">
<span class="text-xs text-muted-foreground shrink-0">Replace with template:</span>
<button
+1 -31
View File
@@ -4,37 +4,7 @@ module.exports = {
"type-enum": [2, "always", ["feat", "fix", "db", "perf", "refactor", "style", "docs", "test", "build", "ci", "chore", "security", "revert"]],
"header-max-length": [2, "always", 100],
"subject-full-stop": [2, "never", "."],
"scope-enum": [
2,
"always",
[
"auth",
"library",
"books",
"authors",
"series",
"cover",
"metadata",
"kobo",
"opds",
"reader",
"collections",
"annotations",
"users",
"stats",
"notifications",
"settings",
"scanner",
"email",
"audit",
"smart-scope",
"types",
"docker",
"deps",
"server",
"client",
],
],
"scope-enum": [0],
"scope-empty": [0],
},
};