diff --git a/src/components/DataEntry/DataEntryTable/NewEntry/index.tsx b/src/components/DataEntry/DataEntryTable/NewEntry/index.tsx index f7b6cdebc8..6cb85e8588 100644 --- a/src/components/DataEntry/DataEntryTable/NewEntry/index.tsx +++ b/src/components/DataEntry/DataEntryTable/NewEntry/index.tsx @@ -21,7 +21,7 @@ import { } from "components/DataEntry/DataEntryTable/EntryCellComponents"; import SenseDialog from "components/DataEntry/DataEntryTable/NewEntry/SenseDialog"; import VernDialog from "components/DataEntry/DataEntryTable/NewEntry/VernDialog"; -import Pronunciations from "components/Pronunciations/PronunciationsComponent"; +import PronunciationsFrontend from "components/Pronunciations/PronunciationsFrontend"; import { StoreState } from "types"; import theme from "types/theme"; @@ -290,12 +290,10 @@ export default function NewEntry(props: NewEntryProps): ReactElement { )} - delNewAudioUrl(fileName)} - uploadAudio={(_, audioFile: File) => addNewAudioUrl(audioFile)} + deleteAudio={delNewAudioUrl} + uploadAudio={addNewAudioUrl} /> diff --git a/src/components/DataEntry/DataEntryTable/NewEntry/tests/index.test.tsx b/src/components/DataEntry/DataEntryTable/NewEntry/tests/index.test.tsx index 314a421fac..54d410dc0e 100644 --- a/src/components/DataEntry/DataEntryTable/NewEntry/tests/index.test.tsx +++ b/src/components/DataEntry/DataEntryTable/NewEntry/tests/index.test.tsx @@ -10,7 +10,7 @@ import { newWritingSystem } from "types/writingSystem"; jest.mock("@mui/material/Autocomplete", () => "div"); -jest.mock("components/Pronunciations/PronunciationsComponent", () => "div"); +jest.mock("components/Pronunciations/PronunciationsFrontend", () => "div"); jest.mock("components/Pronunciations/Recorder"); const mockStore = configureMockStore()({ treeViewState: { open: false } }); diff --git a/src/components/DataEntry/DataEntryTable/RecentEntry.tsx b/src/components/DataEntry/DataEntryTable/RecentEntry.tsx index b0528328a0..95165d2514 100644 --- a/src/components/DataEntry/DataEntryTable/RecentEntry.tsx +++ b/src/components/DataEntry/DataEntryTable/RecentEntry.tsx @@ -8,7 +8,7 @@ import { GlossWithSuggestions, VernWithSuggestions, } from "components/DataEntry/DataEntryTable/EntryCellComponents"; -import Pronunciations from "components/Pronunciations/PronunciationsComponent"; +import PronunciationsBackend from "components/Pronunciations/PronunciationsBackend"; import theme from "types/theme"; import { newGloss } from "types/word"; import { firstGlossText } from "utilities/wordUtilities"; @@ -129,14 +129,14 @@ export default function RecentEntry(props: RecentEntryProps): ReactElement { }} > {!props.disabled && ( - { - props.deleteAudioFromWord(wordId, fileName); + wordId={props.entry.id} + deleteAudio={(fileName: string) => { + props.deleteAudioFromWord(props.entry.id, fileName); }} - uploadAudio={(wordId: string, audioFile: File) => { - props.addAudioToWord(wordId, audioFile); + uploadAudio={(audioFile: File) => { + props.addAudioToWord(props.entry.id, audioFile); }} /> )} diff --git a/src/components/DataEntry/DataEntryTable/index.tsx b/src/components/DataEntry/DataEntryTable/index.tsx index 58520357da..55445b8c76 100644 --- a/src/components/DataEntry/DataEntryTable/index.tsx +++ b/src/components/DataEntry/DataEntryTable/index.tsx @@ -29,7 +29,7 @@ import { getUserId } from "backend/localStorage"; import NewEntry from "components/DataEntry/DataEntryTable/NewEntry"; import RecentEntry from "components/DataEntry/DataEntryTable/RecentEntry"; import { filterWordsWithSenses } from "components/DataEntry/utilities"; -import { getFileNameForWord } from "components/Pronunciations/AudioRecorder"; +import { uploadFileFromUrl } from "components/Pronunciations/utilities"; import { StoreState } from "types"; import { Hash } from "types/hash"; import { useAppSelector } from "types/hooks"; @@ -568,14 +568,7 @@ export default function DataEntryTable( defunctWord(oldId); let newId = oldId; for (const audioURL of audioURLs) { - const audioBlob = await fetch(audioURL).then((result) => result.blob()); - const fileName = getFileNameForWord(newId); - const audioFile = new File([audioBlob], fileName, { - type: audioBlob.type, - lastModified: Date.now(), - }); - newId = await backend.uploadAudio(newId, audioFile); - URL.revokeObjectURL(audioURL); + newId = await uploadFileFromUrl(newId, audioURL); } defunctWord(oldId, newId); return newId; diff --git a/src/components/DataEntry/DataEntryTable/tests/index.test.tsx b/src/components/DataEntry/DataEntryTable/tests/index.test.tsx index ace10b02f5..25e680c564 100644 --- a/src/components/DataEntry/DataEntryTable/tests/index.test.tsx +++ b/src/components/DataEntry/DataEntryTable/tests/index.test.tsx @@ -51,7 +51,7 @@ jest.mock( "components/DataEntry/DataEntryTable/RecentEntry", () => MockRecentEntry ); -jest.mock("components/Pronunciations/PronunciationsComponent", () => "div"); +jest.mock("components/Pronunciations/PronunciationsFrontend", () => "div"); jest.mock("components/Pronunciations/Recorder"); jest.mock("utilities/utilities"); diff --git a/src/components/Pronunciations/AudioPlayer.tsx b/src/components/Pronunciations/AudioPlayer.tsx index 892b5c4139..fae2ac34d5 100644 --- a/src/components/Pronunciations/AudioPlayer.tsx +++ b/src/components/Pronunciations/AudioPlayer.tsx @@ -22,11 +22,10 @@ import { useAppDispatch, useAppSelector } from "types/hooks"; import { themeColors } from "types/theme"; interface PlayerProps { - pronunciationUrl: string; - wordId: string; + deleteAudio: (fileName: string) => void; fileName: string; - deleteAudio?: (wordId: string, fileName: string) => void; isPlaying?: boolean; + pronunciationUrl: string; } const useStyles = makeStyles((theme: Theme) => @@ -62,12 +61,6 @@ export default function AudioPlayer(props: PlayerProps): ReactElement { } }, [audio, dispatchReset, isPlaying]); - function deleteAudio(): void { - if (props.deleteAudio) { - props.deleteAudio(props.wordId, props.fileName); - } - } - function togglePlay(): void { if (!isPlaying) { dispatch(playing(props.fileName)); @@ -161,7 +154,7 @@ export default function AudioPlayer(props: PlayerProps): ReactElement { textId="buttons.deletePermanently" titleId="pronunciations.deleteRecording" onClose={() => setDeleteConf(false)} - onConfirm={deleteAudio} + onConfirm={() => props.deleteAudio(props.fileName)} buttonIdClose="audio-delete-cancel" buttonIdConfirm="audio-delete-confirm" /> diff --git a/src/components/Pronunciations/AudioRecorder.tsx b/src/components/Pronunciations/AudioRecorder.tsx index f21b6e8679..7f1627acf3 100644 --- a/src/components/Pronunciations/AudioRecorder.tsx +++ b/src/components/Pronunciations/AudioRecorder.tsx @@ -5,18 +5,11 @@ import { toast } from "react-toastify"; import Recorder from "components/Pronunciations/Recorder"; import RecorderContext from "components/Pronunciations/RecorderContext"; import RecorderIcon from "components/Pronunciations/RecorderIcon"; +import { getFileNameForWord } from "components/Pronunciations/utilities"; interface RecorderProps { wordId: string; - uploadAudio: (wordId: string, audioFile: File) => void; -} - -export function getFileNameForWord(wordId: string): string { - const fourCharParts = wordId.match(/.{1,6}/g); - const compressed = fourCharParts?.map((i) => - Number("0x" + i).toString(36) - ) ?? ["unknownWord"]; - return compressed.join("") + "_" + new Date().getTime().toString(36); + uploadAudio: (audioFile: File) => void; } export default function AudioRecorder(props: RecorderProps): ReactElement { @@ -38,7 +31,7 @@ export default function AudioRecorder(props: RecorderProps): ReactElement { lastModified: Date.now(), type: Recorder.blobType, }; - props.uploadAudio(props.wordId, new File([blob], fileName, options)); + props.uploadAudio(new File([blob], fileName, options)); } return ( diff --git a/src/components/Pronunciations/PronunciationsBackend.tsx b/src/components/Pronunciations/PronunciationsBackend.tsx new file mode 100644 index 0000000000..0e32782d78 --- /dev/null +++ b/src/components/Pronunciations/PronunciationsBackend.tsx @@ -0,0 +1,64 @@ +import { memo, ReactElement } from "react"; + +import { getAudioUrl } from "backend"; +import AudioPlayer from "components/Pronunciations/AudioPlayer"; +import AudioRecorder from "components/Pronunciations/AudioRecorder"; + +interface PronunciationsBackendProps { + playerOnly?: boolean; + overrideMemo?: boolean; + pronunciationFiles: string[]; + wordId: string; + deleteAudio: (fileName: string) => void; + uploadAudio?: (audioFile: File) => void; +} + +/** Audio recording/playing component for backend audio. */ +export function PronunciationsBackend( + props: PronunciationsBackendProps +): ReactElement { + if (props.playerOnly && props.uploadAudio) { + console.warn("uploadAudio is defined but unused since playerOnly is true"); + } + if (!props.playerOnly && !props.uploadAudio) { + console.warn("uploadAudio undefined; playerOnly should be set to true"); + } + + const audioButtons: ReactElement[] = props.pronunciationFiles.map( + (fileName) => ( + + ) + ); + + return ( + <> + {!props.playerOnly && !!props.uploadAudio && ( + + )} + {audioButtons} + + ); +} + +// Memoize to decrease unnecessary fetching of audio files. +// https://dmitripavlutin.com/use-react-memo-wisely/#11-custom-equality-check-of-props +function propsAreEqual( + prev: PronunciationsBackendProps, + next: PronunciationsBackendProps +): boolean { + if (next.overrideMemo) { + return false; + } + return ( + prev.wordId === next.wordId && + JSON.stringify(prev.pronunciationFiles) === + JSON.stringify(next.pronunciationFiles) + ); +} + +export default memo(PronunciationsBackend, propsAreEqual); diff --git a/src/components/Pronunciations/PronunciationsComponent.tsx b/src/components/Pronunciations/PronunciationsComponent.tsx deleted file mode 100644 index ce25f40ec8..0000000000 --- a/src/components/Pronunciations/PronunciationsComponent.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import { memo, ReactElement } from "react"; - -import { getAudioUrl } from "backend"; -import AudioPlayer from "components/Pronunciations/AudioPlayer"; -import AudioRecorder from "components/Pronunciations/AudioRecorder"; - -interface PronunciationProps { - wordId: string; - audioInFrontend?: boolean; - pronunciationFiles: string[]; - deleteAudio: (wordId: string, fileName: string) => void; - uploadAudio: (wordId: string, audioFile: File) => void; -} - -/** Audio recording/playing component */ -export function Pronunciations(props: PronunciationProps): ReactElement { - const audioButtons: ReactElement[] = props.pronunciationFiles.map( - (fileName) => ( - - ) - ); - - return ( - <> - - {audioButtons} - - ); -} - -// Memoize to decrease unnecessary fetching of audio files. -// https://dmitripavlutin.com/use-react-memo-wisely/#11-custom-equality-check-of-props -function pronunciationPropsAreEqual( - prev: PronunciationProps, - next: PronunciationProps -): boolean { - return ( - prev.wordId === next.wordId && - JSON.stringify(prev.pronunciationFiles) === - JSON.stringify(next.pronunciationFiles) - ); -} - -export default memo(Pronunciations, pronunciationPropsAreEqual); diff --git a/src/components/Pronunciations/PronunciationsFrontend.tsx b/src/components/Pronunciations/PronunciationsFrontend.tsx new file mode 100644 index 0000000000..8fa40abb92 --- /dev/null +++ b/src/components/Pronunciations/PronunciationsFrontend.tsx @@ -0,0 +1,35 @@ +import { ReactElement } from "react"; + +import AudioPlayer from "components/Pronunciations/AudioPlayer"; +import AudioRecorder from "components/Pronunciations/AudioRecorder"; + +interface PronunciationFrontendProps { + pronunciationFiles: string[]; + elemBetweenRecordAndPlay?: ReactElement; + deleteAudio: (fileName: string) => void; + uploadAudio: (audioFile: File) => void; +} + +/** Audio recording/playing component for audio being recorded and held in the frontend. */ +export default function PronunciationsFrontend( + props: PronunciationFrontendProps +): ReactElement { + const audioButtons: ReactElement[] = props.pronunciationFiles.map( + (fileName) => ( + + ) + ); + + return ( + <> + + {props.elemBetweenRecordAndPlay} + {audioButtons} + + ); +} diff --git a/src/components/Pronunciations/tests/Pronunciations.test.tsx b/src/components/Pronunciations/tests/AudioRecorder.test.tsx similarity index 70% rename from src/components/Pronunciations/tests/Pronunciations.test.tsx rename to src/components/Pronunciations/tests/AudioRecorder.test.tsx index 548933048c..1c6bedbbfd 100644 --- a/src/components/Pronunciations/tests/Pronunciations.test.tsx +++ b/src/components/Pronunciations/tests/AudioRecorder.test.tsx @@ -5,9 +5,7 @@ import configureMockStore from "redux-mock-store"; import "tests/reactI18nextMock"; -import AudioPlayer from "components/Pronunciations/AudioPlayer"; import AudioRecorder from "components/Pronunciations/AudioRecorder"; -import Pronunciations from "components/Pronunciations/PronunciationsComponent"; import RecorderIcon, { recordButtonId, recordIconId, @@ -19,13 +17,8 @@ import { } from "components/Pronunciations/Redux/PronunciationsReduxTypes"; import theme from "types/theme"; -// Mock the audio components jest.mock("components/Pronunciations/Recorder"); -jest - .spyOn(window.HTMLMediaElement.prototype, "pause") - .mockImplementation(() => {}); -// Variables let testRenderer: renderer.ReactTestRenderer; const createMockStore = configureMockStore(); @@ -47,12 +40,7 @@ beforeAll(() => { - + @@ -61,17 +49,7 @@ beforeAll(() => { }); describe("Pronunciations", () => { - it("renders one record button and one play button for each pronunciation file", () => { - expect(testRenderer.root.findAllByType(AudioRecorder)).toHaveLength(1); - expect(testRenderer.root.findAllByType(AudioPlayer)).toHaveLength(2); - }); - - // Snapshot - it("displays buttons", () => { - expect(testRenderer.toJSON()).toMatchSnapshot(); - }); - - it("pointerDown and pointerUp", () => { + test("pointerDown and pointerUp", () => { const mockStartRecording = jest.fn(); const mockStopRecording = jest.fn(); renderer.act(() => { @@ -99,18 +77,13 @@ describe("Pronunciations", () => { expect(mockStopRecording).toBeCalled(); }); - it("default style is iconRelease", () => { + test("default style is iconRelease", () => { renderer.act(() => { testRenderer = renderer.create( - + @@ -120,7 +93,7 @@ describe("Pronunciations", () => { expect(icon.props.className.includes("iconRelease")).toBeTruthy(); }); - it("style depends on pronunciations state", () => { + test("style depends on pronunciations state", () => { const wordId = "1"; const mockStore2 = createMockStore(mockRecordingState(wordId)); renderer.act(() => { @@ -128,12 +101,7 @@ describe("Pronunciations", () => { - + diff --git a/src/components/Pronunciations/tests/PronunciationsBackend.test.tsx b/src/components/Pronunciations/tests/PronunciationsBackend.test.tsx new file mode 100644 index 0000000000..e90192cb97 --- /dev/null +++ b/src/components/Pronunciations/tests/PronunciationsBackend.test.tsx @@ -0,0 +1,61 @@ +import { StyledEngineProvider, ThemeProvider } from "@mui/material/styles"; +import { Provider } from "react-redux"; +import renderer from "react-test-renderer"; +import configureMockStore from "redux-mock-store"; + +import "tests/reactI18nextMock"; + +import AudioPlayer from "components/Pronunciations/AudioPlayer"; +import AudioRecorder from "components/Pronunciations/AudioRecorder"; +import PronunciationsBackend from "components/Pronunciations/PronunciationsBackend"; +import { defaultState as pronunciationsState } from "components/Pronunciations/Redux/PronunciationsReduxTypes"; +import theme from "types/theme"; + +// Mock the audio components +jest + .spyOn(window.HTMLMediaElement.prototype, "pause") + .mockImplementation(() => {}); +jest.mock("components/Pronunciations/Recorder"); + +// Test variables +let testRenderer: renderer.ReactTestRenderer; +const mockAudio = ["a.wav", "b.wav"]; +const mockStore = configureMockStore()({ pronunciationsState }); + +const renderPronunciationsBackend = async (withRecord: boolean) => { + await renderer.act(async () => { + testRenderer = renderer.create( + + + + + + + + ); + }); +}; + +describe("PronunciationsBackend", () => { + it("renders with record button and play buttons", async () => { + await renderPronunciationsBackend(true); + expect(testRenderer.root.findAllByType(AudioRecorder)).toHaveLength(1); + expect(testRenderer.root.findAllByType(AudioPlayer)).toHaveLength( + mockAudio.length + ); + }); + + it("renders without a record button and with play buttons", async () => { + await renderPronunciationsBackend(false); + expect(testRenderer.root.findAllByType(AudioRecorder)).toHaveLength(0); + expect(testRenderer.root.findAllByType(AudioPlayer)).toHaveLength( + mockAudio.length + ); + }); +}); diff --git a/src/components/Pronunciations/tests/PronunciationsFrontend.test.tsx b/src/components/Pronunciations/tests/PronunciationsFrontend.test.tsx new file mode 100644 index 0000000000..b94b0da90d --- /dev/null +++ b/src/components/Pronunciations/tests/PronunciationsFrontend.test.tsx @@ -0,0 +1,47 @@ +import { StyledEngineProvider, ThemeProvider } from "@mui/material/styles"; +import { Provider } from "react-redux"; +import renderer from "react-test-renderer"; +import configureMockStore from "redux-mock-store"; + +import "tests/reactI18nextMock"; + +import AudioPlayer from "components/Pronunciations/AudioPlayer"; +import AudioRecorder from "components/Pronunciations/AudioRecorder"; +import PronunciationsFrontend from "components/Pronunciations/PronunciationsFrontend"; +import { defaultState as pronunciationsState } from "components/Pronunciations/Redux/PronunciationsReduxTypes"; +import theme from "types/theme"; + +// Mock the audio components +jest + .spyOn(window.HTMLMediaElement.prototype, "pause") + .mockImplementation(() => {}); +jest.mock("components/Pronunciations/Recorder"); + +// Test variables +let testRenderer: renderer.ReactTestRenderer; +const mockStore = configureMockStore()({ pronunciationsState }); + +describe("PronunciationsFrontend", () => { + it("renders with record button and play buttons", () => { + const audio = ["a.wav", "b.wav"]; + renderer.act(() => { + testRenderer = renderer.create( + + + + + + + + ); + }); + expect(testRenderer.root.findAllByType(AudioRecorder)).toHaveLength(1); + expect(testRenderer.root.findAllByType(AudioPlayer)).toHaveLength( + audio.length + ); + }); +}); diff --git a/src/components/Pronunciations/tests/__snapshots__/Pronunciations.test.tsx.snap b/src/components/Pronunciations/tests/__snapshots__/Pronunciations.test.tsx.snap deleted file mode 100644 index f6d3831c85..0000000000 --- a/src/components/Pronunciations/tests/__snapshots__/Pronunciations.test.tsx.snap +++ /dev/null @@ -1,127 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Pronunciations displays buttons 1`] = ` -Array [ - , - , - , -] -`; diff --git a/src/components/Pronunciations/utilities.ts b/src/components/Pronunciations/utilities.ts new file mode 100644 index 0000000000..08761c38a0 --- /dev/null +++ b/src/components/Pronunciations/utilities.ts @@ -0,0 +1,28 @@ +import { uploadAudio } from "backend"; + +/** Generate a timestamp-based file name for the given `wordId`. */ +export function getFileNameForWord(wordId: string): string { + const fourCharParts = wordId.match(/.{1,6}/g); + const compressed = fourCharParts?.map((i) => + Number("0x" + i).toString(36) + ) ?? ["unknownWord"]; + return compressed.join("") + "_" + new Date().getTime().toString(36); +} + +/** Given an audio file `url` that was generated with `URL.createObjectURL()`, + * add that audio file to the word with the given `wordId`. + * Return the id of the updated word. */ +export async function uploadFileFromUrl( + wordId: string, + url: string +): Promise { + const audioBlob = await fetch(url).then((result) => result.blob()); + const fileName = getFileNameForWord(wordId); + const audioFile = new File([audioBlob], fileName, { + type: audioBlob.type, + lastModified: Date.now(), + }); + const newId = await uploadAudio(wordId, audioFile); + URL.revokeObjectURL(url); + return newId; +} diff --git a/src/goals/ReviewEntries/ReviewEntriesComponent/CellColumns.tsx b/src/goals/ReviewEntries/ReviewEntriesComponent/CellColumns.tsx index f13b46361e..3a48802523 100644 --- a/src/goals/ReviewEntries/ReviewEntriesComponent/CellColumns.tsx +++ b/src/goals/ReviewEntries/ReviewEntriesComponent/CellColumns.tsx @@ -64,7 +64,7 @@ function cleanRegExp(input: string): RegExp { export interface FieldParameterStandard { rowData: ReviewEntriesWord; value: any; - onRowDataChange?: (...args: any) => any; + onRowDataChange?: (word: ReviewEntriesWord) => any; } let currentSort: SortStyle = SortStyle.None; @@ -394,26 +394,59 @@ const columns: Column[] = [ // Audio column { title: ColumnTitle.Pronunciations, + // field determines what is passed as props.value to editComponent field: ReviewEntriesWordField.Pronunciations, - editable: "never", filterPlaceholder: "#", render: (rowData: ReviewEntriesWord) => ( + ), + editComponent: (props: FieldParameterStandard) => ( + { + props.onRowDataChange && + props.onRowDataChange({ + ...props.rowData, + audioNew: [ + ...(props.rowData.audioNew ?? []), + URL.createObjectURL(file), + ], + }); + }, + delNewAudio: (url: string): void => { + props.onRowDataChange && + props.onRowDataChange({ + ...props.rowData, + audioNew: props.rowData.audioNew?.filter((u) => u !== url), + }); + }, + delOldAudio: (fileName: string): void => { + props.onRowDataChange && + props.onRowDataChange({ + ...props.rowData, + audio: props.rowData.audio.filter((f) => f !== fileName), + }); + }, + }} + pronunciationFiles={props.rowData.audio} + pronunciationsNew={props.rowData.audioNew} + wordId={props.rowData.id} /> ), customFilterAndSearch: ( filter: string, rowData: ReviewEntriesWord ): boolean => { - return parseInt(filter) === rowData.pronunciationFiles.length; + return parseInt(filter) === rowData.audio.length; }, customSort: (a: ReviewEntriesWord, b: ReviewEntriesWord): number => { if (currentSort !== SortStyle.Pronunciation) { currentSort = SortStyle.Pronunciation; } - return b.pronunciationFiles.length - a.pronunciationFiles.length; + return b.audio.length - a.audio.length; }, }, diff --git a/src/goals/ReviewEntries/ReviewEntriesComponent/CellComponents/PronunciationsCell.tsx b/src/goals/ReviewEntries/ReviewEntriesComponent/CellComponents/PronunciationsCell.tsx index 41aa905e0d..3785f9379a 100644 --- a/src/goals/ReviewEntries/ReviewEntriesComponent/CellComponents/PronunciationsCell.tsx +++ b/src/goals/ReviewEntries/ReviewEntriesComponent/CellComponents/PronunciationsCell.tsx @@ -1,4 +1,5 @@ -import Pronunciations from "components/Pronunciations/PronunciationsComponent"; +import PronunciationsBackend from "components/Pronunciations/PronunciationsBackend"; +import PronunciationsFrontend from "components/Pronunciations/PronunciationsFrontend"; import { deleteAudio, uploadAudio, @@ -6,22 +7,44 @@ import { import { useAppDispatch } from "types/hooks"; interface PronunciationsCellProps { - wordId: string; + audioFunctions?: { + addNewAudio: (file: File) => void; + delNewAudio: (url: string) => void; + delOldAudio: (fileName: string) => void; + }; pronunciationFiles: string[]; + pronunciationsNew?: string[]; + wordId: string; } -/** Used to connect the pronunciation component to the deleteAudio and uploadAudio actions */ export default function PronunciationsCell(props: PronunciationsCellProps) { const dispatch = useAppDispatch(); - const dispatchDelete = (wordId: string, fileName: string) => - dispatch(deleteAudio(wordId, fileName)); - const dispatchUpload = (oldWordId: string, audioFile: File) => - dispatch(uploadAudio(oldWordId, audioFile)); + const dispatchDelete = (fileName: string) => + dispatch(deleteAudio(props.wordId, fileName)); + const dispatchUpload = (audioFile: File) => + dispatch(uploadAudio(props.wordId, audioFile)); - return ( - + } + pronunciationFiles={props.pronunciationsNew ?? []} + deleteAudio={delNewAudio!} + uploadAudio={addNewAudio!} + /> + ) : ( + diff --git a/src/goals/ReviewEntries/ReviewEntriesComponent/CellComponents/tests/PronunciationsCell.test.tsx b/src/goals/ReviewEntries/ReviewEntriesComponent/CellComponents/tests/PronunciationsCell.test.tsx new file mode 100644 index 0000000000..0ad658ee89 --- /dev/null +++ b/src/goals/ReviewEntries/ReviewEntriesComponent/CellComponents/tests/PronunciationsCell.test.tsx @@ -0,0 +1,156 @@ +import { ThemeProvider } from "@mui/material/styles"; +import { Provider } from "react-redux"; +import renderer from "react-test-renderer"; +import configureMockStore from "redux-mock-store"; + +import "tests/reactI18nextMock"; + +import AudioPlayer from "components/Pronunciations/AudioPlayer"; +import AudioRecorder from "components/Pronunciations/AudioRecorder"; +import { defaultState as pronunciationsState } from "components/Pronunciations/Redux/PronunciationsReduxTypes"; +import PronunciationsCell from "goals/ReviewEntries/ReviewEntriesComponent/CellComponents/PronunciationsCell"; +import theme from "types/theme"; + +// Mock the audio components +jest + .spyOn(window.HTMLMediaElement.prototype, "pause") + .mockImplementation(() => {}); +jest.mock("components/Pronunciations/Recorder"); + +// Mock the store interactions +jest.mock( + "goals/ReviewEntries/ReviewEntriesComponent/Redux/ReviewEntriesActions", + () => ({ + deleteAudio: (...args: any[]) => mockDeleteAudio(...args), + uploadAudio: (...args: any[]) => mockUploadAudio(...args), + }) +); +jest.mock("types/hooks", () => { + return { + ...jest.requireActual("types/hooks"), + useAppDispatch: () => mockDispatch, + }; +}); +const mockDeleteAudio = jest.fn(); +const mockUploadAudio = jest.fn(); +const mockDispatch = jest.fn(); +const mockStore = configureMockStore()({ pronunciationsState }); + +// Mock the functions used for the component in edit mode +const mockAddNewAudio = jest.fn(); +const mockDelNewAudio = jest.fn(); +const mockDelOldAudio = jest.fn(); +const mockAudioFunctions = { + addNewAudio: (...args: any[]) => mockAddNewAudio(...args), + delNewAudio: (...args: any[]) => mockDelNewAudio(...args), + delOldAudio: (...args: any[]) => mockDelOldAudio(...args), +}; + +// Render the cell component with a store and theme +let testRenderer: renderer.ReactTestRenderer; +const renderPronunciationsCell = async ( + pronunciationFiles: string[], + pronunciationsNew?: string[] +) => { + await renderer.act(async () => { + testRenderer = renderer.create( + + + + + + ); + }); +}; + +beforeEach(() => { + jest.resetAllMocks(); +}); + +describe("PronunciationsCell", () => { + describe("not in edit mode", () => { + it("renders", async () => { + const mockAudio = ["1", "2", "3"]; + await renderPronunciationsCell(mockAudio); + const playButtons = testRenderer.root.findAllByType(AudioPlayer); + expect(playButtons).toHaveLength(mockAudio.length); + const recordButtons = testRenderer.root.findAllByType(AudioRecorder); + expect(recordButtons).toHaveLength(1); + }); + + it("has player that dispatches action", async () => { + await renderPronunciationsCell(["1"]); + await renderer.act(async () => { + testRenderer.root.findByType(AudioPlayer).props.deleteAudio(); + }); + expect(mockDeleteAudio).toBeCalled(); + expect(mockDispatch).toBeCalled(); + expect(mockDelNewAudio).not.toBeCalled(); + expect(mockDelOldAudio).not.toBeCalled(); + }); + + it("has recorder that dispatches action", async () => { + await renderPronunciationsCell([]); + await renderer.act(async () => { + testRenderer.root.findByType(AudioRecorder).props.uploadAudio(); + }); + expect(mockUploadAudio).toBeCalled(); + expect(mockDispatch).toBeCalled(); + expect(mockAddNewAudio).not.toBeCalled(); + }); + }); + + describe("in edit mode", () => { + it("renders", async () => { + const mockAudioOld = ["1", "2", "3", "4"]; + const mockAudioNew = ["5", "6"]; + await renderPronunciationsCell(mockAudioOld, mockAudioNew); + const playButtons = testRenderer.root.findAllByType(AudioPlayer); + expect(playButtons).toHaveLength( + mockAudioOld.length + mockAudioNew.length + ); + const recordButtons = testRenderer.root.findAllByType(AudioRecorder); + expect(recordButtons).toHaveLength(1); + }); + + it("has players that call prop functions", async () => { + await renderPronunciationsCell(["old"], ["new"]); + const playButtons = testRenderer.root.findAllByType(AudioPlayer); + + // player for audio present prior to row edit + await renderer.act(async () => { + playButtons[0].props.deleteAudio(); + }); + expect(mockDelOldAudio).toBeCalled(); + expect(mockDelNewAudio).not.toBeCalled(); + expect(mockDeleteAudio).not.toBeCalled(); + expect(mockDispatch).not.toBeCalled(); + + jest.resetAllMocks(); + + // player for audio added during row edit + await renderer.act(async () => { + playButtons[1].props.deleteAudio(); + }); + expect(mockDelNewAudio).toBeCalled(); + expect(mockDelOldAudio).not.toBeCalled(); + expect(mockDeleteAudio).not.toBeCalled(); + expect(mockDispatch).not.toBeCalled(); + }); + + it("has recorder that calls a prop function", async () => { + await renderPronunciationsCell([], []); + await renderer.act(async () => { + testRenderer.root.findByType(AudioRecorder).props.uploadAudio(); + }); + expect(mockAddNewAudio).toBeCalled(); + expect(mockUploadAudio).not.toBeCalled(); + expect(mockDispatch).not.toBeCalled(); + }); + }); +}); diff --git a/src/goals/ReviewEntries/ReviewEntriesComponent/Redux/ReviewEntriesActions.ts b/src/goals/ReviewEntries/ReviewEntriesComponent/Redux/ReviewEntriesActions.ts index 3ddf153954..34e3d6ffa5 100644 --- a/src/goals/ReviewEntries/ReviewEntriesComponent/Redux/ReviewEntriesActions.ts +++ b/src/goals/ReviewEntries/ReviewEntriesComponent/Redux/ReviewEntriesActions.ts @@ -1,5 +1,6 @@ import { Sense } from "api/models"; import * as backend from "backend"; +import { uploadFileFromUrl } from "components/Pronunciations/utilities"; import { ReviewClearReviewEntriesState, ReviewEntriesActionTypes, @@ -128,6 +129,14 @@ export function updateFrontierWord( return Promise.reject(editSource); } + // Set aside audio changes for last. + const delAudio = oldData.audio.filter( + (o) => !newData.audio.find((n) => n === o) + ); + const addAudio = [...(newData.audioNew ?? [])]; + editSource.audio = oldData.audio; + delete editSource.audioNew; + // Get the original word, for updating. const editWord = await backend.getWord(editSource.id); @@ -141,6 +150,15 @@ export function updateFrontierWord( // Update the word in the backend, and retrieve the id. editSource.id = (await backend.updateWord(editWord)).id; + // Add/remove audio. + for (const url of addAudio) { + editSource.id = await uploadFileFromUrl(editSource.id, url); + } + for (const fileName of delAudio) { + editSource.id = await backend.deleteAudio(editSource.id, fileName); + } + editSource.audio = (await backend.getWord(editSource.id)).audio; + // Update the review entries word in the state. dispatch(updateWord(editWord.id, editSource)); }; diff --git a/src/goals/ReviewEntries/ReviewEntriesComponent/ReviewEntriesTypes.ts b/src/goals/ReviewEntries/ReviewEntriesComponent/ReviewEntriesTypes.ts index aa2e4b3d5c..86e894766e 100644 --- a/src/goals/ReviewEntries/ReviewEntriesComponent/ReviewEntriesTypes.ts +++ b/src/goals/ReviewEntries/ReviewEntriesComponent/ReviewEntriesTypes.ts @@ -16,7 +16,7 @@ export enum ReviewEntriesWordField { Id = "id", Vernacular = "vernacular", Senses = "senses", - Pronunciations = "pronunciationFiles", + Pronunciations = "audio", Note = "noteText", Flag = "flag", } @@ -25,7 +25,8 @@ export class ReviewEntriesWord { id: string; vernacular: string; senses: ReviewEntriesSense[]; - pronunciationFiles: string[]; + audio: string[]; + audioNew?: string[]; noteText: string; flag: Flag; protected: boolean; @@ -39,7 +40,7 @@ export class ReviewEntriesWord { this.senses = word.senses.map( (s) => new ReviewEntriesSense(s, analysisLang) ); - this.pronunciationFiles = word.audio; + this.audio = word.audio; this.noteText = word.note.text; this.flag = word.flag; this.protected = word.accessibility === Status.Protected; diff --git a/src/goals/ReviewEntries/ReviewEntriesComponent/index.ts b/src/goals/ReviewEntries/ReviewEntriesComponent/index.ts deleted file mode 100644 index 2a54c8925a..0000000000 --- a/src/goals/ReviewEntries/ReviewEntriesComponent/index.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { connect } from "react-redux"; - -import { - updateAllWords, - updateFrontierWord, -} from "goals/ReviewEntries/ReviewEntriesComponent/Redux/ReviewEntriesActions"; -import ReviewEntriesComponent from "goals/ReviewEntries/ReviewEntriesComponent/ReviewEntriesComponent"; -import { ReviewEntriesWord } from "goals/ReviewEntries/ReviewEntriesComponent/ReviewEntriesTypes"; -import { StoreStateDispatch } from "types/Redux/actions"; - -function mapDispatchToProps(dispatch: StoreStateDispatch) { - return { - updateAllWords: (words: ReviewEntriesWord[]) => - dispatch(updateAllWords(words)), - updateFrontierWord: ( - newData: ReviewEntriesWord, - oldData: ReviewEntriesWord - ) => dispatch(updateFrontierWord(newData, oldData)), - }; -} - -export default connect(null, mapDispatchToProps)(ReviewEntriesComponent); diff --git a/src/goals/ReviewEntries/ReviewEntriesComponent/ReviewEntriesComponent.tsx b/src/goals/ReviewEntries/ReviewEntriesComponent/index.tsx similarity index 53% rename from src/goals/ReviewEntries/ReviewEntriesComponent/ReviewEntriesComponent.tsx rename to src/goals/ReviewEntries/ReviewEntriesComponent/index.tsx index 28bf0e6b7f..44f234c71b 100644 --- a/src/goals/ReviewEntries/ReviewEntriesComponent/ReviewEntriesComponent.tsx +++ b/src/goals/ReviewEntries/ReviewEntriesComponent/index.tsx @@ -1,36 +1,29 @@ import { ReactElement, useEffect, useState } from "react"; import { getFrontierWords } from "backend"; +import { + updateAllWords, + updateFrontierWord, +} from "goals/ReviewEntries/ReviewEntriesComponent/Redux/ReviewEntriesActions"; import ReviewEntriesTable from "goals/ReviewEntries/ReviewEntriesComponent/ReviewEntriesTable"; import { ReviewEntriesWord } from "goals/ReviewEntries/ReviewEntriesComponent/ReviewEntriesTypes"; +import { useAppDispatch } from "types/hooks"; -// Component state/props -interface ReviewEntriesProps { - // Dispatch changes - updateAllWords: (words: ReviewEntriesWord[]) => void; - updateFrontierWord: ( - newData: ReviewEntriesWord, - oldData: ReviewEntriesWord - ) => Promise; -} - -export default function ReviewEntriesComponent( - props: ReviewEntriesProps -): ReactElement { +export default function ReviewEntriesComponent(): ReactElement { + const dispatch = useAppDispatch(); const [loaded, setLoaded] = useState(false); - const { updateAllWords, updateFrontierWord } = props; useEffect(() => { getFrontierWords().then((frontier) => { - updateAllWords(frontier.map((w) => new ReviewEntriesWord(w))); + dispatch(updateAllWords(frontier.map((w) => new ReviewEntriesWord(w)))); setLoaded(true); }); - }, [updateAllWords]); + }, [dispatch]); return loaded ? ( - updateFrontierWord(newData, oldData) + dispatch(updateFrontierWord(newData, oldData)) } /> ) : ( diff --git a/src/goals/ReviewEntries/ReviewEntriesComponent/tests/CellColumns.test.tsx b/src/goals/ReviewEntries/ReviewEntriesComponent/tests/CellColumns.test.tsx index 879d155048..0816d1f440 100644 --- a/src/goals/ReviewEntries/ReviewEntriesComponent/tests/CellColumns.test.tsx +++ b/src/goals/ReviewEntries/ReviewEntriesComponent/tests/CellColumns.test.tsx @@ -12,7 +12,8 @@ import { newSemanticDomain } from "types/semanticDomain"; import { newDefinition, newFlag, newGloss } from "types/word"; import { Bcp47Code } from "types/writingSystem"; -jest.mock("components/Pronunciations/PronunciationsComponent", () => "div"); +jest.mock("components/Pronunciations/PronunciationsBackend", () => "div"); +jest.mock("components/Pronunciations/PronunciationsFrontend", () => "div"); jest.mock("i18next", () => { const i18n = jest.requireActual("i18next"); return { ...i18n, t: (s: string) => s }; diff --git a/src/goals/ReviewEntries/ReviewEntriesComponent/tests/ReviewEntriesComponent.test.tsx b/src/goals/ReviewEntries/ReviewEntriesComponent/tests/index.test.tsx similarity index 84% rename from src/goals/ReviewEntries/ReviewEntriesComponent/tests/ReviewEntriesComponent.test.tsx rename to src/goals/ReviewEntries/ReviewEntriesComponent/tests/index.test.tsx index 81c8d95004..7e43ae1637 100644 --- a/src/goals/ReviewEntries/ReviewEntriesComponent/tests/ReviewEntriesComponent.test.tsx +++ b/src/goals/ReviewEntries/ReviewEntriesComponent/tests/index.test.tsx @@ -5,7 +5,8 @@ import configureMockStore from "redux-mock-store"; import "tests/reactI18nextMock"; -import ReviewEntriesComponent from "goals/ReviewEntries/ReviewEntriesComponent/ReviewEntriesComponent"; +import ReviewEntriesComponent from "goals/ReviewEntries/ReviewEntriesComponent"; +import * as actions from "goals/ReviewEntries/ReviewEntriesComponent/Redux/ReviewEntriesActions"; import { ReviewEntriesWord } from "goals/ReviewEntries/ReviewEntriesComponent/ReviewEntriesTypes"; import mockWords, { mockCreateWord, @@ -14,7 +15,6 @@ import { defaultWritingSystem } from "types/writingSystem"; const mockGetFrontierWords = jest.fn(); const mockMaterialTable = jest.fn(); -const mockUpdateAllWords = jest.fn(); const mockUuid = jest.fn(); // To deal with the table not wanting to behave in testing. @@ -37,11 +37,16 @@ jest.mock("notistack", () => ({ })); jest.mock("uuid", () => ({ v4: () => mockUuid() })); jest.mock("backend", () => ({ - getFrontierWords: () => mockGetFrontierWords(), + getFrontierWords: (...args: any[]) => mockGetFrontierWords(...args), })); // Mock the node module used by AudioRecorder. jest.mock("components/Pronunciations/Recorder"); jest.mock("components/TreeView", () => "div"); +jest.mock("types/hooks", () => ({ + useAppDispatch: () => jest.fn(), +})); + +const updateAllWordsSpy = jest.spyOn(actions, "updateAllWords"); // Mock store + axios const mockReviewEntryWords = mockWords(); @@ -66,6 +71,7 @@ const state = { const mockStore = configureMockStore()(state); function setMockFunctions() { + jest.clearAllMocks(); mockGetFrontierWords.mockResolvedValue( mockReviewEntryWords.map(mockCreateWord) ); @@ -84,10 +90,7 @@ beforeEach(async () => { await renderer.act(async () => { renderer.create( - + ); }); @@ -95,8 +98,8 @@ beforeEach(async () => { describe("ReviewEntriesComponent", () => { it("Initializes correctly", () => { - expect(mockUpdateAllWords).toHaveBeenCalledTimes(1); - const wordIds = mockUpdateAllWords.mock.calls[0][0].map( + expect(updateAllWordsSpy).toHaveBeenCalled(); + const wordIds = updateAllWordsSpy.mock.calls[0][0].map( (w: ReviewEntriesWord) => w.id ); expect(wordIds).toHaveLength(mockReviewEntryWords.length);