open-notebook/frontend/src/components/podcasts/forms/EpisodeProfileFormDialog.tsx
MisonL 67dd85c928
Feat/localization tests docker (#371)
* feat(i18n): complete 100% internationalization and fix Next.js 15 compatibility

* feat(i18n): complete 100% internationalization coverage

* chore(test): finalize component tests and project cleanup

* test(logic): add unit tests for useModalManager hook

* fix(test): resolve timeout in AppSidebar tests by mocking TooltipProvider

* feat(i18n): comprehensive i18n audit, fixes for hardcoded strings, and complete zh-TW support

* fix(i18n): resolve TypeScript warnings and improve translation hook stability

- Remove unused useTranslation import from ConnectionGuard
- Add ref-based checking state to prevent dependency cycles
- Fix useTranslation hook to return empty string for undefined translations
- Add comment for backward compatibility on ExtractedReference interface
- Ensure .replace() string methods work safely with nested translation keys

* feat(i18n): complete internationalization implementation with Docker deployment

- Add LanguageLoadingOverlay component for smooth language transitions
- Update all translation files (en-US, zh-CN, zh-TW) with improved terminology
- Optimize Docker configuration for better performance
- Update version check and config handling for i18n support
- Fix route handling for language-specific content
- Add comprehensive task documentation

* fix(i18n): resolve localization errors, duplicates, and type issues

* chore(i18n): finalize 100% internationalization coverage

* chore(test): supplement i18n test cases and cleanup redundant files

* fix(test): resolve lint type errors and finalize delivery documents

* feat(i18n): finalize full internationalization and zh-TW localization

* fix(frontend): add missing devDependency and fix build tsconfig

* feat(ui): enhance sidebar hover effects with better visual feedback

* fix(frontend): resolve accessibility, i18n, and lint issues

- fix: add missing id, name, autocomplete attributes to dialog inputs
- fix: add aria labels and DialogDescription for accessibility
- fix: resolve uncontrolled component warning in SettingsForm
- fix: correct duplicate 'Traditional Chinese' label in zh-TW locale
- feat: add i18n support for podcast template names
- chore: fix lint errors in Dialogs

* fix: address all 21 PR feedback items from cubic-dev-ai bot

Configuration:
- Remove ignoreDuringBuilds flags from next.config.ts

Testing:
- Fix AppSidebar.test.tsx regex pattern and add missing assertion

Logic:
- Fix ConnectionGuard.tsx re-entry prevention logic

Internationalization (I18n) - Translations:
- Add missing keys: notebooks.archived, common.note/insight, accessibility keys
- Add specific keys: sources.allSourcesDescShort, transformations.selectModel
- Add singular/plural keys: podcasts.usedByCount_one/other, common.note/notes
- Add common.created/updated with {time} placeholder

Internationalization (I18n) - Usage:
- SourcesPage: use allSourcesDescShort instead of string splitting
- TransformationPlayground: use navigation.transformation and selectModel
- CommandPalette: use dedicated keys instead of string concatenation
- GeneratePodcastDialog: fix zh-TW date locale handling
- NotebookHeader: correctly interpolate {time} placeholder
- TransformationCard: use common.description instead of undefined key
- ChatPanel/SpeakerProfilesPanel: implement proper pluralization
- SystemInfo: correctly interpolate {version} placeholder
- LanguageLoadingOverlay: use t.common.loading instead of hardcoded string
- MessageActions: use specific error key cannotSaveNoteNoNotebook

Other:
- Fix SessionManager.tsx exhaustive-deps warning

* fix: remove duplicate locale keys and add missing zh-CN translations

- en-US: remove duplicate loading key (line 59) and addNew key (sources)
- zh-CN: remove duplicate common keys (loading, note, insight, newSource, newNotebook, newPodcast)
- zh-CN: remove duplicate accessibility.searchNotebooks key
- zh-CN: remove duplicate sources.addNew key
- zh-CN: remove duplicate navigation.transformation key
- zh-CN: add missing usedByCount_one and usedByCount_other keys in podcasts
- zh-TW: remove duplicate common keys (loading, note, insight, newSource, newNotebook, newPodcast)
- zh-TW: remove duplicate accessibility.searchNotebooks key
- zh-TW: remove duplicate sources.addNew key

* docs: remove info.md

* fix: remove duplicate notebook keys and unused ts-expect-error

- zh-CN: remove duplicate notebooks keys (archived, archive, unarchive, deleteNotebook, deleteNotebookDesc)
- zh-TW: remove duplicate notebooks keys (archived, archive, unarchive, deleteNotebook, deleteNotebookDesc)
- GeneratePodcastDialog: remove unused @ts-expect-error directive

* fix(a11y): fix unassociated labels in search page

- Replace <Label> with role='group' + aria-labelledby for search type section
- Replace <Label> with role='group' + aria-labelledby for search in section
- Follows WAI-ARIA best practices for labeling form field groups

* fix(a11y): fix unassociated labels across multiple components

- search/page.tsx: use role='group' + aria-labelledby for search type and search in sections
- RebuildEmbeddings.tsx: use role='group' + aria-labelledby for include checkboxes
- TransformationPlayground.tsx: replace Label with span for non-form output label

* chore: revert to npm stack and ensure i18n compatibility

* chore: polish zh-TW translations for better idiomatic usage

* fix: resolve linter errors (ruff import sort, mypy config duplicate)

* style: apply ruff formatting

* fix: finalize upstream compliance (Dockerfile.single, i18n hooks, docker-compose)

* style: polish strings, fix timeout cleanup, and improve test mocks

* fix: use relative imports in test setup to resolve IDE path errors

* perf(docker): optimize build speed by removing apt-get upgrade and build tools

- Remove apt-get upgrade from both builder and runtime stages (saves 10-15 min each)
- Remove gcc/g++/make/git from builder (uv downloads pre-built wheels)
- Add --no-install-recommends to minimize package footprint
- Keep npm mirror (npmmirror.com) for faster frontend deps
- Add npm registry config for reliable China network access

Also includes:
- fix(a11y): add missing labels and aria attributes to form fields
- fix(i18n): add 2s safety timeout to LanguageLoadingOverlay
- fix(i18n): add robustness checks to use-translation proxy

Build time reduced from 2+ hours to ~34 minutes (~70% improvement)

* fix(a11y): resolve 16 form field accessibility warnings in notebook and podcast pages

* fix(a11y): resolve 4 button and 1 select field accessibility warnings in models page

* fix(a11y): resolve redundant attributes and residual warnings in transformations and podcast forms

* fix(i18n): deep fix for language switch hang using proxy protection and safer access

* fix(a11y): add name attributes to ModelSelector, TransformationPlayground, and SourceDetailContent

* fix: add missing Label import to SourceDetailContent

* fix(i18n): use native react-i18next in LanguageLoadingOverlay to prevent hang during language switch

* fix(i18n): rewrite use-translation Proxy with strict depth limit and expanded blocked props to prevent language switch hang

* fix: add type assertion to fix TypeScript comparison error

* fix(i18n): disable useSuspense to prevent thread hang during language resource loading

* fix(i18n): add infinite loop detection circuit breaker to useTranslation hook

* fix(i18n): update traditional chinese label to native script in en-US

* feat: add new localization strings for notebook and note management.

* fix: resolve config priority, docker build deps, and ui glitches

* refactor: improve ui details and test coverage based on feedback

* refactor: improve ui details (version check/lang toggle) and test coverage

* fix: polish language matching and test cleanup

* fix(test): update mocks to resolve timeouts and proxy errors

* fix(frontend): restore tsconfig.json structure and enable IDE support for tests

* fix: address PR review findings and resolve CI OIDC failure

* fix: merge exception headers in custom handler

* fix: comprehensive PR review remediations and async performance fixes

* refactor: address all PR #371 review feedback

- Docker: consolidate SURREAL_URL to docker.env, add single-container override
- Security: restore apt-get upgrade in Dockerfile and Dockerfile.single
- Create centralized getDateLocale helper (lib/utils/date-locale.ts)
- Refactor 7 files to use getDateLocale helper
- Revert config/route.ts to origin/main version
- Move test files to co-located pattern (3 files)
- Remove local useTranslation mock from ConfirmDialog.test.tsx
- Simplify use-version-check to single useEffect pattern
- Fix test import paths after moving to co-located pattern

* fix: add jest-dom types for test files

* fix: address remaining review issues

- Add apt-get upgrade -y to Dockerfile.single backend-builder stage
- Refactor ChatColumn.test.tsx: use 'as unknown as ReturnType<typeof hook>' instead of 'as any'
- Use toBeInTheDocument() assertions instead of toBeDefined()
2026-01-15 13:51:05 -03:00

451 lines
16 KiB
TypeScript

'use client'
import { useCallback, useEffect, useMemo } from 'react'
import { Controller, useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { EpisodeProfile, SpeakerProfile } from '@/lib/types/podcasts'
import {
useCreateEpisodeProfile,
useUpdateEpisodeProfile,
} from '@/lib/hooks/use-podcasts'
import { useTranslation } from '@/lib/hooks/use-translation'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea'
import { Separator } from '@/components/ui/separator'
import { TranslationKeys } from '@/lib/locales'
const episodeProfileSchema = (t: TranslationKeys) => z.object({
name: z.string().min(1, t.podcasts.nameRequired || 'Name is required'),
description: z.string().optional(),
speaker_config: z.string().min(1, t.podcasts.profileRequired || 'Speaker profile is required'),
outline_provider: z.string().min(1, t.podcasts.outlineProviderRequired || 'Outline provider is required'),
outline_model: z.string().min(1, t.podcasts.outlineModelRequired || 'Outline model is required'),
transcript_provider: z.string().min(1, t.podcasts.transcriptProviderRequired || 'Transcript provider is required'),
transcript_model: z.string().min(1, t.podcasts.transcriptModelRequired || 'Transcript model is required'),
default_briefing: z.string().min(1, t.podcasts.defaultBriefingRequired || 'Default briefing is required'),
num_segments: z.number()
.int(t.podcasts.segmentsInteger || 'Must be an integer')
.min(3, t.podcasts.segmentsMin || 'At least 3 segments')
.max(20, t.podcasts.segmentsMax || 'Maximum 20 segments'),
})
export type EpisodeProfileFormValues = z.infer<ReturnType<typeof episodeProfileSchema>>
interface EpisodeProfileFormDialogProps {
mode: 'create' | 'edit'
open: boolean
onOpenChange: (open: boolean) => void
speakerProfiles: SpeakerProfile[]
modelOptions: Record<string, string[]>
initialData?: EpisodeProfile
}
export function EpisodeProfileFormDialog({
mode,
open,
onOpenChange,
speakerProfiles,
modelOptions,
initialData,
}: EpisodeProfileFormDialogProps) {
const { t } = useTranslation()
const createProfile = useCreateEpisodeProfile()
const updateProfile = useUpdateEpisodeProfile()
const providers = useMemo(() => Object.keys(modelOptions), [modelOptions])
const getDefaults = useCallback((): EpisodeProfileFormValues => {
const firstSpeaker = speakerProfiles[0]?.name ?? ''
const firstProvider = providers[0] ?? ''
const firstModel = firstProvider ? modelOptions[firstProvider]?.[0] ?? '' : ''
if (initialData) {
return {
name: initialData.name,
description: initialData.description ?? '',
speaker_config: initialData.speaker_config,
outline_provider: initialData.outline_provider,
outline_model: initialData.outline_model,
transcript_provider: initialData.transcript_provider,
transcript_model: initialData.transcript_model,
default_briefing: initialData.default_briefing,
num_segments: initialData.num_segments,
}
}
return {
name: '',
description: '',
speaker_config: firstSpeaker,
outline_provider: firstProvider,
outline_model: firstModel,
transcript_provider: firstProvider,
transcript_model: firstModel,
default_briefing: '',
num_segments: 5,
}
}, [initialData, modelOptions, providers, speakerProfiles])
const {
control,
register,
handleSubmit,
reset,
setValue,
watch,
formState: { errors },
} = useForm<EpisodeProfileFormValues>({
resolver: zodResolver(episodeProfileSchema(t)),
defaultValues: getDefaults(),
})
const outlineProvider = watch('outline_provider')
const outlineModel = watch('outline_model')
const transcriptProvider = watch('transcript_provider')
const transcriptModel = watch('transcript_model')
const availableOutlineModels = modelOptions[outlineProvider] ?? []
const availableTranscriptModels = modelOptions[transcriptProvider] ?? []
useEffect(() => {
if (!open) {
return
}
reset(getDefaults())
}, [open, reset, getDefaults])
useEffect(() => {
if (!outlineProvider) {
return
}
const models = modelOptions[outlineProvider] ?? []
if (models.length === 0) {
setValue('outline_model', '')
return
}
if (!models.includes(outlineModel)) {
setValue('outline_model', models[0])
}
}, [outlineProvider, outlineModel, modelOptions, setValue])
useEffect(() => {
if (!transcriptProvider) {
return
}
const models = modelOptions[transcriptProvider] ?? []
if (models.length === 0) {
setValue('transcript_model', '')
return
}
if (!models.includes(transcriptModel)) {
setValue('transcript_model', models[0])
}
}, [transcriptProvider, transcriptModel, modelOptions, setValue])
const onSubmit = async (values: EpisodeProfileFormValues) => {
const payload = {
...values,
description: values.description ?? '',
}
if (mode === 'create') {
await createProfile.mutateAsync(payload)
} else if (initialData) {
await updateProfile.mutateAsync({
profileId: initialData.id,
payload,
})
}
onOpenChange(false)
}
const isSubmitting = createProfile.isPending || updateProfile.isPending
const disableSubmit =
isSubmitting || speakerProfiles.length === 0 || providers.length === 0
const isEdit = mode === 'edit'
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-2xl">
<DialogHeader>
<DialogTitle>
{isEdit ? t.podcasts.editEpisodeProfile : t.podcasts.createEpisodeProfile}
</DialogTitle>
<DialogDescription>
{t.podcasts.episodeProfileFormDesc}
</DialogDescription>
</DialogHeader>
{speakerProfiles.length === 0 ? (
<Alert className="bg-amber-50 text-amber-900 border-amber-200">
<AlertTitle>{t.podcasts.noSpeakerProfilesAvailable}</AlertTitle>
<AlertDescription>
{t.podcasts.noSpeakerProfilesDesc}
</AlertDescription>
</Alert>
) : null}
{providers.length === 0 ? (
<Alert className="bg-amber-50 text-amber-900 border-amber-200">
<AlertTitle>{t.podcasts.noLanguageModelsAvailable}</AlertTitle>
<AlertDescription>
{t.podcasts.noLanguageModelsDesc}
</AlertDescription>
</Alert>
) : null}
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6 pt-2">
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="name">{t.podcasts.profileName} *</Label>
<Input id="name" placeholder={t.podcasts.profileNamePlaceholder} {...register('name')} />
{errors.name ? (
<p className="text-xs text-red-600">{errors.name.message}</p>
) : null}
</div>
<div className="space-y-2">
<Label htmlFor="num_segments">{t.podcasts.segments} *</Label>
<Input
id="num_segments"
type="number"
min={3}
max={20}
{...register('num_segments', { valueAsNumber: true })}
autoComplete="off"
/>
{errors.num_segments ? (
<p className="text-xs text-red-600">{errors.num_segments.message}</p>
) : null}
</div>
<div className="md:col-span-2 space-y-2">
<Label htmlFor="description">{t.common.description}</Label>
<Textarea
id="description"
rows={3}
placeholder={t.podcasts.descriptionPlaceholder}
{...register('description')}
autoComplete="off"
/>
</div>
</div>
<div className="space-y-4">
<div>
<h3 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">
{t.podcasts.speakerConfig}
</h3>
<Separator className="mt-2" />
</div>
<Controller
control={control}
name="speaker_config"
render={({ field }) => (
<div className="space-y-2">
<Label htmlFor="speaker_config">{t.podcasts.speakerProfile} *</Label>
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger id="speaker_config">
<SelectValue placeholder={t.podcasts.selectSpeakerProfile} />
</SelectTrigger>
<SelectContent title={t.podcasts.speakerProfile}>
{speakerProfiles.map((profile) => (
<SelectItem key={profile.id} value={profile.name}>
{profile.name}
</SelectItem>
))}
</SelectContent>
</Select>
{errors.speaker_config ? (
<p className="text-xs text-red-600">
{errors.speaker_config.message}
</p>
) : null}
</div>
)}
/>
</div>
<div className="space-y-4">
<div>
<h3 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">
{t.podcasts.outlineGeneration}
</h3>
<Separator className="mt-2" />
</div>
<div className="grid gap-4 md:grid-cols-2">
<Controller
control={control}
name="outline_provider"
render={({ field }) => (
<div className="space-y-2">
<Label htmlFor="outline_provider">{t.models.provider} *</Label>
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger id="outline_provider">
<SelectValue placeholder={t.models.selectProviderPlaceholder} />
</SelectTrigger>
<SelectContent title={t.models.provider}>
{providers.map((provider) => (
<SelectItem key={provider} value={provider}>
<span className="capitalize">{provider}</span>
</SelectItem>
))}
</SelectContent>
</Select>
{errors.outline_provider ? (
<p className="text-xs text-red-600">
{errors.outline_provider.message}
</p>
) : null}
</div>
)}
/>
<Controller
control={control}
name="outline_model"
render={({ field }) => (
<div className="space-y-2">
<Label htmlFor="outline_model">{t.common.model} *</Label>
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger id="outline_model">
<SelectValue placeholder={t.models.selectModelPlaceholder} />
</SelectTrigger>
<SelectContent title={t.common.model}>
{availableOutlineModels.map((model) => (
<SelectItem key={model} value={model}>
{model}
</SelectItem>
))}
</SelectContent>
</Select>
{errors.outline_model ? (
<p className="text-xs text-red-600">
{errors.outline_model.message}
</p>
) : null}
</div>
)}
/>
</div>
</div>
<div className="space-y-4">
<div>
<h3 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">
{t.podcasts.transcriptGeneration}
</h3>
<Separator className="mt-2" />
</div>
<div className="grid gap-4 md:grid-cols-2">
<Controller
control={control}
name="transcript_provider"
render={({ field }) => (
<div className="space-y-2">
<Label htmlFor="transcript_provider">{t.models.provider} *</Label>
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger id="transcript_provider">
<SelectValue placeholder={t.models.selectProviderPlaceholder} />
</SelectTrigger>
<SelectContent title={t.models.provider}>
{providers.map((provider) => (
<SelectItem key={provider} value={provider}>
<span className="capitalize">{provider}</span>
</SelectItem>
))}
</SelectContent>
</Select>
{errors.transcript_provider ? (
<p className="text-xs text-red-600">
{errors.transcript_provider.message}
</p>
) : null}
</div>
)}
/>
<Controller
control={control}
name="transcript_model"
render={({ field }) => (
<div className="space-y-2">
<Label htmlFor="transcript_model">{t.common.model} *</Label>
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger id="transcript_model">
<SelectValue placeholder={t.models.selectModelPlaceholder} />
</SelectTrigger>
<SelectContent title={t.common.model}>
{availableTranscriptModels.map((model) => (
<SelectItem key={model} value={model}>
{model}
</SelectItem>
))}
</SelectContent>
</Select>
{errors.transcript_model ? (
<p className="text-xs text-red-600">
{errors.transcript_model.message}
</p>
) : null}
</div>
)}
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="default_briefing">{t.podcasts.defaultBriefingTitle} *</Label>
<Textarea
id="default_briefing"
rows={6}
placeholder={t.podcasts.defaultBriefingPlaceholder}
{...register('default_briefing')}
/>
{errors.default_briefing ? (
<p className="text-xs text-red-600">
{errors.default_briefing.message}
</p>
) : null}
</div>
<div className="flex justify-end gap-2 pt-2">
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
>
{t.common.cancel}
</Button>
<Button type="submit" disabled={disableSubmit}>
{isSubmitting
? t.common.saving
: isEdit
? t.common.saveChanges
: t.podcasts.createProfile}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
)
}