feat(books): allow sorting by custom metadata fields

Extend book sorting to support custom metadata field references (custom:<id>). Join custom metadata values in BookSortBuilder based on field data type, resolve active field types in BookService, and expose custom fields in BookSortBuilder UI.

Closes #829
This commit is contained in:
neon
2026-07-30 20:46:33 +00:00
parent d9057fed3b
commit 50ba5f151b
31 changed files with 518 additions and 40 deletions
@@ -2,8 +2,9 @@
import { computed } from 'vue'
import { ArrowDownAZ, ArrowUpAZ, ChevronDown, ChevronUp, Plus, Trash2 } from '@lucide/vue'
import type { SortField, SortSpec } from '@bookorbit/types'
import { SORT_FIELDS } from '@bookorbit/types'
import { SORT_FIELDS, customSortField } from '@bookorbit/types'
import { sortFieldLabel } from '@/features/book/lib/filter-labels'
import { useActiveCustomFields } from '@/features/book/composables/useActiveCustomFields'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { useI18n } from 'vue-i18n'
@@ -17,12 +18,27 @@ const emit = defineEmits<{
'update:modelValue': [value: SortSpec[]]
}>()
const { fields: customFields } = useActiveCustomFields()
const customSortFields = computed<SortField[]>(() =>
customFields.value
.filter((f) => !f.archivedAt)
.sort((a, b) => a.displayOrder - b.displayOrder || a.label.localeCompare(b.label))
.map((f) => customSortField(f.id)),
)
const allFields = computed<SortField[]>(() => [...SORT_FIELDS, ...customSortFields.value])
const usedFields = computed(() => new Set(props.modelValue.map((s) => s.field)))
function availableFields(currentField: SortField): SortField[] {
return SORT_FIELDS.filter((f) => f === currentField || !usedFields.value.has(f))
}
function availableCustomFields(currentField: SortField): SortField[] {
return customSortFields.value.filter((f) => f === currentField || !usedFields.value.has(f))
}
function updateField(index: number, field: SortField) {
emit(
'update:modelValue',
@@ -52,12 +68,12 @@ function remove(index: number) {
}
function addTier() {
const nextField = SORT_FIELDS.find((f) => !usedFields.value.has(f))
const nextField = allFields.value.find((f) => !usedFields.value.has(f))
if (!nextField) return
emit('update:modelValue', [...props.modelValue, { field: nextField, dir: 'asc' }])
}
const canAddMore = computed(() => props.modelValue.length < SORT_FIELDS.length)
const canAddMore = computed(() => props.modelValue.length < allFields.value.length)
</script>
<template>
@@ -97,6 +113,11 @@ const canAddMore = computed(() => props.modelValue.length < SORT_FIELDS.length)
<option v-for="field in availableFields(spec.field)" :key="field" :value="field">
{{ sortFieldLabel(field) }}
</option>
<optgroup v-if="customSortFields.length > 0" :label="t('book.sort.customFieldsGroup')">
<option v-for="field in availableCustomFields(spec.field)" :key="field" :value="field">
{{ sortFieldLabel(field) }}
</option>
</optgroup>
</select>
<Tooltip>
<TooltipTrigger as-child>
@@ -0,0 +1,82 @@
import { mount } from '@vue/test-utils'
import { ref } from 'vue'
import { describe, expect, it, vi } from 'vitest'
import type { CustomMetadataFieldSummary, SortSpec } from '@bookorbit/types'
import BookSortBuilder from '../BookSortBuilder.vue'
const customFields = ref<CustomMetadataFieldSummary[]>([])
vi.mock('@/features/book/composables/useActiveCustomFields', () => ({
useActiveCustomFields: () => ({ fields: customFields }),
activeCustomFieldLabel: (fieldId: number) => customFields.value.find((f) => f.id === fieldId)?.label ?? null,
}))
function makeField(overrides: Partial<CustomMetadataFieldSummary> = {}): CustomMetadataFieldSummary {
return { id: 7, label: 'Shelf Location', type: 'text', displayOrder: 0, archivedAt: null, enabledLibraryIds: [], ...overrides }
}
function mountBuilder(modelValue: SortSpec[]) {
return mount(BookSortBuilder, {
props: { modelValue },
global: {
stubs: {
Tooltip: { template: '<div><slot /></div>' },
TooltipTrigger: { template: '<div><slot /></div>' },
TooltipContent: { template: '<div><slot /></div>' },
},
},
})
}
describe('BookSortBuilder', () => {
it('offers no custom field group when none are active', () => {
customFields.value = []
const wrapper = mountBuilder([{ field: 'title', dir: 'asc' }])
expect(wrapper.find('optgroup').exists()).toBe(false)
})
it('lists active custom fields in their own group, labelled verbatim', () => {
customFields.value = [makeField({ id: 7, label: 'Shelf Location', displayOrder: 1 }), makeField({ id: 3, label: 'Condition', displayOrder: 0 })]
const wrapper = mountBuilder([{ field: 'title', dir: 'asc' }])
const group = wrapper.get('optgroup')
expect(group.attributes('label')).toBe('Custom fields')
expect(group.findAll('option').map((o) => [o.attributes('value'), o.text()])).toEqual([
['custom:3', 'Condition'],
['custom:7', 'Shelf Location'],
])
})
it('omits archived custom fields', () => {
customFields.value = [makeField({ id: 7, archivedAt: '2026-01-01T00:00:00.000Z' })]
const wrapper = mountBuilder([{ field: 'title', dir: 'asc' }])
expect(wrapper.find('optgroup').exists()).toBe(false)
})
it('emits a custom sort field when one is selected', async () => {
customFields.value = [makeField({ id: 7 })]
const wrapper = mountBuilder([{ field: 'title', dir: 'asc' }])
await wrapper.get('select').setValue('custom:7')
const events = wrapper.emitted('update:modelValue')
expect(events?.[events.length - 1]?.[0]).toEqual([{ field: 'custom:7', dir: 'asc' }])
})
it('does not offer a custom field that is already used by another tier', () => {
customFields.value = [makeField({ id: 7 })]
const wrapper = mountBuilder([
{ field: 'custom:7', dir: 'asc' },
{ field: 'title', dir: 'asc' },
])
const secondTierGroup = wrapper.findAll('optgroup')[1]
expect(secondTierGroup?.findAll('option')).toHaveLength(0)
})
})
@@ -364,8 +364,8 @@ describe('buildCustomColumnDef', () => {
expect(buildCustomColumnDef(makeField()).lockField).toBeUndefined()
})
it('has no sortField', () => {
expect(buildCustomColumnDef(makeField()).sortField).toBeNull()
it('sorts by the custom field reference', () => {
expect(buildCustomColumnDef(makeField({ id: 42 })).sortField).toBe('custom:42')
})
it('accessor returns the value from book.customMetadata for the matching fieldId', () => {
@@ -1,4 +1,5 @@
import type { BookCard, BookMetadataLockField, CustomMetadataFieldSummary, CustomMetadataFieldType, SortField } from '@bookorbit/types'
import { customSortField } from '@bookorbit/types'
import { formatBytes } from '@/lib/formatting'
function getPrimaryFile(book: BookCard) {
@@ -89,7 +90,7 @@ export function buildCustomColumnDef(field: CustomMetadataFieldSummary): ColumnD
header: field.label,
cellType: customFieldCellType(field.type),
isEditable,
sortField: null,
sortField: customSortField(field.id),
defaultWidth: 160,
minWidth: 80,
defaultVisible: false,
@@ -38,6 +38,11 @@ export function useActiveCustomFields() {
return { fields, loading, initialized, refresh }
}
/** Label lookup for already-loaded fields. Reactive, and never triggers a fetch of its own. */
export function activeCustomFieldLabel(fieldId: number): string | null {
return fields.value.find((f) => f.id === fieldId)?.label ?? null
}
// For testing: resets the singleton state so tests are isolated.
export function _resetActiveCustomFieldsState(): void {
fields.value = []
@@ -1,11 +1,20 @@
import { afterEach, describe, expect, it } from 'vitest'
import type { Rule } from '@bookorbit/types'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { CustomMetadataFieldSummary, Rule } from '@bookorbit/types'
import { RULE_FIELDS, RULE_OPERATORS, SORT_FIELDS } from '@bookorbit/types'
import { activateI18nLocale, i18n } from '@/i18n'
import { compileIcuCatalog } from '@/i18n/icu'
import en from '@/locales/en.json'
import { activeCustomFieldLabel } from '@/features/book/composables/useActiveCustomFields'
import { fieldLabel, operatorLabel, ruleToParts, sortFieldLabel } from '../filter-labels'
vi.mock('@/features/book/composables/useActiveCustomFields', () => ({
activeCustomFieldLabel: vi.fn<(fieldId: number) => string | null>(() => null),
}))
function mockCustomFieldLabel(label: CustomMetadataFieldSummary['label'] | null) {
vi.mocked(activeCustomFieldLabel).mockReturnValue(label)
}
describe('catalog coverage', () => {
it('translates every rule field', () => {
for (const field of RULE_FIELDS) {
@@ -26,6 +35,21 @@ describe('catalog coverage', () => {
})
})
describe('custom metadata sort fields', () => {
it('shows the user-authored field label verbatim', () => {
mockCustomFieldLabel('Shelf Location')
expect(sortFieldLabel('custom:7')).toBe('Shelf Location')
expect(activeCustomFieldLabel).toHaveBeenCalledWith(7)
})
it('falls back to a translated placeholder when the field no longer resolves', () => {
mockCustomFieldLabel(null)
expect(sortFieldLabel('custom:7')).toBe('Custom field')
})
})
describe('active locale', () => {
afterEach(() => {
activateI18nLocale('en')
@@ -1,5 +1,7 @@
import type { Rule, RuleField, RuleOperator, SortField } from '@bookorbit/types'
import { parseCustomSortFieldId } from '@bookorbit/types'
import { i18n } from '@/i18n'
import { activeCustomFieldLabel } from '@/features/book/composables/useActiveCustomFields'
import { PROVIDER_SHORT_LABELS } from '@/lib/provider-colors'
// Resolved through the global composer rather than useI18n() because these helpers also run
@@ -8,7 +10,10 @@ import { PROVIDER_SHORT_LABELS } from '@/lib/provider-colors'
const t = i18n.global.t
export function sortFieldLabel(field: SortField): string {
return t(`book.sort.fields.${field}`)
const customFieldId = parseCustomSortFieldId(field)
if (customFieldId === null) return t(`book.sort.fields.${field}`)
// Custom field labels are user-authored, so they are shown as-is rather than translated.
return activeCustomFieldLabel(customFieldId) ?? t('book.sort.customFieldFallback')
}
export function fieldLabel(field: RuleField): string {
+2
View File
@@ -3263,6 +3263,8 @@
"language": "Language",
"metadataScore": "Metadata Score"
},
"customFieldsGroup": "Custom fields",
"customFieldFallback": "Custom field",
"remove": "Entfernen",
"emptyState": "Keine Sortierung konfiguriert. Titel aufsteigend wird verwendet.",
"addLevel": "Sortierebene hinzufügen"
+2
View File
@@ -3263,6 +3263,8 @@
"language": "Language",
"metadataScore": "Metadata Score"
},
"customFieldsGroup": "Custom fields",
"customFieldFallback": "Custom field",
"remove": "Remove",
"emptyState": "No sort configured. Title ascending will be used.",
"addLevel": "Add sort level"
+2
View File
@@ -3263,6 +3263,8 @@
"language": "Language",
"metadataScore": "Metadata Score"
},
"customFieldsGroup": "Custom fields",
"customFieldFallback": "Custom field",
"remove": "Quitar",
"emptyState": "No hay clasificación configurada. Se utilizará título ascendente.",
"addLevel": "Agregar nivel de clasificación"
+2
View File
@@ -3263,6 +3263,8 @@
"language": "Language",
"metadataScore": "Metadata Score"
},
"customFieldsGroup": "Custom fields",
"customFieldFallback": "Custom field",
"remove": "Supprimer",
"emptyState": "Aucun tri configuré. Le titre ascendant sera utilisé.",
"addLevel": "Ajouter un niveau de tri"
+2
View File
@@ -3263,6 +3263,8 @@
"language": "Language",
"metadataScore": "Metadata Score"
},
"customFieldsGroup": "Custom fields",
"customFieldFallback": "Custom field",
"remove": "Rimuovi",
"emptyState": "Nessun ordinamento configurato. Verrà utilizzato il titolo crescente.",
"addLevel": "Aggiungi livello di ordinamento"
+2
View File
@@ -3263,6 +3263,8 @@
"language": "Language",
"metadataScore": "Metadata Score"
},
"customFieldsGroup": "Custom fields",
"customFieldFallback": "Custom field",
"remove": "Verwijderen",
"emptyState": "Geen sortering ingesteld. Titel oplopend wordt gebruikt.",
"addLevel": "Sorteerniveau toevoegen"
+2
View File
@@ -3263,6 +3263,8 @@
"language": "Language",
"metadataScore": "Metadata Score"
},
"customFieldsGroup": "Custom fields",
"customFieldFallback": "Custom field",
"remove": "Usuń",
"emptyState": "Nie skonfigurowano sortowania. Tytuł będzie używany w kolejności rosnącej.",
"addLevel": "Dodaj poziom sortowania"
+2
View File
@@ -3263,6 +3263,8 @@
"language": "Language",
"metadataScore": "Metadata Score"
},
"customFieldsGroup": "Custom fields",
"customFieldFallback": "Custom field",
"remove": "Remover",
"emptyState": "Nenhuma ordenação configurada. Ordem alfabética por título (crescente) será utilizada.",
"addLevel": "Adicionar nível de ordenação"
+2
View File
@@ -3263,6 +3263,8 @@
"language": "Language",
"metadataScore": "Metadata Score"
},
"customFieldsGroup": "Custom fields",
"customFieldFallback": "Custom field",
"remove": "Odstrani",
"emptyState": "Razvrščanje ni nastavljeno. Uporabljen bo naslov naraščajoče.",
"addLevel": "Dodaj raven razvrščanja"