add import read list table

This commit is contained in:
Gauthier Roebroeck 2025-10-10 13:37:16 +08:00
parent 7a2deacb92
commit 3254d329d5
7 changed files with 783 additions and 1 deletions

View file

@ -0,0 +1,16 @@
import { defineMutation, useMutation } from '@pinia/colada'
import { komgaClient } from '@/api/komga-client'
import type { components } from '@/generated/openapi/komga'
export const QUERY_KEYS_READLIST = {
root: ['readlists'] as const,
}
export const useCreateReadList = defineMutation(() => {
return useMutation({
mutation: (readList: components['schemas']['ReadListCreationDto']) =>
komgaClient.POST('/api/v1/readlists', {
body: readList,
}),
})
})

View file

@ -33,6 +33,7 @@ declare module 'vue' {
FragmentHistoryExpandTable: typeof import('./fragments/fragment/history/expand/Table.vue')['default']
FragmentHistoryTable: typeof import('./fragments/fragment/history/Table.vue')['default']
FragmentImportBooksTransientBooksTable: typeof import('./fragments/fragment/import/books/TransientBooksTable.vue')['default']
FragmentImportReadlistTable: typeof import('./fragments/fragment/import/readlist/Table.vue')['default']
FragmentLocaleSelector: typeof import('./fragments/fragment/LocaleSelector.vue')['default']
FragmentRemoteFileList: typeof import('./fragments/fragment/RemoteFileList.vue')['default']
FragmentSnackQueue: typeof import('./fragments/fragment/SnackQueue.vue')['default']

View file

@ -0,0 +1,97 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { http, delay } from 'msw'
import Table from './Table.vue'
import { response400BadRequest } from '@/mocks/api/handlers'
import SnackQueue from '@/fragments/fragment/SnackQueue.vue'
import { matchCbl } from '@/mocks/api/handlers/readlists'
import { expect, waitFor } from 'storybook/test'
const meta = {
component: Table,
subcomponents: { SnackQueue },
render: (args: object) => ({
components: { Table, SnackQueue },
setup() {
return { args }
},
template: '<Table v-bind="args"/><SnackQueue/>',
}),
parameters: {
// More on how to position stories at: https://storybook.js.org/docs/configure/story-layout
},
args: {},
} satisfies Meta<typeof Table>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
args: {
match: matchCbl,
},
}
const singleMatch = {
...matchCbl,
requests: [
{
request: { series: ['Space Adventures (2018)', 'Space Adventures'], number: '1' },
matches: [
{
series: {
seriesId: '63',
title: 'Space Adventures',
releaseDate: '2018-07-10',
},
books: [{ bookId: '0F99E5W723ENS', number: '1', title: 'Volume 1' }],
},
],
},
],
}
export const Created: Story = {
args: {
match: singleMatch,
},
play: async ({ canvas, userEvent }) => {
await userEvent.click(canvas.getByRole('button', { name: /create/i }))
await waitFor(() => expect(canvas.getByRole('button', { name: /create/i })).toBeDisabled())
},
render: (args: object) => ({
components: { Table, SnackQueue },
setup() {
return { args }
},
template: '<Table v-bind="args"/>',
}),
}
export const Empty: Story = {
args: {
match: { ...matchCbl, requests: [] },
},
}
export const Loading: Story = {
args: {
match: singleMatch,
},
parameters: {
msw: {
handlers: [http.all('*', async () => await delay(2_000))],
},
},
}
export const ErrorOnCreation: Story = {
args: {
match: singleMatch,
},
parameters: {
msw: {
handlers: [http.post('*/v1/readlists', response400BadRequest)],
},
},
}

View file

