mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -06:00
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:
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user