/*
* Copyright (C) 2025 - present Instructure, Inc.
*
* This file is part of Canvas.
*
* Canvas is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3 of the License.
*
* Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
* A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License along
* with this program. If not, see .
*/
import {Alert} from '@instructure/ui-alerts'
import {Button} from '@instructure/ui-buttons'
import {Flex} from '@instructure/ui-flex'
import {FormField} from '@instructure/ui-form-field'
import {Heading} from '@instructure/ui-heading'
import {Text} from '@instructure/ui-text'
import {TruncateText} from '@instructure/ui-truncate-text'
import {View} from '@instructure/ui-view'
import {useEffect, useRef, useState} from 'react'
import CanvasSelect from '../shared/CanvasSelect'
import {CC_FILE_MAX_BYTES} from '../shared/constants'
import {trackPendoEvent} from '../utils/trackPendoEvent'
import {
type ClosedCaptionPanelTranslations,
defaultClosedCaptionPanelTranslations,
} from './translations'
import {validateCaptionFile} from './utils/validation'
interface ManualCaptionCreatorProps {
languages: {id: string; label: string}[]
onPrimary: (languageId: string, file: File) => void
onCancel: () => void
liveRegion: () => HTMLElement | null
mountNode?: HTMLElement | (() => HTMLElement | null)
onDirtyStateChanged?: (isDirty: boolean) => void
elementRef?: (el: Element | null) => void
translations?: ClosedCaptionPanelTranslations
}
const LANGUAGE_ERROR_ID = 'cc-language-error'
const FILE_ERROR_ID = 'cc-file-error'
export function ManualCaptionCreator({
languages,
onPrimary,
onCancel,
liveRegion,
mountNode,
onDirtyStateChanged,
elementRef,
translations = defaultClosedCaptionPanelTranslations,
}: ManualCaptionCreatorProps) {
const [selectedLanguageId, setSelectedLanguageId] = useState('')
const [selectedFile, setSelectedFile] = useState(null)
const [showLanguageError, setShowLanguageError] = useState(false)
const [fileValidationError, setFileValidationError] = useState('')
const fileInputRef = useRef(null)
const languageInputRef = useRef(null)
const fileButtonRef = useRef(null)
useEffect(() => {
languageInputRef.current?.focus()
}, [])
const handleLanguageChange = (_event: React.SyntheticEvent, data: string) => {
if (data) {
setSelectedLanguageId(String(data))
setShowLanguageError(false)
}
onDirtyStateChanged?.(Boolean(data || selectedFile))
}
const handleFileUpload = (event: React.ChangeEvent) => {
const file = event.target.files?.[0]
onDirtyStateChanged?.(Boolean(file || selectedLanguageId))
if (!file) return
const validation = validateCaptionFile(file, translations)
if (!validation.valid) {
if (file.size > CC_FILE_MAX_BYTES) {
trackPendoEvent('canvas_caption_validation_error', {
flow_type: 'upload_file',
error_type: 'file_too_large',
})
}
setFileValidationError(validation.error || '')
setSelectedFile(null)
} else {
setFileValidationError('')
setSelectedFile(file)
}
}
const handleUploadClick = () => {
const hasLanguageError = !selectedLanguageId
const hasFileError = !selectedFile
if (hasLanguageError) {
trackPendoEvent('canvas_caption_validation_error', {
flow_type: 'upload_file',
error_type: 'missing_language',
})
setShowLanguageError(true)
}
if (hasFileError) {
trackPendoEvent('canvas_caption_validation_error', {
flow_type: 'upload_file',
error_type: 'missing_file',
})
setFileValidationError(translations.pleaseSelectFileBeforeUploading())
}
// Focus the first faulty field
if (hasLanguageError) {
languageInputRef.current?.focus()
} else if (hasFileError) {
fileButtonRef.current?.focus()
}
// Only proceed if both are selected
if (selectedLanguageId && selectedFile) {
onPrimary(selectedLanguageId, selectedFile)
}
}
return (
{translations.addNewCaption()}
{translations.uploadSubtitleHint()}
{
languageInputRef.current = el
}}
label=""
placeholder={translations.selectLanguagePlaceholder()}
value={selectedLanguageId}
mountNode={mountNode}
translatedStrings={{
USE_ARROWS: translations.useArrowKeys(),
LIST_COLLAPSED: translations.listCollapsed(),
LIST_EXPANDED: translations.listExpanded(),
OPTION_SELECTED: '{option} selected.',
}}
onChange={handleLanguageChange}
liveRegion={liveRegion}
aria-invalid={showLanguageError ? 'true' : undefined}
aria-describedby={showLanguageError ? LANGUAGE_ERROR_ID : undefined}
messages={
showLanguageError
? [
{
type: 'newError',
text: translations.pleaseSelectLanguage(),
},
]
: []
}
>
{languages.map(option => (
// @ts-expect-error - CanvasSelect.Option is a JS component without TS definitions
{option.label}
))}
{showLanguageError && (
{translations.pleaseSelectLanguage()}
)}
el?.style.setProperty('min-width', '0')} // to allow truncation
messages={
fileValidationError
? [
{
type: 'newError',
text: fileValidationError,
},
]
: []
}
>
{selectedFile ? (
{selectedFile.name}
) : (
{translations.noFileChosen()}
)}
{fileValidationError && (
{fileValidationError}
)}
)
}