diff --git a/jsapp/js/components/submissions/BulkProcessingModals/BulkTranscriptionModal/BulkTranscriptionModal.tsx b/jsapp/js/components/submissions/BulkProcessingModals/BulkTranscriptionModal/BulkTranscriptionModal.tsx index e5e697d0dd..7ce67610a7 100644 --- a/jsapp/js/components/submissions/BulkProcessingModals/BulkTranscriptionModal/BulkTranscriptionModal.tsx +++ b/jsapp/js/components/submissions/BulkProcessingModals/BulkTranscriptionModal/BulkTranscriptionModal.tsx @@ -43,6 +43,12 @@ function getAlreadyTranscribedMessage(count: number, duration: string): string { .replace('##duration##', duration) } +function isAlreadyTranscribedSubmission(submission: SubmissionResponse, sourceRowPath: string): boolean { + const transcript = submission._supplementalDetails?.[sourceRowPath]?.transcript + + return Boolean(transcript?.value || transcript?.pendingReview) +} + export interface BulkTranscriptionModalProps { fieldXpath: string assetUid: string @@ -113,18 +119,40 @@ export function BulkTranscriptionModal(props: BulkTranscriptionModalProps) { const advancedFeatures = advancedFeaturesData?.status === 200 ? advancedFeaturesData.data : [] const suggestedLanguages = getSuggestedLanguages(advancedFeatures) + const { sourceRowPath } = getSupplementalPathParts(props.fieldXpath) + + // Keep the quota estimate aligned with the rows that will actually be sent. + // The alert hook filters already-transcribed submissions later, so we mirror + // that exclusion here instead of counting every selected row up front. + const transcribableSubmissions = useMemo( + () => props.selectedSubmissions.filter((submission) => !isAlreadyTranscribedSubmission(submission, sourceRowPath)), + [props.selectedSubmissions, sourceRowPath], + ) + + const { + duration: totalSelectedAudioDuration, + isLoading: isTotalSelectedAudioDurationLoading, + isError: isTotalSelectedAudioDurationError, + } = useCalculateAudioDuration({ + selectedSubmissions: transcribableSubmissions, + fieldId: sourceRowPath, + assetUid: props.assetUid, + }) + + const requiredSeconds = + isTotalSelectedAudioDurationLoading || isTotalSelectedAudioDurationError ? undefined : totalSelectedAudioDuration + const { activeAlerts, hasErrors, hasBlockingError, eligibleSubmissions } = useBulkProcessingAlerts({ actionType: 'transcript', selectedSubmissions: props.selectedSubmissions, selectedLanguage: selectedLanguage || undefined, selectedRegion: selectedRegion || undefined, fieldXpath: props.fieldXpath, + requiredAmount: requiredSeconds, serviceUsageData: serviceUsageData || undefined, activeBulkActions: props.activeBulkActions, }) - const { sourceRowPath } = getSupplementalPathParts(props.fieldXpath) - const eligibleSubmissionUuids = eligibleSubmissions.map((s) => s._uuid) const alreadyTranscribedSubmissionUuids = useMemo( @@ -240,7 +268,6 @@ export function BulkTranscriptionModal(props: BulkTranscriptionModalProps) { {!showWarningModal && ( - {/* Legacy alert - will be removed once audio duration evaluators are implemented see DEV-1399 */} {isAudioDurationError && audioDurationErrorMesssage && ( {audioDurationErrorMesssage} @@ -280,13 +307,6 @@ export function BulkTranscriptionModal(props: BulkTranscriptionModalProps) { - {/* Legacy alert - will be removed once evaluators are implemented */} - {hasExceededLimit && activeAlertsWithResolvedMinutes.length === 0 && ( - - {t("You've reached your automatic transcription limit. Please purchase an add‑on to continue.")} - - )} - {t('Automatic transcription is provided by Google Cloud Platform.')}   diff --git a/jsapp/js/components/submissions/BulkProcessingModals/BulkTranslationModal/BulkTranslationModal.tsx b/jsapp/js/components/submissions/BulkProcessingModals/BulkTranslationModal/BulkTranslationModal.tsx index 9fa49d74e3..eabeae34dd 100644 --- a/jsapp/js/components/submissions/BulkProcessingModals/BulkTranslationModal/BulkTranslationModal.tsx +++ b/jsapp/js/components/submissions/BulkProcessingModals/BulkTranslationModal/BulkTranslationModal.tsx @@ -1,6 +1,6 @@ import { Anchor, Group, Stack, Text } from '@mantine/core' import { useQueryClient } from '@tanstack/react-query' -import { useState } from 'react' +import { useMemo, useState } from 'react' import { useNavigate } from 'react-router-dom' import { ACCOUNT_ROUTES } from '#/account/routes.constants' import type { ServerError } from '#/api/ServerError' @@ -16,7 +16,6 @@ import { useOrganizationsServiceUsageRetrieve, } from '#/api/react-query/user-team-organization-usage' import ButtonNew from '#/components/common/ButtonNew' -import Alert from '#/components/common/alert' import LanguageSelector from '#/components/languages/LanguageSelector' import type { LanguageCode } from '#/components/languages/languagesStore' import { getSuggestedLanguages } from '#/components/processing/common/utils' @@ -102,12 +101,37 @@ export function BulkTranslationModal(props: BulkTranslationModalProps) { const advancedFeatures = advancedFeaturesData?.status === 200 ? advancedFeaturesData.data : [] const suggestedLanguages = getSuggestedLanguages(advancedFeatures) + const { sourceRowPath } = getSupplementalPathParts(props.fieldXpath) + // Use bulk processing alerts hook + // Near-limit should reflect only the submissions that still need translation. + // Rows that already have a translation in the selected language will be filtered + // out by the alert pipeline, so we exclude them here as well to keep the limit check aligned. + const requiredCharacters = useMemo(() => { + if (!selectedLanguage) { + return undefined + } + + return props.selectedSubmissions.reduce((sum, sub) => { + const supplementalDetails = sub._supplementalDetails?.[sourceRowPath] + const translation = supplementalDetails?.translation?.[selectedLanguage] + + if (translation?.value) { + // This row is already translated in the chosen language and won't consume MT quota. + return sum + } + + const supplementalValue = getSupplementalDetailsContent(sub, props.fieldXpath) || '' + return sum + supplementalValue.length + }, 0) + }, [props.selectedSubmissions, props.fieldXpath, selectedLanguage, sourceRowPath]) + const { activeAlerts, hasErrors, hasBlockingError, eligibleSubmissions } = useBulkProcessingAlerts({ actionType: 'translation', selectedSubmissions: props.selectedSubmissions, selectedLanguage: selectedLanguage || undefined, fieldXpath: props.fieldXpath, + requiredAmount: requiredCharacters, serviceUsageData: serviceUsageData || undefined, activeBulkActions: props.activeBulkActions, }) @@ -123,10 +147,6 @@ export function BulkTranslationModal(props: BulkTranslationModalProps) { const eligibleSubmissionUuids = eligibleSubmissions.map((submission) => submission._uuid) const handleStartTranslation = () => { - // Extract the source row path from the transcript column path - // e.g., "_supplementalDetails/q1/transcript_en" -> "q1" - const { sourceRowPath } = getSupplementalPathParts(props.fieldXpath) - // Use eligibleSubmissionUuids from the alerts hook to filter out submissions // that have been flagged by warning evaluators (e.g., already translated, no source) createBulkTranslation({ @@ -185,13 +205,6 @@ export function BulkTranslationModal(props: BulkTranslationModalProps) { - {/* Legacy alert - will be removed once evaluators are implemented */} - {hasExceededLimit && activeAlerts.length === 0 && ( - - {t("You've reached your automatic translation limit. Please purchase an add‑on to continue.")} - - )} - {t('Automatic translation is provided by Google Cloud Platform.')}   diff --git a/jsapp/js/components/submissions/BulkProcessingModals/alerts/BulkProcessingAlerts.stories.tsx b/jsapp/js/components/submissions/BulkProcessingModals/alerts/BulkProcessingAlerts.stories.tsx index d1a1ceb353..27ebdcba76 100644 --- a/jsapp/js/components/submissions/BulkProcessingModals/alerts/BulkProcessingAlerts.stories.tsx +++ b/jsapp/js/components/submissions/BulkProcessingModals/alerts/BulkProcessingAlerts.stories.tsx @@ -41,7 +41,7 @@ function createMockAlert( // Mock alerts const mockErrorAlert = createMockAlert('reached-limit', {}) -const mockNearLimitAlert = createMockAlert('near-limit', { remainingMinutes: 5 }) +const mockNearLimitAlert = createMockAlert('near-limit', { remainingSeconds: 5 }) const mockWarningAlreadyTranscribed = createMockAlert('already-transcribed', { count: 3, duration: '15 minutes' }) const mockWarningNoSource = createMockAlert('no-source', { count: 2 }) const mockWarningConflicting = createMockAlert('conflicting-job', {}) diff --git a/jsapp/js/components/submissions/BulkProcessingModals/alerts/alertDefinitions.ts b/jsapp/js/components/submissions/BulkProcessingModals/alerts/alertDefinitions.ts index 60f185de0a..2c63fe683d 100644 --- a/jsapp/js/components/submissions/BulkProcessingModals/alerts/alertDefinitions.ts +++ b/jsapp/js/components/submissions/BulkProcessingModals/alerts/alertDefinitions.ts @@ -36,11 +36,13 @@ export function getAlertDefinitions(actionType: BulkActionType): AlertDefinition type: 'error', evaluator: evaluateNearLimit, messageTemplate: (values) => { - const remaining = isTranscription ? (values.remainingMinutes ?? '0') : (values.remainingCharacters ?? '0') + const remaining = isTranscription + ? formatTimeFromSeconds(Number(values.remainingSeconds ?? 0)) + : (values.remainingCharacters ?? '0') return isTranscription ? t( - '##remainingMinutes## minutes of automated transcription left, that is not enough to process all selected submissions. Please select fewer files, purchase an add-on, or upgrade your plan.', - ).replace('##remainingMinutes##', String(remaining)) + '##remainingDuration## of automated transcription left, that is not enough to process all selected submissions. Please select fewer files, purchase an add-on, or upgrade your plan.', + ).replace('##remainingDuration##', String(remaining)) : t( '##remainingCharacters## characters of automated translation left, that is not enough to process all selected submissions. Please select fewer files, purchase an add-on, or upgrade your plan.', ).replace('##remainingCharacters##', String(remaining)) diff --git a/jsapp/js/components/submissions/BulkProcessingModals/alerts/alertEvaluators.tests.ts b/jsapp/js/components/submissions/BulkProcessingModals/alerts/alertEvaluators.tests.ts index 520172c02c..a8ca6b5b9a 100644 --- a/jsapp/js/components/submissions/BulkProcessingModals/alerts/alertEvaluators.tests.ts +++ b/jsapp/js/components/submissions/BulkProcessingModals/alerts/alertEvaluators.tests.ts @@ -3,11 +3,12 @@ import { ActionIdEnum } from '#/api/models/actionIdEnum' import { BulkActionResponseStatusEnum } from '#/api/models/bulkActionResponseStatusEnum' import assetDataFactory from '#/endpoints/assetData.factory' import bulkActionFactory from '#/endpoints/bulkAction.factory' -import { asrExceeded, mtExceeded, withinLimits } from '#/endpoints/serviceUsage.factory' +import { asrExceeded, asrNearLimit, mtExceeded, mtNearLimit, withinLimits } from '#/endpoints/serviceUsage.factory' import { evaluateAlreadyTranscribed, evaluateAlreadyTranslated, evaluateConflictingJob, + evaluateNearLimit, evaluateNoEligibleSubmissions, evaluateNoSource, evaluateReachedLimit, @@ -37,10 +38,10 @@ describe('evaluateNoEligibleSubmissions', () => { const result = evaluateNoEligibleSubmissions(context) - expect(result.shouldShow).to.equal(true) - expect(result.type).to.equal('error') - expect(result.filteredSubmissionUuids).to.deep.equal([]) - expect(result.computedValues).to.deep.equal({ + expect(result).to.not.equal(null) + expect(result?.type).to.equal('error') + expect(result?.filteredSubmissionUuids).to.deep.equal([]) + expect(result?.computedValues).to.deep.equal({ totalCount: 3, filteredCount: 3, }) @@ -54,13 +55,7 @@ describe('evaluateNoEligibleSubmissions', () => { const result = evaluateNoEligibleSubmissions(context) - expect(result.shouldShow).to.equal(false) - expect(result.type).to.equal('error') - expect(result.filteredSubmissionUuids).to.deep.equal([]) - expect(result.computedValues).to.deep.equal({ - totalCount: 3, - filteredCount: 2, - }) + expect(result).to.equal(null) }) it('should not show alert when no submissions are filtered', () => { @@ -71,13 +66,7 @@ describe('evaluateNoEligibleSubmissions', () => { const result = evaluateNoEligibleSubmissions(context) - expect(result.shouldShow).to.equal(false) - expect(result.type).to.equal('error') - expect(result.filteredSubmissionUuids).to.deep.equal([]) - expect(result.computedValues).to.deep.equal({ - totalCount: 3, - filteredCount: 0, - }) + expect(result).to.equal(null) }) it('should handle empty submissions array', () => { @@ -89,9 +78,9 @@ describe('evaluateNoEligibleSubmissions', () => { const result = evaluateNoEligibleSubmissions(context) - expect(result.shouldShow).to.equal(true) - expect(result.type).to.equal('error') - expect(result.computedValues).to.deep.equal({ + expect(result).to.not.equal(null) + expect(result?.type).to.equal('error') + expect(result?.computedValues).to.deep.equal({ totalCount: 0, filteredCount: 0, }) @@ -121,9 +110,7 @@ describe('evaluateConflictingJob', () => { const result = evaluateConflictingJob(context) - expect(result.shouldShow).to.equal(false) - expect(result.type).to.equal('warning') - expect(result.filteredSubmissionUuids).to.deep.equal([]) + expect(result).to.equal(null) }) it('should not show alert when ongoing jobs are for different field', () => { @@ -140,9 +127,7 @@ describe('evaluateConflictingJob', () => { const result = evaluateConflictingJob(context) - expect(result.shouldShow).to.equal(false) - expect(result.type).to.equal('warning') - expect(result.filteredSubmissionUuids).to.deep.equal([]) + expect(result).to.equal(null) }) it('should not show alert when jobs are completed', () => { @@ -159,9 +144,7 @@ describe('evaluateConflictingJob', () => { const result = evaluateConflictingJob(context) - expect(result.shouldShow).to.equal(false) - expect(result.type).to.equal('warning') - expect(result.filteredSubmissionUuids).to.deep.equal([]) + expect(result).to.equal(null) }) it('should show alert for transcription when ongoing transcription job conflicts', () => { @@ -179,10 +162,10 @@ describe('evaluateConflictingJob', () => { const result = evaluateConflictingJob(context) - expect(result.shouldShow).to.equal(true) - expect(result.type).to.equal('warning') - expect(result.filteredSubmissionUuids).to.deep.equal(['uuid-1', 'uuid-2']) - expect(result.computedValues).to.deep.equal({ + expect(result).to.not.equal(null) + expect(result?.type).to.equal('warning') + expect(result?.filteredSubmissionUuids).to.deep.equal(['uuid-1', 'uuid-2']) + expect(result?.computedValues).to.deep.equal({ count: 2, conflictingJobCount: 1, }) @@ -203,10 +186,10 @@ describe('evaluateConflictingJob', () => { const result = evaluateConflictingJob(context) - expect(result.shouldShow).to.equal(true) - expect(result.type).to.equal('warning') - expect(result.filteredSubmissionUuids).to.deep.equal(['uuid-3']) - expect(result.computedValues.count).to.equal(1) + expect(result).to.not.equal(null) + expect(result?.type).to.equal('warning') + expect(result?.filteredSubmissionUuids).to.deep.equal(['uuid-3']) + expect(result?.computedValues.count).to.equal(1) }) it('should show alert for translation when ongoing translation job conflicts with same language', () => { @@ -226,9 +209,9 @@ describe('evaluateConflictingJob', () => { const result = evaluateConflictingJob(context) - expect(result.shouldShow).to.equal(true) - expect(result.type).to.equal('warning') - expect(result.filteredSubmissionUuids).to.deep.equal(['uuid-1']) + expect(result).to.not.equal(null) + expect(result?.type).to.equal('warning') + expect(result?.filteredSubmissionUuids).to.deep.equal(['uuid-1']) }) it('should not show alert for translation when ongoing translation job is for different language', () => { @@ -248,9 +231,7 @@ describe('evaluateConflictingJob', () => { const result = evaluateConflictingJob(context) - expect(result.shouldShow).to.equal(false) - expect(result.type).to.equal('warning') - expect(result.filteredSubmissionUuids).to.deep.equal([]) + expect(result).to.equal(null) }) it('should show alert for translation when ongoing transcription job conflicts', () => { @@ -270,9 +251,9 @@ describe('evaluateConflictingJob', () => { const result = evaluateConflictingJob(context) - expect(result.shouldShow).to.equal(true) - expect(result.type).to.equal('warning') - expect(result.filteredSubmissionUuids).to.deep.equal(['uuid-2']) + expect(result).to.not.equal(null) + expect(result?.type).to.equal('warning') + expect(result?.filteredSubmissionUuids).to.deep.equal(['uuid-2']) }) it('should handle multiple conflicting jobs', () => { @@ -296,10 +277,10 @@ describe('evaluateConflictingJob', () => { const result = evaluateConflictingJob(context) - expect(result.shouldShow).to.equal(true) - expect(result.type).to.equal('warning') - expect(result.filteredSubmissionUuids).to.have.members(['uuid-1', 'uuid-2', 'uuid-3']) - expect(result.computedValues).to.deep.equal({ + expect(result).to.not.equal(null) + expect(result?.type).to.equal('warning') + expect(result?.filteredSubmissionUuids).to.have.members(['uuid-1', 'uuid-2', 'uuid-3']) + expect(result?.computedValues).to.deep.equal({ count: 3, conflictingJobCount: 2, }) @@ -320,9 +301,7 @@ describe('evaluateConflictingJob', () => { const result = evaluateConflictingJob(context) - expect(result.shouldShow).to.equal(false) - expect(result.type).to.equal('warning') - expect(result.filteredSubmissionUuids).to.deep.equal([]) + expect(result).to.equal(null) }) it('should not show alert for translation when ongoing translation job is for different field', () => { @@ -342,9 +321,7 @@ describe('evaluateConflictingJob', () => { const result = evaluateConflictingJob(context) - expect(result.shouldShow).to.equal(false) - expect(result.type).to.equal('warning') - expect(result.filteredSubmissionUuids).to.deep.equal([]) + expect(result).to.equal(null) }) }) @@ -367,9 +344,9 @@ describe('evaluateReachedLimit', () => { const result = evaluateReachedLimit(context) - expect(result.shouldShow).to.equal(true) - expect(result.type).to.equal('error') - expect(result.filteredSubmissionUuids).to.deep.equal([]) + expect(result).to.not.equal(null) + expect(result?.type).to.equal('error') + expect(result?.filteredSubmissionUuids).to.deep.equal([]) }) it('should not show alert when transcription quota is not exceeded', () => { @@ -380,8 +357,7 @@ describe('evaluateReachedLimit', () => { const result = evaluateReachedLimit(context) - expect(result.shouldShow).to.equal(false) - expect(result.type).to.equal('error') + expect(result).to.equal(null) }) it('should show alert when translation quota is exceeded', () => { @@ -393,9 +369,9 @@ describe('evaluateReachedLimit', () => { const result = evaluateReachedLimit(context) - expect(result.shouldShow).to.equal(true) - expect(result.type).to.equal('error') - expect(result.filteredSubmissionUuids).to.deep.equal([]) + expect(result).to.not.equal(null) + expect(result?.type).to.equal('error') + expect(result?.filteredSubmissionUuids).to.deep.equal([]) }) it('should not show alert when translation quota is not exceeded', () => { @@ -407,8 +383,7 @@ describe('evaluateReachedLimit', () => { const result = evaluateReachedLimit(context) - expect(result.shouldShow).to.equal(false) - expect(result.type).to.equal('error') + expect(result).to.equal(null) }) it('should not show alert when serviceUsageData is missing', () => { @@ -419,8 +394,90 @@ describe('evaluateReachedLimit', () => { const result = evaluateReachedLimit(context) - expect(result.shouldShow).to.equal(false) - expect(result.type).to.equal('error') + expect(result).to.equal(null) + }) +}) + +describe('evaluateNearLimit', () => { + const mockSubmissions = [assetDataFactory(1, { _uuid: 'uuid-1' }), assetDataFactory(2, { _uuid: 'uuid-2' })] + + const baseContext: AlertEvaluationContext = { + submissions: mockSubmissions, + fieldXpath: 'audio_question', + actionType: 'transcript', + activeBulkActions: [], + previouslyFilteredSubmissionUuids: new Set(), + } + + it('should show alert for transcription when remaining balance is positive but below required amount', () => { + const context: AlertEvaluationContext = { + ...baseContext, + serviceUsageData: asrNearLimit(95), + requiredAmount: 120, + } + + const result = evaluateNearLimit(context) + + expect(result).to.not.equal(null) + expect(result?.type).to.equal('error') + expect(result?.filteredSubmissionUuids).to.deep.equal([]) + expect(result?.computedValues).to.deep.equal({ + remainingSeconds: 30, + }) + }) + + it('should not show alert when remaining amount is enough to process the full job', () => { + const context: AlertEvaluationContext = { + ...baseContext, + serviceUsageData: asrNearLimit(95), + requiredAmount: 20, + } + + const result = evaluateNearLimit(context) + + expect(result).to.equal(null) + }) + + it('should not show alert when balance is exceeded', () => { + const context: AlertEvaluationContext = { + ...baseContext, + serviceUsageData: asrExceeded(), + requiredAmount: 120, + } + + const result = evaluateNearLimit(context) + + expect(result).to.equal(null) + }) + + it('should show alert for translation when remaining characters are below required amount', () => { + const context: AlertEvaluationContext = { + ...baseContext, + actionType: 'translation', + serviceUsageData: mtNearLimit(95), + requiredAmount: 3000, + } + + const result = evaluateNearLimit(context) + + expect(result).to.not.equal(null) + expect(result?.type).to.equal('error') + expect(result?.filteredSubmissionUuids).to.deep.equal([]) + expect(result?.computedValues).to.deep.equal({ + remainingCharacters: 2500, + }) + }) + + it('should not show alert when required amount is missing', () => { + const context: AlertEvaluationContext = { + ...baseContext, + serviceUsageData: asrNearLimit(95), + requiredAmount: undefined, + } + + const result = evaluateNearLimit(context) + + expect(result).to.equal(null) }) }) @@ -456,8 +513,7 @@ describe('evaluateAlreadyTranslated', () => { const result = evaluateAlreadyTranslated(context) - expect(result.shouldShow).to.equal(false) - expect(result.type).to.equal('warning') + expect(result).to.equal(null) }) it('should not show alert when no submissions have translations', () => { @@ -470,9 +526,7 @@ describe('evaluateAlreadyTranslated', () => { const result = evaluateAlreadyTranslated(context) - expect(result.shouldShow).to.equal(false) - expect(result.type).to.equal('warning') - expect(result.filteredSubmissionUuids).to.deep.equal([]) + expect(result).to.equal(null) }) it('should show alert when submissions have existing translations', () => { @@ -507,10 +561,10 @@ describe('evaluateAlreadyTranslated', () => { const result = evaluateAlreadyTranslated(context) - expect(result.shouldShow).to.equal(true) - expect(result.type).to.equal('warning') - expect(result.filteredSubmissionUuids).to.deep.equal(['uuid-1', 'uuid-2']) - expect(result.computedValues).to.deep.equal({ + expect(result).to.not.equal(null) + expect(result?.type).to.equal('warning') + expect(result?.filteredSubmissionUuids).to.deep.equal(['uuid-1', 'uuid-2']) + expect(result?.computedValues).to.deep.equal({ count: 2, characters: 7 + 9, // 'Bonjour' + 'Au revoir' }) @@ -547,9 +601,9 @@ describe('evaluateAlreadyTranslated', () => { const result = evaluateAlreadyTranslated(context) - expect(result.shouldShow).to.equal(true) - expect(result.filteredSubmissionUuids).to.deep.equal(['uuid-2']) - expect(result.computedValues.count).to.equal(1) + expect(result).to.not.equal(null) + expect(result?.filteredSubmissionUuids).to.deep.equal(['uuid-2']) + expect(result?.computedValues.count).to.equal(1) }) it('should not flag submissions with empty translation value', () => { @@ -583,8 +637,7 @@ describe('evaluateAlreadyTranslated', () => { const result = evaluateAlreadyTranslated(context) - expect(result.shouldShow).to.equal(false) - expect(result.filteredSubmissionUuids).to.deep.equal([]) + expect(result).to.equal(null) }) it('should skip submissions already filtered by previous evaluators', () => { @@ -619,9 +672,9 @@ describe('evaluateAlreadyTranslated', () => { const result = evaluateAlreadyTranslated(context) - expect(result.shouldShow).to.equal(true) - expect(result.filteredSubmissionUuids).to.deep.equal(['uuid-2']) - expect(result.computedValues).to.deep.equal({ + expect(result).to.not.equal(null) + expect(result?.filteredSubmissionUuids).to.deep.equal(['uuid-2']) + expect(result?.computedValues).to.deep.equal({ count: 1, characters: 9, // Only 'Au revoir' }) @@ -648,8 +701,7 @@ describe('evaluateAlreadyTranslated', () => { const result = evaluateAlreadyTranslated(context) - expect(result.shouldShow).to.equal(false) - expect(result.filteredSubmissionUuids).to.deep.equal([]) + expect(result).to.equal(null) }) it('should work with direct field xpath (not transcript column xpath)', () => { @@ -674,9 +726,9 @@ describe('evaluateAlreadyTranslated', () => { const result = evaluateAlreadyTranslated(context) - expect(result.shouldShow).to.equal(true) - expect(result.filteredSubmissionUuids).to.deep.equal(['uuid-1']) - expect(result.computedValues).to.deep.equal({ + expect(result).to.not.equal(null) + expect(result?.filteredSubmissionUuids).to.deep.equal(['uuid-1']) + expect(result?.computedValues).to.deep.equal({ count: 1, characters: 7, }) @@ -702,13 +754,7 @@ describe('evaluateAlreadyTranscribed', () => { const result = evaluateAlreadyTranscribed(context) - expect(result.shouldShow).to.equal(false) - expect(result.type).to.equal('warning') - expect(result.filteredSubmissionUuids).to.deep.equal([]) - expect(result.computedValues).to.deep.equal({ - count: 0, - duration: 0, - }) + expect(result).to.equal(null) }) it('should show alert when submissions have existing transcripts in any language', () => { @@ -745,10 +791,10 @@ describe('evaluateAlreadyTranscribed', () => { const result = evaluateAlreadyTranscribed(context) - expect(result.shouldShow).to.equal(true) - expect(result.type).to.equal('warning') - expect(result.filteredSubmissionUuids).to.deep.equal(['uuid-1', 'uuid-2']) - expect(result.computedValues).to.deep.equal({ + expect(result).to.not.equal(null) + expect(result?.type).to.equal('warning') + expect(result?.filteredSubmissionUuids).to.deep.equal(['uuid-1', 'uuid-2']) + expect(result?.computedValues).to.deep.equal({ count: 2, duration: 0, }) @@ -776,9 +822,9 @@ describe('evaluateAlreadyTranscribed', () => { const result = evaluateAlreadyTranscribed(context) - expect(result.shouldShow).to.equal(true) - expect(result.filteredSubmissionUuids).to.deep.equal(['uuid-1']) - expect(result.computedValues.count).to.equal(1) + expect(result).to.not.equal(null) + expect(result?.filteredSubmissionUuids).to.deep.equal(['uuid-1']) + expect(result?.computedValues.count).to.equal(1) }) it('should skip submissions already filtered by previous evaluators', () => { @@ -815,9 +861,9 @@ describe('evaluateAlreadyTranscribed', () => { const result = evaluateAlreadyTranscribed(context) - expect(result.shouldShow).to.equal(true) - expect(result.filteredSubmissionUuids).to.deep.equal(['uuid-2']) - expect(result.computedValues).to.deep.equal({ + expect(result).to.not.equal(null) + expect(result?.filteredSubmissionUuids).to.deep.equal(['uuid-2']) + expect(result?.computedValues).to.deep.equal({ count: 1, duration: 0, }) @@ -873,9 +919,7 @@ describe('evaluateNoSource', () => { const result = evaluateNoSource(context) - expect(result.shouldShow).to.equal(false) - expect(result.type).to.equal('warning') - expect(result.filteredSubmissionUuids).to.deep.equal([]) + expect(result).to.equal(null) }) it('should show alert when submissions are missing audio attachments', () => { @@ -905,10 +949,10 @@ describe('evaluateNoSource', () => { const result = evaluateNoSource(context) - expect(result.shouldShow).to.equal(true) - expect(result.type).to.equal('warning') - expect(result.filteredSubmissionUuids).to.deep.equal(['uuid-2', 'uuid-3']) - expect(result.computedValues.count).to.equal(2) + expect(result).to.not.equal(null) + expect(result?.type).to.equal('warning') + expect(result?.filteredSubmissionUuids).to.deep.equal(['uuid-2', 'uuid-3']) + expect(result?.computedValues.count).to.equal(2) }) it('should ignore deleted attachments', () => { @@ -936,8 +980,8 @@ describe('evaluateNoSource', () => { const result = evaluateNoSource(context) - expect(result.shouldShow).to.equal(true) - expect(result.filteredSubmissionUuids).to.deep.equal(['uuid-1']) + expect(result).to.not.equal(null) + expect(result?.filteredSubmissionUuids).to.deep.equal(['uuid-1']) }) it('should check correct field xpath', () => { @@ -965,8 +1009,8 @@ describe('evaluateNoSource', () => { const result = evaluateNoSource(context) - expect(result.shouldShow).to.equal(true) - expect(result.filteredSubmissionUuids).to.deep.equal(['uuid-1']) + expect(result).to.not.equal(null) + expect(result?.filteredSubmissionUuids).to.deep.equal(['uuid-1']) }) it('should skip submissions already filtered by previous evaluators', () => { @@ -983,9 +1027,9 @@ describe('evaluateNoSource', () => { const result = evaluateNoSource(context) - expect(result.shouldShow).to.equal(true) - expect(result.filteredSubmissionUuids).to.deep.equal(['uuid-2']) - expect(result.computedValues.count).to.equal(1) + expect(result).to.not.equal(null) + expect(result?.filteredSubmissionUuids).to.deep.equal(['uuid-2']) + expect(result?.computedValues.count).to.equal(1) }) }) @@ -1025,9 +1069,7 @@ describe('evaluateNoSource', () => { const result = evaluateNoSource(context) - expect(result.shouldShow).to.equal(false) - expect(result.type).to.equal('warning') - expect(result.filteredSubmissionUuids).to.deep.equal([]) + expect(result).to.equal(null) }) it('should show alert when submissions are missing transcripts', () => { @@ -1056,10 +1098,10 @@ describe('evaluateNoSource', () => { const result = evaluateNoSource(context) - expect(result.shouldShow).to.equal(true) - expect(result.type).to.equal('warning') - expect(result.filteredSubmissionUuids).to.deep.equal(['uuid-2', 'uuid-3']) - expect(result.computedValues.count).to.equal(2) + expect(result).to.not.equal(null) + expect(result?.type).to.equal('warning') + expect(result?.filteredSubmissionUuids).to.deep.equal(['uuid-2', 'uuid-3']) + expect(result?.computedValues.count).to.equal(2) }) it('should flag submissions with empty transcript value', () => { @@ -1089,8 +1131,8 @@ describe('evaluateNoSource', () => { const result = evaluateNoSource(context) - expect(result.shouldShow).to.equal(true) - expect(result.filteredSubmissionUuids).to.deep.equal(['uuid-1', 'uuid-2']) + expect(result).to.not.equal(null) + expect(result?.filteredSubmissionUuids).to.deep.equal(['uuid-1', 'uuid-2']) }) it('should check correct field xpath', () => { @@ -1112,8 +1154,8 @@ describe('evaluateNoSource', () => { const result = evaluateNoSource(context) - expect(result.shouldShow).to.equal(true) - expect(result.filteredSubmissionUuids).to.deep.equal(['uuid-1']) + expect(result).to.not.equal(null) + expect(result?.filteredSubmissionUuids).to.deep.equal(['uuid-1']) }) it('should skip submissions already filtered by previous evaluators', () => { @@ -1127,9 +1169,9 @@ describe('evaluateNoSource', () => { const result = evaluateNoSource(context) - expect(result.shouldShow).to.equal(true) - expect(result.filteredSubmissionUuids).to.deep.equal(['uuid-2']) - expect(result.computedValues.count).to.equal(1) + expect(result).to.not.equal(null) + expect(result?.filteredSubmissionUuids).to.deep.equal(['uuid-2']) + expect(result?.computedValues.count).to.equal(1) }) }) }) diff --git a/jsapp/js/components/submissions/BulkProcessingModals/alerts/alertEvaluators.ts b/jsapp/js/components/submissions/BulkProcessingModals/alerts/alertEvaluators.ts index 003bd52da8..f4a9e659d6 100644 --- a/jsapp/js/components/submissions/BulkProcessingModals/alerts/alertEvaluators.ts +++ b/jsapp/js/components/submissions/BulkProcessingModals/alerts/alertEvaluators.ts @@ -2,25 +2,28 @@ import { ActionIdEnum } from '#/api/models/actionIdEnum' import { BulkActionResponseStatusEnum } from '#/api/models/bulkActionResponseStatusEnum' import { getSupplementalPathParts } from '#/components/processing/processingUtils' import type { AlertEvaluationContext, AlertEvaluationResult } from './types' -import { createInactiveResult } from './utils' /** * Checks if user has reached their quota limit (0 remaining) */ -export function evaluateReachedLimit(context: AlertEvaluationContext): AlertEvaluationResult { +export function evaluateReachedLimit(context: AlertEvaluationContext): AlertEvaluationResult | null { const { actionType, serviceUsageData } = context // Can't evaluate without service usage data if (!serviceUsageData?.balances) { - return createInactiveResult('error') + return null } // Check the appropriate balance based on action type const balance = actionType === 'transcript' ? serviceUsageData.balances.asr_seconds : serviceUsageData.balances.mt_characters + const exceeded = balance?.exceeded || false + if (!exceeded) { + return null + } + return { - shouldShow: balance?.exceeded || false, type: 'error', filteredSubmissionUuids: [], computedValues: {}, @@ -29,13 +32,51 @@ export function evaluateReachedLimit(context: AlertEvaluationContext): AlertEval /** * Checks if remaining quota is less than required but greater than 0 - * TODO: DEV-1399 - Implement this evaluator (depends on DEV-2255 for audio duration) */ -export function evaluateNearLimit(context: AlertEvaluationContext): AlertEvaluationResult { - console.log('[BulkProcessingAlerts] Evaluator evaluateNearLimit - STUBBED, returning no alerts', context) +export function evaluateNearLimit(context: AlertEvaluationContext): AlertEvaluationResult | null { + const { actionType, serviceUsageData, requiredAmount } = context + + if (!serviceUsageData?.balances || requiredAmount === undefined) { + return null + } + + if (requiredAmount <= 0) { + return null + } + + const balance = + actionType === 'transcript' ? serviceUsageData.balances.asr_seconds : serviceUsageData.balances.mt_characters + + if (!balance) { + return null + } + + const remainingAmount = balance.balance_value - // STUB: Return inactive result - return createInactiveResult('error') + // Don't show this alert if: + // 1. remainingAmount <= 0 — no quota left (the reached-limit alert handles this, runs first) + // 2. remainingAmount >= requiredAmount — enough quota to process everything + // + // Show only when 0 < remainingAmount < requiredAmount. + // That's when you have some quota but not enough for all the submissions you selected. + if (remainingAmount <= 0 || remainingAmount >= requiredAmount) { + return null + } + + const computedValues = + actionType === 'transcript' + ? { + remainingSeconds: remainingAmount, + } + : { + remainingCharacters: remainingAmount, + } + + return { + type: 'error', + filteredSubmissionUuids: [], + computedValues, + } } /** @@ -46,7 +87,7 @@ export function evaluateNearLimit(context: AlertEvaluationContext): AlertEvaluat * - Ongoing translation jobs on the same field AND same target language (write-locked output) * - Ongoing transcription jobs on the input transcript field (write-locked input) */ -export function evaluateConflictingJob(context: AlertEvaluationContext): AlertEvaluationResult { +export function evaluateConflictingJob(context: AlertEvaluationContext): AlertEvaluationResult | null { const { activeBulkActions, fieldXpath, actionType, submissions, selectedLanguage } = context // Filter to only ongoing jobs (pending or in_progress) @@ -57,12 +98,7 @@ export function evaluateConflictingJob(context: AlertEvaluationContext): AlertEv ) if (ongoingJobs.length === 0) { - return { - shouldShow: false, - type: 'warning', - filteredSubmissionUuids: [], - computedValues: {}, - } + return null } // Find conflicting jobs based on action type @@ -96,12 +132,7 @@ export function evaluateConflictingJob(context: AlertEvaluationContext): AlertEv } if (conflictingJobs.length === 0) { - return { - shouldShow: false, - type: 'warning', - filteredSubmissionUuids: [], - computedValues: {}, - } + return null } // Collect all submission UUIDs from conflicting jobs @@ -115,8 +146,11 @@ export function evaluateConflictingJob(context: AlertEvaluationContext): AlertEv .filter((submission) => conflictingUuids.has(submission._uuid)) .map((submission) => submission._uuid) + if (filteredSubmissionUuids.length === 0) { + return null + } + return { - shouldShow: filteredSubmissionUuids.length > 0, type: 'warning', filteredSubmissionUuids, computedValues: { @@ -130,7 +164,7 @@ export function evaluateConflictingJob(context: AlertEvaluationContext): AlertEv * Checks for submissions missing audio attachments (transcription) * or missing transcripts (translation) */ -export function evaluateNoSource(context: AlertEvaluationContext): AlertEvaluationResult { +export function evaluateNoSource(context: AlertEvaluationContext): AlertEvaluationResult | null { const { submissions, fieldXpath, actionType, previouslyFilteredSubmissionUuids } = context const missingSource: string[] = [] @@ -164,8 +198,11 @@ export function evaluateNoSource(context: AlertEvaluationContext): AlertEvaluati } }) + if (missingSource.length === 0) { + return null + } + return { - shouldShow: missingSource.length > 0, type: 'warning', filteredSubmissionUuids: missingSource, computedValues: { @@ -177,7 +214,7 @@ export function evaluateNoSource(context: AlertEvaluationContext): AlertEvaluati /** * Checks for submissions with existing transcripts */ -export function evaluateAlreadyTranscribed(context: AlertEvaluationContext): AlertEvaluationResult { +export function evaluateAlreadyTranscribed(context: AlertEvaluationContext): AlertEvaluationResult | null { const { submissions, fieldXpath, previouslyFilteredSubmissionUuids } = context const { sourceRowPath } = getSupplementalPathParts(fieldXpath) @@ -197,8 +234,11 @@ export function evaluateAlreadyTranscribed(context: AlertEvaluationContext): Ale } }) + if (alreadyTranscribed.length === 0) { + return null + } + return { - shouldShow: alreadyTranscribed.length > 0, type: 'warning', filteredSubmissionUuids: alreadyTranscribed, // The exact duration (in minutes) is resolved in the transcription modal @@ -213,12 +253,12 @@ export function evaluateAlreadyTranscribed(context: AlertEvaluationContext): Ale /** * Checks for submissions with existing translations in the selected language */ -export function evaluateAlreadyTranslated(context: AlertEvaluationContext): AlertEvaluationResult { +export function evaluateAlreadyTranslated(context: AlertEvaluationContext): AlertEvaluationResult | null { const { submissions, fieldXpath, selectedLanguage, previouslyFilteredSubmissionUuids } = context // Can't evaluate without a selected language if (!selectedLanguage) { - return createInactiveResult('warning') + return null } const { sourceRowPath } = getSupplementalPathParts(fieldXpath) @@ -243,8 +283,11 @@ export function evaluateAlreadyTranslated(context: AlertEvaluationContext): Aler } }) + if (alreadyTranslated.length === 0) { + return null + } + return { - shouldShow: alreadyTranslated.length > 0, type: 'warning', filteredSubmissionUuids: alreadyTranslated, computedValues: { @@ -257,10 +300,14 @@ export function evaluateAlreadyTranslated(context: AlertEvaluationContext): Aler /** * Checks if all submissions have been filtered out by previous evaluators */ -export function evaluateNoEligibleSubmissions(context: AlertEvaluationContext): AlertEvaluationResult { +export function evaluateNoEligibleSubmissions(context: AlertEvaluationContext): AlertEvaluationResult | null { const eligibleCount = context.submissions.length - context.previouslyFilteredSubmissionUuids.size + + if (eligibleCount > 0) { + return null + } + return { - shouldShow: eligibleCount === 0, type: 'error', filteredSubmissionUuids: [], computedValues: { diff --git a/jsapp/js/components/submissions/BulkProcessingModals/alerts/types.ts b/jsapp/js/components/submissions/BulkProcessingModals/alerts/types.ts index ce8d5f2e55..df916c9ac8 100644 --- a/jsapp/js/components/submissions/BulkProcessingModals/alerts/types.ts +++ b/jsapp/js/components/submissions/BulkProcessingModals/alerts/types.ts @@ -23,17 +23,17 @@ export interface AlertEvaluationContext { /** For transcription only */ selectedRegion?: string actionType: BulkActionType + /** Required amount to process all selected submissions in base units: seconds (transcription) or characters (translation). */ + requiredAmount?: number serviceUsageData?: ServiceUsageResponse activeBulkActions: BulkActionResponse[] previouslyFilteredSubmissionUuids: Set } /** - * Result returned by alert evaluators + * A "show alert" result returned by alert evaluators (otherwise it returns `null`) */ export interface AlertEvaluationResult { - /** Whether this alert should be displayed */ - shouldShow: boolean type: AlertSeverity /** Submission Uuids filtered out by this evaluator */ filteredSubmissionUuids: string[] @@ -49,7 +49,7 @@ export interface AlertDefinition { /** Unique alert identifier */ id: string type: AlertSeverity - evaluator: (context: AlertEvaluationContext) => AlertEvaluationResult + evaluator: (context: AlertEvaluationContext) => AlertEvaluationResult | null messageTemplate: (values: Record) => string } diff --git a/jsapp/js/components/submissions/BulkProcessingModals/alerts/useBulkProcessingAlerts.ts b/jsapp/js/components/submissions/BulkProcessingModals/alerts/useBulkProcessingAlerts.ts index 0bf115e990..8860cda5be 100644 --- a/jsapp/js/components/submissions/BulkProcessingModals/alerts/useBulkProcessingAlerts.ts +++ b/jsapp/js/components/submissions/BulkProcessingModals/alerts/useBulkProcessingAlerts.ts @@ -13,6 +13,8 @@ interface UseBulkProcessingAlertsProps { /** Selected region (transcription only) */ selectedRegion?: string fieldXpath: string + /** Required amount for full job in base units: seconds (transcription) or characters (translation). */ + requiredAmount?: number serviceUsageData?: ServiceUsageResponse activeBulkActions: BulkActionResponse[] } @@ -42,6 +44,7 @@ export function useBulkProcessingAlerts(props: UseBulkProcessingAlertsProps): Us selectedLanguage, selectedRegion, fieldXpath, + requiredAmount, serviceUsageData, activeBulkActions, } = props @@ -63,6 +66,7 @@ export function useBulkProcessingAlerts(props: UseBulkProcessingAlertsProps): Us selectedLanguage, selectedRegion, actionType, + requiredAmount, serviceUsageData, activeBulkActions, previouslyFilteredSubmissionUuids: filteredSubmissionUuids, @@ -72,7 +76,7 @@ export function useBulkProcessingAlerts(props: UseBulkProcessingAlertsProps): Us for (const alertDef of alertDefinitions) { const result = alertDef.evaluator(context) - if (result.shouldShow) { + if (result) { // Add filtered submission uuids to the set (for warnings) if (result.type === 'warning') { result.filteredSubmissionUuids.forEach((uuid) => filteredSubmissionUuids.add(uuid)) @@ -119,6 +123,7 @@ export function useBulkProcessingAlerts(props: UseBulkProcessingAlertsProps): Us selectedLanguage, selectedRegion, actionType, + requiredAmount, serviceUsageData, activeBulkActions, alertDefinitions, diff --git a/jsapp/js/components/submissions/BulkProcessingModals/alerts/utils.ts b/jsapp/js/components/submissions/BulkProcessingModals/alerts/utils.ts deleted file mode 100644 index 239ccca309..0000000000 --- a/jsapp/js/components/submissions/BulkProcessingModals/alerts/utils.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { AlertEvaluationResult, AlertSeverity } from './types' - -/** - * Helper function to create an inactive alert result - * Use this when an evaluator determines no alert should be shown - */ -export function createInactiveResult(type: AlertSeverity = 'warning'): AlertEvaluationResult { - return { - shouldShow: false, - type, - filteredSubmissionUuids: [], - computedValues: {}, - } -}