From 402d1cde5d9c65a1f50fb2d14f8482b65c5ecfb0 Mon Sep 17 00:00:00 2001 From: neon <18559658+neonsolstice@users.noreply.github.com> Date: Tue, 19 May 2026 10:26:29 -0600 Subject: [PATCH] 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 --- .../book/components/BookFilterBuilder.vue | 3 +- .../__tests__/BookFilterBuilder.spec.ts | 51 +++++++++++++++++++ .../components/SmartScopeEditorPanel.vue | 8 ++- commitlint.config.cjs | 32 +----------- 4 files changed, 60 insertions(+), 34 deletions(-) create mode 100644 client/src/features/book/components/__tests__/BookFilterBuilder.spec.ts diff --git a/client/src/features/book/components/BookFilterBuilder.vue b/client/src/features/book/components/BookFilterBuilder.vue index 8c2f15b90..13e3ea1d0 100644 --- a/client/src/features/book/components/BookFilterBuilder.vue +++ b/client/src/features/book/components/BookFilterBuilder.vue @@ -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 diff --git a/client/src/features/book/components/__tests__/BookFilterBuilder.spec.ts b/client/src/features/book/components/__tests__/BookFilterBuilder.spec.ts new file mode 100644 index 000000000..67ae934c4 --- /dev/null +++ b/client/src/features/book/components/__tests__/BookFilterBuilder.spec.ts @@ -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>(), + }), +})) + +const incompleteFilter: GroupRule = { + type: 'group', + join: 'AND', + rules: [{ type: 'rule', field: 'title', operator: 'contains' }], +} + +function lastUpdate(wrapper: ReturnType>) { + 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: [] }) + }) +}) diff --git a/client/src/features/smart-scope/components/SmartScopeEditorPanel.vue b/client/src/features/smart-scope/components/SmartScopeEditorPanel.vue index 9fb838f45..230fd9523 100644 --- a/client/src/features/smart-scope/components/SmartScopeEditorPanel.vue +++ b/client/src/features/smart-scope/components/SmartScopeEditorPanel.vue @@ -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() {