mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -06:00
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:
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
+1
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
+8
@@ -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')
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -74,6 +74,7 @@ export type BookCard = {
|
||||
hardcoverEditionId?: string | null;
|
||||
narrators: string[];
|
||||
tags: string[];
|
||||
customMetadata: CustomMetadataBookValue[];
|
||||
collapsedSeries?: import("./series-collapse").CollapsedSeriesInfo;
|
||||
};
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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>>();
|
||||
|
||||
Reference in New Issue
Block a user