Implement functionality to add shows to existing collections from suggestions
ci / build-backend (push) Successful in 1m40s
ci / build-frontend (push) Successful in 53s
ci / tests (push) Successful in 1m46s
ci / sonar (push) Successful in 6m47s

Added a new endpoint for adding shows to already assembled franchises based on the same suggestion key. Updated the CollectionSuggestion and CollectionSuggestionDto records to include a CollectionId for tracking existing collections. Enhanced the CollectionSuggestions component in the frontend to support this new functionality, allowing users to add missing parts to existing collections. Updated localization strings to reflect the new addition feature in both English and Russian, ensuring clarity for users. Adjusted tests to verify the correct behavior of the addition process and its integration with existing collection logic.
This commit is contained in:
Leonid Pershin
2026-07-28 14:49:27 +03:00
parent 6c85db2d60
commit ac41111fa0
12 changed files with 413 additions and 39 deletions
@@ -8,7 +8,7 @@ import { useApiError } from '@/shared/lib/use-api-error'
import { Badge } from '@/shared/ui/badge'
import { Button } from '@/shared/ui/button'
import { toast } from '@/shared/ui/toast-store'
import { createCollectionFromSuggestion, suggestCollections } from './api'
import { addShowsFromSuggestion, createCollectionFromSuggestion, suggestCollections } from './api'
/**
* Что стоит собрать во франшизу. Состав виден целиком до создания — по названиям иногда
@@ -31,13 +31,28 @@ export function CollectionSuggestions() {
queryFn: suggestCollections,
})
const invalidate = () => {
void queryClient.invalidateQueries({ queryKey: qk.collections.all })
void queryClient.invalidateQueries({ queryKey: qk.collections.suggestions })
void queryClient.invalidateQueries({ queryKey: qk.shows.all })
}
const create = useMutation({
mutationFn: (suggestion: CollectionSuggestionDto) =>
createCollectionFromSuggestion(suggestion.key),
onSuccess: () => {
toast.success(t('admin.collections.suggestions.created'))
void queryClient.invalidateQueries({ queryKey: qk.collections.all })
void queryClient.invalidateQueries({ queryKey: qk.shows.all })
invalidate()
},
onError,
})
/** Дополнение уже собранной франшизы: коллекцию сервер находит по тому же ключу предложения. */
const add = useMutation({
mutationFn: (suggestion: CollectionSuggestionDto) => addShowsFromSuggestion(suggestion.key),
onSuccess: (added) => {
toast.success(t('admin.collections.suggestions.added', { count: added }))
invalidate()
},
onError,
})
@@ -78,27 +93,49 @@ export function CollectionSuggestions() {
<li key={suggestion.key} className="flex flex-col gap-1 px-4 py-2">
<div className="flex flex-wrap items-center gap-2">
<span className="min-w-0 flex-1 truncate font-medium">{suggestion.name}</span>
{/* Дополнение и создание в одном списке: без пометки строки неотличимы, а
действия у них разные — одна заводит франшизу, другая правит собранную. */}
{suggestion.collectionId && (
<Badge variant="muted">
{t('admin.collections.suggestions.additionBadge')}
</Badge>
)}
<Badge variant={suggestion.source === 'Metadata' ? 'default' : 'muted'}>
{t(`admin.collections.suggestions.sources.${suggestion.source}`)}
</Badge>
<span className="text-muted-foreground">
{t('admin.collections.suggestions.parts', { count: suggestion.parts.length })}
</span>
{suggestion.alreadyExists ? (
<Badge variant="muted">{t('admin.collections.suggestions.exists')}</Badge>
) : (
{suggestion.collectionId ? (
<Button
size="sm"
variant="outline"
disabled={create.isPending}
onClick={() => create.mutate(suggestion)}
disabled={add.isPending}
onClick={() => add.mutate(suggestion)}
>
<Plus className="h-4 w-4" />
{t('common.create')}
{t('admin.collections.suggestions.add')}
</Button>
) : (
<>
{suggestion.alreadyExists ? (
<Badge variant="muted">{t('admin.collections.suggestions.exists')}</Badge>
) : (
<Button
size="sm"
variant="outline"
disabled={create.isPending}
onClick={() => create.mutate(suggestion)}
>
<Plus className="h-4 w-4" />
{t('common.create')}
</Button>
)}
</>
)}
</div>
{/* Состав в предлагаемом порядке: по нему и видно, промахнулась ли догадка. */}
{/* Состав в предлагаемом порядке: по нему и видно, промахнулась ли догадка.
У дополнения это только недостающие части — их и добавит кнопка. */}
<p className="text-xs text-muted-foreground">
{suggestion.parts
.map((part) => (part.year ? `${part.name} (${part.year})` : part.name))
@@ -61,6 +61,14 @@ export function createCollectionFromSuggestion(key: string) {
})
}
/** Дополняет уже собранную франшизу по тому же ключу предложения; возвращает, сколько добавлено. */
export function addShowsFromSuggestion(key: string) {
return apiRequest<number>('/admin/collections/suggestions/add', {
method: 'POST',
body: { key },
})
}
export function addCollectionShow(id: string, showId: string) {
return apiRequest<void>(`/admin/collections/${id}/shows`, { method: 'POST', body: { showId } })
}
+5 -1
View File
@@ -275,13 +275,17 @@ export type CollectionDto = {
/** Откуда взялось предложение франшизы: данные источника либо догадка по названиям. */
export type CollectionSuggestionSource = 'Metadata' | 'NameGuess'
/** Предложение собрать франшизу: имя и части в предлагаемом порядке. */
/**
* Предложение собрать франшизу: имя и части в предлагаемом порядке. `collectionId` задан —
* франшиза уже собрана, и предлагается дополнить её недостающими частями.
*/
export type CollectionSuggestionDto = {
key: string
source: CollectionSuggestionSource
name: string
parts: { showId: string; name: string; year: number | null }[]
alreadyExists: boolean
collectionId: string | null
}
/** Коллекция, в которую входит шоу — для блока на экране шоу. */
+4 -1
View File
@@ -175,9 +175,12 @@ export const en = {
orderHint: 'Drag to set the order of the parts — that is the order they air in.',
suggestions: {
title: 'Franchise suggestions',
hint: 'Built from movies not in any collection yet. Check the parts — the name-based guess sometimes misses.',
hint: 'Built from movies not in any collection yet: what to assemble into a new franchise and what to add to an assembled one. Check the parts — the name-based guess sometimes misses.',
parts: '{{count}} parts',
exists: 'already exists',
additionBadge: 'addition',
add: 'Add',
added: 'Added to the collection: {{count}}',
created: 'Collection created',
allCreated: 'Every suggestion has been created already.',
showExisting: 'Show already created ({{count}})',
+4 -1
View File
@@ -175,9 +175,12 @@ export const ru = {
orderHint: 'Порядок частей задаётся перетаскиванием — в нём они и пойдут в эфир.',
suggestions: {
title: 'Предложения франшиз',
hint: 'Собрано из полнометражек, не входящих ни в одну коллекцию. Проверьте состав — по названиям догадка иногда промахивается.',
hint: 'Собрано из полнометражек, не входящих ни в одну коллекцию: что собрать в новую франшизу и чем дополнить уже собранную. Проверьте состав — по названиям догадка иногда промахивается.',
parts: '{{count}} частей',
exists: 'уже есть',
additionBadge: 'дополнение',
add: 'Добавить',
added: 'Добавлено в коллекцию: {{count}}',
created: 'Коллекция создана',
allCreated: 'Все предложения уже созданы.',
showExisting: 'Показать уже созданные ({{count}})',