@ -0,0 +1,506 @@
<template>
<v-data-table
v-model="selectedIndices"
:loading="creating || loading"
:items="readListEntries"
item-value="index"
:headers="headers"
:search="filterRef"
:custom-filter="filterFn"
:hide-default-footer="hideFooter"
fixed-header
fixed-footer
show-select
item-selectable="selectable"
select-strategy="page"
mobile-breakpoint="md"
>
<template #no-data>
<v-empty-state
icon="i-mdi:filter"
:title="
$formatMessage({
description:
'Import reading list table: shown when table has no data because of the selected filter - title',
defaultMessage: 'No data',
id: 'AJa6Tq',
})
"
:text="
$formatMessage({
description:
'Import reading list table: shown when table has no data because of the selected filter - subtitle',
defaultMessage: 'Try adjusting the filters',
id: 'NpjqFA',
})
"
/>
</template>
<template #top>
<v-toolbar flat>
<v-spacer />
<v-chip-group
v-model="filterSelect"
multiple
class="ms-2"
:disabled="finishedState"
>
<v-chip
v-for="f in filterOptions"
:key="f.value"
:value="f.value"
:text="f.title"
filter
rounded
color="primary"
/>
</v-chip-group>
</v-toolbar>
</template>
<template #[`item.request.request.series`]="{ value }">
<div
v-for="s in value"
:key="s"
>
{{ s }}
</div>
</template>
<template #[`item.series`]="{ item, internalItem, isSelected, value }">
<div
:class="finishedState ? undefined : 'cursor-pointer'"
@mouseenter="
finishedState
? undefined
: (dialogSeriesPickerActivator = $event.currentTarget as Element)
"
@click="finishedState ? undefined : (currentActionedItem = item)"
>
<template v-if="value">
<div>{{ value?.title }}</div>
<div v-if="value?.releaseDate">
{{ $formatDate(value?.releaseDate, { year: 'numeric', timeZone: 'UTC' }) }}
</div>
</template>
<div
v-else
style="height: 2em"
:class="isSelected(internalItem) ? 'missing' : ''"
/>
</div>
</template>
<template #[`item.book`]="{ item, internalItem, isSelected, value }">
<div
:class="finishedState || !item?.series ? undefined : 'cursor-pointer'"
@mouseenter="
finishedState || !item?.series
? undefined
: (dialogBookPickerActivator = $event.currentTarget as Element)
"
@click="finishedState || !item?.series ? undefined : (currentActionedItem = item)"
>
<span v-if="value">{{ value.number }} - {{ value.title }}</span>
<div
v-else
style="height: 2em"
:class="isSelected(internalItem) && item?.series ? 'missing' : ''"
/>
</div>
</template>
<template #[`header.statusMessage`]>
<v-icon icon="i-mdi:alert-circle-outline" />
</template>
<template #[`item.statusMessage`]="{ item, value, internalItem, isSelected }">
<template v-if="isSelected(internalItem)">
<v-icon
v-if="duplicateBookIds?.includes(item.book?.bookId)"
v-tooltip="
$formatMessage({
description: 'Import reading list table: tooltip for status - duplicate book',
defaultMessage: 'Duplicate book',
id: '1MAL38',
})
"
icon="i-mdi:alert-circle"
color="warning"
/>
<v-icon
v-else-if="value"
v-tooltip="value"
icon="i-mdi:alert-circle"
color="error"
/>
</template>
</template>
</v-data-table>
<v-container fluid>
<v-row
justify="space-between"
align="end"
>
<v-col
cols="12"
sm=""
>
<v-row>
<v-col>
<v-text-field
v-model="readListName"
:rules="['required']"
:disabled="finishedState"
clearable
:label="
$formatMessage({
description: 'Import reading list: bottom bar: reading list name',
defaultMessage: 'Name',
id: 'rrF/Z2',
})
"
></v-text-field>
</v-col>
</v-row>
<v-row>
<v-col>
<v-textarea
v-model="readListSummary"
rows="2"
hide-details
:disabled="finishedState"
clearable
:label="
$formatMessage({
description: 'Import reading list: bottom bar: reading list summary',
defaultMessage: 'Summary',
id: 'uW+6XG',
})
"
/>
</v-col>
</v-row>
</v-col>
<v-col cols="auto">
<v-btn
:color="finishedState ? 'success' : 'primary'"
:text="
$formatMessage({
description: 'Import reading list: bottom bar: create button',
defaultMessage: 'Create',
id: 'dipMGb',
})
"
:disabled="!isFormValid || creating || loading || finishedState"
:prepend-icon="finishedState ? 'i-mdi:check' : undefined"
@click="doCreateReadList"
/>
</v-col>
</v-row>
</v-container>
<FragmentDialogSeriesPicker
:activator="dialogSeriesPickerActivator"
:fullscreen="display.xs.value"
@selected-series="(series) => seriesPicked(series)"
/>
<DialogBookPicker
:activator="dialogBookPickerActivator"
:fullscreen="display.xs.value"
:books="dialogBookPickerBooks"
@selected-book="(book) => bookPicked(book)"
/>
</template>
<script setup lang="ts">
import type { components } from '@/generated/openapi/komga'
import { useIntl } from 'vue-intl'
import {
asyncComputed,
syncRefs,
useArrayFilter,
useArrayMap,
useMemoize,
watchImmediate,
} from '@vueuse/core'
import { useDisplay } from 'vuetify'
import { useQuery } from '@pinia/colada'
import { bookListQuery } from '@/colada/books'
import { useCreateReadList } from '@/colada/readlists'
import { useMessagesStore } from '@/stores/messages'
import type { ErrorCause } from '@/api/komga-client'
import { commonMessages } from '@/utils/i18n/common-messages'
class ReadListEntry {
index: number
request: components['schemas']['ReadListRequestBookMatchesDto']
series?: components['schemas']['ReadListRequestBookMatchSeriesDto']
book?: components['schemas']['ReadListRequestBookMatchBookDto']
constructor(request: components['schemas']['ReadListRequestBookMatchesDto'], index: number) {
this.index = index
this.request = request
const match = request.matches.find(Boolean)
if (match) {
this.series = match.series
this.book = match.books.find(Boolean)
}
}
public get selectable(): boolean {
return !finishedState.value
}
public get importable(): boolean {
return !!this.series && !!this.book
}
public get statusMessage(): string {
if (!this.series)
return intl.formatMessage({
description: 'Import reading list: status message: choose a series',
defaultMessage: 'Choose a series',
id: 'H2B6uF',
})
if (!this.book)
return intl.formatMessage({
description: 'Import reading list: status message: choose a book',
defaultMessage: 'Choose a book',
id: 'xYp/8u',
})
return ''
}
}
const display = useDisplay()
const intl = useIntl()
const messagesStore = useMessagesStore()
const { match, loading = false } = defineProps<{
match: components['schemas']['ReadListRequestMatchDto']
loading?: boolean
}>()
// a read-only array of ReadListEntry, kept in sync with the requests prop
const readListEntriesRO = useArrayMap(
toRef(() => match.requests),
(it, index) => new ReadListEntry(it, index + 1),
)
// read-write array of ReadListEntry, will be modified by the different actions
const readListEntries = ref<ReadListEntry[]>([])
// 1-way sync from readListEntriesRO to readListEntries
syncRefs(readListEntriesRO, readListEntries)
// the current item being acted upon, used for dialog callback
const currentActionedItem = ref<ReadListEntry>()
// the selected indices, used to programmatically select items
const selectedIndices = ref<number[]>([])
// a read-only list of the request indices, used to select all on initialization
const entriesIndex = useArrayMap(readListEntriesRO, (it) => it.index)
// 1-way sync from entriesIndex to selectedIndices, will effectively select all on initialization
syncRefs(entriesIndex, selectedIndices)
// the selected books
const selectedBooks = useArrayFilter(readListEntries, (it) =>
selectedIndices.value.includes(it.index),
)
const duplicateBookIds = useArrayFilter(
useArrayMap(readListEntries, (it) => it.book?.bookId),
(it, index, array) => !!it && array.indexOf(it) !== index,
)
const readListName = ref<string>(match.readListMatch.name)
const readListSummary = ref<string>()
const readListCreated = ref<components['schemas']['ReadListDto']>()
// if the prop changes, reset some data
watchImmediate(
() => match,
(m) => {
readListName.value = m.readListMatch.name
readListSummary.value = ''
readListCreated.value = undefined
},
)
const finishedState = computed<boolean>(() => !!readListCreated.value)
// Table setup
const hideFooter = computed(() => readListEntries.value.length < 10)
const headers = [
{
title: '#',
key: 'index',
},
{
title: intl.formatMessage({
description: 'Import reading list table header: requested series',
defaultMessage: 'Requested series',
id: 'LD5j8J',
}),
key: 'request.request.series',
},
{
title: intl.formatMessage({
description: 'Import reading list table header: requested number',
defaultMessage: 'Requested number',
id: 'Wlzzv8',
}),
key: 'request.request.number',
},
{
title: intl.formatMessage({
description: 'Import reading list table header: Series',
defaultMessage: 'Series',
id: 'ThHjN4',
}),
key: 'series',
},
{
title: intl.formatMessage({
description: 'Import reading list table header: Book',
defaultMessage: 'Book',
id: '700A3r',
}),
key: 'book',
},
{
title: intl.formatMessage({
description: 'Import reading list table header: status message',
defaultMessage: 'Status',
id: 'J44THG',
}),
key: 'statusMessage',
align: 'end',
},
] as const // workaround for https://github.com/vuetifyjs/vuetify/issues/18901
// Filtering
const filterSelect = ref<string[]>([])
const filterRef = computed(() => filterSelect.value.join(''))
const filterOptions = [
{
title: 'OK',
value: 'o',
},
{
title: 'Error',
value: 'e',
},
{
title: 'Duplicate',
value: 'd',
},
]
function filterFn(
value: string,
query: string,
item?: { raw: ReadListEntry },
): boolean | number | [number, number] | [number, number][] {
const error = item?.raw.statusMessage
const duplicate = duplicateBookIds.value.includes(item?.raw.book?.bookId)
if (error && query.includes('e')) return true
if (!error && duplicate && query.includes('d')) return true
if (!error && !duplicate && query.includes('o')) return true
return false
}
// Series Picker Dialog
const dialogSeriesPickerActivator = ref<Element | undefined>(undefined)
function seriesPicked(series: components['schemas']['SeriesDto']) {
if (currentActionedItem.value) {
currentActionedItem.value.series = {
seriesId: series.id,
title: series.metadata.title,
releaseDate: series.booksMetadata?.releaseDate,
}
}
}
// Book Picker Dialog
const dialogBookPickerActivator = ref<Element | undefined>(undefined)
const dialogBookPickerBooks = asyncComputed(async () =>
currentActionedItem.value?.series
? (await getSeriesBooks(currentActionedItem.value.series.seriesId))?.content
: undefined,
)
function bookPicked(book: components['schemas']['BookDto']) {
if (currentActionedItem.value) {
currentActionedItem.value.book = {
bookId: book.id,
title: book.metadata.title,
number: book.metadata.number,
}
}
}
const getSeriesBooks = useMemoize(async (seriesId: string) =>
useQuery(bookListQuery, () => ({
search: {
condition: {
seriesId: { operator: 'Is', value: seriesId },
},
} as components['schemas']['BookSearch'],
}))
.refresh()
.then(({ data }) => data),
)
const isFormValid = computed<boolean>(() => !!readListName.value)
const createPayload = computed(
() =>
({
name: readListName.value,
summary: readListSummary.value,
ordered: true,
bookIds: [],
}) as components['schemas']['ReadListCreationDto'],
)
const { mutateAsync: postReadList, isLoading: creating } = useCreateReadList()
function doCreateReadList() {
if (selectedBooks.value.length == 0) {
messagesStore.messages.push('Select some books')
return
}
if (selectedBooks.value.some((it) => !it.importable)) {
messagesStore.messages.push('Some of the selected books are in error')
return
}
if (selectedBooks.value.some((it) => duplicateBookIds.value?.includes(it.book?.bookId))) {
messagesStore.messages.push('Some of the selected books are duplicates')
return
}
postReadList(createPayload.value)
.then(({ data }) => {
readListCreated.value = data
//TODO: add link
messagesStore.messages.push({
text: 'Readlist created',
})
})
.catch((error) => {
messagesStore.messages.push({
text:
(error?.cause as ErrorCause)?.message || intl.formatMessage(commonMessages.networkError),
})
})
}
</script>
<style scoped>
.missing {
border: 2px dashed red;
}
</style>

