diff --git a/client/src/features/book/components/BookFilterBuilder.vue b/client/src/features/book/components/BookFilterBuilder.vue index 00a9cc07a..a4b003645 100644 --- a/client/src/features/book/components/BookFilterBuilder.vue +++ b/client/src/features/book/components/BookFilterBuilder.vue @@ -3,6 +3,9 @@ import { ref, watch } from 'vue' import { Plus, Trash2 } from 'lucide-vue-next' import { FIELD_OPERATORS, RULE_FIELDS, type GroupRule, type Rule, type RuleField, type RuleOperator } from '@projectx/types' import { FIELD_LABELS, OPERATOR_LABELS } from '@/features/book/lib/filter-labels' +import FilterChipTypeahead from './FilterChipTypeahead.vue' +import FilterFormatPicker from './FilterFormatPicker.vue' +import FilterTextTypeahead from './FilterTextTypeahead.vue' const props = defineProps<{ modelValue: GroupRule | undefined @@ -13,52 +16,79 @@ const emit = defineEmits<{ 'update:modelValue': [value: GroupRule | undefined] }>() -const MAX_DEPTH = 2 +const MAX_DEPTH = 5 const NUMERIC_FIELDS: RuleField[] = ['seriesIndex', 'publishedYear', 'pageCount', 'rating'] const DATE_FIELDS: RuleField[] = ['addedAt'] const NO_VALUE_OPERATORS: RuleOperator[] = ['isEmpty', 'isNotEmpty', 'isMissing', 'isPresent', 'isUnread', 'isInProgress', 'isFinished'] const BETWEEN_OPERATORS: RuleOperator[] = ['between'] const COLLECTION_OPERATORS: RuleOperator[] = ['includesAny', 'includesAll', 'excludesAll'] +const CHIP_TYPEAHEAD_FIELDS: RuleField[] = ['author', 'genre', 'tag', 'collection'] +const TEXT_TYPEAHEAD_FIELDS: RuleField[] = ['publisher', 'series', 'language'] + +const ENDPOINT_BY_FIELD: Partial> = { + author: '/api/v1/metadata/authors', + genre: '/api/v1/metadata/genres', + tag: '/api/v1/metadata/tags', + collection: '/api/v1/metadata/collections', + publisher: '/api/v1/metadata/publishers', + series: '/api/v1/metadata/series', + language: '/api/v1/metadata/languages', +} + +type WithinLastUnit = 'days' | 'weeks' | 'months' + interface EditableRule { field: RuleField operator: RuleOperator value: string + valueChips: string[] valueTo: string + valueUnit: WithinLastUnit } -type LocalNode = { kind: 'rule'; rule: EditableRule } | { kind: 'group'; group: GroupRule } +type LocalNode = { id: number; kind: 'rule'; rule: EditableRule } | { id: number; kind: 'group'; group: GroupRule } -function makeEmptyRule(): EditableRule { - return { field: 'title', operator: 'contains', value: '', valueTo: '' } +let nodeIdCounter = 0 +function nextId() { + return ++nodeIdCounter +} + +function makeEmptyRule(): LocalNode { + return { id: nextId(), kind: 'rule', rule: { field: 'title', operator: 'contains', value: '', valueChips: [], valueTo: '', valueUnit: 'days' } } } function toEditableRule(r: Rule): EditableRule { + const usesChips = Array.isArray(r.value) return { field: r.field, operator: r.operator, - value: Array.isArray(r.value) ? (r.value as string[]).join(', ') : String(r.value ?? ''), + value: usesChips ? '' : String(r.value ?? ''), + valueChips: usesChips ? (r.value as string[]) : [], valueTo: String(r.valueTo ?? ''), + valueUnit: 'days', } } function toLocalNodes(group: GroupRule | undefined): LocalNode[] { if (!group) return [] return group.rules.map((r) => - r.type === 'rule' ? { kind: 'rule' as const, rule: toEditableRule(r) } : { kind: 'group' as const, group: r as GroupRule }, + r.type === 'rule' + ? { id: nextId(), kind: 'rule' as const, rule: toEditableRule(r) } + : { id: nextId(), kind: 'group' as const, group: r as GroupRule }, ) } -function parseValue(field: RuleField, operator: RuleOperator, raw: string): Rule['value'] { +function parseValue(field: RuleField, operator: RuleOperator, raw: string, chips: string[], unit: WithinLastUnit): Rule['value'] { if (NO_VALUE_OPERATORS.includes(operator)) return undefined - if (COLLECTION_OPERATORS.includes(operator)) { - return raw - .split(',') - .map((s) => s.trim()) - .filter(Boolean) - } + if (COLLECTION_OPERATORS.includes(operator)) return chips if (NUMERIC_FIELDS.includes(field)) return raw === '' ? undefined : Number(raw) - if (DATE_FIELDS.includes(field) && operator === 'withinLast') return raw === '' ? undefined : Number(raw) + if (DATE_FIELDS.includes(field) && operator === 'withinLast') { + if (raw === '') return undefined + const n = Number(raw) + const multiplier = unit === 'weeks' ? 7 : unit === 'months' ? 30 : 1 + return n * multiplier + } return raw || undefined } @@ -77,7 +107,7 @@ watch( function isRuleComplete(r: EditableRule): boolean { if (NO_VALUE_OPERATORS.includes(r.operator)) return true - if (COLLECTION_OPERATORS.includes(r.operator)) return r.value.split(',').some((s) => s.trim() !== '') + if (COLLECTION_OPERATORS.includes(r.operator)) return r.valueChips.length > 0 return r.value.trim() !== '' } @@ -91,14 +121,14 @@ function emitUpdate() { return } const rules: (Rule | GroupRule)[] = nodes.value - .filter((n) => n.kind === 'group' || isRuleComplete(n.rule)) + .filter((n) => (n.kind === 'group' && n.group.rules.length > 0) || (n.kind === 'rule' && isRuleComplete(n.rule))) .map((n) => { if (n.kind === 'group') return n.group return { type: 'rule' as const, field: n.rule.field, operator: n.rule.operator, - value: parseValue(n.rule.field, n.rule.operator, n.rule.value), + value: parseValue(n.rule.field, n.rule.operator, n.rule.value, n.rule.valueChips, n.rule.valueUnit), valueTo: BETWEEN_OPERATORS.includes(n.rule.operator) && n.rule.valueTo !== '' ? NUMERIC_FIELDS.includes(n.rule.field) @@ -107,7 +137,15 @@ function emitUpdate() { : undefined, } }) - emit('update:modelValue', rules.length > 0 ? { type: 'group', join: join.value, rules } : undefined) + const isSubGroup = (props.depth ?? 0) > 0 + if (!isSubGroup && rules.length === 0) { + // Top-level: no complete rules means no active filter — don't send an empty group to the API. + emit('update:modelValue', undefined) + return + } + // Sub-group: always emit a group when nodes exist so the parent keeps this node alive. + // undefined is reserved for "zero nodes" and signals the parent to remove the group. + emit('update:modelValue', { type: 'group', join: join.value, rules }) } function setJoin(value: 'AND' | 'OR') { @@ -116,13 +154,14 @@ function setJoin(value: 'AND' | 'OR') { } function addRule() { - nodes.value.push({ kind: 'rule', rule: makeEmptyRule() }) + nodes.value.push(makeEmptyRule()) } function addGroup() { nodes.value.push({ + id: nextId(), kind: 'group', - group: { type: 'group', join: 'AND', rules: [{ type: 'rule', field: 'title', operator: 'contains' }] }, + group: { type: 'group', join: 'AND', rules: [] }, }) } @@ -139,7 +178,21 @@ function onFieldChange(index: number) { node.rule.operator = validOps[0]! } node.rule.value = '' + node.rule.valueChips = [] node.rule.valueTo = '' + node.rule.valueUnit = 'days' + emitUpdate() +} + +function onOperatorChange(index: number) { + const node = nodes.value[index] + if (node?.kind !== 'rule') return + if (COLLECTION_OPERATORS.includes(node.rule.operator)) { + node.rule.value = '' + node.rule.valueTo = '' + } else { + node.rule.valueChips = [] + } emitUpdate() } @@ -160,10 +213,6 @@ function valueInputType(field: RuleField, operator: RuleOperator): string { return 'text' } -function showValueInput(operator: RuleOperator): boolean { - return !NO_VALUE_OPERATORS.includes(operator) -} - function showValueToInput(operator: RuleOperator): boolean { return BETWEEN_OPERATORS.includes(operator) } @@ -171,8 +220,8 @@ function showValueToInput(operator: RuleOperator): boolean {