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);