feat(books): surface custom metadata fields in table view

Adds support for displaying custom metadata as columns in the book
table view. The frontend now fetches active fields and dynamically
renders the corresponding cells, backed by book card API updates.

Closes #465
This commit is contained in:
neon
2026-06-29 22:42:40 +00:00
parent a4b39542a0
commit 9e40a3c86a
57 changed files with 1588 additions and 173 deletions
@@ -158,6 +158,7 @@ function makeBook(overrides: Partial<BookCard> = {}): BookCard {
pageCount: 412,
isbn13: '9780441013593',
narrators: [],
customMetadata: [],
tags: [],
...overrides,
}
@@ -3,7 +3,7 @@ import { ref } from 'vue'
import { Copy, Download, RotateCcw, Star, Upload, X } from '@lucide/vue'
import type { TableDensity } from '@/composables/useDisplaySettings'
import type { SavedView } from '@/features/book/composables/useSavedViews'
import type { ColumnDef, ColumnId } from '@/features/book/composables/useTableColumns'
import type { ColumnDef } from '@/features/book/composables/useTableColumns'
import type { TablePreset } from '@/features/book/composables/useTablePresets'
const props = withDefaults(
@@ -20,7 +20,7 @@ const props = withDefaults(
)
const emit = defineEmits<{
'toggle-column': [id: ColumnId]
'toggle-column': [id: string]
'reorder-columns': [cols: (ColumnDef & { visible: boolean })[]]
'apply-preset': [preset: TablePreset]
'save-preset': [name: string]
@@ -80,6 +80,7 @@ function makeBook(id: number, overrides: Partial<BookCard> = {}): BookCard {
pageCount: null,
isbn13: null,
narrators: [],
customMetadata: [],
...overrides,
}
}
@@ -3,14 +3,7 @@ import { ref, computed, watch, nextTick, onMounted, onUnmounted, onActivated, on
import { breakpointsTailwind, useBreakpoints } from '@vueuse/core'
import { useVirtualizer } from '@tanstack/vue-virtual'
import { AlertTriangle, ArrowDown, ArrowUp, BookOpen, CheckCircle2, ChevronUp, Loader2, RotateCcw, X } from '@lucide/vue'
import {
useTableColumns,
COLUMN_DEFS,
COLUMN_DEF_MAP,
LOCK_ROW_COLUMN_DEF,
type ColumnId,
type ColumnDef,
} from '@/features/book/composables/useTableColumns'
import { useTableColumns, COLUMN_DEFS, COLUMN_DEF_MAP, LOCK_ROW_COLUMN_DEF, type ColumnDef } from '@/features/book/composables/useTableColumns'
import { useTableCellEditor } from '@/features/book/composables/useTableCellEditor'
import { useTableLocks } from '@/features/book/composables/useTableLocks'
import { useTablePresets } from '@/features/book/composables/useTablePresets'
@@ -46,12 +39,14 @@ import { isBookPlaceholder, type BookSlot } from '@/features/book/composables/us
import { useNarratorSearch } from '@/features/book/composables/useNarratorSearch'
import { SORT_FIELD_LABELS } from '@/features/book/lib/filter-labels'
import { useDisplaySettings } from '@/composables/useDisplaySettings'
import { useActiveCustomFields } from '@/features/book/composables/useActiveCustomFields'
const props = withDefaults(
defineProps<{
books: BookSlot[]
sort: SortSpec[]
viewType: TableViewType
libraryId?: number
selectionMode?: boolean
isSelected?: (id: number) => boolean
hasMore?: boolean
@@ -96,8 +91,29 @@ const isReadOnly = computed(() => !md.value)
const { tableDensity, tableZebraStriping } = useDisplaySettings()
const router = useRouter()
const { layout, visibleColumns, allColumns, toggleColumn, setColumnOrder, setColumnWidth, setLayout, pinColumn, unpinColumn, resetLayout } =
useTableColumns(props.viewType)
const { fields: allCustomFields } = useActiveCustomFields()
// In a library view, only show fields enabled for that specific library.
// In collection/smart scope views (no single library), show all active fields.
const customFields = computed(() => {
const lid = props.libraryId
if (lid == null) return allCustomFields.value
return allCustomFields.value.filter((f) => f.enabledLibraryIds.includes(lid))
})
const {
layout,
visibleColumns,
allColumns,
toggleColumn,
setColumnOrder,
setColumnWidth,
setLayout,
pinColumn,
unpinColumn,
resetLayout,
combinedColumnMap,
} = useTableColumns(props.viewType, customFields)
const editor = useTableCellEditor()
const locks = useTableLocks()
const tablePresets = useTablePresets(
@@ -124,6 +140,7 @@ const { getQuickFilterOptions, buildQuickFilterRule } = useTableQuickFilters(pro
const { getCellValue, isCellLocked, isCellReadOnly, isMandatoryFieldEmpty, isBookFileMissing, getPinnedCellBackground } = useTableCellHelpers(
locks,
() => isReadOnly.value,
() => combinedColumnMap.value,
)
const { coverDialogBook, handleCoverClick, handleCoverDialogUpdateBook } = useTableCoverDialog(
() => props.selectionMode ?? false,
@@ -131,7 +148,7 @@ const { coverDialogBook, handleCoverClick, handleCoverDialogUpdateBook } = useTa
() => loadedBooks.value,
)
function getChipsSearchFn(colId: ColumnId): (q: string) => Promise<string[]> {
function getChipsSearchFn(colId: string): (q: string) => Promise<string[]> {
if (colId === 'authors') return searchAuthors
if (colId === 'genres') return searchGenres
if (colId === 'tags') return searchTags
@@ -139,12 +156,12 @@ function getChipsSearchFn(colId: ColumnId): (q: string) => Promise<string[]> {
return () => Promise.resolve([])
}
function getChipsLinkFn(colId: ColumnId): ((chip: string) => string | null) | undefined {
function getChipsLinkFn(colId: string): ((chip: string) => string | null) | undefined {
if (colId === 'authors') return (name) => `/authors?q=${encodeURIComponent(name)}`
return undefined
}
function getChipsActionFn(colId: ColumnId): ((chip: string) => void) | undefined {
function getChipsActionFn(colId: string): ((chip: string) => void) | undefined {
if (props.viewType !== 'library') return undefined
if (colId === 'genres') {
return (name) => emit('quick-filter', { type: 'rule', field: 'genre', operator: 'includesAny', value: [name] })
@@ -155,7 +172,7 @@ function getChipsActionFn(colId: ColumnId): ((chip: string) => void) | undefined
return undefined
}
function getTextCellOpenLink(book: BookCard, colId: ColumnId): string | null {
function getTextCellOpenLink(book: BookCard, colId: string): string | null {
if (colId === 'title') return `/book/${book.id}`
if (colId === 'seriesName' && book.seriesId != null) return `/series/${book.seriesId}`
return null
@@ -167,13 +184,13 @@ const rowPaddingClass = computed(() => {
return 'py-1.5'
})
function getTextCellOpenLinkLabel(colId: ColumnId): string | null {
function getTextCellOpenLinkLabel(colId: string): string | null {
if (colId === 'title') return 'Open book details'
if (colId === 'seriesName') return 'Open series'
return null
}
function getTextSearchFn(colId: ColumnId): ((q: string) => Promise<string[]>) | undefined {
function getTextSearchFn(colId: string): ((q: string) => Promise<string[]>) | undefined {
if (colId === 'publisher') return searchPublisher
if (colId === 'seriesName') return searchSeriesName
if (colId === 'language') return searchLanguage
@@ -194,7 +211,7 @@ function isRowRefreshing(bookId: number): boolean {
return getRowFeedback(bookId)?.state === 'refreshing' || isRefreshing(bookId)
}
function isCellChanged(bookId: number, colId: ColumnId): boolean {
function isCellChanged(bookId: number, colId: string): boolean {
return refreshFeedback.isCellChanged(bookId, colId)
}
@@ -245,7 +262,7 @@ const pinnedLeftOffsets = computed(() => {
}
return offsets
})
const statusAnchorColumnId = computed<ColumnId | null>(() => {
const statusAnchorColumnId = computed<string | null>(() => {
const visibleIds = displayColumns.value.map((column) => column.id)
if (visibleIds.includes('title')) return 'title'
if (visibleIds.includes('authors')) return 'authors'
@@ -440,6 +457,7 @@ const { focusedRowIndex, focusedColIndex, isFocusedCell, handleTableKeydown } =
onActivate: handleActivate,
onSelect: (id, event) => emit('select', id, event),
onCopyRow: () => {},
columnMapGetter: () => combinedColumnMap.value,
})
const shortcutOverlayOpen = ref(false)
@@ -476,7 +494,7 @@ function shouldPreserveTargetFocus(target: EventTarget | null): boolean {
)
}
function focusTableCell(rowIndex: number, colIndex: number, event?: MouseEvent, bookId?: number, colId?: ColumnId) {
function focusTableCell(rowIndex: number, colIndex: number, event?: MouseEvent, bookId?: number, colId?: string) {
focusedRowIndex.value = rowIndex
focusedColIndex.value = colIndex
if (bookId != null && colId && editor.isActive(bookId, colId)) return
@@ -521,9 +539,9 @@ watch(
)
// Editable column IDs in display order (for Tab navigation)
const editableColumnIds = computed<ColumnId[]>(() => visibleColumns.value.filter((c) => c.isEditable).map((c) => c.id))
const editableColumnIds = computed<string[]>(() => visibleColumns.value.filter((c) => c.isEditable).map((c) => c.id))
function handleActivate(book: BookCard, colId: ColumnId) {
function handleActivate(book: BookCard, colId: string) {
if (isBookPlaceholder(book as BookSlot)) return
if (isReadOnly.value || props.selectionMode) return
const col = displayColumns.value.find((c) => c.id === colId)
@@ -531,18 +549,24 @@ function handleActivate(book: BookCard, colId: ColumnId) {
editor.activateCell(book.id, colId, getCellValue(book, colId))
}
function handleSave(book: BookCard, colId: ColumnId, newValue: unknown) {
editor.saveCell(book.id, colId, newValue, (patch) => {
const updated = mergeBookPatchWithLatest(loadedBooks.value, book, patch)
emit('update:book', updated)
})
function handleSave(book: BookCard, colId: string, newValue: unknown) {
editor.saveCell(
book.id,
colId,
newValue,
(patch) => {
const updated = mergeBookPatchWithLatest(loadedBooks.value, book, patch)
emit('update:book', updated)
},
book,
)
}
function handleCancel(bookId: number, colId: ColumnId) {
function handleCancel(bookId: number, colId: string) {
editor.cancelCellIfActive(bookId, colId)
}
function handleNavigate(book: BookCard, colId: ColumnId, direction: 'next' | 'prev' | 'rowUp' | 'rowDown') {
function handleNavigate(book: BookCard, colId: string, direction: 'next' | 'prev' | 'rowUp' | 'rowDown') {
if (direction === 'rowUp' || direction === 'rowDown') {
editor.navigateRow(direction === 'rowDown' ? 'down' : 'up', loadedBooks.value, book.id, colId)
return
@@ -566,7 +590,7 @@ function emitLockStateUpdate(book: BookCard, nextFields: BookCard['lockedFields'
emit('update:book', updated)
}
async function handleToggleCellLock(book: BookCard, colId: ColumnId) {
async function handleToggleCellLock(book: BookCard, colId: string) {
const lockField = COLUMN_DEF_MAP.get(colId)?.lockField
if (!lockField) return
const beforeFields = [...locks.getFields(book.id)]
@@ -109,6 +109,7 @@ const missingBook: BookCard = {
pageCount: null,
isbn13: null,
narrators: [],
customMetadata: [],
}
const presentBook: BookCard = {
@@ -137,6 +138,7 @@ const presentBook: BookCard = {
pageCount: null,
isbn13: null,
narrators: [],
customMetadata: [],
}
const presentBookWithCover: BookCard = {
@@ -75,6 +75,7 @@ const missingBook: BookCard = {
pageCount: null,
isbn13: null,
narrators: [],
customMetadata: [],
}
const presentBook: BookCard = {
@@ -103,6 +104,7 @@ const presentBook: BookCard = {
pageCount: null,
isbn13: null,
narrators: [],
customMetadata: [],
}
describe('BookListRow — missing state', () => {
@@ -243,6 +243,7 @@ describe('BookTableReadButtonCell', () => {
pageCount: null,
isbn13: null,
narrators: [],
customMetadata: [],
tags: [],
}
}
@@ -75,6 +75,7 @@ function makeBook(overrides?: Partial<BookCard>): BookCard {
pageCount: null,
isbn13: null,
narrators: [],
customMetadata: [],
...overrides,
collapsedSeries: {
...collapsedSeries,
@@ -0,0 +1,52 @@
<script setup lang="ts">
const props = defineProps<{
value: boolean | null
isReadOnly?: boolean
}>()
const emit = defineEmits<{
save: [value: boolean | null]
}>()
function handleToggle() {
if (props.isReadOnly) return
// Tri-state cycle: null (unset) -> true -> false -> null
if (props.value === null) emit('save', true)
else if (props.value === true) emit('save', false)
else emit('save', null)
}
function ariaLabel(): string {
if (props.value === null) return 'Not set - click to set true'
return props.value ? 'True - click to set false' : 'False - click to clear'
}
</script>
<template>
<div class="flex h-full w-full items-center px-1">
<button
type="button"
class="flex h-5 w-5 items-center justify-center rounded border transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
:class="[
value === true
? 'border-primary bg-primary text-primary-foreground'
: value === false
? 'border-border bg-background text-muted-foreground'
: 'border-border/50 bg-background text-transparent opacity-40',
isReadOnly ? 'cursor-default' : 'cursor-pointer hover:border-primary/70',
]"
:aria-label="ariaLabel()"
:aria-checked="value ?? 'mixed'"
role="checkbox"
:disabled="isReadOnly"
@click.stop="handleToggle"
>
<svg v-if="value === true" viewBox="0 0 12 12" fill="none" class="h-3 w-3" aria-hidden="true">
<path d="M2 6l3 3 5-5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
</svg>
<svg v-else-if="value === false" viewBox="0 0 12 12" fill="none" class="h-3 w-3" aria-hidden="true">
<path d="M3 6h6" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
</svg>
</button>
</div>
</template>
@@ -15,14 +15,15 @@ import BookTableActionsCell from './BookTableActionsCell.vue'
import BookTableProgressCell from './BookTableProgressCell.vue'
import BookTableMetadataScoreCell from './BookTableMetadataScoreCell.vue'
import BookTableLockableCell from './BookTableLockableCell.vue'
import type { CellType, ColumnId } from '@/features/book/composables/tableColumnSchema'
import BookTableBooleanCell from './BookTableBooleanCell.vue'
import type { CellType } from '@/features/book/composables/tableColumnSchema'
type NavigationDirection = 'next' | 'prev' | 'rowUp' | 'rowDown'
type BookActionType = 'quick-view' | 'add-to-collection' | 'delete'
const props = defineProps<{
book: BookCard
colId: ColumnId
colId: string
cellType: CellType
hasLockField: boolean
isLocked: boolean
@@ -62,6 +63,10 @@ function asNumber(v: unknown): number | null {
return v as number | null
}
function asBoolean(v: unknown): boolean | null {
return v as boolean | null
}
function asStringArray(v: unknown): string[] {
return v as string[]
}
@@ -168,6 +173,8 @@ const isComic = computed(() => primaryFile.value?.format != null && FORMAT_TO_GR
<BookTableReadButtonCell v-else-if="cellType === 'read'" :book="book" />
<BookTableDateCell v-else-if="cellType === 'date'" :value="asString(value)" />
<BookTableBooleanCell v-else-if="cellType === 'customBoolean'" :value="asBoolean(value)" :is-read-only="isReadOnly" @save="emit('save', $event)" />
<BookTableActionsCell
v-else-if="cellType === 'actions' && !selectionMode"
:book="book"
@@ -3,12 +3,11 @@ import { computed } from 'vue'
import { ChevronRight, LibraryBig } from '@lucide/vue'
import { FORMAT_TO_GROUP, type BookCard } from '@bookorbit/types'
import BookCoverSurface from '../BookCoverSurface.vue'
import type { ColumnId } from '@/features/book/composables/useTableColumns'
import { useCoverVersions } from '@/features/book/composables/useCoverVersions'
const props = defineProps<{
book: BookCard
colId: ColumnId
colId: string
}>()
const collapsed = computed(() => props.book.collapsedSeries!)
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { ArrowDown, ArrowUp, ArrowUpDown, GripVertical, Loader2 } from '@lucide/vue'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import type { ColumnDef, ColumnId } from '@/features/book/composables/tableColumnSchema'
import type { ColumnDef } from '@/features/book/composables/tableColumnSchema'
import type { SortSpec } from '@bookorbit/types'
const props = defineProps<{
@@ -14,8 +14,8 @@ const props = defineProps<{
someBooksSelected: boolean
isReadOnly: boolean
pinnedLeftOffsets: Map<string, number>
dragSourceColId: ColumnId | null
dropTargetColId: ColumnId | null
dragSourceColId: string | null
dropTargetColId: string | null
dropSide: 'before' | 'after' | null
sortable: boolean
}>()
@@ -24,13 +24,13 @@ const emit = defineEmits<{
selectAll: [event: Event]
columnSort: [sortField: string | null, event: MouseEvent]
headerContextMenu: [event: MouseEvent, col: ColumnDef]
colDragStart: [event: DragEvent, colId: ColumnId]
colDragStart: [event: DragEvent, colId: string]
colDragOver: [event: DragEvent, col: ColumnDef]
colDragLeave: [colId: ColumnId]
colDrop: [event: DragEvent, colId: ColumnId]
colDragLeave: [colId: string]
colDrop: [event: DragEvent, colId: string]
colDragEnd: []
resizeStart: [event: MouseEvent, colId: ColumnId, currentWidth: number]
autoFitColumn: [colId: ColumnId]
resizeStart: [event: MouseEvent, colId: string, currentWidth: number]
autoFitColumn: [colId: string]
}>()
function getSortDir(sortField: string): 'asc' | 'desc' | null {
@@ -31,6 +31,7 @@ function makeBook(overrides: Partial<BookCard> = {}): BookCard {
pageCount: null,
isbn13: null,
narrators: [],
customMetadata: [],
...overrides,
}
}
@@ -39,6 +39,7 @@ function makeBook(format: string | null): BookCard {
pageCount: null,
isbn13: null,
narrators: [],
customMetadata: [],
collapsedSeries: {
bookCount: 3,
readCount: 1,
@@ -1,12 +1,66 @@
import { describe, expect, it, vi } from 'vitest'
import type { BookCard } from '@bookorbit/types'
import type { BookCard, CustomMetadataFieldSummary } from '@bookorbit/types'
vi.mock('@/lib/formatting', () => ({
formatBytes: vi.fn<(bytes: number | null) => string>((bytes) => `formatted:${bytes}`),
}))
import { formatBytes } from '@/lib/formatting'
import { COLUMN_DEFS, COLUMN_DEF_MAP, DEFAULT_HIDDEN, DEFAULT_ORDER, DEFAULT_WIDTHS, LOCK_ROW_COLUMN_DEF } from '../tableColumnSchema'
import {
COLUMN_DEFS,
COLUMN_DEF_MAP,
DEFAULT_HIDDEN,
DEFAULT_ORDER,
DEFAULT_WIDTHS,
LOCK_ROW_COLUMN_DEF,
buildCustomColumnDef,
isCustomColumnId,
parseCustomFieldId,
} from '../tableColumnSchema'
function makeField(overrides: Partial<CustomMetadataFieldSummary> = {}): CustomMetadataFieldSummary {
return {
id: 42,
label: 'My Field',
type: 'text',
displayOrder: 0,
archivedAt: null,
enabledLibraryIds: [1],
...overrides,
}
}
function makeBook(overrides: Partial<BookCard> = {}): BookCard {
return {
id: 1,
status: 'present',
title: 'Dune',
authors: [],
seriesName: null,
seriesIndex: null,
files: [],
publishedYear: null,
language: null,
genres: [],
rating: null,
readingProgress: null,
readStatus: null,
addedAt: '2025-01-01T00:00:00Z',
updatedAt: null,
metadataScore: null,
hasCover: false,
hasMetadataLocks: false,
lockedFields: [],
subtitle: null,
publisher: null,
pageCount: null,
isbn13: null,
narrators: [],
tags: [],
customMetadata: [],
...overrides,
}
}
describe('tableColumnSchema', () => {
it('exposes 26 total column definitions including the lock row', () => {
@@ -78,6 +132,7 @@ describe('tableColumnSchema', () => {
isbn13: null,
narrators: [],
tags: [],
customMetadata: [],
} satisfies BookCard
const value = fileSizeColumn?.accessor?.(book)
@@ -85,4 +140,252 @@ describe('tableColumnSchema', () => {
expect(formatBytes).toHaveBeenCalledWith(2048)
expect(value).toBe('formatted:2048')
})
it('all column accessors can be called without throwing', () => {
const book = makeBook({
title: 'Dune',
seriesName: 'Dune Series',
seriesIndex: 1,
publishedYear: 1965,
language: 'en',
rating: 4,
metadataScore: 90,
genres: ['Sci-Fi'],
tags: ['classic'],
subtitle: 'A Novel',
publisher: 'Chilton Books',
pageCount: 412,
isbn13: '9780441013593',
narrators: ['Scott Brick'],
readingProgress: 60,
readStatus: { status: 'reading', source: 'manual', startedAt: '2025-01-01T00:00:00Z', finishedAt: null, updatedAt: '2025-01-02T00:00:00Z' },
updatedAt: '2025-06-01T00:00:00Z',
addedAt: '2025-01-01T00:00:00Z',
files: [{ id: 1, format: 'epub', role: 'primary', sizeBytes: 1024 }],
})
const columnsWithAccessors = COLUMN_DEFS.filter((c) => c.accessor)
for (const column of columnsWithAccessors) {
expect(() => column.accessor!(book)).not.toThrow()
}
})
it('title accessor returns the book title', () => {
const book = makeBook({ title: 'Dune' })
const def = COLUMN_DEFS.find((c) => c.id === 'title')!
expect(def.accessor!(book)).toBe('Dune')
})
it('seriesName accessor returns the series name', () => {
const book = makeBook({ seriesName: 'Dune Cycle' })
const def = COLUMN_DEFS.find((c) => c.id === 'seriesName')!
expect(def.accessor!(book)).toBe('Dune Cycle')
})
it('seriesIndex accessor returns the series index', () => {
const book = makeBook({ seriesIndex: 2 })
const def = COLUMN_DEFS.find((c) => c.id === 'seriesIndex')!
expect(def.accessor!(book)).toBe(2)
})
it('readingProgress accessor returns the progress value', () => {
const book = makeBook({ readingProgress: 75 })
const def = COLUMN_DEFS.find((c) => c.id === 'readingProgress')!
expect(def.accessor!(book)).toBe(75)
})
it('finishedAt accessor returns finishedAt from readStatus', () => {
const book = makeBook({
readStatus: { status: 'read', source: 'manual', startedAt: null, finishedAt: '2025-06-01T00:00:00Z', updatedAt: '2025-06-01T00:00:00Z' },
})
const def = COLUMN_DEFS.find((c) => c.id === 'finishedAt')!
expect(def.accessor!(book)).toBe('2025-06-01T00:00:00Z')
})
it('finishedAt accessor returns null when readStatus is null', () => {
const book = makeBook({ readStatus: null })
const def = COLUMN_DEFS.find((c) => c.id === 'finishedAt')!
expect(def.accessor!(book)).toBeNull()
})
it('updatedAt accessor returns updatedAt from book', () => {
const book = makeBook({ updatedAt: '2025-06-01T00:00:00Z' })
const def = COLUMN_DEFS.find((c) => c.id === 'updatedAt')!
expect(def.accessor!(book)).toBe('2025-06-01T00:00:00Z')
})
it('updatedAt accessor returns null when updatedAt is null', () => {
const book = makeBook({ updatedAt: null })
const def = COLUMN_DEFS.find((c) => c.id === 'updatedAt')!
expect(def.accessor!(book)).toBeNull()
})
it('addedAt accessor returns addedAt from book', () => {
const book = makeBook({ addedAt: '2024-12-01T00:00:00Z' })
const def = COLUMN_DEFS.find((c) => c.id === 'addedAt')!
expect(def.accessor!(book)).toBe('2024-12-01T00:00:00Z')
})
it('tags accessor returns empty array when tags is empty', () => {
const book = makeBook({ tags: [] })
const def = COLUMN_DEFS.find((c) => c.id === 'tags')!
expect(def.accessor!(book)).toEqual([])
})
it('fileSize accessor returns null via formatBytes when book has no files', () => {
const book = makeBook({ files: [] })
const def = COLUMN_DEFS.find((c) => c.id === 'fileSize')!
def.accessor!(book)
expect(formatBytes).toHaveBeenCalledWith(null)
})
it('fileSize accessor uses the first file when no primary file exists', () => {
const book = makeBook({ files: [{ id: 99, format: 'epub', role: 'content', sizeBytes: 512 }] })
const def = COLUMN_DEFS.find((c) => c.id === 'fileSize')!
def.accessor!(book)
expect(formatBytes).toHaveBeenCalledWith(512)
})
})
describe('isCustomColumnId', () => {
it('returns true for a custom:N pattern', () => {
expect(isCustomColumnId('custom:42')).toBe(true)
})
it('returns true for custom:0', () => {
expect(isCustomColumnId('custom:0')).toBe(true)
})
it('returns false for a static column ID', () => {
expect(isCustomColumnId('title')).toBe(false)
})
it('returns false for an empty string', () => {
expect(isCustomColumnId('')).toBe(false)
})
it('returns false for a string starting with custom but not the right prefix', () => {
expect(isCustomColumnId('customField')).toBe(false)
})
})
describe('parseCustomFieldId', () => {
it('returns the numeric field ID for a valid custom column ID', () => {
expect(parseCustomFieldId('custom:42')).toBe(42)
})
it('returns 0 for custom:0', () => {
expect(parseCustomFieldId('custom:0')).toBe(0)
})
it('returns null for a non-custom ID', () => {
expect(parseCustomFieldId('title')).toBeNull()
})
it('returns null when the suffix is not a valid integer', () => {
expect(parseCustomFieldId('custom:abc')).toBeNull()
})
it('returns null for custom: with no number', () => {
expect(parseCustomFieldId('custom:')).toBeNull()
})
})
describe('buildCustomColumnDef', () => {
it('produces a column with id matching custom:fieldId', () => {
const def = buildCustomColumnDef(makeField({ id: 7 }))
expect(def.id).toBe('custom:7')
})
it('uses field label as column header', () => {
const def = buildCustomColumnDef(makeField({ label: 'Award Winner' }))
expect(def.header).toBe('Award Winner')
})
it('sets customFieldId and customFieldType on the def', () => {
const def = buildCustomColumnDef(makeField({ id: 42, type: 'number' }))
expect(def.customFieldId).toBe(42)
expect(def.customFieldType).toBe('number')
})
it('maps text type to text cell type', () => {
expect(buildCustomColumnDef(makeField({ type: 'text' })).cellType).toBe('text')
})
it('maps url type to text cell type', () => {
expect(buildCustomColumnDef(makeField({ type: 'url' })).cellType).toBe('text')
})
it('maps number type to number cell type', () => {
expect(buildCustomColumnDef(makeField({ type: 'number' })).cellType).toBe('number')
})
it('maps date type to date cell type', () => {
expect(buildCustomColumnDef(makeField({ type: 'date' })).cellType).toBe('date')
})
it('maps boolean type to customBoolean cell type', () => {
expect(buildCustomColumnDef(makeField({ type: 'boolean' })).cellType).toBe('customBoolean')
})
it('is not visible by default', () => {
expect(buildCustomColumnDef(makeField()).defaultVisible).toBe(false)
})
it('is editable for text, url, number, and boolean types', () => {
for (const type of ['text', 'url', 'number', 'boolean'] as const) {
expect(buildCustomColumnDef(makeField({ type })).isEditable).toBe(true)
}
})
it('is NOT editable for date type (BookTableDateCell is display-only)', () => {
expect(buildCustomColumnDef(makeField({ type: 'date' })).isEditable).toBe(false)
})
it('has no pinned side', () => {
expect(buildCustomColumnDef(makeField()).pinned).toBeNull()
})
it('has no lockField', () => {
expect(buildCustomColumnDef(makeField()).lockField).toBeUndefined()
})
it('has no sortField', () => {
expect(buildCustomColumnDef(makeField()).sortField).toBeNull()
})
it('accessor returns the value from book.customMetadata for the matching fieldId', () => {
const def = buildCustomColumnDef(makeField({ id: 42 }))
const book = makeBook({
customMetadata: [{ fieldId: 42, key: 'my_field', label: 'My Field', type: 'text', displayOrder: 0, value: 'hello' }],
})
expect(def.accessor!(book)).toBe('hello')
})
it('accessor returns null when no matching entry exists in book.customMetadata', () => {
const def = buildCustomColumnDef(makeField({ id: 99 }))
const book = makeBook({
customMetadata: [{ fieldId: 42, key: 'other', label: 'Other', type: 'text', displayOrder: 0, value: 'hello' }],
})
expect(def.accessor!(book)).toBeNull()
})
it('accessor returns null for empty customMetadata array', () => {
const def = buildCustomColumnDef(makeField({ id: 42 }))
const book = makeBook({ customMetadata: [] })
expect(def.accessor!(book)).toBeNull()
})
it('accessor returns boolean value correctly', () => {
const def = buildCustomColumnDef(makeField({ id: 5, type: 'boolean' }))
const book = makeBook({
customMetadata: [{ fieldId: 5, key: 'bool_field', label: 'Bool', type: 'boolean', displayOrder: 0, value: true }],
})
expect(def.accessor!(book)).toBe(true)
})
it('has a positive defaultWidth and minWidth', () => {
const def = buildCustomColumnDef(makeField())
expect(def.defaultWidth).toBeGreaterThan(0)
expect(def.minWidth).toBeGreaterThan(0)
})
})
@@ -0,0 +1,152 @@
import { describe, it, expect, beforeEach, vi, type Mock } from 'vitest'
import { nextTick } from 'vue'
const mocks = vi.hoisted(() => ({
api: vi.fn<(input: RequestInfo | URL, init?: RequestInit) => Promise<Response>>(),
}))
vi.mock('@/lib/api', () => ({ api: mocks.api }))
import { useActiveCustomFields, _resetActiveCustomFieldsState } from '../useActiveCustomFields'
import type { CustomMetadataFieldSummary } from '@bookorbit/types'
function makeField(overrides: Partial<CustomMetadataFieldSummary> = {}): CustomMetadataFieldSummary {
return {
id: 1,
label: 'Award Winner',
type: 'text',
displayOrder: 0,
archivedAt: null,
enabledLibraryIds: [1],
...overrides,
}
}
describe('useActiveCustomFields', () => {
beforeEach(() => {
vi.clearAllMocks()
_resetActiveCustomFieldsState()
})
it('starts with empty fields and loading false before fetch completes', () => {
let resolveApi!: (r: Response) => void
;(mocks.api as Mock).mockReturnValue(
new Promise<Response>((res) => {
resolveApi = res
}),
)
const { fields } = useActiveCustomFields()
expect(fields.value).toEqual([])
resolveApi({ ok: true, json: async () => [] } as Response)
})
it('fetches active fields from the correct endpoint', async () => {
;(mocks.api as Mock).mockResolvedValue({ ok: true, json: async () => [] } as Response)
useActiveCustomFields()
await nextTick()
await new Promise((r) => setTimeout(r, 0))
expect(mocks.api).toHaveBeenCalledWith('/api/v1/custom-metadata/fields/active')
})
it('populates fields after a successful fetch', async () => {
const fieldList = [makeField({ id: 1, label: 'Award' }), makeField({ id: 2, label: 'Score' })]
;(mocks.api as Mock).mockResolvedValue({ ok: true, json: async () => fieldList } as Response)
const { fields } = useActiveCustomFields()
await nextTick()
await new Promise((r) => setTimeout(r, 0))
expect(fields.value).toHaveLength(2)
expect(fields.value[0]?.label).toBe('Award')
})
it('leaves fields as empty array on API failure', async () => {
;(mocks.api as Mock).mockResolvedValue({ ok: false, status: 500 } as Response)
const { fields } = useActiveCustomFields()
await nextTick()
await new Promise((r) => setTimeout(r, 0))
expect(fields.value).toEqual([])
})
it('leaves fields as empty array on network error', async () => {
;(mocks.api as Mock).mockRejectedValue(new Error('network error'))
const { fields } = useActiveCustomFields()
await nextTick()
await new Promise((r) => setTimeout(r, 0))
expect(fields.value).toEqual([])
})
it('returns the same fields ref from multiple composable calls (singleton)', async () => {
;(mocks.api as Mock).mockResolvedValue({ ok: true, json: async () => [makeField({ id: 1 })] } as Response)
const { fields: fields1 } = useActiveCustomFields()
const { fields: fields2 } = useActiveCustomFields()
await nextTick()
await new Promise((r) => setTimeout(r, 0))
expect(fields1).toBe(fields2)
})
it('only fetches once even when called multiple times', async () => {
;(mocks.api as Mock).mockResolvedValue({ ok: true, json: async () => [] } as Response)
useActiveCustomFields()
useActiveCustomFields()
useActiveCustomFields()
await nextTick()
await new Promise((r) => setTimeout(r, 0))
expect(mocks.api).toHaveBeenCalledTimes(1)
})
it('refresh re-fetches and updates the fields', async () => {
;(mocks.api as Mock)
.mockResolvedValueOnce({ ok: true, json: async () => [makeField({ id: 1 })] } as Response)
.mockResolvedValueOnce({ ok: true, json: async () => [makeField({ id: 1 }), makeField({ id: 2 })] } as Response)
const { fields, refresh } = useActiveCustomFields()
await nextTick()
await new Promise((r) => setTimeout(r, 0))
expect(fields.value).toHaveLength(1)
await refresh()
expect(fields.value).toHaveLength(2)
expect(mocks.api).toHaveBeenCalledTimes(2)
})
it('sets initialized to true after first successful fetch', async () => {
;(mocks.api as Mock).mockResolvedValue({ ok: true, json: async () => [] } as Response)
const { initialized } = useActiveCustomFields()
expect(initialized.value).toBe(false)
await nextTick()
await new Promise((r) => setTimeout(r, 0))
expect(initialized.value).toBe(true)
})
it('does not re-fetch when already initialized', async () => {
;(mocks.api as Mock).mockResolvedValue({ ok: true, json: async () => [] } as Response)
useActiveCustomFields()
await nextTick()
await new Promise((r) => setTimeout(r, 0))
vi.clearAllMocks()
;(mocks.api as Mock).mockResolvedValue({ ok: true, json: async () => [] } as Response)
useActiveCustomFields()
await nextTick()
await new Promise((r) => setTimeout(r, 0))
expect(mocks.api).not.toHaveBeenCalled()
})
})
@@ -101,6 +101,7 @@ function makeBook(overrides: Partial<BookCard> = {}): BookCard {
pageCount: null,
isbn13: null,
narrators: [],
customMetadata: [],
...overrides,
}
}
@@ -31,6 +31,7 @@ function makeBook(id: number): BookCard {
pageCount: null,
isbn13: null,
narrators: [],
customMetadata: [],
}
}
@@ -98,6 +98,7 @@ function makeBook(overrides: Partial<BookCard> = {}): BookCard {
pageCount: null,
isbn13: null,
narrators: [],
customMetadata: [],
...overrides,
}
}
@@ -37,6 +37,7 @@ function makeBook(id: number): BookCard {
pageCount: null,
isbn13: null,
narrators: [],
customMetadata: [],
}
}
@@ -38,6 +38,7 @@ function makeBook(id: number): BookCard {
pageCount: null,
isbn13: null,
narrators: [],
customMetadata: [],
} as BookCard
}
@@ -37,6 +37,7 @@ function makeBook(id: number, overrides: Partial<BookCard> = {}): BookCard {
pageCount: null,
isbn13: null,
narrators: [],
customMetadata: [],
...overrides,
} as BookCard
}
@@ -49,6 +49,7 @@ function makeBook(overrides: Partial<BookCard> = {}): BookCard {
pageCount: null,
isbn13: null,
narrators: [],
customMetadata: [],
tags: [],
...overrides,
}
@@ -43,6 +43,7 @@ function makeBook(overrides: Partial<BookCard> = {}): BookCard {
pageCount: null,
isbn13: null,
narrators: [],
customMetadata: [],
...overrides,
}
}
@@ -413,3 +414,159 @@ describe('useTableCellEditor', () => {
expect(editor.activeCellKey.value).toBe('2:title')
})
})
describe('useTableCellEditor - custom field saving', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('saveCell for custom:N calls PATCH /metadata with customMetadata payload', async () => {
;(mocks.api as Mock).mockResolvedValue({ ok: true } as Response)
const editor = useTableCellEditor()
const book = makeBook({ id: 5 })
const onSuccess = vi.fn<(patch: Partial<BookCard>) => void>()
await editor.saveCell(5, 'custom:42', 'Winner', onSuccess, book)
expect(mocks.api).toHaveBeenCalledWith(
'/api/v1/books/5/metadata',
expect.objectContaining({
method: 'PATCH',
body: JSON.stringify({ customMetadata: [{ fieldId: 42, value: 'Winner' }] }),
}),
)
})
it('saveCell for custom:N calls onSuccess with patched customMetadata for existing entry', async () => {
;(mocks.api as Mock).mockResolvedValue({ ok: true } as Response)
const editor = useTableCellEditor()
const book = makeBook({
id: 5,
customMetadata: [{ fieldId: 42, key: 'award', label: 'Award', type: 'text', displayOrder: 0, value: 'Old Value' }],
})
const onSuccess = vi.fn<(patch: Partial<BookCard>) => void>()
await editor.saveCell(5, 'custom:42', 'New Value', onSuccess, book)
expect(onSuccess).toHaveBeenCalledWith({
customMetadata: [{ fieldId: 42, key: 'award', label: 'Award', type: 'text', displayOrder: 0, value: 'New Value' }],
})
})
it('saveCell for custom:N preserves other customMetadata entries when patching', async () => {
;(mocks.api as Mock).mockResolvedValue({ ok: true } as Response)
const editor = useTableCellEditor()
const book = makeBook({
id: 5,
customMetadata: [
{ fieldId: 1, key: 'f1', label: 'F1', type: 'text', displayOrder: 0, value: 'keep' },
{ fieldId: 42, key: 'award', label: 'Award', type: 'text', displayOrder: 1, value: 'old' },
],
})
const onSuccess = vi.fn<(patch: Partial<BookCard>) => void>()
await editor.saveCell(5, 'custom:42', 'new', onSuccess, book)
const result = onSuccess.mock.calls[0]![0] as Partial<BookCard>
expect(result.customMetadata).toHaveLength(2)
expect(result.customMetadata!.find((f) => f.fieldId === 1)?.value).toBe('keep')
expect(result.customMetadata!.find((f) => f.fieldId === 42)?.value).toBe('new')
})
it('saveCell for custom:N with no existing entry in book leaves customMetadata unchanged', async () => {
;(mocks.api as Mock).mockResolvedValue({ ok: true } as Response)
const editor = useTableCellEditor()
const book = makeBook({ id: 5, customMetadata: [] })
const onSuccess = vi.fn<(patch: Partial<BookCard>) => void>()
await editor.saveCell(5, 'custom:42', 'first value', onSuccess, book)
const result = onSuccess.mock.calls[0]![0] as Partial<BookCard>
expect(result.customMetadata).toHaveLength(0)
})
it('saveCell for custom:N uses empty array when currentBook is not provided', async () => {
;(mocks.api as Mock).mockResolvedValue({ ok: true } as Response)
const editor = useTableCellEditor()
const onSuccess = vi.fn<(patch: Partial<BookCard>) => void>()
await editor.saveCell(5, 'custom:42', 'value', onSuccess)
const result = onSuccess.mock.calls[0]![0] as Partial<BookCard>
expect(result.customMetadata).toEqual([])
})
it('saveCell for custom:N shows error toast on API failure', async () => {
;(mocks.api as Mock).mockResolvedValue({ ok: false, status: 500, json: async () => ({}) } as Response)
const editor = useTableCellEditor()
const book = makeBook({ id: 5 })
const onSuccess = vi.fn<(patch: Partial<BookCard>) => void>()
await editor.saveCell(5, 'custom:42', 'value', onSuccess, book)
expect(onSuccess).not.toHaveBeenCalled()
expect(mocks.toastError).toHaveBeenCalled()
})
it('saveCell for custom:N shows error toast for invalid field ID', async () => {
const editor = useTableCellEditor()
const onSuccess = vi.fn<(patch: Partial<BookCard>) => void>()
await editor.saveCell(5, 'custom:notanumber', 'value', onSuccess)
expect(mocks.api).not.toHaveBeenCalled()
expect(mocks.toastError).toHaveBeenCalledWith('Cannot save: invalid custom field ID in "custom:notanumber"')
})
it('saveCell for custom:N saves boolean values correctly', async () => {
;(mocks.api as Mock).mockResolvedValue({ ok: true } as Response)
const editor = useTableCellEditor()
const book = makeBook({
id: 5,
customMetadata: [{ fieldId: 7, key: 'featured', label: 'Featured', type: 'boolean', displayOrder: 0, value: false }],
})
const onSuccess = vi.fn<(patch: Partial<BookCard>) => void>()
await editor.saveCell(5, 'custom:7', true, onSuccess, book)
expect(mocks.api).toHaveBeenCalledWith(
'/api/v1/books/5/metadata',
expect.objectContaining({ body: JSON.stringify({ customMetadata: [{ fieldId: 7, value: true }] }) }),
)
const result = onSuccess.mock.calls[0]![0] as Partial<BookCard>
expect(result.customMetadata!.find((f) => f.fieldId === 7)?.value).toBe(true)
})
it('saveCell for custom:N saves number values correctly', async () => {
;(mocks.api as Mock).mockResolvedValue({ ok: true } as Response)
const editor = useTableCellEditor()
const book = makeBook({
id: 5,
customMetadata: [{ fieldId: 3, key: 'score', label: 'Score', type: 'number', displayOrder: 0, value: null }],
})
const onSuccess = vi.fn<(patch: Partial<BookCard>) => void>()
await editor.saveCell(5, 'custom:3', 95, onSuccess, book)
expect(mocks.api).toHaveBeenCalledWith(
'/api/v1/books/5/metadata',
expect.objectContaining({ body: JSON.stringify({ customMetadata: [{ fieldId: 3, value: 95 }] }) }),
)
const result = onSuccess.mock.calls[0]![0] as Partial<BookCard>
expect(result.customMetadata!.find((f) => f.fieldId === 3)?.value).toBe(95)
})
it('saveCell shows a friendly message when field is not enabled for the book library', async () => {
;(mocks.api as Mock).mockResolvedValue({
ok: false,
status: 400,
json: async () => ({ message: 'Custom metadata field 42 is not enabled for this book' }),
} as Response)
const editor = useTableCellEditor()
const book = makeBook({ id: 5 })
await editor.saveCell(5, 'custom:42', 'value', vi.fn<(patch: Partial<BookCard>) => void>(), book)
expect(mocks.toastError).toHaveBeenCalledWith('This field is not available for this book - enable it for the library in Settings first')
})
})
@@ -42,6 +42,7 @@ function makeBook(overrides: Partial<BookCard> = {}): BookCard {
pageCount: null,
isbn13: null,
narrators: [],
customMetadata: [],
tags: [],
...overrides,
}
@@ -140,6 +141,30 @@ describe('useTableCellHelpers', () => {
const book = makeBook({ files: [] })
expect(helpers.isCellReadOnly(book, { id: 'title', isEditable: true })).toBe(false)
})
it('returns true for a custom column when book has no customMetadata entry (field not enabled)', () => {
const book = makeBook({ customMetadata: [] })
expect(helpers.isCellReadOnly(book, { id: 'custom:42', isEditable: true })).toBe(true)
})
it('returns false for a custom column when book has a matching entry (field is enabled)', () => {
const book = makeBook({
customMetadata: [{ fieldId: 42, key: 'award', label: 'Award', type: 'text', displayOrder: 0, value: null }],
})
expect(helpers.isCellReadOnly(book, { id: 'custom:42', isEditable: true })).toBe(false)
})
it('returns false for a custom column when the book has a non-null value for the field', () => {
const book = makeBook({
customMetadata: [{ fieldId: 42, key: 'award', label: 'Award', type: 'text', displayOrder: 0, value: 'Winner' }],
})
expect(helpers.isCellReadOnly(book, { id: 'custom:42', isEditable: true })).toBe(false)
})
it('returns true for a custom column with an invalid field ID format', () => {
const book = makeBook({ customMetadata: [] })
expect(helpers.isCellReadOnly(book, { id: 'custom:notanumber', isEditable: true })).toBe(true)
})
})
describe('isMandatoryFieldEmpty', () => {
@@ -206,3 +231,60 @@ describe('useTableCellHelpers', () => {
})
})
})
describe('useTableCellHelpers with custom columnMapGetter', () => {
it('getCellValue uses the custom map when provided', () => {
const customAccessor = vi.fn<(book: BookCard) => unknown>(() => 'custom-value')
const customMap = new Map([
[
'custom:42',
{
id: 'custom:42',
header: 'My Field',
cellType: 'text' as const,
isEditable: true,
sortField: null,
defaultWidth: 160,
minWidth: 80,
defaultVisible: false,
pinned: null,
accessor: customAccessor,
},
],
])
const helpers = useTableCellHelpers(
makeMockLocks(),
() => false,
() => customMap as never,
)
const book = makeBook()
expect(helpers.getCellValue(book, 'custom:42')).toBe('custom-value')
expect(customAccessor).toHaveBeenCalledWith(book)
})
it('getCellValue falls back to static map when no getter provided', () => {
const helpers = useTableCellHelpers(makeMockLocks(), () => false)
const book = makeBook({ title: 'Dune' })
expect(helpers.getCellValue(book, 'title')).toBe('Dune')
})
it('getCellValue returns null for a custom column not in the custom map', () => {
const customMap = new Map()
const helpers = useTableCellHelpers(
makeMockLocks(),
() => false,
() => customMap as never,
)
const book = makeBook()
expect(helpers.getCellValue(book, 'custom:99')).toBeNull()
})
it('isCellLocked always returns false for custom columns (no lockField)', () => {
const helpers = useTableCellHelpers(
makeMockLocks({ isLocked: vi.fn<(bookId: number, field: BookMetadataLockField) => boolean>(() => true) }),
() => false,
)
expect(helpers.isCellLocked(makeBook(), 'custom:42')).toBe(false)
})
})
@@ -1,5 +1,6 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { nextTick } from 'vue'
import { nextTick, ref } from 'vue'
import type { CustomMetadataFieldSummary } from '@bookorbit/types'
const mockStorage: Record<string, string> = {}
@@ -16,9 +17,21 @@ vi.stubGlobal('localStorage', {
},
})
import { useTableColumns, COLUMN_DEFS, LOCK_ROW_COLUMN_DEF, type ColumnId } from '../useTableColumns'
import { useTableColumns, COLUMN_DEFS, LOCK_ROW_COLUMN_DEF } from '../useTableColumns'
const ALL_IDS = COLUMN_DEFS.map((c) => c.id) as ColumnId[]
const ALL_IDS = COLUMN_DEFS.map((c) => c.id)
function makeField(overrides: Partial<CustomMetadataFieldSummary> = {}): CustomMetadataFieldSummary {
return {
id: 1,
label: 'Award',
type: 'text',
displayOrder: 0,
archivedAt: null,
enabledLibraryIds: [1],
...overrides,
}
}
describe('useTableColumns', () => {
beforeEach(() => {
@@ -72,7 +85,7 @@ describe('useTableColumns', () => {
it('setColumnOrder reorders columns', () => {
const { allColumns, setColumnOrder } = useTableColumns('library')
const ids = allColumns.value.map((c) => c.id)
const reversed = [...ids].reverse() as ColumnId[]
const reversed = [...ids].reverse() as string[]
setColumnOrder(reversed)
@@ -82,7 +95,7 @@ describe('useTableColumns', () => {
it('setColumnOrder updates visibleColumns order', () => {
const { visibleColumns, allColumns, setColumnOrder } = useTableColumns('library')
const unpinnedVisible = allColumns.value.filter((c) => c.pinned === null && c.visible)
const reversedAll = allColumns.value.map((c) => c.id).reverse() as ColumnId[]
const reversedAll = allColumns.value.map((c) => c.id).reverse() as string[]
setColumnOrder(reversedAll)
@@ -93,7 +106,7 @@ describe('useTableColumns', () => {
it('setColumnOrder persists the new order to localStorage', async () => {
const { allColumns, setColumnOrder } = useTableColumns('library')
const ids = allColumns.value.map((c) => c.id) as ColumnId[]
const ids = allColumns.value.map((c) => c.id) as string[]
const swapped = [...ids]
const unpinnedIdxs = swapped.reduce<number[]>((acc, id, i) => {
const col = allColumns.value.find((c) => c.id === id)
@@ -118,7 +131,7 @@ describe('useTableColumns', () => {
})
it('setColumnOrder with unknown IDs: loadLayout filters them out and appends missing', () => {
const knownIds = COLUMN_DEFS.map((c) => c.id) as ColumnId[]
const knownIds = COLUMN_DEFS.map((c) => c.id) as string[]
mockStorage['bookorbit:tableLayout:library'] = JSON.stringify({
columnOrder: ['unknown_col', ...knownIds],
hiddenColumns: [],
@@ -131,7 +144,7 @@ describe('useTableColumns', () => {
})
it('loadLayout initializes pinnedColumns as empty object when missing from storage', () => {
const knownIds = COLUMN_DEFS.map((c) => c.id) as ColumnId[]
const knownIds = COLUMN_DEFS.map((c) => c.id) as string[]
mockStorage['bookorbit:tableLayout:library'] = JSON.stringify({
columnOrder: knownIds,
hiddenColumns: [],
@@ -144,7 +157,7 @@ describe('useTableColumns', () => {
})
it('loadLayout filters invalid pinnedColumns entries and keeps explicit null overrides', () => {
const knownIds = COLUMN_DEFS.map((c) => c.id) as ColumnId[]
const knownIds = COLUMN_DEFS.map((c) => c.id) as string[]
mockStorage['bookorbit:tableLayout:library'] = JSON.stringify({
columnOrder: knownIds,
hiddenColumns: [],
@@ -383,3 +396,240 @@ describe('LOCK_ROW_COLUMN_DEF', () => {
expect(LOCK_ROW_COLUMN_DEF.defaultWidth).toBeLessThanOrEqual(48)
})
})
describe('useTableColumns with customFields', () => {
beforeEach(() => {
vi.useFakeTimers()
Object.keys(mockStorage).forEach((k) => delete mockStorage[k])
})
afterEach(() => {
vi.useRealTimers()
vi.restoreAllMocks()
})
it('includes custom columns in allColumns when customFields is provided', () => {
const customFields = ref([makeField({ id: 10, label: 'Award' })])
const { allColumns } = useTableColumns('library', customFields)
expect(allColumns.value.some((c) => c.id === 'custom:10')).toBe(true)
})
it('custom columns are hidden by default', () => {
const customFields = ref([makeField({ id: 10 })])
const { allColumns } = useTableColumns('library', customFields)
const col = allColumns.value.find((c) => c.id === 'custom:10')
expect(col?.visible).toBe(false)
})
it('custom columns do not appear in visibleColumns when hidden', () => {
const customFields = ref([makeField({ id: 10 })])
const { visibleColumns } = useTableColumns('library', customFields)
expect(visibleColumns.value.some((c) => c.id === 'custom:10')).toBe(false)
})
it('toggleColumn makes a custom column visible', () => {
const customFields = ref([makeField({ id: 10 })])
const { toggleColumn, visibleColumns } = useTableColumns('library', customFields)
toggleColumn('custom:10')
expect(visibleColumns.value.some((c) => c.id === 'custom:10')).toBe(true)
})
it('custom column header reflects field label', () => {
const customFields = ref([makeField({ id: 10, label: 'My Award' })])
const { allColumns } = useTableColumns('library', customFields)
const col = allColumns.value.find((c) => c.id === 'custom:10')
expect(col?.header).toBe('My Award')
})
it('reconciliation: new custom field is appended to columnOrder as hidden', async () => {
const customFields = ref<CustomMetadataFieldSummary[]>([])
const { allColumns } = useTableColumns('library', customFields)
expect(allColumns.value.some((c) => c.id === 'custom:10')).toBe(false)
customFields.value = [makeField({ id: 10 })]
await nextTick()
expect(allColumns.value.some((c) => c.id === 'custom:10')).toBe(true)
const col = allColumns.value.find((c) => c.id === 'custom:10')
expect(col?.visible).toBe(false)
})
it('reconciliation: archived custom field is removed from columnOrder', async () => {
const customFields = ref([makeField({ id: 10 })])
const { allColumns, toggleColumn } = useTableColumns('library', customFields)
toggleColumn('custom:10')
expect(allColumns.value.some((c) => c.id === 'custom:10')).toBe(true)
customFields.value = []
await nextTick()
expect(allColumns.value.some((c) => c.id === 'custom:10')).toBe(false)
})
it('reconciliation: archived field is also removed from visible columns', async () => {
const customFields = ref([makeField({ id: 10 })])
const { toggleColumn, visibleColumns } = useTableColumns('library', customFields)
toggleColumn('custom:10')
expect(visibleColumns.value.some((c) => c.id === 'custom:10')).toBe(true)
customFields.value = []
await nextTick()
expect(visibleColumns.value.some((c) => c.id === 'custom:10')).toBe(false)
})
it('reconciliation: pinned custom field is also removed from pinnedColumns when archived', async () => {
const customFields = ref([makeField({ id: 10 })])
const { toggleColumn, pinColumn, layout } = useTableColumns('library', customFields)
toggleColumn('custom:10')
pinColumn('custom:10', 'right')
expect(layout.value.pinnedColumns?.['custom:10']).toBe('right')
customFields.value = []
await nextTick()
expect(layout.value.pinnedColumns?.['custom:10']).toBeUndefined()
})
it('stale custom:N IDs from localStorage are removed when those fields are gone', async () => {
mockStorage['bookorbit:tableLayout:library'] = JSON.stringify({
columnOrder: [...ALL_IDS, 'custom:99'],
hiddenColumns: ['custom:99'],
columnWidths: {},
})
// Start with empty fields, then provide a field set that does NOT include custom:99
const customFields = ref<CustomMetadataFieldSummary[]>([])
const { allColumns } = useTableColumns('library', customFields)
// Even before fields load, allColumns filters out undefined defs
expect(allColumns.value.some((c) => c.id === 'custom:99')).toBe(false)
// When a field set loads (first load trigger), orphaned IDs are cleaned from columnOrder
customFields.value = [makeField({ id: 1 })]
await nextTick()
expect(allColumns.value.some((c) => c.id === 'custom:99')).toBe(false)
})
it('custom:N IDs from localStorage are preserved visible when the field is still active', async () => {
mockStorage['bookorbit:tableLayout:library'] = JSON.stringify({
columnOrder: [...ALL_IDS, 'custom:10'],
hiddenColumns: [],
columnWidths: {},
})
const customFields = ref([makeField({ id: 10 })])
const { visibleColumns } = useTableColumns('library', customFields)
await nextTick()
// custom:10 was NOT in hiddenColumns in storage, so it should be visible (no watch clobbering it)
expect(visibleColumns.value.some((c) => c.id === 'custom:10')).toBe(true)
})
it('resetLayout resets custom columns to hidden but keeps them in the picker', async () => {
const customFields = ref([makeField({ id: 10 })])
const { allColumns, toggleColumn, resetLayout } = useTableColumns('library', customFields)
await nextTick()
toggleColumn('custom:10')
expect(allColumns.value.find((c) => c.id === 'custom:10')?.visible).toBe(true)
resetLayout()
await nextTick()
const col = allColumns.value.find((c) => c.id === 'custom:10')
expect(col).toBeDefined()
expect(col?.visible).toBe(false)
})
it('setLayout validates custom column IDs against active fields', async () => {
const customFields = ref([makeField({ id: 10 })])
const { setLayout, allColumns } = useTableColumns('library', customFields)
await nextTick()
setLayout({
columnOrder: [...ALL_IDS, 'custom:10', 'custom:99'],
hiddenColumns: [],
columnWidths: {},
})
await nextTick()
expect(allColumns.value.some((c) => c.id === 'custom:10')).toBe(true)
expect(allColumns.value.some((c) => c.id === 'custom:99')).toBe(false)
})
it('setColumnWidth works for a custom column', () => {
const customFields = ref([makeField({ id: 10 })])
const { toggleColumn, setColumnWidth, visibleColumns } = useTableColumns('library', customFields)
toggleColumn('custom:10')
setColumnWidth('custom:10', 240)
const col = visibleColumns.value.find((c) => c.id === 'custom:10')
expect(col?.defaultWidth).toBe(240)
})
it('no custom columns when customFields is undefined', () => {
const { allColumns } = useTableColumns('library')
expect(allColumns.value.every((c) => !c.id.startsWith('custom:'))).toBe(true)
})
it('archived custom fields are excluded from columns', async () => {
const customFields = ref([makeField({ id: 10, archivedAt: '2025-01-01T00:00:00Z' })])
const { allColumns } = useTableColumns('library', customFields)
await nextTick()
expect(allColumns.value.some((c) => c.id === 'custom:10')).toBe(false)
})
it('combinedColumnMap includes both static and custom columns', () => {
const customFields = ref([makeField({ id: 10 })])
const { combinedColumnMap } = useTableColumns('library', customFields)
expect(combinedColumnMap.value.has('title')).toBe(true)
expect(combinedColumnMap.value.has('custom:10')).toBe(true)
})
})
describe('useTableColumns loadLayout with custom column widths', () => {
beforeEach(() => {
vi.useFakeTimers()
Object.keys(mockStorage).forEach((k) => delete mockStorage[k])
})
afterEach(() => {
vi.useRealTimers()
vi.restoreAllMocks()
})
it('loads and preserves a user-set width for a custom:N column from storage', async () => {
const customFields = ref([makeField({ id: 10 })])
mockStorage['bookorbit:tableLayout:library'] = JSON.stringify({
columnOrder: [...ALL_IDS, 'custom:10'],
hiddenColumns: [],
columnWidths: { 'custom:10': 300 },
})
const { visibleColumns } = useTableColumns('library', customFields)
await nextTick()
const col = visibleColumns.value.find((c) => c.id === 'custom:10')
expect(col?.defaultWidth).toBe(300)
})
it('ignores invalid (non-positive) widths for custom:N columns in storage', async () => {
const customFields = ref([makeField({ id: 10 })])
// hiddenColumns is empty so custom:10 starts visible from storage
mockStorage['bookorbit:tableLayout:library'] = JSON.stringify({
columnOrder: [...ALL_IDS, 'custom:10'],
hiddenColumns: [],
columnWidths: { 'custom:10': -5 },
})
const { visibleColumns } = useTableColumns('library', customFields)
await nextTick()
const col = visibleColumns.value.find((c) => c.id === 'custom:10')
// -5 is invalid so it falls back to the def's defaultWidth (160)
expect(col?.defaultWidth).toBe(160)
})
it('null-def rows are filtered from visibleColumns when combinedMap is missing an entry', async () => {
mockStorage['bookorbit:tableLayout:library'] = JSON.stringify({
columnOrder: [...ALL_IDS, 'custom:999'],
hiddenColumns: [],
columnWidths: {},
})
const customFields = ref<ReturnType<typeof makeField>[]>([])
const { visibleColumns } = useTableColumns('library', customFields)
await nextTick()
expect(visibleColumns.value.some((c) => c.id === 'custom:999')).toBe(false)
})
})
@@ -28,6 +28,7 @@ function makeBook(overrides: Partial<BookCard> = {}): BookCard {
pageCount: null,
isbn13: null,
narrators: [],
customMetadata: [],
tags: [],
...overrides,
}
@@ -1,7 +1,7 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { ref, type Ref } from 'vue'
import { useTableDragReorder } from '../useTableDragReorder'
import type { ColumnDef, ColumnId } from '../useTableColumns'
import type { ColumnDef } from '../useTableColumns'
function makeColumns(): ColumnDef[] {
return [
@@ -15,11 +15,11 @@ function makeColumns(): ColumnDef[] {
describe('useTableDragReorder', () => {
let allColumns: Ref<ColumnDef[]>
let setOrderSpy: (order: ColumnId[]) => void
let setOrderSpy: (order: string[]) => void
beforeEach(() => {
allColumns = ref(makeColumns())
setOrderSpy = vi.fn<(order: ColumnId[]) => void>()
setOrderSpy = vi.fn<(order: string[]) => void>()
})
it('isDraggableCol returns false for pinned columns', () => {
@@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'
import { ref, type Ref } from 'vue'
import type { BookCard } from '@bookorbit/types'
import { useTableKeyboard } from '../useTableKeyboard'
import type { ColumnDef, ColumnId } from '../useTableColumns'
import type { ColumnDef } from '../useTableColumns'
function makeBook(overrides: Partial<BookCard> = {}): BookCard {
return {
@@ -31,7 +31,7 @@ describe('useTableKeyboard', () => {
let books: BookCard[]
let displayColumns: Ref<ColumnDef[]>
let activeCellKey: Ref<string | null>
let onActivate: (book: BookCard, colId: ColumnId) => void
let onActivate: (book: BookCard, colId: string) => void
let onSelect: (id: number, event: MouseEvent) => void
let scrollToIndexSpy: ReturnType<typeof vi.fn>
@@ -57,7 +57,7 @@ describe('useTableKeyboard', () => {
books = [makeBook({ id: 1, title: 'Book A' }), makeBook({ id: 2, title: 'Book B' }), makeBook({ id: 3, title: 'Book C' })]
displayColumns = ref(makeColumns())
activeCellKey = ref(null)
onActivate = vi.fn<(book: BookCard, colId: ColumnId) => void>()
onActivate = vi.fn<(book: BookCard, colId: string) => void>()
onSelect = vi.fn<(id: number, event: MouseEvent) => void>()
scrollToIndexSpy = vi.fn<() => void>()
})
@@ -1,7 +1,7 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { ref, type Ref } from 'vue'
import { useTableResize } from '../useTableResize'
import type { ColumnDef, ColumnId } from '../useTableColumns'
import type { ColumnDef } from '../useTableColumns'
vi.mock('vue', async (importOriginal) => {
const actual = await importOriginal<typeof import('vue')>()
@@ -21,13 +21,13 @@ function makeDefs(): ColumnDef[] {
describe('useTableResize', () => {
let scrollRef: Ref<HTMLDivElement | null>
let displayColumns: Ref<ColumnDef[]>
let setWidthSpy: (id: ColumnId, px: number) => void
let setWidthSpy: (id: string, px: number) => void
let isReadOnly: Ref<boolean>
beforeEach(() => {
scrollRef = ref(null)
displayColumns = ref(makeDefs())
setWidthSpy = vi.fn<(id: ColumnId, px: number) => void>()
setWidthSpy = vi.fn<(id: string, px: number) => void>()
isReadOnly = ref(false)
})
@@ -1,11 +1,11 @@
import type { BookCard, BookMetadataLockField, SortField } from '@bookorbit/types'
import type { BookCard, BookMetadataLockField, CustomMetadataFieldSummary, CustomMetadataFieldType, SortField } from '@bookorbit/types'
import { formatBytes } from '@/lib/formatting'
function getPrimaryFile(book: BookCard) {
return book.files.find((f) => f.role === 'primary') ?? book.files[0] ?? null
}
export type ColumnId =
export type StaticColumnId =
| 'lockRow'
| 'cover'
| 'title'
@@ -33,6 +33,10 @@ export type ColumnId =
| 'addedAt'
| 'actions'
export type CustomColumnId = `custom:${number}`
export type ColumnId = StaticColumnId | CustomColumnId
export type CellType =
| 'lockRow'
| 'cover'
@@ -46,9 +50,10 @@ export type CellType =
| 'date'
| 'progress'
| 'actions'
| 'customBoolean'
export type ColumnDef = {
id: ColumnId
id: string
header: string
cellType: CellType
isEditable: boolean
@@ -59,6 +64,51 @@ export type ColumnDef = {
pinned: 'left' | 'right' | null
accessor?: (book: BookCard) => unknown
lockField?: BookMetadataLockField
customFieldId?: number
customFieldType?: CustomMetadataFieldType
}
export function isCustomColumnId(id: string): id is CustomColumnId {
return id.startsWith('custom:')
}
export function parseCustomFieldId(id: string): number | null {
if (!isCustomColumnId(id)) return null
const n = parseInt(id.slice(7), 10)
return Number.isFinite(n) ? n : null
}
function customFieldCellType(type: CustomMetadataFieldType): CellType {
switch (type) {
case 'number':
return 'number'
case 'date':
return 'date'
case 'boolean':
return 'customBoolean'
default:
return 'text'
}
}
export function buildCustomColumnDef(field: CustomMetadataFieldSummary): ColumnDef {
const id: CustomColumnId = `custom:${field.id}`
// date fields use BookTableDateCell which is display-only (no edit UI in the table).
const isEditable = field.type !== 'date'
return {
id,
header: field.label,
cellType: customFieldCellType(field.type),
isEditable,
sortField: null,
defaultWidth: 160,
minWidth: 80,
defaultVisible: false,
pinned: null,
customFieldId: field.id,
customFieldType: field.type,
accessor: (book: BookCard) => book.customMetadata.find((f) => f.fieldId === field.id)?.value ?? null,
}
}
export const LOCK_ROW_COLUMN_DEF: ColumnDef = {
@@ -385,8 +435,8 @@ export const COLUMN_DEFS: ColumnDef[] = [
},
]
export const COLUMN_DEF_MAP = new Map(COLUMN_DEFS.map((c) => [c.id, c]))
export const COLUMN_DEF_MAP = new Map<string, ColumnDef>(COLUMN_DEFS.map((c) => [c.id, c]))
export const DEFAULT_ORDER: ColumnId[] = COLUMN_DEFS.map((c) => c.id)
export const DEFAULT_HIDDEN: ColumnId[] = COLUMN_DEFS.filter((c) => !c.defaultVisible).map((c) => c.id)
export const DEFAULT_ORDER: string[] = COLUMN_DEFS.map((c) => c.id)
export const DEFAULT_HIDDEN: string[] = COLUMN_DEFS.filter((c) => !c.defaultVisible).map((c) => c.id)
export const DEFAULT_WIDTHS: Record<string, number> = Object.fromEntries(COLUMN_DEFS.map((c) => [c.id, c.defaultWidth]))
@@ -0,0 +1,47 @@
import { ref } from 'vue'
import type { CustomMetadataFieldSummary } from '@bookorbit/types'
import { api } from '@/lib/api'
// Module-level singleton: shared across all table instances in the same session.
// Fields are fetched once and reused; callers can trigger an explicit refresh.
const fields = ref<CustomMetadataFieldSummary[]>([])
const loading = ref(false)
const initialized = ref(false)
let fetchPromise: Promise<void> | null = null
async function doFetch(): Promise<void> {
loading.value = true
try {
const res = await api('/api/v1/custom-metadata/fields/active')
if (!res.ok) throw new Error(`HTTP ${res.status}`)
fields.value = (await res.json()) as CustomMetadataFieldSummary[]
initialized.value = true
} catch {
fields.value = []
} finally {
loading.value = false
}
}
function ensureLoaded(): void {
if (initialized.value || loading.value) return
fetchPromise = doFetch()
}
function refresh(): Promise<void> {
fetchPromise = doFetch()
return fetchPromise
}
export function useActiveCustomFields() {
ensureLoaded()
return { fields, loading, initialized, refresh }
}
// For testing: resets the singleton state so tests are isolated.
export function _resetActiveCustomFieldsState(): void {
fields.value = []
loading.value = false
initialized.value = false
fetchPromise = null
}
@@ -1,11 +1,10 @@
import { ref } from 'vue'
import type { ColumnId } from './useTableColumns'
export type BookRefreshFeedbackState = 'refreshing' | 'success' | 'failed'
export type BookRefreshFeedbackEntry = {
state: BookRefreshFeedbackState
changedColumns: Set<ColumnId>
changedColumns: Set<string>
message: string | null
updatedAt: number
}
@@ -48,7 +47,7 @@ function scheduleFlashClear(bookId: number, expectedUpdatedAt: number, delayMs =
const current = feedbackByBookId.value.get(bookId)
if (!current) return
if (current.updatedAt !== expectedUpdatedAt || current.state !== 'success') return
setEntry(bookId, { ...current, changedColumns: new Set<ColumnId>() })
setEntry(bookId, { ...current, changedColumns: new Set<string>() })
flashTimers.delete(bookId)
}, delayMs),
)
@@ -75,7 +74,7 @@ export function useBookRefreshFeedback() {
clearFlashTimer(bookId)
setEntry(bookId, {
state: 'refreshing',
changedColumns: new Set<ColumnId>(),
changedColumns: new Set<string>(),
message: null,
updatedAt: Date.now(),
})
@@ -87,7 +86,7 @@ export function useBookRefreshFeedback() {
}
}
function markSuccess(bookId: number, changedColumns: ColumnId[] = []) {
function markSuccess(bookId: number, changedColumns: string[] = []) {
clearTimer(bookId)
clearFlashTimer(bookId)
const updatedAt = Date.now()
@@ -107,7 +106,7 @@ export function useBookRefreshFeedback() {
const updatedAt = Date.now()
setEntry(bookId, {
state: 'failed',
changedColumns: new Set<ColumnId>(),
changedColumns: new Set<string>(),
message,
updatedAt,
})
@@ -124,7 +123,7 @@ export function useBookRefreshFeedback() {
return feedbackByBookId.value.get(bookId) ?? null
}
function isCellChanged(bookId: number, columnId: ColumnId): boolean {
function isCellChanged(bookId: number, columnId: string): boolean {
return feedbackByBookId.value.get(bookId)?.changedColumns.has(columnId) ?? false
}
@@ -1,9 +1,9 @@
import { ref } from 'vue'
import { toast } from 'vue-sonner'
import { api } from '@/lib/api'
import type { BookCard, ReadStatus } from '@bookorbit/types'
import type { ColumnId } from './useTableColumns'
import { COLUMN_DEF_MAP } from './useTableColumns'
import type { BookCard, CustomMetadataBookValue, ReadStatus } from '@bookorbit/types'
import type { ColumnId } from './tableColumnSchema'
import { COLUMN_DEF_MAP, isCustomColumnId, parseCustomFieldId } from './tableColumnSchema'
const METADATA_FIELD_MAP: Partial<Record<ColumnId, string>> = {
title: 'title',
@@ -36,24 +36,34 @@ async function getApiErrorMessage(response: Response): Promise<string> {
function getToastErrorMessage(err: unknown): string {
if (!(err instanceof Error) || !err.message) return 'Failed to save - change reverted'
if (err.message.startsWith('Metadata fields are locked:')) return err.message
if (err.message.includes('is not enabled for this book'))
return 'This field is not available for this book - enable it for the library in Settings first'
if (/^HTTP \d+$/.test(err.message)) return 'Failed to save - change reverted'
return err.message
}
function patchCustomMetadata(current: CustomMetadataBookValue[], fieldId: number, value: unknown): CustomMetadataBookValue[] {
const exists = current.some((f) => f.fieldId === fieldId)
if (exists) {
return current.map((f) => (f.fieldId === fieldId ? { ...f, value: value as CustomMetadataBookValue['value'] } : f))
}
return current
}
export function useTableCellEditor() {
const activeCellKey = ref<string | null>(null)
const editValue = ref<unknown>(null)
const isSaving = ref(false)
function cellKey(bookId: number, columnId: ColumnId): string {
function cellKey(bookId: number, columnId: string): string {
return `${bookId}:${columnId}`
}
function isActive(bookId: number, columnId: ColumnId): boolean {
function isActive(bookId: number, columnId: string): boolean {
return activeCellKey.value === cellKey(bookId, columnId)
}
function activateCell(bookId: number, columnId: ColumnId, currentValue: unknown): void {
function activateCell(bookId: number, columnId: string, currentValue: unknown): void {
if (isSaving.value) return
activeCellKey.value = cellKey(bookId, columnId)
editValue.value = Array.isArray(currentValue) ? [...(currentValue as unknown[])] : currentValue
@@ -64,13 +74,13 @@ export function useTableCellEditor() {
editValue.value = null
}
function cancelCellIfActive(bookId: number, columnId: ColumnId): boolean {
function cancelCellIfActive(bookId: number, columnId: string): boolean {
if (!isActive(bookId, columnId)) return false
cancelCell()
return true
}
async function saveCell(bookId: number, columnId: ColumnId, newValue: unknown, onSuccess: SaveCallback): Promise<void> {
async function saveCell(bookId: number, columnId: string, newValue: unknown, onSuccess: SaveCallback, currentBook?: BookCard): Promise<void> {
if (isSaving.value) return
isSaving.value = true
const sourceCellKey = cellKey(bookId, columnId)
@@ -91,6 +101,21 @@ export function useTableCellEditor() {
updatedReadStatus = { status, source: 'manual', startedAt: null, finishedAt: null, updatedAt: new Date().toISOString() }
}
onSuccess({ readStatus: updatedReadStatus })
} else if (isCustomColumnId(columnId)) {
const fieldId = parseCustomFieldId(columnId)
if (fieldId === null) {
toast.error(`Cannot save: invalid custom field ID in "${columnId}"`)
if (activeCellKey.value === sourceCellKey) cancelCellIfActive(bookId, columnId)
return
}
const res = await api(`/api/v1/books/${bookId}/metadata`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ customMetadata: [{ fieldId, value: newValue }] }),
})
if (!res.ok) throw new Error(await getApiErrorMessage(res as Response))
const existing = currentBook?.customMetadata ?? []
onSuccess({ customMetadata: patchCustomMetadata(existing, fieldId, newValue) })
} else {
if (columnId === 'narrators') {
const res = await api(`/api/v1/books/${bookId}/metadata`, {
@@ -103,7 +128,7 @@ export function useTableCellEditor() {
if (activeCellKey.value === sourceCellKey) cancelCellIfActive(bookId, columnId)
return
}
const metaKey = METADATA_FIELD_MAP[columnId]
const metaKey = METADATA_FIELD_MAP[columnId as ColumnId]
if (!metaKey) {
toast.error(`Cannot save: unsupported field "${columnId}"`)
if (activeCellKey.value === sourceCellKey) cancelCellIfActive(bookId, columnId)
@@ -126,7 +151,7 @@ export function useTableCellEditor() {
}
}
function navigateCell(direction: 'next' | 'prev', editableColumnIds: ColumnId[], book: BookCard, currentColumnId: ColumnId): void {
function navigateCell(direction: 'next' | 'prev', editableColumnIds: string[], book: BookCard, currentColumnId: string): void {
const idx = editableColumnIds.indexOf(currentColumnId)
if (idx === -1) return
const nextIdx = direction === 'next' ? idx + 1 : idx - 1
@@ -137,7 +162,7 @@ export function useTableCellEditor() {
activateCell(book.id, nextCol, targetValue)
}
function navigateRow(direction: 'up' | 'down', books: BookCard[], currentBookId: number, columnId: ColumnId): void {
function navigateRow(direction: 'up' | 'down', books: BookCard[], currentBookId: number, columnId: string): void {
const idx = books.findIndex((book) => book.id === currentBookId)
if (idx === -1) return
const nextIdx = direction === 'down' ? idx + 1 : idx - 1
@@ -148,8 +173,8 @@ export function useTableCellEditor() {
activateCell(nextBook.id, columnId, targetValue)
}
const navigateRowUp = (books: BookCard[], currentBookId: number, columnId: ColumnId) => navigateRow('up', books, currentBookId, columnId)
const navigateRowDown = (books: BookCard[], currentBookId: number, columnId: ColumnId) => navigateRow('down', books, currentBookId, columnId)
const navigateRowUp = (books: BookCard[], currentBookId: number, columnId: string) => navigateRow('up', books, currentBookId, columnId)
const navigateRowDown = (books: BookCard[], currentBookId: number, columnId: string) => navigateRow('down', books, currentBookId, columnId)
return {
activeCellKey,
@@ -1,15 +1,20 @@
import type { BookCard } from '@bookorbit/types'
import { isAudioFormat } from '@bookorbit/types'
import { COLUMN_DEF_MAP, type ColumnId } from './tableColumnSchema'
import { COLUMN_DEF_MAP, isCustomColumnId, parseCustomFieldId, type ColumnDef } from './tableColumnSchema'
import type { useTableLocks } from './useTableLocks'
export function useTableCellHelpers(locks: ReturnType<typeof useTableLocks>, isReadOnly: () => boolean) {
function getCellValue(book: BookCard, colId: ColumnId): unknown {
return COLUMN_DEF_MAP.get(colId)?.accessor?.(book) ?? null
export function useTableCellHelpers(
locks: ReturnType<typeof useTableLocks>,
isReadOnly: () => boolean,
columnMapGetter?: () => Map<string, ColumnDef>,
) {
function getCellValue(book: BookCard, colId: string): unknown {
const map = columnMapGetter ? columnMapGetter() : (COLUMN_DEF_MAP as Map<string, ColumnDef>)
return map.get(colId)?.accessor?.(book) ?? null
}
function isCellLocked(book: BookCard, colId: ColumnId): boolean {
const lockField = COLUMN_DEF_MAP.get(colId)?.lockField
function isCellLocked(book: BookCard, colId: string): boolean {
const lockField = (COLUMN_DEF_MAP as Map<string, ColumnDef>).get(colId)?.lockField
if (!lockField) return false
return locks.isLocked(book.id, lockField)
}
@@ -19,15 +24,22 @@ export function useTableCellHelpers(locks: ReturnType<typeof useTableLocks>, isR
return primaryFile?.format ? isAudioFormat(primaryFile.format) : false
}
function isCellReadOnly(book: BookCard, col: { id: ColumnId; isEditable: boolean }): boolean {
function isCellReadOnly(book: BookCard, col: { id: string; isEditable: boolean }): boolean {
if (!col.isEditable) return true
if (col.id === 'narrators' && !isBookAudio(book)) return true
if (isCellLocked(book, col.id)) return true
// Custom fields: block editing when the book has no entry for this field.
// An entry (even value=null) means the field is enabled for the book's library.
// No entry means the field is not enabled - editing would be rejected by the backend.
if (isCustomColumnId(col.id)) {
const fieldId = parseCustomFieldId(col.id)
if (fieldId === null || !book.customMetadata.some((f) => f.fieldId === fieldId)) return true
}
if (isReadOnly()) return true
return false
}
function isMandatoryFieldEmpty(book: BookCard, colId: ColumnId): boolean {
function isMandatoryFieldEmpty(book: BookCard, colId: string): boolean {
if (colId === 'title') return !book.title
if (colId === 'authors') return book.authors.length === 0
return false
@@ -37,7 +49,7 @@ export function useTableCellHelpers(locks: ReturnType<typeof useTableLocks>, isR
return book.status === 'missing'
}
function getPinnedCellBackground(book: BookCard, colId: ColumnId, isSelected: boolean): string {
function getPinnedCellBackground(book: BookCard, colId: string, isSelected: boolean): string {
const isMandatory = isMandatoryFieldEmpty(book, colId)
if (isSelected && isMandatory)
return 'color-mix(in oklch, color-mix(in oklch, oklch(0.769 0.188 70.08) 5%, var(--background)) 92%, var(--primary))'
@@ -1,7 +1,8 @@
import { computed, ref } from 'vue'
import { computed, ref, watch } from 'vue'
import type { Ref } from 'vue'
import { watchDebounced } from '@vueuse/core'
import { storage } from '@/services/storage'
import type { TableLayoutState, TableViewType } from '@bookorbit/types'
import type { CustomMetadataFieldSummary, TableLayoutState, TableViewType } from '@bookorbit/types'
import {
type ColumnId,
type CellType,
@@ -12,15 +13,17 @@ import {
DEFAULT_ORDER,
DEFAULT_HIDDEN,
DEFAULT_WIDTHS,
buildCustomColumnDef,
isCustomColumnId,
} from './tableColumnSchema'
export type { ColumnId, CellType, ColumnDef }
export { LOCK_ROW_COLUMN_DEF, COLUMN_DEFS, COLUMN_DEF_MAP, DEFAULT_ORDER, DEFAULT_HIDDEN, DEFAULT_WIDTHS }
const FIXED_WIDTH_COLUMN_IDS = new Set<ColumnId>(['cover', 'read'])
const FIXED_WIDTH_COLUMN_IDS = new Set<string>(['cover', 'read'])
function resolveColumnWidth(id: string, fallback: number, userWidths: Record<string, number>): number {
if (FIXED_WIDTH_COLUMN_IDS.has(id as ColumnId)) return fallback
if (FIXED_WIDTH_COLUMN_IDS.has(id)) return fallback
return userWidths[id] ?? fallback
}
@@ -28,11 +31,11 @@ function storageKey(viewType: TableViewType): string {
return `bookorbit:tableLayout:${viewType}`
}
function loadPinnedColumns(raw: unknown, knownIds: Set<ColumnId>): Record<string, 'left' | 'right' | null> {
function loadPinnedColumns(raw: unknown, knownIds: Set<string>): Record<string, 'left' | 'right' | null> {
if (!raw || typeof raw !== 'object') return {}
const result: Record<string, 'left' | 'right' | null> = {}
for (const [id, dir] of Object.entries(raw as Record<string, unknown>)) {
if (knownIds.has(id as ColumnId) && (dir === 'left' || dir === 'right' || dir === null)) {
if (knownIds.has(id) && (dir === 'left' || dir === 'right' || dir === null)) {
result[id] = dir
}
}
@@ -56,24 +59,85 @@ function loadLayout(viewType: TableViewType): TableLayoutState {
if (!raw || !Array.isArray(raw.columnOrder) || !Array.isArray(raw.hiddenColumns)) {
return { columnOrder: [...DEFAULT_ORDER], hiddenColumns: [...DEFAULT_HIDDEN], columnWidths: { ...DEFAULT_WIDTHS } }
}
const knownIds = new Set(DEFAULT_ORDER)
const order = raw.columnOrder.filter((id) => knownIds.has(id as ColumnId)) as ColumnId[]
const staticIds = new Set(DEFAULT_ORDER)
// Keep static known IDs and any custom:N IDs (validated against active fields later)
const order = (raw.columnOrder as unknown[]).filter((id): id is string => typeof id === 'string' && (staticIds.has(id) || isCustomColumnId(id)))
const missing = DEFAULT_ORDER.filter((id) => !order.includes(id))
const finalOrder = [...order, ...missing]
const hidden = raw.hiddenColumns.filter((id) => knownIds.has(id as ColumnId)) as ColumnId[]
const hidden = (raw.hiddenColumns as unknown[]).filter(
(id): id is string => typeof id === 'string' && (staticIds.has(id) || isCustomColumnId(id)),
)
const widths: Record<string, number> = { ...DEFAULT_WIDTHS }
for (const [id, w] of Object.entries(raw.columnWidths ?? {})) {
if (knownIds.has(id as ColumnId) && typeof w === 'number' && w > 0) widths[id] = w
if ((staticIds.has(id) || isCustomColumnId(id)) && typeof w === 'number' && w > 0) widths[id] = w
}
return {
columnOrder: finalOrder,
hiddenColumns: hidden,
columnWidths: widths,
pinnedColumns: loadPinnedColumns(raw.pinnedColumns, new Set(finalOrder)),
}
return { columnOrder: finalOrder, hiddenColumns: hidden, columnWidths: widths, pinnedColumns: loadPinnedColumns(raw.pinnedColumns, knownIds) }
} catch {
return { columnOrder: [...DEFAULT_ORDER], hiddenColumns: [...DEFAULT_HIDDEN], columnWidths: { ...DEFAULT_WIDTHS }, pinnedColumns: {} }
}
}
export function useTableColumns(viewType: TableViewType) {
export function useTableColumns(viewType: TableViewType, customFields?: Ref<CustomMetadataFieldSummary[]>) {
const layout = ref<TableLayoutState>(loadLayout(viewType))
const customColumnDefs = computed<ColumnDef[]>(() => {
if (!customFields?.value?.length) return []
return customFields.value
.filter((f) => !f.archivedAt)
.sort((a, b) => a.displayOrder - b.displayOrder || a.label.localeCompare(b.label))
.map(buildCustomColumnDef)
})
const combinedColumnMap = computed<Map<string, ColumnDef>>(() => {
const map = new Map<string, ColumnDef>(COLUMN_DEF_MAP)
for (const def of customColumnDefs.value) {
map.set(def.id, def)
}
return map
})
// Reconcile layout when custom fields change: add new fields hidden, remove stale custom IDs.
// Uses { immediate: true } so custom columns are added on mount when fields are pre-loaded,
// but skips cleanup when newDefs is empty (fields may still be loading - don't clobber storage).
watch(
customColumnDefs,
(newDefs, oldDefs) => {
const activeCustomIds = new Set(newDefs.map((d) => d.id))
const isInitialRun = oldDefs === undefined
let staleIds: string[]
if (isInitialRun) {
// On immediate run: only clean up orphaned IDs if we actually have field data.
// If the field list is still empty, fields are still loading - preserve stored layout as-is.
staleIds = newDefs.length > 0 ? layout.value.columnOrder.filter((id) => isCustomColumnId(id) && !activeCustomIds.has(id)) : []
} else {
// Subsequent changes: only remove IDs that were previously active but are now gone.
const previousCustomIds = new Set(oldDefs.map((d) => d.id))
staleIds = layout.value.columnOrder.filter((id) => isCustomColumnId(id) && previousCustomIds.has(id) && !activeCustomIds.has(id))
}
const newIds = [...activeCustomIds].filter((id) => !layout.value.columnOrder.includes(id))
if (staleIds.length === 0 && newIds.length === 0) return
const staleSet = new Set(staleIds)
const pinnedColumns = layout.value.pinnedColumns ?? {}
layout.value = {
...layout.value,
columnOrder: [...layout.value.columnOrder.filter((id) => !staleSet.has(id)), ...newIds],
hiddenColumns: [...layout.value.hiddenColumns.filter((id) => !staleSet.has(id)), ...newIds],
columnWidths: Object.fromEntries(Object.entries(layout.value.columnWidths).filter(([id]) => !staleSet.has(id))),
pinnedColumns: Object.fromEntries(Object.entries(pinnedColumns).filter(([id]) => !staleSet.has(id))),
}
},
{ immediate: true },
)
watchDebounced(layout, (v) => storage.set(storageKey(viewType), v), { deep: true, debounce: 500 })
const visibleColumns = computed<ColumnDef[]>(() => {
@@ -82,25 +146,30 @@ export function useTableColumns(viewType: TableViewType) {
return layout.value.columnOrder
.filter((id) => !hiddenSet.has(id))
.map((id) => {
const def = COLUMN_DEF_MAP.get(id as ColumnId)!
const def = combinedColumnMap.value.get(id)
if (!def) return null
return {
...def,
defaultWidth: resolveColumnWidth(id, def.defaultWidth, layout.value.columnWidths),
pinned: resolvePinnedColumn(userPins, id, def.pinned),
}
})
.filter((def): def is ColumnDef => def !== null)
})
const allColumns = computed<(ColumnDef & { visible: boolean })[]>(() => {
const hiddenSet = new Set(layout.value.hiddenColumns)
const userPins = layout.value.pinnedColumns ?? {}
return layout.value.columnOrder.map((id) => {
const def = COLUMN_DEF_MAP.get(id as ColumnId)!
return { ...def, visible: !hiddenSet.has(id), pinned: resolvePinnedColumn(userPins, id, def.pinned) }
})
return layout.value.columnOrder
.map((id) => {
const def = combinedColumnMap.value.get(id)
if (!def) return null
return { ...def, visible: !hiddenSet.has(id), pinned: resolvePinnedColumn(userPins, id, def.pinned) }
})
.filter((def): def is ColumnDef & { visible: boolean } => def !== null)
})
function toggleColumn(id: ColumnId): void {
function toggleColumn(id: string): void {
const hidden = new Set(layout.value.hiddenColumns)
if (hidden.has(id)) {
hidden.delete(id)
@@ -110,16 +179,17 @@ export function useTableColumns(viewType: TableViewType) {
layout.value = { ...layout.value, hiddenColumns: [...hidden] }
}
function setColumnOrder(order: ColumnId[]): void {
const seen = new Set<ColumnId>()
const deduped = order.filter((id) => COLUMN_DEF_MAP.has(id) && !seen.has(id) && seen.add(id) !== undefined)
const missing = DEFAULT_ORDER.filter((id) => !seen.has(id))
function setColumnOrder(order: string[]): void {
const seen = new Set<string>()
const allKnown = combinedColumnMap.value
const deduped = order.filter((id) => allKnown.has(id) && !seen.has(id) && seen.add(id) !== undefined)
const missing = [...allKnown.keys()].filter((id) => !seen.has(id))
layout.value = { ...layout.value, columnOrder: [...deduped, ...missing] }
}
function setColumnWidth(id: ColumnId, px: number): void {
function setColumnWidth(id: string, px: number): void {
if (FIXED_WIDTH_COLUMN_IDS.has(id)) return
const def = COLUMN_DEF_MAP.get(id)
const def = combinedColumnMap.value.get(id)
const min = def?.minWidth ?? 40
const max = 800
const clamped = Math.min(Math.max(px, min), max)
@@ -130,20 +200,20 @@ export function useTableColumns(viewType: TableViewType) {
}
function setLayout(nextLayout: TableLayoutState): void {
const knownIds = new Set(DEFAULT_ORDER)
const order = nextLayout.columnOrder.filter((id) => knownIds.has(id as ColumnId)) as ColumnId[]
const missing = DEFAULT_ORDER.filter((id) => !order.includes(id))
const hidden = nextLayout.hiddenColumns.filter((id) => knownIds.has(id as ColumnId)) as ColumnId[]
const allKnown = combinedColumnMap.value
const order = nextLayout.columnOrder.filter((id) => allKnown.has(id))
const missing = [...allKnown.keys()].filter((id) => !order.includes(id))
const hidden = nextLayout.hiddenColumns.filter((id) => allKnown.has(id))
const widths: Record<string, number> = { ...DEFAULT_WIDTHS }
for (const [id, width] of Object.entries(nextLayout.columnWidths ?? {})) {
if (knownIds.has(id as ColumnId) && typeof width === 'number' && width > 0) {
if (allKnown.has(id) && typeof width === 'number' && width > 0) {
widths[id] = width
}
}
const pins: Record<string, 'left' | 'right' | null> = {}
if (nextLayout.pinnedColumns) {
for (const [id, dir] of Object.entries(nextLayout.pinnedColumns)) {
if (knownIds.has(id as ColumnId) && (dir === 'left' || dir === 'right' || dir === null)) {
if (allKnown.has(id) && (dir === 'left' || dir === 'right' || dir === null)) {
pins[id] = dir
}
}
@@ -156,7 +226,7 @@ export function useTableColumns(viewType: TableViewType) {
}
}
function pinColumn(id: ColumnId, side: 'left' | 'right'): void {
function pinColumn(id: string, side: 'left' | 'right'): void {
const currentPins = { ...layout.value.pinnedColumns }
if (currentPins[id] === side) return
const MAX_PINNED = 3
@@ -166,7 +236,7 @@ export function useTableColumns(viewType: TableViewType) {
layout.value = { ...layout.value, pinnedColumns: currentPins }
}
function unpinColumn(id: ColumnId): void {
function unpinColumn(id: string): void {
const currentPins = { ...layout.value.pinnedColumns }
currentPins[id] = null
layout.value = { ...layout.value, pinnedColumns: currentPins }
@@ -174,8 +244,27 @@ export function useTableColumns(viewType: TableViewType) {
function resetLayout(): void {
storage.remove(storageKey(viewType))
layout.value = { columnOrder: [...DEFAULT_ORDER], hiddenColumns: [...DEFAULT_HIDDEN], columnWidths: { ...DEFAULT_WIDTHS }, pinnedColumns: {} }
// Keep active custom columns in the picker but reset them to hidden.
const customIds = customColumnDefs.value.map((d) => d.id)
layout.value = {
columnOrder: [...DEFAULT_ORDER, ...customIds],
hiddenColumns: [...DEFAULT_HIDDEN, ...customIds],
columnWidths: { ...DEFAULT_WIDTHS },
pinnedColumns: {},
}
}
return { layout, visibleColumns, allColumns, toggleColumn, setColumnOrder, setColumnWidth, setLayout, pinColumn, unpinColumn, resetLayout }
return {
layout,
combinedColumnMap,
visibleColumns,
allColumns,
toggleColumn,
setColumnOrder,
setColumnWidth,
setLayout,
pinColumn,
unpinColumn,
resetLayout,
}
}
@@ -1,16 +1,16 @@
import { ref, type Ref } from 'vue'
import type { ColumnId, ColumnDef } from './useTableColumns'
import type { ColumnDef } from './useTableColumns'
export function useTableDragReorder(allColumns: Ref<ColumnDef[]>, setColumnOrder: (order: ColumnId[]) => void) {
const dragSourceColId = ref<ColumnId | null>(null)
const dropTargetColId = ref<ColumnId | null>(null)
export function useTableDragReorder(allColumns: Ref<ColumnDef[]>, setColumnOrder: (order: string[]) => void) {
const dragSourceColId = ref<string | null>(null)
const dropTargetColId = ref<string | null>(null)
const dropSide = ref<'before' | 'after' | null>(null)
function isDraggableCol(col: ColumnDef): boolean {
return col.pinned === null
}
function handleColDragStart(e: DragEvent, colId: ColumnId) {
function handleColDragStart(e: DragEvent, colId: string) {
dragSourceColId.value = colId
if (e.dataTransfer) {
e.dataTransfer.effectAllowed = 'move'
@@ -29,14 +29,14 @@ export function useTableDragReorder(allColumns: Ref<ColumnDef[]>, setColumnOrder
dropSide.value = e.clientX < midX ? 'before' : 'after'
}
function handleColDragLeave(colId: ColumnId) {
function handleColDragLeave(colId: string) {
if (dropTargetColId.value === colId) {
dropTargetColId.value = null
dropSide.value = null
}
}
function handleColDrop(e: DragEvent, targetColId: ColumnId) {
function handleColDrop(e: DragEvent, targetColId: string) {
e.preventDefault()
const sourceId = dragSourceColId.value
if (!sourceId || sourceId === targetColId) {
@@ -1,11 +1,11 @@
import { ref, type Ref } from 'vue'
import type { BookCard } from '@bookorbit/types'
import { copyToClipboard } from '@/lib/clipboard'
import type { ColumnDef, ColumnId } from './useTableColumns'
import type { ColumnDef } from './useTableColumns'
import { COLUMN_DEF_MAP } from './useTableColumns'
function formatCellForClipboard(book: BookCard, colId: ColumnId): string {
const def = COLUMN_DEF_MAP.get(colId)
function formatCellForClipboard(book: BookCard, colId: string, columnMap: Map<string, ColumnDef>): string {
const def = columnMap.get(colId)
if (!def?.accessor) return ''
const value = def.accessor(book)
if (value == null) return ''
@@ -16,9 +16,9 @@ function formatCellForClipboard(book: BookCard, colId: ColumnId): string {
return String(value)
}
function formatRowForClipboard(book: BookCard, columns: ColumnDef[]): string {
function formatRowForClipboard(book: BookCard, columns: ColumnDef[], columnMap: Map<string, ColumnDef>): string {
return columns
.map((column) => `${column.header ?? column.id}: ${formatCellForClipboard(book, column.id)}`)
.map((column) => `${column.header ?? column.id}: ${formatCellForClipboard(book, column.id, columnMap)}`)
.filter((line) => line.trim().length > 0)
.join('\n')
}
@@ -30,10 +30,11 @@ export function useTableKeyboard(opts: {
selectionMode: () => boolean
isReadOnly: () => boolean
virtualizer: Ref<{ scrollToIndex: (index: number, opts?: Record<string, unknown>) => void }>
isCellReadOnly: (book: BookCard, col: { id: ColumnId; isEditable: boolean }) => boolean
onActivate: (book: BookCard, colId: ColumnId) => void
isCellReadOnly: (book: BookCard, col: { id: string; isEditable: boolean }) => boolean
onActivate: (book: BookCard, colId: string) => void
onSelect: (id: number, event: MouseEvent) => void
onCopyRow?: (book: BookCard) => void
columnMapGetter?: () => Map<string, ColumnDef>
}) {
const focusedRowIndex = ref<number | null>(null)
const focusedColIndex = ref<number | null>(null)
@@ -170,8 +171,10 @@ export function useTableKeyboard(opts: {
const book = books[target.rowIndex]
if (!book) return
const columnMap = opts.columnMapGetter ? opts.columnMapGetter() : (COLUMN_DEF_MAP as Map<string, ColumnDef>)
if (event.shiftKey) {
const text = formatRowForClipboard(book, columns)
const text = formatRowForClipboard(book, columns, columnMap)
void copyToClipboard(text)
opts.onCopyRow?.(book)
return
@@ -179,7 +182,7 @@ export function useTableKeyboard(opts: {
const col = columns[target.colIndex]
if (col) {
const text = formatCellForClipboard(book, col.id)
const text = formatCellForClipboard(book, col.id, columnMap)
void copyToClipboard(text)
}
}
@@ -2,7 +2,6 @@ import { computed, ref } from 'vue'
import { watchDebounced } from '@vueuse/core'
import { storage } from '@/services/storage'
import { cloneTableLayout, validateTableLayout, type SortSpec, type TableLayoutState, type TableViewType } from '@bookorbit/types'
import type { ColumnId } from './useTableColumns'
/**
* Column presets: reusable column layouts only.
@@ -31,7 +30,7 @@ function presetsStorageKey(viewType: TableViewType): string {
return `bookorbit:tablePresets:${viewType}`
}
function getBuiltInPresets(allColumnIds: ColumnId[]): TablePreset[] {
function getBuiltInPresets(allColumnIds: string[]): TablePreset[] {
const defaultVisible = ['cover', 'title', 'authors', 'seriesName', 'seriesIndex', 'publishedYear', 'rating', 'readStatus', 'format', 'actions']
const compactVisible = ['cover', 'title', 'authors', 'rating', 'readStatus', 'actions']
const metadataVisible = ['cover', 'title', 'authors', 'subtitle', 'publisher', 'language', 'publishedYear', 'pageCount', 'format', 'actions']
@@ -64,7 +63,7 @@ function getBuiltInPresets(allColumnIds: ColumnId[]): TablePreset[] {
]
}
function isValidPreset(p: unknown, knownIds: ColumnId[]): p is TablePreset {
function isValidPreset(p: unknown, knownIds: string[]): p is TablePreset {
if (typeof p !== 'object' || p === null) return false
const obj = p as Record<string, unknown>
if (typeof obj.id !== 'string' || typeof obj.name !== 'string' || validateTableLayout(obj.layout, knownIds) === null) return false
@@ -80,13 +79,13 @@ function isValidSort(value: unknown): value is SortSpec[] {
)
}
function loadCustomPresets(viewType: TableViewType, knownIds: ColumnId[]): TablePreset[] {
function loadCustomPresets(viewType: TableViewType, knownIds: string[]): TablePreset[] {
const raw = storage.get<unknown[]>(presetsStorageKey(viewType), [])
if (!Array.isArray(raw)) return []
return raw.filter((preset): preset is TablePreset => isValidPreset(preset, knownIds))
}
export function useTablePresets(viewType: TableViewType, allColumnIds: ColumnId[]) {
export function useTablePresets(viewType: TableViewType, allColumnIds: string[]) {
const customPresets = ref<TablePreset[]>(loadCustomPresets(viewType, allColumnIds))
watchDebounced(customPresets, (value) => storage.set(presetsStorageKey(viewType), value), { deep: true, debounce: 500 })
@@ -1,7 +1,6 @@
import type { Rule, RuleField, TableViewType } from '@bookorbit/types'
import type { ColumnId } from './tableColumnSchema'
const TEXT_FIELD_MAP: Partial<Record<ColumnId, RuleField>> = {
const TEXT_FIELD_MAP: Partial<Record<string, RuleField>> = {
title: 'title',
seriesName: 'series',
publisher: 'publisher',
@@ -10,7 +9,7 @@ const TEXT_FIELD_MAP: Partial<Record<ColumnId, RuleField>> = {
subtitle: 'description',
}
const VALUE_FIELD_MAP: Partial<Record<ColumnId, RuleField>> = {
const VALUE_FIELD_MAP: Partial<Record<string, RuleField>> = {
authors: 'author',
genres: 'genre',
tags: 'tag',
@@ -22,7 +21,7 @@ const VALUE_FIELD_MAP: Partial<Record<ColumnId, RuleField>> = {
}
export function useTableQuickFilters(viewType: TableViewType) {
function getQuickFilterOptions(colId: ColumnId): { key: string; label: string }[] {
function getQuickFilterOptions(colId: string): { key: string; label: string }[] {
if (viewType !== 'library') return []
if (colId === 'format') {
@@ -66,7 +65,7 @@ export function useTableQuickFilters(viewType: TableViewType) {
return []
}
function buildQuickFilterRule(colId: ColumnId, key: string): Rule | null {
function buildQuickFilterRule(colId: string, key: string): Rule | null {
if (colId === 'format') return { type: 'rule', field: 'fileAvailability', operator: key === 'missing' ? 'isMissing' : 'isPresent' }
if (colId === 'cover') return { type: 'rule', field: 'cover', operator: key === 'missing' ? 'isMissing' : 'isPresent' }
@@ -1,16 +1,15 @@
import { ref, onMounted, onUnmounted, type Ref } from 'vue'
import type { ColumnId } from './useTableColumns'
import type { ColumnDef } from './useTableColumns'
const NON_RESIZABLE_COLUMNS = new Set<ColumnId>(['lockRow', 'cover', 'read'])
const NON_RESIZABLE_COLUMNS = new Set<string>(['lockRow', 'cover', 'read'])
export function useTableResize(
scrollContainerRef: Ref<HTMLDivElement | null>,
displayColumns: Ref<ColumnDef[]>,
setColumnWidth: (id: ColumnId, px: number) => void,
setColumnWidth: (id: string, px: number) => void,
isReadOnly: Ref<boolean>,
) {
const resizingColumnId = ref<ColumnId | null>(null)
const resizingColumnId = ref<string | null>(null)
const resizeStartX = ref(0)
const resizeStartWidth = ref(0)
@@ -18,7 +17,7 @@ export function useTableResize(
return !isReadOnly.value && !NON_RESIZABLE_COLUMNS.has(col.id)
}
function startResize(e: MouseEvent, colId: ColumnId, currentWidth: number) {
function startResize(e: MouseEvent, colId: string, currentWidth: number) {
if (isReadOnly.value) return
if (NON_RESIZABLE_COLUMNS.has(colId)) return
resizingColumnId.value = colId
@@ -37,7 +36,7 @@ export function useTableResize(
resizingColumnId.value = null
}
function autoFitColumn(colId: ColumnId): void {
function autoFitColumn(colId: string): void {
if (NON_RESIZABLE_COLUMNS.has(colId)) return
if (!scrollContainerRef.value) return
const table = scrollContainerRef.value.querySelector('table')
@@ -1,6 +1,6 @@
import { ref } from 'vue'
import type { SortSpec, TableLayoutState } from '@bookorbit/types'
import type { ColumnDef, ColumnId } from './useTableColumns'
import type { ColumnDef } from './useTableColumns'
import type { TablePreset } from './useTablePresets'
export type TableViewHandle = {
@@ -8,8 +8,8 @@ export type TableViewHandle = {
currentLayout: TableLayoutState
allPresets: TablePreset[]
resetLayout: () => void
toggleColumn: (id: ColumnId) => void
setColumnOrder: (ids: ColumnId[]) => void
toggleColumn: (id: string) => void
setColumnOrder: (ids: string[]) => void
applyPreset: (layout: TableLayoutState, sort?: SortSpec[]) => void
saveCurrentPreset: (name: string) => void
deletePreset: (id: string) => void
@@ -27,11 +27,11 @@ export function useTableViewControls() {
tableRef.value?.resetLayout()
}
function handleToggleColumn(id: ColumnId) {
function handleToggleColumn(id: string) {
tableRef.value?.toggleColumn(id)
}
function handleSetColumnOrder(ids: ColumnId[]) {
function handleSetColumnOrder(ids: string[]) {
tableRef.value?.setColumnOrder(ids)
}
@@ -11,6 +11,7 @@ function makeBook(overrides: Partial<BookCard> = {}): BookCard {
genres: [],
files: [],
narrators: [],
customMetadata: [],
seriesName: null,
seriesIndex: null,
seriesMemberships: [],
@@ -29,6 +29,7 @@ function makeBook(overrides: Partial<BookCard> = {}): BookCard {
pageCount: null,
isbn13: null,
narrators: [],
customMetadata: [],
...overrides,
}
}
@@ -103,6 +103,7 @@ function makeBook(id: number, format: string): BookCard {
pageCount: null,
isbn13: null,
narrators: [],
customMetadata: [],
tags: [],
}
}
@@ -30,6 +30,7 @@ function makeBook(format: string | null | undefined, overrides: Partial<BookCard
pageCount: null,
isbn13: null,
narrators: [],
customMetadata: [],
...overrides,
}
}
@@ -131,6 +131,7 @@ function makeBook(overrides: Partial<BookCard> = {}): BookCard {
pageCount: null,
isbn13: null,
narrators: [],
customMetadata: [],
...overrides,
}
}
@@ -2,6 +2,7 @@ import { computed, ref } from 'vue'
import { toast } from 'vue-sonner'
import type { CustomMetadataFieldDefinition, CustomMetadataFieldType } from '@bookorbit/types'
import { api } from '@/lib/api'
import { useActiveCustomFields } from '@/features/book/composables/useActiveCustomFields'
type CreateFieldPayload = {
label: string
@@ -15,6 +16,7 @@ type UpdateFieldPayload = {
}
export function useCustomMetadataFields() {
const { refresh: refreshActiveFields } = useActiveCustomFields()
const fields = ref<CustomMetadataFieldDefinition[]>([])
const loading = ref(false)
const creating = ref(false)
@@ -57,6 +59,7 @@ export function useCustomMetadataFields() {
const created = (await res.json()) as CustomMetadataFieldDefinition
fields.value.push(created)
toast.success('Custom metadata field created')
void refreshActiveFields()
return created
} catch {
toast.error('Failed to create custom metadata field')
@@ -78,6 +81,7 @@ export function useCustomMetadataFields() {
const updated = (await res.json()) as CustomMetadataFieldDefinition
fields.value = fields.value.map((item) => (item.id === updated.id ? updated : item))
toast.success('Custom metadata field saved')
void refreshActiveFields()
return updated
} catch {
toast.error('Failed to save custom metadata field')
@@ -95,6 +99,7 @@ export function useCustomMetadataFields() {
const now = new Date().toISOString()
fields.value = fields.value.map((item) => (item.id === fieldId ? { ...item, archivedAt: now } : item))
toast.success('Custom metadata field archived')
void refreshActiveFields()
return true
} catch {
toast.error('Failed to archive custom metadata field')
@@ -112,6 +117,7 @@ export function useCustomMetadataFields() {
const restored = fields.value.find((item) => item.id === fieldId)
fields.value = fields.value.map((item) => (item.id === fieldId ? { ...item, archivedAt: null } : item))
toast.success('Custom metadata field restored')
void refreshActiveFields()
return restored ? { ...restored, archivedAt: null } : null
} catch {
toast.error('Failed to restore custom metadata field')
@@ -128,6 +134,7 @@ export function useCustomMetadataFields() {
if (!res.ok) throw new Error(`HTTP ${res.status}`)
fields.value = fields.value.filter((item) => item.id !== fieldId)
toast.success('Custom metadata field permanently deleted')
void refreshActiveFields()
return true
} catch {
toast.error('Failed to permanently delete custom metadata field')
@@ -149,6 +156,7 @@ export function useCustomMetadataFields() {
const reordered = (await res.json()) as CustomMetadataFieldDefinition[]
const archived = fields.value.filter((item) => item.archivedAt)
fields.value = [...reordered, ...archived]
void refreshActiveFields()
return true
} catch {
toast.error('Failed to reorder custom metadata fields')
+1
View File
@@ -929,6 +929,7 @@ defineOptions({ name: 'HomeView' })
:loading="loading"
:total="total"
view-type="library"
:library-id="libraryId ?? undefined"
:selection-mode="selectionMode"
:is-selected="isSelected"
:selected-count="selectedCount"
+1
View File
@@ -74,6 +74,7 @@ export type BookCard = {
hardcoverEditionId?: string | null;
narrators: string[];
tags: string[];
customMetadata: CustomMetadataBookValue[];
collapsedSeries?: import("./series-collapse").CollapsedSeriesInfo;
};
+10
View File
@@ -17,6 +17,16 @@ export interface CustomMetadataFieldDefinition {
usageCount: number;
}
/** Lean summary used for table column building. Omits admin-only attributes (usageCount, timestamps). */
export interface CustomMetadataFieldSummary {
id: number;
label: string;
type: CustomMetadataFieldType;
displayOrder: number;
archivedAt: string | null;
enabledLibraryIds: number[];
}
export interface CustomMetadataLibraryEnablement {
fieldId: number;
libraryId: number;
@@ -203,6 +203,7 @@ function makeService(overrides: { bookMetadataLockService?: unknown } = {}) {
};
const customMetadataService = {
getBookValues: vi.fn().mockResolvedValue([]),
getCardValues: vi.fn().mockResolvedValue([]),
getExportValues: vi.fn().mockResolvedValue(new Map()),
parseFileValuesForBook: vi.fn().mockResolvedValue([]),
updateBookValues: vi.fn().mockResolvedValue(undefined),
+15 -1
View File
@@ -1024,6 +1024,7 @@ export class BookService {
offset: page * size,
userId,
});
// Collapsed rows render BookTableCollapsedSeriesCell which does not display custom metadata.
const result = {
items: assembleCollapsedBookCards(
rows,
@@ -1058,8 +1059,21 @@ export class BookService {
offset: page * size,
userId,
});
const bookIds = rows.map((r) => r.id);
const customMetadataRows = await this.customMetadataService.getCardValues(bookIds);
const result = {
items: assembleBookCards(rows, authorRows, fileRows, genreRows, progressRows, statusRows, narratorRows, tagRows, seriesMembershipRows),
items: assembleBookCards(
rows,
authorRows,
fileRows,
genreRows,
progressRows,
statusRows,
narratorRows,
tagRows,
seriesMembershipRows,
customMetadataRows,
),
total,
page,
size,
@@ -1,6 +1,6 @@
import { basename } from 'path';
import type { BookCard, BookMetadataLockField, CollapsedSeriesInfo, UserBookStatus } from '@bookorbit/types';
import type { BookCard, BookMetadataLockField, CollapsedSeriesInfo, CustomMetadataBookValue, UserBookStatus } from '@bookorbit/types';
import { BOOK_METADATA_LOCK_FIELDS } from '@bookorbit/types';
const LOCK_FIELD_SET = new Set<string>(BOOK_METADATA_LOCK_FIELDS);
@@ -59,6 +59,15 @@ type StatusRow = {
finishedAt: Date | null;
updatedAt: Date;
};
type CustomMetadataRow = {
bookId: number;
fieldId: number;
key: string;
label: string;
type: string;
displayOrder: number;
value: string | number | boolean | null;
};
function serializeDateByBookId(values: Record<number, Date | null> | null | undefined): Record<number, string | null> {
const result: Record<number, string | null> = {};
@@ -78,6 +87,7 @@ export function assembleBookCards(
narratorRows: NarratorRow[] = [],
tagRows: NameRow[] = [],
seriesMembershipRows: SeriesMembershipRow[] = [],
customMetadataRows: CustomMetadataRow[] = [],
): BookCard[] {
const authorsByBook = new Map<number, string[]>();
for (const row of authorRows) {
@@ -137,6 +147,20 @@ export function assembleBookCards(
seriesMembershipsByBook.set(row.bookId, list);
}
const customMetadataByBook = new Map<number, CustomMetadataBookValue[]>();
for (const row of customMetadataRows) {
const list = customMetadataByBook.get(row.bookId) ?? [];
list.push({
fieldId: row.fieldId,
key: row.key,
label: row.label,
type: row.type as CustomMetadataBookValue['type'],
displayOrder: row.displayOrder,
value: row.value,
});
customMetadataByBook.set(row.bookId, list);
}
return rows.map((row) => {
const rawFiles = filesByBook.get(row.id) ?? [];
const primaryFile =
@@ -191,6 +215,7 @@ export function assembleBookCards(
hardcoverEditionId: row.hardcoverEditionId ?? null,
narrators: narratorsByBook.get(row.id) ?? [],
tags: tagsByBook.get(row.id) ?? [],
customMetadata: customMetadataByBook.get(row.id) ?? [],
};
});
}
@@ -205,8 +230,20 @@ export function assembleCollapsedBookCards(
narratorRows: NarratorRow[] = [],
tagRows: NameRow[] = [],
seriesMembershipRows: SeriesMembershipRow[] = [],
customMetadataRows: CustomMetadataRow[] = [],
): BookCard[] {
const base = assembleBookCards(rows, authorRows, fileRows, genreRows, progressRows, statusRows, narratorRows, tagRows, seriesMembershipRows);
const base = assembleBookCards(
rows,
authorRows,
fileRows,
genreRows,
progressRows,
statusRows,
narratorRows,
tagRows,
seriesMembershipRows,
customMetadataRows,
);
for (let i = 0; i < base.length; i++) {
const row = rows[i];
@@ -25,6 +25,11 @@ import { CustomMetadataService } from './custom-metadata.service';
export class CustomMetadataController {
constructor(private readonly service: CustomMetadataService) {}
@Get('fields/active')
listActiveFields() {
return this.service.listFieldSummaries();
}
@Get('fields')
@RequirePermission(Permission.ManageLibraries)
listFields(@Query('includeArchived', new DefaultValuePipe(false), ParseBoolPipe) includeArchived: boolean) {
@@ -126,6 +126,35 @@ export class CustomMetadataRepository {
.orderBy(asc(customMetadataFields.displayOrder), asc(customMetadataFields.label), asc(customMetadataFields.id));
}
findCardValuesForBooks(bookIds: number[]) {
if (bookIds.length === 0) return Promise.resolve([]);
// Drive from books so every enabled field gets a row, even if no value is stored yet.
// This lets the frontend distinguish "enabled, no value" (entry with value=null) from
// "not enabled for this library" (no entry at all).
return this.db
.select({
bookId: books.id,
fieldId: customMetadataFields.id,
key: customMetadataFields.key,
label: customMetadataFields.label,
type: customMetadataFields.type,
displayOrder: customMetadataLibraryFields.displayOrder,
valueText: bookCustomMetadataValues.valueText,
valueNumber: bookCustomMetadataValues.valueNumber,
valueDate: bookCustomMetadataValues.valueDate,
valueBoolean: bookCustomMetadataValues.valueBoolean,
})
.from(books)
.innerJoin(customMetadataLibraryFields, eq(customMetadataLibraryFields.libraryId, books.libraryId))
.innerJoin(customMetadataFields, and(eq(customMetadataFields.id, customMetadataLibraryFields.fieldId), isNull(customMetadataFields.archivedAt)))
.leftJoin(
bookCustomMetadataValues,
and(eq(bookCustomMetadataValues.bookId, books.id), eq(bookCustomMetadataValues.fieldId, customMetadataFields.id)),
)
.where(inArray(books.id, bookIds))
.orderBy(asc(customMetadataLibraryFields.displayOrder), asc(customMetadataFields.label), asc(customMetadataFields.id));
}
async findBookLibraryId(bookId: number) {
const [row] = await this.db.select({ libraryId: books.libraryId }).from(books).where(eq(books.id, bookId)).limit(1);
return row?.libraryId ?? null;
@@ -1,5 +1,11 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import type { CustomMetadataBookValue, CustomMetadataFieldDefinition, CustomMetadataFieldType, CustomMetadataPrimitiveValue } from '@bookorbit/types';
import type {
CustomMetadataBookValue,
CustomMetadataFieldDefinition,
CustomMetadataFieldSummary,
CustomMetadataFieldType,
CustomMetadataPrimitiveValue,
} from '@bookorbit/types';
import type { CustomMetadataField } from '../../db/schema';
import { CreateCustomMetadataFieldDto } from './dto/create-custom-metadata-field.dto';
@@ -34,6 +40,24 @@ export class CustomMetadataService {
return fields.map((field) => this.toDefinition(field, enabledByFieldId.get(field.id) ?? [], usageByFieldId.get(field.id) ?? 0));
}
async listFieldSummaries(): Promise<CustomMetadataFieldSummary[]> {
const [fields, enablements] = await Promise.all([this.repository.listFields(false), this.repository.listEnablements()]);
const enabledByFieldId = new Map<number, number[]>();
for (const enablement of enablements) {
const current = enabledByFieldId.get(enablement.fieldId) ?? [];
current.push(enablement.libraryId);
enabledByFieldId.set(enablement.fieldId, current);
}
return fields.map((field) => ({
id: field.id,
label: field.label,
type: field.type,
displayOrder: field.displayOrder,
archivedAt: field.archivedAt?.toISOString() ?? null,
enabledLibraryIds: enabledByFieldId.get(field.id) ?? [],
}));
}
async createField(dto: CreateCustomMetadataFieldDto): Promise<CustomMetadataFieldDefinition> {
const label = normalizeLabel(dto.label);
const libraryIds = await this.normalizeLibraryIds(dto.enabledLibraryIds ?? []);
@@ -127,6 +151,20 @@ export class CustomMetadataService {
});
}
async getCardValues(bookIds: number[]): Promise<Array<CustomMetadataBookValue & { bookId: number }>> {
if (bookIds.length === 0) return [];
const rows = await this.repository.findCardValuesForBooks(bookIds);
return rows.map((row) => ({
bookId: row.bookId,
fieldId: row.fieldId,
key: row.key,
label: row.label,
type: row.type as CustomMetadataBookValue['type'],
displayOrder: row.displayOrder,
value: valueFromColumns(row),
}));
}
async getExportValues(bookIds: number[]): Promise<Map<number, Record<string, CustomMetadataPrimitiveValue>>> {
const rows = await this.repository.findValuesForBooks(bookIds);
const valuesByBookId = new Map<number, Record<string, CustomMetadataPrimitiveValue>>();