View file

@ -12,6 +12,7 @@ import { seriesHandlers } from '@/mocks/api/handlers/series'
import { booksHandlers } from '@/mocks/api/handlers/books'
import { filesystemHandlers } from '@/mocks/api/handlers/filesystem'
import { transientBooksHandlers } from '@/mocks/api/handlers/transient-books'
import { readListsHandlers } from '@/mocks/api/handlers/readlists'
export const handlers = [
...actuatorHandlers,
@ -21,6 +22,7 @@ export const handlers = [
...filesystemHandlers,
...historyHandlers,
...librariesHandlers,
...readListsHandlers,
...referentialHandlers,
...releasesHandlers,
...seriesHandlers,

View file

@ -0,0 +1,151 @@
import { httpTyped } from '@/mocks/api/httpTyped'
export const matchCbl = {
readListMatch: { name: "Jupiter's Legacy", errorCode: '' },
requests: [
{
request: { series: ['Space Adventures (2018)', 'Space Adventures'], number: '1' },
matches: [
{
series: {
seriesId: '63',
title: 'Space Adventures',
releaseDate: '2018-07-10',
},
books: [{ bookId: '0F99E5W723ENS', number: '1', title: 'Volume 1' }],
},
],
},
{
request: { series: ["Jupiter's Legacy (2013)", "Jupiter's Legacy"], number: '2' },
matches: [
{
series: {
seriesId: '63',
title: 'Space Adventures',
releaseDate: '2018-07-10',
},
books: [{ bookId: '0F99E5W723ENS', number: '1', title: 'Volume 1' }],
},
],
},
{
request: { series: ["Jupiter's Legacy (2013)", "Jupiter's Legacy"], number: '3' },
matches: [
{
series: {
seriesId: '63',
title: "Jupiter's Legacy",
releaseDate: '2018-07-10',
},
books: [{ bookId: '0F99E5W763ECC', number: '3', title: 'Volume 3' }],
},
],
},
{
request: { series: ["Jupiter's Legacy (2013)", "Jupiter's Legacy"], number: '4' },
matches: [
{
series: {
seriesId: '63',
title: "Jupiter's Legacy",
releaseDate: '2018-07-10',
},
books: [{ bookId: '0F99E5W723ENT', number: '4', title: 'Volume 4' }],
},
],
},
{
request: { series: ["Jupiter's Legacy (2013)", "Jupiter's Legacy"], number: '5' },
matches: [
{
series: {
seriesId: '63',
title: "Jupiter's Legacy",
releaseDate: '2018-07-10',
},
books: [{ bookId: '0F99E5W723ENR', number: '5', title: 'Volume 5' }],
},
],
},
{
request: { series: ["Jupiter's Legacy 2 (2016)", "Jupiter's Legacy 2"], number: '1' },
matches: [],
},
{
request: { series: ["Jupiter's Legacy 2 (2016)", "Jupiter's Legacy 2"], number: '2' },
matches: [],
},
{
request: { series: ["Jupiter's Legacy 2 (2016)", "Jupiter's Legacy 2"], number: '3' },
matches: [],
},
{
request: { series: ["Jupiter's Legacy 2 (2016)", "Jupiter's Legacy 2"], number: '4' },
matches: [],
},
{
request: { series: ["Jupiter's Legacy 2 (2016)", "Jupiter's Legacy 2"], number: '5' },
matches: [],
},
{
request: {
series: ["Jupiter's Legacy Requiem (2021)", "Jupiter's Legacy Requiem"],
number: '1',
},
matches: [],
},
{
request: {
series: ["Jupiter's Legacy Requiem (2021)", "Jupiter's Legacy Requiem"],
number: '2',
},
matches: [],
},
{
request: {
series: ["Jupiter's Legacy Requiem (2021)", "Jupiter's Legacy Requiem"],
number: '3',
},
matches: [],
},
{
request: {
series: ["Jupiter's Legacy Requiem (2021)", "Jupiter's Legacy Requiem"],
number: '4',
},
matches: [],
},
{
request: {
series: ["Jupiter's Legacy Requiem (2021)", "Jupiter's Legacy Requiem"],
number: '5',
},
matches: [],
},
{
request: {
series: ["Jupiter's Legacy Requiem (2021)", "Jupiter's Legacy Requiem"],
number: '6',
},
matches: [],
},
],
errorCode: '',
}
export const readListsHandlers = [
httpTyped.post('/api/v1/readlists', async ({ request, response }) => {
const body = await request.json()
return response(200).json({
...body,
createdDate: new Date(),
lastModifiedDate: new Date(),
id: (Math.random() + 1).toString(36).substring(7),
filtered: false,
})
}),
httpTyped.post('/api/v1/readlists/match/comicrack', ({ response }) => {
return response(200).json(matchCbl)
}),
]

View file

@ -1,8 +1,17 @@
// Utilities
import { defineStore } from 'pinia'
type Message =
| {
text: string
color?: string
timer?: string | boolean
timeout?: string | number
}
| string
export const useMessagesStore = defineStore('messages', {
state: () => ({
messages: [] as object[],
messages: [] as Message[],
}),
})