From 550e4ac14e8c109848a873fded862023e20dad6d Mon Sep 17 00:00:00 2001 From: "D. Ror" Date: Wed, 10 Apr 2024 11:52:48 -0400 Subject: [PATCH] [Review Entries] Refactor table with `material-react-table` (#2881) --- .gitlint | 2 + .../images/reviewEntriesColumnFilter.png | Bin 0 -> 384 bytes .../assets/images/reviewEntriesColumnFlag.png | Bin 0 -> 341 bytes .../reviewEntriesColumnPronunciations.png | Bin 0 -> 725 bytes .../assets/images/reviewEntriesColumnSort.png | Bin 0 -> 376 bytes .../assets/images/reviewEntriesColumns.es.png | Bin 8328 -> 14582 bytes .../assets/images/reviewEntriesColumns.png | Bin 11216 -> 19040 bytes .../assets/images/reviewEntriesColumns.zh.png | Bin 4946 -> 14423 bytes .../images/reviewEntriesColumnsEdit.png | Bin 0 -> 237 bytes .../assets/images/reviewEntriesRowDelete.png | Bin 0 -> 375 bytes .../assets/images/reviewEntriesRowEdit.png | Bin 0 -> 674 bytes .../assets/licenses/frontend_licenses.txt | 541 ++++++----------- docs/user_guide/docs/goals.md | 42 +- package-lock.json | 375 ++++-------- package.json | 4 +- public/locales/en/translation.json | 29 +- src/components/App/DefaultState.ts | 2 - .../Buttons/IconButtonWithTooltip.tsx | 2 +- src/components/Buttons/PartOfSpeechButton.tsx | 8 +- .../DataEntryTable/NewEntry/SenseDialog.tsx | 20 +- .../DataEntryTable/NewEntry/VernDialog.tsx | 20 +- .../NewEntry/tests/SenseDialog.test.tsx | 5 - .../NewEntry/tests/VernDialog.test.tsx | 6 - src/components/WordCard/SenseCard.tsx | 8 +- src/goals/DefaultGoal/BaseGoalScreen.tsx | 2 - .../Redux/ReviewEntriesActions.ts | 253 -------- .../Redux/ReviewEntriesReducer.ts | 38 -- .../Redux/ReviewEntriesReduxTypes.ts | 12 - .../Redux/tests/ReviewEntriesActions.test.tsx | 573 ------------------ .../ReviewEntriesTable/CellColumns.tsx | 490 --------------- .../CellComponents/AlignedList.tsx | 40 -- .../CellComponents/DefinitionCell.tsx | 139 ----- .../CellComponents/DeleteCell.tsx | 39 -- .../CellComponents/DomainCell.tsx | 147 ----- .../CellComponents/FlagCell.tsx | 24 - .../CellComponents/GlossCell.tsx | 132 ---- .../CellComponents/NoteCell.tsx | 27 - .../CellComponents/PartOfSpeechCell.tsx | 28 - .../CellComponents/PronunciationsCell.tsx | 64 -- .../CellComponents/SenseCell.tsx | 47 -- .../CellComponents/VernacularCell.tsx | 39 -- .../CellComponents/index.ts | 23 - .../CellComponents/tests/AlignedList.test.tsx | 17 - .../tests/DefinitionCell.test.tsx | 40 -- .../CellComponents/tests/DeleteCell.test.tsx | 56 -- .../CellComponents/tests/DomainCell.test.tsx | 40 -- .../CellComponents/tests/FlagCell.test.tsx | 22 - .../CellComponents/tests/GlossCell.test.tsx | 40 -- .../CellComponents/tests/NoteCell.test.tsx | 19 - .../tests/PartOfSpeechCell.test.tsx | 14 - .../tests/PronunciationsCell.test.tsx | 161 ----- .../CellComponents/tests/SenseCell.test.tsx | 20 - .../tests/VernacularCell.test.tsx | 31 - .../ReviewEntriesTable/Cells/CellTypes.ts | 7 + .../Cells/DefinitionsCell.tsx | 47 ++ .../ReviewEntriesTable/Cells/DeleteCell.tsx | 35 ++ .../ReviewEntriesTable/Cells/DomainsCell.tsx | 30 + .../Cells/EditCell/EditDialog.tsx | 496 +++++++++++++++ .../Cells/EditCell/EditSenseDialog.tsx | 471 ++++++++++++++ .../Cells/EditCell/EditSensesCardContent.tsx | 197 ++++++ .../Cells/EditCell/index.tsx | 40 ++ .../Cells/EditCell/tests/EditDialog.test.tsx | 267 ++++++++ .../EditCell/tests/EditSenseDialog.test.tsx | 213 +++++++ .../Cells/EditCell/tests/utilities.test.ts | 280 +++++++++ .../Cells/EditCell/utilities.ts | 161 +++++ .../ReviewEntriesTable/Cells/FlagCell.tsx | 10 + .../ReviewEntriesTable/Cells/GlossesCell.tsx | 47 ++ .../ReviewEntriesTable/Cells/NoteCell.tsx | 21 + .../Cells/PartOfSpeechCell.tsx | 29 + .../Cells/PronunciationsCell.tsx | 49 ++ .../Cells/VernacularCell.tsx | 12 + .../ReviewEntriesTable/Cells/index.ts | 10 + .../ReviewEntriesTable/filterFn.ts | 102 ++++ .../ReviewEntriesTable/icons.tsx | 73 --- .../ReviewEntriesTable/index.tsx | 473 ++++++++++----- .../ReviewEntriesTable/sortingFn.ts | 92 +++ .../tests/CellColumns.test.tsx | 334 ---------- .../ReviewEntriesTable/tests/WordsMock.ts | 106 ++++ .../ReviewEntriesTable/tests/filterFn.test.ts | 103 ++++ .../ReviewEntriesTable/tests/index.test.tsx | 203 +++++++ src/goals/ReviewEntries/ReviewEntriesTypes.ts | 135 ----- src/goals/ReviewEntries/index.tsx | 38 +- src/goals/ReviewEntries/tests/WordsMock.ts | 52 -- src/goals/ReviewEntries/tests/index.test.tsx | 99 --- src/rootReducer.ts | 6 +- src/types/index.ts | 2 - 86 files changed, 3713 insertions(+), 4168 deletions(-) create mode 100644 .gitlint create mode 100644 docs/user_guide/assets/images/reviewEntriesColumnFilter.png create mode 100644 docs/user_guide/assets/images/reviewEntriesColumnFlag.png create mode 100644 docs/user_guide/assets/images/reviewEntriesColumnPronunciations.png create mode 100644 docs/user_guide/assets/images/reviewEntriesColumnSort.png create mode 100644 docs/user_guide/assets/images/reviewEntriesColumnsEdit.png create mode 100644 docs/user_guide/assets/images/reviewEntriesRowDelete.png create mode 100644 docs/user_guide/assets/images/reviewEntriesRowEdit.png delete mode 100644 src/goals/ReviewEntries/Redux/ReviewEntriesActions.ts delete mode 100644 src/goals/ReviewEntries/Redux/ReviewEntriesReducer.ts delete mode 100644 src/goals/ReviewEntries/Redux/ReviewEntriesReduxTypes.ts delete mode 100644 src/goals/ReviewEntries/Redux/tests/ReviewEntriesActions.test.tsx delete mode 100644 src/goals/ReviewEntries/ReviewEntriesTable/CellColumns.tsx delete mode 100644 src/goals/ReviewEntries/ReviewEntriesTable/CellComponents/AlignedList.tsx delete mode 100644 src/goals/ReviewEntries/ReviewEntriesTable/CellComponents/DefinitionCell.tsx delete mode 100644 src/goals/ReviewEntries/ReviewEntriesTable/CellComponents/DeleteCell.tsx delete mode 100644 src/goals/ReviewEntries/ReviewEntriesTable/CellComponents/DomainCell.tsx delete mode 100644 src/goals/ReviewEntries/ReviewEntriesTable/CellComponents/FlagCell.tsx delete mode 100644 src/goals/ReviewEntries/ReviewEntriesTable/CellComponents/GlossCell.tsx delete mode 100644 src/goals/ReviewEntries/ReviewEntriesTable/CellComponents/NoteCell.tsx delete mode 100644 src/goals/ReviewEntries/ReviewEntriesTable/CellComponents/PartOfSpeechCell.tsx delete mode 100644 src/goals/ReviewEntries/ReviewEntriesTable/CellComponents/PronunciationsCell.tsx delete mode 100644 src/goals/ReviewEntries/ReviewEntriesTable/CellComponents/SenseCell.tsx delete mode 100644 src/goals/ReviewEntries/ReviewEntriesTable/CellComponents/VernacularCell.tsx delete mode 100644 src/goals/ReviewEntries/ReviewEntriesTable/CellComponents/index.ts delete mode 100644 src/goals/ReviewEntries/ReviewEntriesTable/CellComponents/tests/AlignedList.test.tsx delete mode 100644 src/goals/ReviewEntries/ReviewEntriesTable/CellComponents/tests/DefinitionCell.test.tsx delete mode 100644 src/goals/ReviewEntries/ReviewEntriesTable/CellComponents/tests/DeleteCell.test.tsx delete mode 100644 src/goals/ReviewEntries/ReviewEntriesTable/CellComponents/tests/DomainCell.test.tsx delete mode 100644 src/goals/ReviewEntries/ReviewEntriesTable/CellComponents/tests/FlagCell.test.tsx delete mode 100644 src/goals/ReviewEntries/ReviewEntriesTable/CellComponents/tests/GlossCell.test.tsx delete mode 100644 src/goals/ReviewEntries/ReviewEntriesTable/CellComponents/tests/NoteCell.test.tsx delete mode 100644 src/goals/ReviewEntries/ReviewEntriesTable/CellComponents/tests/PartOfSpeechCell.test.tsx delete mode 100644 src/goals/ReviewEntries/ReviewEntriesTable/CellComponents/tests/PronunciationsCell.test.tsx delete mode 100644 src/goals/ReviewEntries/ReviewEntriesTable/CellComponents/tests/SenseCell.test.tsx delete mode 100644 src/goals/ReviewEntries/ReviewEntriesTable/CellComponents/tests/VernacularCell.test.tsx create mode 100644 src/goals/ReviewEntries/ReviewEntriesTable/Cells/CellTypes.ts create mode 100644 src/goals/ReviewEntries/ReviewEntriesTable/Cells/DefinitionsCell.tsx create mode 100644 src/goals/ReviewEntries/ReviewEntriesTable/Cells/DeleteCell.tsx create mode 100644 src/goals/ReviewEntries/ReviewEntriesTable/Cells/DomainsCell.tsx create mode 100644 src/goals/ReviewEntries/ReviewEntriesTable/Cells/EditCell/EditDialog.tsx create mode 100644 src/goals/ReviewEntries/ReviewEntriesTable/Cells/EditCell/EditSenseDialog.tsx create mode 100644 src/goals/ReviewEntries/ReviewEntriesTable/Cells/EditCell/EditSensesCardContent.tsx create mode 100644 src/goals/ReviewEntries/ReviewEntriesTable/Cells/EditCell/index.tsx create mode 100644 src/goals/ReviewEntries/ReviewEntriesTable/Cells/EditCell/tests/EditDialog.test.tsx create mode 100644 src/goals/ReviewEntries/ReviewEntriesTable/Cells/EditCell/tests/EditSenseDialog.test.tsx create mode 100644 src/goals/ReviewEntries/ReviewEntriesTable/Cells/EditCell/tests/utilities.test.ts create mode 100644 src/goals/ReviewEntries/ReviewEntriesTable/Cells/EditCell/utilities.ts create mode 100644 src/goals/ReviewEntries/ReviewEntriesTable/Cells/FlagCell.tsx create mode 100644 src/goals/ReviewEntries/ReviewEntriesTable/Cells/GlossesCell.tsx create mode 100644 src/goals/ReviewEntries/ReviewEntriesTable/Cells/NoteCell.tsx create mode 100644 src/goals/ReviewEntries/ReviewEntriesTable/Cells/PartOfSpeechCell.tsx create mode 100644 src/goals/ReviewEntries/ReviewEntriesTable/Cells/PronunciationsCell.tsx create mode 100644 src/goals/ReviewEntries/ReviewEntriesTable/Cells/VernacularCell.tsx create mode 100644 src/goals/ReviewEntries/ReviewEntriesTable/Cells/index.ts create mode 100644 src/goals/ReviewEntries/ReviewEntriesTable/filterFn.ts delete mode 100644 src/goals/ReviewEntries/ReviewEntriesTable/icons.tsx create mode 100644 src/goals/ReviewEntries/ReviewEntriesTable/sortingFn.ts delete mode 100644 src/goals/ReviewEntries/ReviewEntriesTable/tests/CellColumns.test.tsx create mode 100644 src/goals/ReviewEntries/ReviewEntriesTable/tests/WordsMock.ts create mode 100644 src/goals/ReviewEntries/ReviewEntriesTable/tests/filterFn.test.ts create mode 100644 src/goals/ReviewEntries/ReviewEntriesTable/tests/index.test.tsx delete mode 100644 src/goals/ReviewEntries/tests/WordsMock.ts delete mode 100644 src/goals/ReviewEntries/tests/index.test.tsx diff --git a/.gitlint b/.gitlint new file mode 100644 index 0000000000..437a777fae --- /dev/null +++ b/.gitlint @@ -0,0 +1,2 @@ +[title-min-length] +min-length=4 diff --git a/docs/user_guide/assets/images/reviewEntriesColumnFilter.png b/docs/user_guide/assets/images/reviewEntriesColumnFilter.png new file mode 100644 index 0000000000000000000000000000000000000000..353e94b8b882b191d933973d9e975633d23f74cd GIT binary patch literal 384 zcmeAS@N?(olHy`uVBq!ia0vp^%0R5a!3HEtanMpaxM-7srqY_qWqeuWB*iX?q+i_`a*_{r@7p zBXdtn25qvMF>}dbM)99!Z}qp;ZMm3KAak5s&dKAgvVkPue%NBhTmT4}1{~+sugr=6{o!_~)6}psm9TGmiad&udww9!d zr%?EX`3{Yxv2!P|ZRH7?wsbXXU!ueU4KA;uJt+cfT6eQ2ZTwd2@7cbcCy#aAf%sX= z7S5B)pZWhliiJQ!h literal 0 HcmV?d00001 diff --git a/docs/user_guide/assets/images/reviewEntriesColumnFlag.png b/docs/user_guide/assets/images/reviewEntriesColumnFlag.png new file mode 100644 index 0000000000000000000000000000000000000000..b1a86a7c6cee234a09cc7d91ad558ebe72054081 GIT binary patch literal 341 zcmeAS@N?(olHy`uVBq!ia0vp^Y9P$P1|(P5zFY^S7>k44ofy`glX(f`u%tWsIx;Y9 z?C1WI$O`0h7I;J!GcfQS24TkI`72U@g0DPX977`9-(E4~I%FWy@NoLAsFrhFcT28$ zYjji#9#!rT7m#6H|M-DIoU{8%^AjNx_{{Y313RBxU6xXD|I!{G`-Pnog0u=Iw8uX$ zkPvMTECdmf`~Un*<30Mc=W==PN0wQqMg5*eSQ<`^D-4}CRan@Oq0Z>&%gOff54_HM z-OykAmodDmMth}2PuJlUK;qAVt}^yN4>`)?MS~7KE;-_P{z;>1d*xromfN!L_MI%= zab8H~%d7gIhXq_@9)Fmh<-f)WtozhWgU8X4cY*>U*_65ty#7{jUGDLQT_5e;jwXim dwK3W-UN|2WcPx#1ZP1_K>z@;j|==^1poj532;bRa{vGi!vFvd!vV){sAK>D0%}P_K~!i%?bvT* zRdE;x@bA6O#FC9wE3T-P{?Q+`5u3{PqNwyDS(hTE=|#j;nqritW<)PiN-xGtdXXCS zqNy}uEB&FAR*_4s+O?TRcHG-}j_1C)w%yVt_I-P|= zoPYm&8U)iImM7 z8^YSZE^-hQfZyGP?2qqUnOtnnTzHL1(LnBfzK^X7xA2vWNI^_oy8^%GE?=4Pwrxkr z!6v?PlkC}t6&1^{XL%zQmoMU~8|ytHN(hA3&{`>Phu`6JUn_3Dzr__dRzZY+hX^6$ z&|2yt>Gp>^IR3Z=FNR)mB^tXRf<3{NM2~!_u@u?|I&l5<4UA8WbJdP<5OgOCZHy+U zhxAjC)BEKinjf9OlT;s9%oztkuShA9z4DFz9>wLCS8?(AC4Boe#MQ#gg9y$EQY3p# zrT^pcU$rKTgUB70BuMs}j-T<-`KAkJpSI!S$S1B&&T6wPX2wC#u~~{_ujzPk z4|`W8acVqb^a)6?0abB~6j8Bzoja%u3)`bu$jE+K5Yo(utolABEdqEr4R<7nsG*&?n8l|IzAvuN<8pF@jwqp0vU0iXa zA7+?-s$?bA_V*a&T-et1Woz literal 0 HcmV?d00001 diff --git a/docs/user_guide/assets/images/reviewEntriesColumnSort.png b/docs/user_guide/assets/images/reviewEntriesColumnSort.png new file mode 100644 index 0000000000000000000000000000000000000000..5d5997ff2eb3be2f694cfad46916c514d27e1385 GIT binary patch literal 376 zcmeAS@N?(olHy`uVBq!ia0vp^sz9vH!3HEd!Wub%6k~CayA#8@b22Z19F}xPUq=Rp zjs4tz5?O(K&H|6fVg?4j!ywFfJby(BPy?^0i(^QH``a0(yILH0T#c2xxZd6EkpBO_ zJ4Pe#NUQgKtFyeUyuS89zNN~}d@Wq&PO>^KAD6%XZEq#HKh0KDOVxGhk`+^?{8ucM zVZW}vanh~tb6@Rx=2Xwm!GHL`*L8a$vLeM+t?D{f_{6FI&9vOx6>U!c`IJ^>&Z?MP zInUSWj`HC%rus{-l`dWPT7!eBYte}%M{JGG6!dzp*(X#GlNwnRFT(15S83-n(QE$q z%>y(wp*D(4o%HC*-FES!%O?E$B@P)Z(K&tMd+lVK<0t1HN~@d~EOT6KXA_U{N`Zp1 znt9JZKJ%P3qh*JC+e}{hp9e2)(_NoA>r+jsO4v literal 0 HcmV?d00001 diff --git a/docs/user_guide/assets/images/reviewEntriesColumns.es.png b/docs/user_guide/assets/images/reviewEntriesColumns.es.png index 12fd3ed78d82e8daa7f8454771cea085f2f9f412..62818f6396f64368fe59bf56b64cfa5cba4137a1 100644 GIT binary patch literal 14582 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 literal 8328 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 diff --git a/docs/user_guide/assets/images/reviewEntriesColumns.png b/docs/user_guide/assets/images/reviewEntriesColumns.png index 864b07bf1e21a62b06e44db526e944e0f96c2d47..c1567baf390d48cf91ce3e0ea6b90914cdd73b80 100644 GIT binary patch literal 19040 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 Sp>q1TClA$i)hh4Xh5sLgnr?gm literal 4946 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 diff --git a/docs/user_guide/assets/images/reviewEntriesColumnsEdit.png b/docs/user_guide/assets/images/reviewEntriesColumnsEdit.png new file mode 100644 index 0000000000000000000000000000000000000000..6f0d14188eb84da61e4a9964e52ef794bc8c2f0a GIT binary patch literal 237 zcmeAS@N?(olHy`uVBq!ia0vp^IzX(&!3HEx+}$Yyq!^2X+?^QKos)S9a~60+7BevL9R^{>=F7qst75{Vn^QNuS zmbjacmf literal 0 HcmV?d00001 diff --git a/docs/user_guide/assets/images/reviewEntriesRowDelete.png b/docs/user_guide/assets/images/reviewEntriesRowDelete.png new file mode 100644 index 0000000000000000000000000000000000000000..658c39aaebc36c7ec48d6bce491449500f1b00a5 GIT binary patch literal 375 zcmeAS@N?(olHy`uVBq!ia0vp^20(1W!3HGHOx`^aNHG=%xjQkeJ16rJ$YDu$^mSxl z*x1kgCy^D%=PdAuEM{QfI}E~%$MaXD05$MwTCbgAZgEGGEzWQ<3t!`BQ#H+$I)g_~(r<6G|9E44+}@)M zCTt4~8G0ErcpQp<>6Xk}e&(~z_0pH8b}Mi!(K-G6s%Sdn_kOuH$7`imr~ZFhsh4}( zs@s5T#xl=ig;!=um&SUBhFblWF*ls`y*^_N)4RP7noe&1pCo#rbZ+09FSY9zzb_PC z3^hNQL5lf;M8h!#lhq93?#kP~Z{YH{9OC|Llg{OdlB*k*WvX;3&8^k%d;HusCt|*r z%yGrjn@;R|zw6UX70)FaT%3mk!p~IHuQ_yG17;4}f=W4sPYg@Dd|X~hs?Grh7K5j& KpUXO@geCxeb(b0d literal 0 HcmV?d00001 diff --git a/docs/user_guide/assets/images/reviewEntriesRowEdit.png b/docs/user_guide/assets/images/reviewEntriesRowEdit.png new file mode 100644 index 0000000000000000000000000000000000000000..ce8afdb06aefa7d3392067d69b902fd5dca93d0a GIT binary patch literal 674 zcmV;T0$u%yP)Px#1ZP1_K>z@;j|==^1poj532;bRa{vGi!vFvd!vV){sAK>D0yjxSK~!i%?b%I> zK~WgM@yAGrl!Yi+v5|#uKv8@L7FhUH-Ysn8L)cnKDJvz)OO&^~ZDi&-ookF{#<}jx zq3*vX_dGVg%XM6hZn<1u;7$9Wk8hcV=aq)%m4@e)hUXPG{PB2%cDoIy(}_;R$L025 zu~kI+)7bcGAyJfF|Q z*Hu6#Y^jRJOC%T!($&xKgzyA5n+=^jP8IxkJce$!OOE65ID|qWa(o}Y1HE34t{$g2 zelnRrwOWNnqj7~rqfv-NBINisd}jiI09`#!QT%i|g-WGDw!_Qi0`+m4~awqd_Espw^}V|Hk*)6r@`THkQr-= z;AgWLdBqQh1D*T~Pav60g4gRMYdk@d$wZFDB>QDEpU?k=W9Rc3YPA}SMkBI*29C*! zM>y`||G|ZHIvx3wl5BYV=46E%3 (http://alogicalparadox.com) - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. - - -deepmerge 4.3.1 -MIT -The MIT License (MIT) - -Copyright (c) 2012 James Halliday, Josh Duff, and other contributors - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. - - delayed-stream 1.0.0 MIT Copyright (c) 2011 Debuggable Limited @@ -41965,6 +41794,29 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +highlight-words 1.2.2 +MIT +Copyright (c) 2019 Bogdan Lazar + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + + history 5.3.0 MIT MIT License @@ -42678,11 +42530,11 @@ The above copyright notice and this permission notice shall be included in all c THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -memoize-one 5.2.1 +material-react-table 2.9.2 MIT MIT License -Copyright (c) 2019 Alexander Reardon +Copyright (c) 2022 Kevin Vandy Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal @@ -42702,7 +42554,7 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -memoize-one 6.0.0 +memoize-one 5.2.1 MIT MIT License @@ -43796,11 +43648,11 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -requires-port 1.0.0 +remove-accents 0.4.2 MIT The MIT License (MIT) -Copyright (c) 2015 Unshift.io, Arnout Kazemier, the Contributors. +Copyright (c) 2015 Marin Atanasov Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal @@ -43822,11 +43674,11 @@ SOFTWARE. -reselect 4.1.8 +requires-port 1.0.0 MIT The MIT License (MIT) -Copyright (c) 2015-2018 Reselect Contributors +Copyright (c) 2015 Unshift.io, Arnout Kazemier, the Contributors. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal @@ -43847,24 +43699,12 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -resolve-from 4.0.0 -MIT -MIT License - -Copyright (c) Sindre Sorhus (sindresorhus.com) - -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - - -resolve 1.22.8 +reselect 4.1.8 MIT -MIT License +The MIT License (MIT) -Copyright (c) 2012 James Halliday +Copyright (c) 2015-2018 Reselect Contributors Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal @@ -43885,11 +43725,24 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -rifm 0.12.1 +resolve-from 4.0.0 MIT MIT License -Copyright (c) 2018 Ivan Starkov and Bogdan Chadkin +Copyright (c) Sindre Sorhus (sindresorhus.com) + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + +resolve 1.22.8 +MIT +MIT License + +Copyright (c) 2012 James Halliday Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal @@ -44212,29 +44065,6 @@ LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. -type-detect 4.0.8 -MIT -Copyright (c) 2013 Jake Luer (http://alogicalparadox.com) - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. - - unfetch 3.1.2 MIT The MIT License (MIT) @@ -44572,28 +44402,3 @@ INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. - - -zustand 4.4.6 -MIT -MIT License - -Copyright (c) 2019 Paul Henschel - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/docs/user_guide/docs/goals.md b/docs/user_guide/docs/goals.md index 2a91976807..e49d643704 100644 --- a/docs/user_guide/docs/goals.md +++ b/docs/user_guide/docs/goals.md @@ -4,29 +4,47 @@ The Review Entries table shows all of the entries in the selected project. -### Sorting and Filtering Columns +### Columns -The columns are: Edit, Vernacular, Senses, Glosses, Domains, Pronunciations, Note, Flag, and Delete. +The columns are: Edit (no header), Vernacular, Number of Senses (#), Glosses, Domains, Pronunciations +(![Review Entries pronunciations column header](images/reviewEntriesColumnPronunciations.png){width=28}), Note, Flag +(![Review Entries flag column header](images/reviewEntriesColumnFlag.png){width=16}), and Delete (no header). ![Review Entries column headers](images/reviewEntriesColumns.png) -At the top of a column with predominantly text content (Vernacular, Glosses, Domains, Note, or Flag), you can sort -alphabetically or filter with a text search. - -At the top of either the Senses or Pronunciations column, you can sort or filter by the number of senses or recordings -that entries have. +To show/hide columns or rearrange their order, click on the +![Review Entries columns edit icon](images/reviewEntriesColumnsEdit.png){width=25} icon in the top corner. Due to the nature of Rapid Word Collection, [Data Entry](dataEntry.md) in The Combine does not support the addition of definitions or parts of speech. However, if the project has imported data in which definitions or parts of speech were -already present, additional columns will automatically be added to the Review Entries table. +already present, additional columns will be available in the Review Entries table. + +#### Sorting and Filtering + +There are icons at the top of each column to +![Review Entries column filter icon](images/reviewEntriesColumnFilter.png){width=20} filter and +![Review Entries column sort icon](images/reviewEntriesColumnSort.png){width=20} sort the data. + +In a column with predominantly text content (Vernacular, Glosses, Note, or Flag), you can sort alphabetically or filter +with a text search. + +In the Number of Senses column or Pronunciations column, you can sort or filter by the number of senses or recordings +that entries have. In the Pronunciations column, you can also filter by speaker name. + +In the Domains column, sorting is numerical by each entry's least domain id. To filter by domain, type a domain id with +or without periods. For example, "8111" and "8.1.1.1" both show all entries with a sense in domain 8.1.1.1. To also +include subdomains, add a final period to your filter. For example, "8111." includes domains "8.1.1.1", "8.1.1.1.1", and +"8.1.1.1.2". Filter with just a period (".") to show all entries with any semantic domain. ### Editing Entry Rows -You can record, play, or delete an entry's audio recordings by using the icons in the Pronunciations column. You can -delete an entire entry by using the icon in the Delete column. +You can record, play, or delete an entry's audio recordings by using the icons in the Pronunciations column. + +To edit any other part of an entry, click the ![Review Entries row edit icon](images/reviewEntriesRowEdit.png){width=20} +edit icon in the initial column. -To edit an entry's vernacular form, senses (including glosses and domains), note, or flag, click the icon in the Edit -column. +You can delete an entire entry by clicking the +![Review Entries row delete icon](images/reviewEntriesRowDelete.png){width=20} delete icon in the final column. ## Merge Duplicates {#merge-duplicates} diff --git a/package-lock.json b/package-lock.json index 2f9431efd8..b9c109d83c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,6 @@ "@emotion/react": "^11.11.1", "@emotion/styled": "^11.11.0", "@loadable/component": "^5.16.3", - "@material-table/core": "^6.3.0", "@matt-block/react-recaptcha-v2": "^2.0.1", "@microsoft/signalr": "^8.0.0", "@mui/icons-material": "^5.15.7", @@ -33,6 +32,7 @@ "i18next-http-backend": "^2.2.2", "js-base64": "^3.7.7", "make-dir": "^4.0.0", + "material-react-table": "^2.9.2", "motion": "^10.16.2", "mui-language-picker": "^1.2.8", "notistack": "^3.0.1", @@ -87,7 +87,7 @@ "eslint-plugin-unused-imports": "^3.1.0", "hunspell-reader": "^7.0.0", "jest-canvas-mock": "^2.5.2", - "license-checker-rseidelsohn": "^4.2.11", + "license-checker-rseidelsohn": "^4.3.0", "madge": "^6.1.0", "npm-run-all": "^4.1.5", "prettier": "^3.0.3", @@ -2775,75 +2775,6 @@ "postcss-selector-parser": "^6.0.10" } }, - "node_modules/@date-io/core": { - "version": "2.17.0", - "resolved": "https://registry.npmjs.org/@date-io/core/-/core-2.17.0.tgz", - "integrity": "sha512-+EQE8xZhRM/hsY0CDTVyayMDDY5ihc4MqXCrPxooKw19yAzUIC6uUqsZeaOFNL9YKTNxYKrJP5DFgE8o5xRCOw==" - }, - "node_modules/@date-io/date-fns": { - "version": "2.17.0", - "resolved": "https://registry.npmjs.org/@date-io/date-fns/-/date-fns-2.17.0.tgz", - "integrity": "sha512-L0hWZ/mTpy3Gx/xXJ5tq5CzHo0L7ry6KEO9/w/JWiFWFLZgiNVo3ex92gOl3zmzjHqY/3Ev+5sehAr8UnGLEng==", - "dependencies": { - "@date-io/core": "^2.17.0" - }, - "peerDependencies": { - "date-fns": "^2.0.0" - }, - "peerDependenciesMeta": { - "date-fns": { - "optional": true - } - } - }, - "node_modules/@date-io/dayjs": { - "version": "2.17.0", - "resolved": "https://registry.npmjs.org/@date-io/dayjs/-/dayjs-2.17.0.tgz", - "integrity": "sha512-Iq1wjY5XzBh0lheFA0it6Dsyv94e8mTiNR8vuTai+KopxDkreL3YjwTmZHxkgB7/vd0RMIACStzVgWvPATnDCA==", - "dependencies": { - "@date-io/core": "^2.17.0" - }, - "peerDependencies": { - "dayjs": "^1.8.17" - }, - "peerDependenciesMeta": { - "dayjs": { - "optional": true - } - } - }, - "node_modules/@date-io/luxon": { - "version": "2.17.0", - "resolved": "https://registry.npmjs.org/@date-io/luxon/-/luxon-2.17.0.tgz", - "integrity": "sha512-l712Vdm/uTddD2XWt9TlQloZUiTiRQtY5TCOG45MQ/8u0tu8M17BD6QYHar/3OrnkGybALAMPzCy1r5D7+0HBg==", - "dependencies": { - "@date-io/core": "^2.17.0" - }, - "peerDependencies": { - "luxon": "^1.21.3 || ^2.x || ^3.x" - }, - "peerDependenciesMeta": { - "luxon": { - "optional": true - } - } - }, - "node_modules/@date-io/moment": { - "version": "2.17.0", - "resolved": "https://registry.npmjs.org/@date-io/moment/-/moment-2.17.0.tgz", - "integrity": "sha512-e4nb4CDZU4k0WRVhz1Wvl7d+hFsedObSauDHKtZwU9kt7gdYEAzKgnrSCTHsEaXrDumdrkCYTeZ0Tmyk7uV4tw==", - "dependencies": { - "@date-io/core": "^2.17.0" - }, - "peerDependencies": { - "moment": "^2.24.0" - }, - "peerDependenciesMeta": { - "moment": { - "optional": true - } - } - }, "node_modules/@dependents/detective-less": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/@dependents/detective-less/-/detective-less-3.0.2.tgz", @@ -2887,11 +2818,6 @@ "stylis": "4.2.0" } }, - "node_modules/@emotion/core": { - "version": "11.0.0", - "resolved": "https://registry.npmjs.org/@emotion/core/-/core-11.0.0.tgz", - "integrity": "sha512-w4sE3AmHmyG6RDKf6mIbtHpgJUSJ2uGvPQb8VXFL7hFjMPibE8IiehG8cMX3Ztm4svfCQV6KqusQbeIOkurBcA==" - }, "node_modules/@emotion/hash": { "version": "0.9.1", "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.1.tgz", @@ -3108,24 +3034,6 @@ "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.1.6.tgz", "integrity": "sha512-OfX7E2oUDYxtBvsuS4e/jSn4Q9Qb6DzgeYtsAdkPZ47znpoNsMgZw0+tVijiv3uGNR6dgNlty6r9rzIzHjtd/A==" }, - "node_modules/@hello-pangea/dnd": { - "version": "16.3.0", - "resolved": "https://registry.npmjs.org/@hello-pangea/dnd/-/dnd-16.3.0.tgz", - "integrity": "sha512-RYQ/K8shtJoyNPvFWz0gfXIK7HF3P3mL9UZFGMuHB0ljRSXVgMjVFI/FxcZmakMzw6tO7NflWLriwTNBow/4vw==", - "dependencies": { - "@babel/runtime": "^7.22.5", - "css-box-model": "^1.2.1", - "memoize-one": "^6.0.0", - "raf-schd": "^4.0.3", - "react-redux": "^8.1.1", - "redux": "^4.2.1", - "use-memo-one": "^1.1.3" - }, - "peerDependencies": { - "react": "^16.8.5 || ^17.0.0 || ^18.0.0", - "react-dom": "^16.8.5 || ^17.0.0 || ^18.0.0" - } - }, "node_modules/@humanwhocodes/config-array": { "version": "0.11.14", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz", @@ -4272,102 +4180,6 @@ "node": ">=8" } }, - "node_modules/@material-table/core": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/@material-table/core/-/core-6.3.0.tgz", - "integrity": "sha512-8j21QC07rj1syuVkpZGScc5uKya7yVs7+NA8ZRawqSPk8S/r+tK+L3IF4pRq8B2SKlNXqx5KqNII+5+FelqnzQ==", - "dependencies": { - "@babel/runtime": "^7.19.0", - "@date-io/core": "^2.16.0", - "@date-io/date-fns": "^2.16.0", - "@emotion/core": "^11.0.0", - "@emotion/react": "^11.10.4", - "@emotion/styled": "^11.10.4", - "@hello-pangea/dnd": "^16.0.0", - "@mui/icons-material": ">=5.10.6", - "@mui/material": ">=5.11.12", - "@mui/x-date-pickers": "^5.0.3", - "classnames": "^2.3.2", - "date-fns": "^2.29.3", - "debounce": "^1.2.1", - "deep-eql": "^4.1.1", - "deepmerge": "^4.2.2", - "prop-types": "^15.8.1", - "uuid": "^9.0.0", - "zustand": "^4.3.0" - }, - "peerDependencies": { - "@mui/system": ">=5.10.7", - "react": ">=18.0.0", - "react-dom": ">=18.0.0" - } - }, - "node_modules/@material-table/core/node_modules/@mui/x-date-pickers": { - "version": "5.0.20", - "resolved": "https://registry.npmjs.org/@mui/x-date-pickers/-/x-date-pickers-5.0.20.tgz", - "integrity": "sha512-ERukSeHIoNLbI1C2XRhF9wRhqfsr+Q4B1SAw2ZlU7CWgcG8UBOxgqRKDEOVAIoSWL+DWT6GRuQjOKvj6UXZceA==", - "dependencies": { - "@babel/runtime": "^7.18.9", - "@date-io/core": "^2.15.0", - "@date-io/date-fns": "^2.15.0", - "@date-io/dayjs": "^2.15.0", - "@date-io/luxon": "^2.15.0", - "@date-io/moment": "^2.15.0", - "@mui/utils": "^5.10.3", - "@types/react-transition-group": "^4.4.5", - "clsx": "^1.2.1", - "prop-types": "^15.7.2", - "react-transition-group": "^4.4.5", - "rifm": "^0.12.1" - }, - "engines": { - "node": ">=12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/mui" - }, - "peerDependencies": { - "@emotion/react": "^11.9.0", - "@emotion/styled": "^11.8.1", - "@mui/material": "^5.4.1", - "@mui/system": "^5.4.1", - "date-fns": "^2.25.0", - "dayjs": "^1.10.7", - "luxon": "^1.28.0 || ^2.0.0 || ^3.0.0", - "moment": "^2.29.1", - "react": "^17.0.2 || ^18.0.0", - "react-dom": "^17.0.2 || ^18.0.0" - }, - "peerDependenciesMeta": { - "@emotion/react": { - "optional": true - }, - "@emotion/styled": { - "optional": true - }, - "date-fns": { - "optional": true - }, - "dayjs": { - "optional": true - }, - "luxon": { - "optional": true - }, - "moment": { - "optional": true - } - } - }, - "node_modules/@material-table/core/node_modules/clsx": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz", - "integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==", - "engines": { - "node": ">=6" - } - }, "node_modules/@matt-block/react-recaptcha-v2": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/@matt-block/react-recaptcha-v2/-/react-recaptcha-v2-2.0.1.tgz", @@ -8272,6 +8084,77 @@ "url": "https://github.com/sponsors/gregberge" } }, + "node_modules/@tanstack/match-sorter-utils": { + "version": "8.11.7", + "resolved": "https://registry.npmjs.org/@tanstack/match-sorter-utils/-/match-sorter-utils-8.11.7.tgz", + "integrity": "sha512-4PUKgaaFpiB7MK406N5VAiLu2VUhDumojGWhEC8kNQ767RGU2vsJDI7Xp4D8lMBzijqswRWz3U8ioa2zUKnFeQ==", + "dependencies": { + "remove-accents": "0.4.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-table": { + "version": "8.11.7", + "resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.11.7.tgz", + "integrity": "sha512-ZbzfMkLjxUTzNPBXJYH38pv2VpC9WUA+Qe5USSHEBz0dysDTv4z/ARI3csOed/5gmlmrPzVUN3UXGuUMbod3Jg==", + "dependencies": { + "@tanstack/table-core": "8.11.7" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": ">=16", + "react-dom": ">=16" + } + }, + "node_modules/@tanstack/react-virtual": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.0.2.tgz", + "integrity": "sha512-9XbRLPKgnhMwwmuQMnJMv+5a9sitGNCSEtf/AZXzmJdesYk7XsjYHaEDny+IrJzvPNwZliIIDwCRiaUqR3zzCA==", + "dependencies": { + "@tanstack/virtual-core": "3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/@tanstack/table-core": { + "version": "8.11.7", + "resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.11.7.tgz", + "integrity": "sha512-N3ksnkbPbsF3PjubuZCB/etTqvctpXWRHIXTmYfJFnhynQKjeZu8BCuHvdlLPpumKbA+bjY4Ay9AELYLOXPWBg==", + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/virtual-core": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.0.0.tgz", + "integrity": "sha512-SYXOBTjJb05rXa2vl55TTwO40A6wKu0R5i1qQwhJYNDIqaIGF7D0HsLw+pJAyi2OvntlEIVusx3xtbbgSUi6zg==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@testing-library/dom": { "version": "9.3.3", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-9.3.3.tgz", @@ -10964,11 +10847,6 @@ "integrity": "sha512-0TNiGstbQmCFwt4akjjBg5pLRTSyj/PkWQ1ZoO2zntmg9yLqSRxwEa4iCfQLGjqhiqBfOJa7W/E8wfGrTDmlZQ==", "dev": true }, - "node_modules/classnames": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.3.2.tgz", - "integrity": "sha512-CSbhY4cFEJRe6/GQzIk5qXZ4Jeg5pcsP7b5peFSDpffpe1cqjASH/n9UTjBwOp6XpMSTwQ8Za2K5V02ueA7Tmw==" - }, "node_modules/clean-css": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/clean-css/-/clean-css-5.3.2.tgz", @@ -11921,6 +11799,8 @@ "version": "2.30.0", "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz", "integrity": "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==", + "optional": true, + "peer": true, "dependencies": { "@babel/runtime": "^7.21.0" }, @@ -11937,11 +11817,6 @@ "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.10.tgz", "integrity": "sha512-vjAczensTgRcqDERK0SR2XMwsF/tSvnvlv6VcF2GIhg6Sx4yOIt/irsr1RDJsKiIyBzJDpCoXiWWq28MqH2cnQ==" }, - "node_modules/debounce": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/debounce/-/debounce-1.2.1.tgz", - "integrity": "sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug==" - }, "node_modules/debug": { "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", @@ -11972,17 +11847,6 @@ } } }, - "node_modules/deep-eql": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.3.tgz", - "integrity": "sha512-WaEtAOpRA1MQ0eohqZjpGD8zdI0Ovsm8mmFhaDN8dvDZzyoUMcYDnf5Y6iu7HTXxf8JDS23qWa4a+hKCDyOPzw==", - "dependencies": { - "type-detect": "^4.0.0" - }, - "engines": { - "node": ">=6" - } - }, "node_modules/deep-equal": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-2.2.3.tgz", @@ -12034,6 +11898,7 @@ "version": "4.3.1", "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, "engines": { "node": ">=0.10.0" } @@ -15262,6 +15127,15 @@ "resolved": "https://registry.npmjs.org/hey-listen/-/hey-listen-1.0.8.tgz", "integrity": "sha512-COpmrF2NOg4TBWUJ5UVyaCU2A88wEMkUPK4hNqyCkqHbxT92BbvfjoSozkAIIm6XhicGlJHhFdullInrdhwU8Q==" }, + "node_modules/highlight-words": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/highlight-words/-/highlight-words-1.2.2.tgz", + "integrity": "sha512-Mf4xfPXYm8Ay1wTibCrHpNWeR2nUMynMVFkXCi4mbl+TEgmNOe+I4hV7W3OCZcSvzGL6kupaqpfHOemliMTGxQ==", + "engines": { + "node": ">= 16", + "npm": ">= 8" + } + }, "node_modules/history": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/history/-/history-5.3.0.tgz", @@ -19324,9 +19198,9 @@ } }, "node_modules/license-checker-rseidelsohn": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/license-checker-rseidelsohn/-/license-checker-rseidelsohn-4.2.11.tgz", - "integrity": "sha512-ZzLC4k66TqlcJQliZCy8ZOngy2IJrh1xUhMz/xyUQmg9LEt+mUisl9/eacEObIBmZ353bEMSK7Ne/T9pFQwlPA==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/license-checker-rseidelsohn/-/license-checker-rseidelsohn-4.3.0.tgz", + "integrity": "sha512-A2LQ+3kUIG1hCJ/hh4WUvPsyhooT7o5mFhNyTean0cqH3rZeB1ZUCthxlcdgWESSqx+3DLC6J/8ghbiWRYKdUA==", "dev": true, "dependencies": { "chalk": "4.1.2", @@ -19674,6 +19548,33 @@ "tmpl": "1.0.5" } }, + "node_modules/material-react-table": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/material-react-table/-/material-react-table-2.9.2.tgz", + "integrity": "sha512-w7y6tUU64rDSs8PZE2NF8//eaaUz8ZcGrGoY5YyeOtyNxB0S7fB+1/gu2tsx8IKMlC8aOubJsiKYoQK0vi3bvw==", + "dependencies": { + "@tanstack/match-sorter-utils": "8.11.7", + "@tanstack/react-table": "8.11.7", + "@tanstack/react-virtual": "3.0.2", + "highlight-words": "1.2.2" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kevinvandy" + }, + "peerDependencies": { + "@emotion/react": ">=11.11", + "@emotion/styled": ">=11.11", + "@mui/icons-material": ">=5.11", + "@mui/material": ">=5.13", + "@mui/x-date-pickers": ">=6.15.0", + "react": ">=18.0", + "react-dom": ">=18.0" + } + }, "node_modules/mdn-data": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.4.tgz", @@ -19701,11 +19602,6 @@ "node": ">= 4.0.0" } }, - "node_modules/memoize-one": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz", - "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==" - }, "node_modules/memorystream": { "version": "0.3.1", "resolved": "https://registry.npmjs.org/memorystream/-/memorystream-0.3.1.tgz", @@ -24940,6 +24836,11 @@ "node": ">= 0.10" } }, + "node_modules/remove-accents": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/remove-accents/-/remove-accents-0.4.2.tgz", + "integrity": "sha512-7pXIJqJOq5tFgG1A2Zxti3Ht8jJF337m4sowbuHsW30ZnkQFnDzy9qBNhgzX8ZLW4+UBcXiiR7SwR6pokHsxiA==" + }, "node_modules/renderkid": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/renderkid/-/renderkid-3.0.0.tgz", @@ -25171,14 +25072,6 @@ "node": ">=0.10.0" } }, - "node_modules/rifm": { - "version": "0.12.1", - "resolved": "https://registry.npmjs.org/rifm/-/rifm-0.12.1.tgz", - "integrity": "sha512-OGA1Bitg/dSJtI/c4dh90svzaUPt228kzFsUkJbtA2c964IqEAwWXeL9ZJi86xWv3j5SMqRvGULl7bA6cK0Bvg==", - "peerDependencies": { - "react": ">=16.8" - } - }, "node_modules/rimraf": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", @@ -27233,6 +27126,7 @@ "version": "4.0.8", "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "dev": true, "engines": { "node": ">=4" } @@ -28695,33 +28589,6 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } - }, - "node_modules/zustand": { - "version": "4.4.6", - "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.4.6.tgz", - "integrity": "sha512-Rb16eW55gqL4W2XZpJh0fnrATxYEG3Apl2gfHTyDSE965x/zxslTikpNch0JgNjJA9zK6gEFW8Fl6d1rTZaqgg==", - "dependencies": { - "use-sync-external-store": "1.2.0" - }, - "engines": { - "node": ">=12.7.0" - }, - "peerDependencies": { - "@types/react": ">=16.8", - "immer": ">=9.0", - "react": ">=16.8" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "immer": { - "optional": true - }, - "react": { - "optional": true - } - } } } } diff --git a/package.json b/package.json index 077165fc3a..7e58a9181a 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,6 @@ "@emotion/react": "^11.11.1", "@emotion/styled": "^11.11.0", "@loadable/component": "^5.16.3", - "@material-table/core": "^6.3.0", "@matt-block/react-recaptcha-v2": "^2.0.1", "@microsoft/signalr": "^8.0.0", "@mui/icons-material": "^5.15.7", @@ -61,6 +60,7 @@ "i18next-http-backend": "^2.2.2", "js-base64": "^3.7.7", "make-dir": "^4.0.0", + "material-react-table": "^2.9.2", "motion": "^10.16.2", "mui-language-picker": "^1.2.8", "notistack": "^3.0.1", @@ -115,7 +115,7 @@ "eslint-plugin-unused-imports": "^3.1.0", "hunspell-reader": "^7.0.0", "jest-canvas-mock": "^2.5.2", - "license-checker-rseidelsohn": "^4.2.11", + "license-checker-rseidelsohn": "^4.3.0", "madge": "^6.1.0", "npm-run-all": "^4.1.5", "prettier": "^3.0.3", diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json index b235a6f1c6..9806465667 100644 --- a/public/locales/en/translation.json +++ b/public/locales/en/translation.json @@ -284,17 +284,15 @@ }, "reviewEntries": { "title": "Review Entries", - "noVernacular": "No vernacular input!", - "noDefinition": "No definitions input", - "noGloss": "No glosses input", + "allEntriesPerPageOption": "All", + "editSense": "Edit sense", + "discardChanges": "Discard changes?", "noDomain": "No domain selected", "duplicateDomain": "This sense already has domain {{ val }}.", - "noNote": "No note", "deleteWordWarning": "This word will be permanently deleted!", "deleteDisabled": "This was imported with data that The Combine doesn't handle, so it may not be deleted.", "error": { "gloss": "Glosses cannot be left blank", - "domain": "Domains cannot be left blank", "senses": "Cannot save an entry with no senses", "vernacular": "Vernacular cannot be left blank" }, @@ -302,33 +300,16 @@ "definitions": "Definitions", "delete": "Delete", "domains": "Domains", + "edit": "Edit", "flag": "Flag", "glosses": "Glosses", "note": "Note", "partOfSpeech": "Part of Speech", "pronunciations": "Pronunciations", "senses": "Senses", + "sensesCount": "Number of Senses", "vernacular": "Vernacular" }, - "materialTable": { - "body": { - "edit": "Edit", - "emptyDataSourceMessage": "No entries to display", - "filter": "Filter" - }, - "pagination": { - "labelDisplayedRows": "{from}-{to} of {count}", - "labelRows": "rows", - "labelRowsPerPage": "Rows per page:", - "first": "First Page", - "last": "Last Page", - "next": "Next Page", - "previous": "Previous Page" - }, - "toolbar": { - "search": "Search" - } - }, "completed": { "number": "Number of entries edited: " }, diff --git a/src/components/App/DefaultState.ts b/src/components/App/DefaultState.ts index afb78a040e..e872be5aed 100644 --- a/src/components/App/DefaultState.ts +++ b/src/components/App/DefaultState.ts @@ -6,7 +6,6 @@ import { defaultState as treeViewState } from "components/TreeView/Redux/TreeVie import { defaultState as characterInventoryState } from "goals/CharacterInventory/Redux/CharacterInventoryReduxTypes"; import { defaultState as mergeDuplicateGoal } from "goals/MergeDuplicates/Redux/MergeDupsReduxTypes"; import { defaultState as goalsState } from "goals/Redux/GoalReduxTypes"; -import { defaultState as reviewEntriesState } from "goals/ReviewEntries/Redux/ReviewEntriesReduxTypes"; import { defaultState as analyticsState } from "types/Redux/analyticsReduxTypes"; export const defaultState = { @@ -19,7 +18,6 @@ export const defaultState = { //data entry and review entries goal treeViewState: { ...treeViewState }, - reviewEntriesState: { ...reviewEntriesState }, pronunciationsState: { ...pronunciationsState }, //goal timeline and current goal diff --git a/src/components/Buttons/IconButtonWithTooltip.tsx b/src/components/Buttons/IconButtonWithTooltip.tsx index fd854f1e77..f0878bb37a 100644 --- a/src/components/Buttons/IconButtonWithTooltip.tsx +++ b/src/components/Buttons/IconButtonWithTooltip.tsx @@ -9,7 +9,7 @@ interface IconButtonWithTooltipProps { textId?: string; size?: "large" | "medium" | "small"; onClick?: MouseEventHandler; - buttonId: string; + buttonId?: string; side?: "bottom" | "left" | "right" | "top"; } diff --git a/src/components/Buttons/PartOfSpeechButton.tsx b/src/components/Buttons/PartOfSpeechButton.tsx index b7eac606c9..4720e3974f 100644 --- a/src/components/Buttons/PartOfSpeechButton.tsx +++ b/src/components/Buttons/PartOfSpeechButton.tsx @@ -7,14 +7,16 @@ import { GramCatGroup, GrammaticalInfo } from "api/models"; import { IconButtonWithTooltip } from "components/Buttons"; import { getGramCatGroupColor } from "utilities/wordUtilities"; -interface PartOfSpeechProps { - buttonId: string; +interface PartOfSpeechButtonProps { + buttonId?: string; gramInfo: GrammaticalInfo; onClick?: () => void; onlyIcon?: boolean; } -export default function PartOfSpeech(props: PartOfSpeechProps): ReactElement { +export default function PartOfSpeechButton( + props: PartOfSpeechButtonProps +): ReactElement { const { t } = useTranslation(); const { catGroup, grammaticalCategory } = props.gramInfo; if (catGroup === GramCatGroup.Unspecified && !props.onClick) { diff --git a/src/components/DataEntry/DataEntryTable/NewEntry/SenseDialog.tsx b/src/components/DataEntry/DataEntryTable/NewEntry/SenseDialog.tsx index 5e5d5a9cce..81ba60cba6 100644 --- a/src/components/DataEntry/DataEntryTable/NewEntry/SenseDialog.tsx +++ b/src/components/DataEntry/DataEntryTable/NewEntry/SenseDialog.tsx @@ -5,17 +5,14 @@ import { MenuList, Typography, } from "@mui/material"; -import { ReactElement } from "react"; +import { type ReactElement } from "react"; import { useTranslation } from "react-i18next"; -import { GramCatGroup, Sense, Word } from "api/models"; +import { GramCatGroup, type Sense, type Word } from "api/models"; import { CloseButton } from "components/Buttons"; import StyledMenuItem from "components/DataEntry/DataEntryTable/NewEntry/StyledMenuItem"; -import { - DomainCell, - PartOfSpeechCell, -} from "goals/ReviewEntries/ReviewEntriesTable/CellComponents"; -import { ReviewEntriesWord } from "goals/ReviewEntries/ReviewEntriesTypes"; +import DomainsCell from "goals/ReviewEntries/ReviewEntriesTable/Cells/DomainsCell"; +import PartOfSpeechCell from "goals/ReviewEntries/ReviewEntriesTable/Cells/PartOfSpeechCell"; import { firstGlossText } from "utilities/wordUtilities"; interface SenseDialogProps { @@ -62,10 +59,7 @@ export function SenseList(props: SenseListProps): ReactElement { ); const menuItem = (sense: Sense): ReactElement => { - const entry = new ReviewEntriesWord( - { ...props.selectedWord, senses: [sense] }, - props.analysisLang - ); + const word: Word = { ...props.selectedWord, senses: [sense] }; const gloss = firstGlossText(sense); return ( {hasPartsOfSpeech && ( - + )} - + diff --git a/src/components/DataEntry/DataEntryTable/NewEntry/VernDialog.tsx b/src/components/DataEntry/DataEntryTable/NewEntry/VernDialog.tsx index 7f9d3db566..03fc935f64 100644 --- a/src/components/DataEntry/DataEntryTable/NewEntry/VernDialog.tsx +++ b/src/components/DataEntry/DataEntryTable/NewEntry/VernDialog.tsx @@ -5,18 +5,15 @@ import { MenuList, Typography, } from "@mui/material"; -import { ReactElement } from "react"; +import { type ReactElement } from "react"; import { useTranslation } from "react-i18next"; -import { GramCatGroup, Word } from "api/models"; +import { GramCatGroup, type Word } from "api/models"; import { CloseButton } from "components/Buttons"; import StyledMenuItem from "components/DataEntry/DataEntryTable/NewEntry/StyledMenuItem"; -import { - DomainCell, - GlossCell, - PartOfSpeechCell, -} from "goals/ReviewEntries/ReviewEntriesTable/CellComponents"; -import { ReviewEntriesWord } from "goals/ReviewEntries/ReviewEntriesTypes"; +import DomainsCell from "goals/ReviewEntries/ReviewEntriesTable/Cells/DomainsCell"; +import GlossesCell from "goals/ReviewEntries/ReviewEntriesTable/Cells/GlossesCell"; +import PartOfSpeechCell from "goals/ReviewEntries/ReviewEntriesTable/Cells/PartOfSpeechCell"; interface vernDialogProps { vernacularWords: Word[]; @@ -64,7 +61,6 @@ export function VernList(props: VernListProps): ReactElement { ); const menuItem = (word: Word): ReactElement => { - const entry = new ReviewEntriesWord(word, props.analysisLang); return ( {word.vernacular} - + {hasPartsOfSpeech && ( - + )} - + diff --git a/src/components/DataEntry/DataEntryTable/NewEntry/tests/SenseDialog.test.tsx b/src/components/DataEntry/DataEntryTable/NewEntry/tests/SenseDialog.test.tsx index df9051b5e5..2abf53266c 100644 --- a/src/components/DataEntry/DataEntryTable/NewEntry/tests/SenseDialog.test.tsx +++ b/src/components/DataEntry/DataEntryTable/NewEntry/tests/SenseDialog.test.tsx @@ -14,11 +14,6 @@ import { defaultWritingSystem } from "types/writingSystem"; // MUI: Unable to set focus to a MenuItem whose component has not been rendered. jest.mock("@mui/material/MenuItem", () => "div"); -jest.mock("goals/ReviewEntries/ReviewEntriesTable/CellComponents", () => ({ - DomainCell: () =>
, - PartOfSpeechCell: () =>
, -})); - let testRenderer: renderer.ReactTestRenderer; const mockState = { diff --git a/src/components/DataEntry/DataEntryTable/NewEntry/tests/VernDialog.test.tsx b/src/components/DataEntry/DataEntryTable/NewEntry/tests/VernDialog.test.tsx index 620ea49dc3..0c3aca046c 100644 --- a/src/components/DataEntry/DataEntryTable/NewEntry/tests/VernDialog.test.tsx +++ b/src/components/DataEntry/DataEntryTable/NewEntry/tests/VernDialog.test.tsx @@ -14,12 +14,6 @@ import { defaultWritingSystem } from "types/writingSystem"; // MUI: Unable to set focus to a MenuItem whose component has not been rendered. jest.mock("@mui/material/MenuItem", () => "div"); -jest.mock("goals/ReviewEntries/ReviewEntriesTable/CellComponents", () => ({ - DomainCell: () =>
, - GlossCell: () =>
, - PartOfSpeechCell: () =>
, -})); - let testRenderer: renderer.ReactTestRenderer; const mockState = { diff --git a/src/components/WordCard/SenseCard.tsx b/src/components/WordCard/SenseCard.tsx index 10d8e3ac27..99ea2774ed 100644 --- a/src/components/WordCard/SenseCard.tsx +++ b/src/components/WordCard/SenseCard.tsx @@ -7,6 +7,7 @@ import DomainChipsGrid from "components/WordCard/DomainChipsGrid"; import SenseCardText from "components/WordCard/SenseCardText"; interface SenseCardProps { + bgColor?: string; languages?: string[]; minimal?: boolean; provenance?: boolean; @@ -18,7 +19,12 @@ export default function SenseCard(props: SenseCardProps): ReactElement { const semDoms = props.sense.semanticDomains; return ( - + {/* Part of speech (if any) */}
diff --git a/src/goals/DefaultGoal/BaseGoalScreen.tsx b/src/goals/DefaultGoal/BaseGoalScreen.tsx index c9cb367961..743ce37255 100644 --- a/src/goals/DefaultGoal/BaseGoalScreen.tsx +++ b/src/goals/DefaultGoal/BaseGoalScreen.tsx @@ -7,7 +7,6 @@ import DisplayProgress from "goals/DefaultGoal/DisplayProgress"; import Loading from "goals/DefaultGoal/Loading"; import { clearTree } from "goals/MergeDuplicates/Redux/MergeDupsActions"; import { setCurrentGoal } from "goals/Redux/GoalActions"; -import { resetReviewEntries } from "goals/ReviewEntries/Redux/ReviewEntriesActions"; import { type StoreState } from "types"; import { Goal, GoalStatus, GoalType } from "types/goals"; import { useAppDispatch, useAppSelector } from "types/hooks"; @@ -63,7 +62,6 @@ export function BaseGoalScreen(): ReactElement { useEffect(() => { return function cleanup(): void { dispatch(setCurrentGoal()); - dispatch(resetReviewEntries()); dispatch(clearTree()); }; }, [dispatch]); diff --git a/src/goals/ReviewEntries/Redux/ReviewEntriesActions.ts b/src/goals/ReviewEntries/Redux/ReviewEntriesActions.ts deleted file mode 100644 index 039afd8816..0000000000 --- a/src/goals/ReviewEntries/Redux/ReviewEntriesActions.ts +++ /dev/null @@ -1,253 +0,0 @@ -import { Action, PayloadAction } from "@reduxjs/toolkit"; - -import { Pronunciation, Sense, Word } from "api/models"; -import * as backend from "backend"; -import { uploadFileFromPronunciation } from "components/Pronunciations/utilities"; -import { addEntryEditToGoal, asyncUpdateGoal } from "goals/Redux/GoalActions"; -import { - deleteWordAction, - resetReviewEntriesAction, - setAllWordsAction, - setSortByAction, - updateWordAction, -} from "goals/ReviewEntries/Redux/ReviewEntriesReducer"; -import { - ColumnId, - ReviewEntriesSense, - ReviewEntriesWord, -} from "goals/ReviewEntries/ReviewEntriesTypes"; -import { StoreStateDispatch } from "types/Redux/actions"; -import { - FileWithSpeakerId, - newNote, - newSense, - updateSpeakerInAudio, -} from "types/word"; - -// Action Creation Functions - -export function deleteWord(wordId: string): Action { - return deleteWordAction(wordId); -} - -export function resetReviewEntries(): Action { - return resetReviewEntriesAction(); -} - -export function setAllWords(words: Word[]): PayloadAction { - return setAllWordsAction(words); -} - -export function setSortBy(columnId?: ColumnId): PayloadAction { - return setSortByAction(columnId); -} - -interface WordUpdate { - oldId: string; - updatedWord: Word; -} - -export function updateWord(update: WordUpdate): PayloadAction { - return updateWordAction(update); -} - -// Dispatch Functions - -/** Updates a word and the current goal. */ -function asyncUpdateWord(oldId: string, updatedWord: Word) { - return async (dispatch: StoreStateDispatch) => { - dispatch(addEntryEditToGoal({ newId: updatedWord.id, oldId })); - await dispatch(asyncUpdateGoal()); - dispatch(updateWord({ oldId, updatedWord })); - }; -} - -/** Return the translation code for our error, or undefined if there is no error */ -export function getSenseError( - sense: ReviewEntriesSense, - checkGlosses = true, - checkDomains = false -): string | undefined { - if (checkGlosses && sense.glosses.length === 0) { - return "reviewEntries.error.gloss"; - } - if (checkDomains && sense.domains.length === 0) { - return "reviewEntries.error.domain"; - } - return undefined; -} - -/** Returns a cleaned array of senses ready to be saved (none with .deleted=true): - * - If a sense is marked as deleted or is utterly blank, it is removed - * - If a sense lacks gloss, return error - * - If the user attempts to delete all senses, return old senses with deleted senses removed */ -function cleanSenses( - senses: ReviewEntriesSense[], - oldSenses: ReviewEntriesSense[] -): ReviewEntriesSense[] | string { - const cleanSenses: ReviewEntriesSense[] = []; - let error: string | undefined; - - for (const newSense of senses) { - // Remove empty definitions, empty glosses, and duplicate domains. - newSense.definitions = newSense.definitions.filter((d) => d.text.length); - newSense.glosses = newSense.glosses.filter((g) => g.def.length); - const domainIds = [...new Set(newSense.domains.map((dom) => dom.id))]; - domainIds.sort(); - newSense.domains = domainIds.map( - (id) => newSense.domains.find((dom) => dom.id === id)! - ); - - // Skip senses which are deleted or empty. - if ( - newSense.deleted || - (newSense.definitions.length === 0 && - newSense.glosses.length === 0 && - newSense.domains.length === 0) - ) { - continue; - } - - error = getSenseError(newSense); - if (error) { - return error; - } - - cleanSenses.push(newSense); - } - - if (cleanSenses.length) { - return cleanSenses; - } - return oldSenses.filter((s) => !s.deleted); -} - -/** Clean the vernacular field of a word: - * - If all senses are deleted, reject - * - If there's no vernacular field, add in the vernacular of old field - * - If neither the word nor oldWord has a vernacular, reject */ -function cleanWord( - word: ReviewEntriesWord, - oldWord: ReviewEntriesWord -): ReviewEntriesWord | string { - if (!word.senses.find((s) => !s.deleted)) { - return "reviewEntries.error.senses"; - } - const vernacular = word.vernacular.length - ? word.vernacular - : oldWord.vernacular; - if (!vernacular.length) { - return "reviewEntries.error.vernacular"; - } - const senses = cleanSenses(word.senses, oldWord.senses); - return typeof senses === "string" ? senses : { ...word, vernacular, senses }; -} - -/** Converts the ReviewEntriesWord into a Word to send to the backend */ -export function updateFrontierWord( - newData: ReviewEntriesWord, - oldData?: ReviewEntriesWord -) { - return async (dispatch: StoreStateDispatch) => { - oldData ??= new ReviewEntriesWord(); - - // Clean + check data; if there's something wrong, return the error. - const editSource = cleanWord(newData, oldData); - if (typeof editSource === "string") { - return Promise.reject(editSource); - } - const oldId = editSource.id; - - // Get the original word, for updating. - const editWord = await backend.getWord(oldId); - - // Update the data. - editWord.vernacular = editSource.vernacular; - editWord.senses = editSource.senses.map((s) => - getSenseFromEditSense(s, editWord.senses) - ); - editWord.note = newNote(editSource.noteText, editWord.note?.language); - editWord.flag = { ...editSource.flag }; - - // Apply any speakerId changes, but save adding/deleting audio for later. - editWord.audio = oldData.audio.map( - (o) => newData.audio.find((n) => n.fileName === o.fileName) ?? o - ); - const delAudio = oldData.audio.filter( - (o) => !newData.audio.find((n) => n.fileName === o.fileName) - ); - const addAudio = [...(newData.audioNew ?? [])]; - - // Update the word in the backend, and retrieve the id. - let newId = (await backend.updateWord(editWord)).id; - - // Add/delete audio. - for (const audio of addAudio) { - newId = await uploadFileFromPronunciation(newId, audio); - } - for (const audio of delAudio) { - newId = await backend.deleteAudio(newId, audio.fileName); - } - - // Update the word in the state. - await dispatch(asyncUpdateWord(oldId, await backend.getWord(newId))); - }; -} - -/** Creates a Sense from a cleaned ReviewEntriesSense and array of old senses. */ -export function getSenseFromEditSense( - editSense: ReviewEntriesSense, - oldSenses: Sense[] -): Sense { - // If we match an old sense, copy it over. - const oldSense = oldSenses.find((s) => s.guid === editSense.guid); - const sense = oldSense ?? newSense(); - - // Use the cleaned definitions, glosses, and domains. - sense.definitions = [...editSense.definitions]; - sense.glosses = [...editSense.glosses]; - sense.semanticDomains = [...editSense.domains]; - - return sense; -} - -/** Performs specified backend Word-updating function, then makes state ReviewEntriesWord-updating dispatch */ -function asyncRefreshWord( - oldWordId: string, - wordUpdater: (wordId: string) => Promise -) { - return async (dispatch: StoreStateDispatch): Promise => { - const newWordId = await wordUpdater(oldWordId); - const word = await backend.getWord(newWordId); - await dispatch(asyncUpdateWord(oldWordId, word)); - }; -} - -export function deleteAudio( - wordId: string, - fileName: string -): (dispatch: StoreStateDispatch) => Promise { - return asyncRefreshWord(wordId, (wordId: string) => - backend.deleteAudio(wordId, fileName) - ); -} - -export function replaceAudio( - wordId: string, - pro: Pronunciation -): (dispatch: StoreStateDispatch) => Promise { - return asyncRefreshWord(wordId, async (oldId: string) => { - const word = await backend.getWord(oldId); - const audio = updateSpeakerInAudio(word.audio, pro); - return audio ? (await backend.updateWord({ ...word, audio })).id : oldId; - }); -} - -export function uploadAudio( - wordId: string, - file: FileWithSpeakerId -): (dispatch: StoreStateDispatch) => Promise { - return asyncRefreshWord(wordId, (wordId: string) => - backend.uploadAudio(wordId, file) - ); -} diff --git a/src/goals/ReviewEntries/Redux/ReviewEntriesReducer.ts b/src/goals/ReviewEntries/Redux/ReviewEntriesReducer.ts deleted file mode 100644 index b104d272d5..0000000000 --- a/src/goals/ReviewEntries/Redux/ReviewEntriesReducer.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { createSlice } from "@reduxjs/toolkit"; - -import { defaultState } from "goals/ReviewEntries/Redux/ReviewEntriesReduxTypes"; -import { StoreActionTypes } from "rootActions"; - -const reviewEntriesSlice = createSlice({ - name: "reviewEntriesState", - initialState: defaultState, - reducers: { - deleteWordAction: (state, action) => { - state.words = state.words.filter((w) => w.id !== action.payload); - }, - resetReviewEntriesAction: () => defaultState, - setAllWordsAction: (state, action) => { - state.words = action.payload; - }, - setSortByAction: (state, action) => { - state.sortBy = action.payload; - }, - updateWordAction: (state, action) => { - state.words = state.words.map((w) => - w.id === action.payload.oldId ? action.payload.updatedWord : w - ); - }, - }, - extraReducers: (builder) => - builder.addCase(StoreActionTypes.RESET, () => defaultState), -}); - -export const { - deleteWordAction, - resetReviewEntriesAction, - setAllWordsAction, - setSortByAction, - updateWordAction, -} = reviewEntriesSlice.actions; - -export default reviewEntriesSlice.reducer; diff --git a/src/goals/ReviewEntries/Redux/ReviewEntriesReduxTypes.ts b/src/goals/ReviewEntries/Redux/ReviewEntriesReduxTypes.ts deleted file mode 100644 index 9547cdd04d..0000000000 --- a/src/goals/ReviewEntries/Redux/ReviewEntriesReduxTypes.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { Word } from "api/models"; -import { ColumnId } from "goals/ReviewEntries/ReviewEntriesTypes"; - -export interface ReviewEntriesState { - words: Word[]; - sortBy?: ColumnId; -} - -export const defaultState: ReviewEntriesState = { - words: [], - sortBy: undefined, -}; diff --git a/src/goals/ReviewEntries/Redux/tests/ReviewEntriesActions.test.tsx b/src/goals/ReviewEntries/Redux/tests/ReviewEntriesActions.test.tsx deleted file mode 100644 index 696f6a0e3c..0000000000 --- a/src/goals/ReviewEntries/Redux/tests/ReviewEntriesActions.test.tsx +++ /dev/null @@ -1,573 +0,0 @@ -import { PreloadedState } from "redux"; - -import { Pronunciation, Sense, Word } from "api/models"; -import { defaultState } from "components/App/DefaultState"; -import { - deleteAudio, - deleteWord, - getSenseError, - getSenseFromEditSense, - replaceAudio, - resetReviewEntries, - setAllWords, - setSortBy, - updateFrontierWord, - updateWord, - uploadAudio, -} from "goals/ReviewEntries/Redux/ReviewEntriesActions"; -import { - ColumnId, - ReviewEntriesSense, - ReviewEntriesWord, -} from "goals/ReviewEntries/ReviewEntriesTypes"; -import { RootState, setupStore } from "store"; -import { newSemanticDomain } from "types/semanticDomain"; -import { - newFlag, - newGloss, - newNote, - newPronunciation, - newSense, - newWord, -} from "types/word"; -import { Bcp47Code } from "types/writingSystem"; - -const mockDeleteAudio = jest.fn(); -const mockGetWord = jest.fn(); -const mockUpdateWord = jest.fn(); -const mockUploadAudio = jest.fn(); -function mockGetWordResolve(data: Word): void { - mockGetWord.mockResolvedValue(JSON.parse(JSON.stringify(data))); -} - -jest.mock("backend", () => ({ - deleteAudio: (args: any[]) => mockDeleteAudio(...args), - getWord: (wordId: string) => mockGetWord(wordId), - updateWord: (word: Word) => mockUpdateWord(word), - uploadAudio: (args: any[]) => mockUploadAudio(...args), -})); -jest.mock("goals/Redux/GoalActions", () => ({ - addEntryEditToGoal: () => jest.fn(), - asyncUpdateGoal: () => jest.fn(), -})); - -// Dummy strings, glosses, and domains. -const commonGuid = "mockGuid"; -const gloss0 = newGloss("gloss", Bcp47Code.En); -const gloss0Es = newGloss("glossario", Bcp47Code.Es); -const gloss1 = newGloss("infinite", Bcp47Code.En); -const domain0 = newSemanticDomain("1", "Universe"); -const domain1 = newSemanticDomain("8.3.3.2.1", "Shadow"); -const colId = ColumnId.Definitions; -const wordId = "mockId"; - -// Dummy sense and word creators. -function sense0(): Sense { - return { - ...newSense(), - guid: commonGuid + "0", - glosses: [{ ...gloss0 }, { ...gloss0Es }], - semanticDomains: [{ ...domain0 }], - }; -} -function sense1(): Sense { - return { - ...newSense(), - guid: commonGuid + "1", - glosses: [{ ...gloss1 }], - semanticDomains: [{ ...domain1 }], - }; -} -function sense1_local(): ReviewEntriesSense { - return new ReviewEntriesSense(sense1()); -} -function mockFrontierWord(vernacular = "word"): Word { - return { - ...newWord(vernacular), - guid: commonGuid, - id: wordId, - senses: [sense0()], - }; -} -function mockReviewEntriesWord(vernacular = "word"): ReviewEntriesWord { - return { - ...new ReviewEntriesWord(), - id: wordId, - vernacular, - senses: [new ReviewEntriesSense(sense0())], - }; -} - -// Preloaded values for store when testing -const persistedDefaultState: PreloadedState = { - ...defaultState, - _persist: { version: 1, rehydrated: false }, -}; - -beforeEach(() => { - jest.clearAllMocks(); -}); - -describe("ReviewEntriesActions", () => { - describe("Action Creation Functions", () => { - test("deleteWord", () => { - const store = setupStore({ - ...persistedDefaultState, - reviewEntriesState: { - sortBy: colId, - words: [mockFrontierWord()], - }, - }); - - store.dispatch(deleteWord(wordId)); - expect(store.getState().reviewEntriesState.sortBy).toEqual(colId); - expect(store.getState().reviewEntriesState.words).toHaveLength(0); - }); - - test("resetReviewEntries", () => { - const store = setupStore({ - ...persistedDefaultState, - reviewEntriesState: { - sortBy: colId, - words: [mockFrontierWord()], - }, - }); - - store.dispatch(resetReviewEntries()); - expect(store.getState().reviewEntriesState.sortBy).toBeUndefined(); - expect(store.getState().reviewEntriesState.words).toHaveLength(0); - }); - - test("setAllWords", () => { - const store = setupStore({ - ...persistedDefaultState, - reviewEntriesState: { - sortBy: colId, - words: [], - }, - }); - - const frontier = [mockFrontierWord("wordA"), mockFrontierWord("wordB")]; - store.dispatch(setAllWords(frontier)); - expect(store.getState().reviewEntriesState.sortBy).toEqual(colId); - expect(store.getState().reviewEntriesState.words).toHaveLength( - frontier.length - ); - }); - - test("setSortBy", () => { - const store = setupStore(persistedDefaultState); - - store.dispatch(setSortBy(colId)); - expect(store.getState().reviewEntriesState.sortBy).toEqual(colId); - - store.dispatch(setSortBy()); - expect(store.getState().reviewEntriesState.sortBy).toBeUndefined(); - }); - - test("updateWord", () => { - const frontier: Word[] = [ - { ...mockFrontierWord(), id: "otherA" }, - mockFrontierWord(), - { ...mockFrontierWord(), id: "otherB" }, - ]; - const store = setupStore({ - ...persistedDefaultState, - reviewEntriesState: { sortBy: colId, words: frontier }, - }); - - const newVern = "updatedVern"; - const newId = "updatedId"; - const updatedWord: Word = { ...mockFrontierWord(newVern), id: newId }; - store.dispatch(updateWord({ oldId: wordId, updatedWord })); - - const { sortBy, words } = store.getState().reviewEntriesState; - expect(sortBy).toEqual(colId); - expect(words).toHaveLength(3); - expect(words.find((w) => w.id === wordId)).toBeUndefined(); - const newWord = words.find((w) => w.id === newId); - expect(newWord?.vernacular).toEqual(newVern); - }); - }); - - describe("asyncRefreshWord", () => { - test("deleteAudio", async () => { - // Setup state with word with audio - const fileName = "audio-file-name"; - const oldWord: Word = { - ...mockFrontierWord(), - audio: [newPronunciation(fileName)], - }; - const store = setupStore({ - ...persistedDefaultState, - reviewEntriesState: { sortBy: colId, words: [oldWord] }, - }); - - // Prep replacement word without the audio - const newId = "id-after-audio-deleted"; - const word: Word = { ...oldWord, audio: [], id: newId }; - - // Mock backend function that will be called - mockDeleteAudio.mockResolvedValueOnce(newId); - mockGetWord.mockResolvedValueOnce(word); - - // Dispatch the audio delete - await store.dispatch(deleteAudio(wordId, fileName)); - expect(mockDeleteAudio).toHaveBeenCalledTimes(1); - - // Verify the replacement word in state has the audio removed - const words = store.getState().reviewEntriesState.words; - expect(words.find((w) => w.id === wordId)).toBeUndefined(); - const wordInState = words.find((w) => w.id === newId); - expect(wordInState?.audio).toHaveLength(0); - }); - - test("replaceAudio", async () => { - // Setup state with word with audio - const oldPro = newPronunciation("audio-file-name"); - const oldWord: Word = { ...mockFrontierWord(), audio: [oldPro] }; - const store = setupStore({ - ...persistedDefaultState, - reviewEntriesState: { sortBy: colId, words: [oldWord] }, - }); - - // Prep replacement word with a new speaker on the audio - const newId = "id-after-audio-replaced"; - const speakerId = "id-of-audio-speaker"; - const pro: Pronunciation = { ...oldPro, speakerId }; - const word: Word = { ...oldWord, audio: [pro], id: newId }; - - // Mock backend function that will be called - mockGetWord.mockResolvedValueOnce(oldWord).mockResolvedValueOnce(word); - mockUpdateWord.mockResolvedValueOnce(newId); - - // Dispatch the audio replace - await store.dispatch(replaceAudio(wordId, pro)); - expect(mockUpdateWord).toHaveBeenCalledTimes(1); - - // Verify the replacement word in state has the updated speaker id - const words = store.getState().reviewEntriesState.words; - expect(words.find((w) => w.id === wordId)).toBeUndefined(); - const audioInState = words.find((w) => w.id === newId)?.audio; - expect(audioInState).toHaveLength(1); - expect(audioInState![0].speakerId).toEqual(speakerId); - }); - - test("uploadAudio", async () => { - // Setup state with word without audio - const pro = newPronunciation("audio-file-name"); - const oldWord = mockFrontierWord(); - const store = setupStore({ - ...persistedDefaultState, - reviewEntriesState: { sortBy: colId, words: [oldWord] }, - }); - - // Prep replacement word with audio added - const newId = "id-after-audio-uploaded"; - const word: Word = { ...oldWord, audio: [pro], id: newId }; - - // Mock backend function that will be called - mockUploadAudio.mockResolvedValueOnce(newId); - mockGetWord.mockResolvedValueOnce(word); - - // Dispatch the audio upload - await store.dispatch(uploadAudio(wordId, new File([], pro.fileName))); - expect(mockUploadAudio).toHaveBeenCalledTimes(1); - - // Verify the replacement word in state has the audio added - const words = store.getState().reviewEntriesState.words; - expect(words.find((w) => w.id === wordId)).toBeUndefined(); - const audioInState = words.find((w) => w.id === newId)?.audio; - expect(audioInState).toHaveLength(1); - expect(audioInState![0].fileName).toEqual(pro.fileName); - }); - }); - - describe("updateFrontierWord", () => { - beforeEach(() => { - mockUpdateWord.mockResolvedValue(newWord()); - mockGetWordResolve(mockFrontierWord()); - }); - - // Functions to make dispatch and check results at end of each test. - async function makeDispatch( - newRevWord: ReviewEntriesWord, - oldRevWord: ReviewEntriesWord - ): Promise { - const store = setupStore(); - await store.dispatch(updateFrontierWord(newRevWord, oldRevWord)); - } - function checkResultantData(newFrontierWord: Word): void { - expect(mockUpdateWord).toHaveBeenCalled(); - expect(mockUpdateWord.mock.calls[0][0]).toEqual(newFrontierWord); - } - - describe("Changes data", () => { - it("Changes the vernacular.", async () => { - const newRevWord = mockReviewEntriesWord("foo2"); - const newFrontierWord = mockFrontierWord("foo2"); - - await makeDispatch(newRevWord, mockReviewEntriesWord()); - checkResultantData(newFrontierWord); - }); - - it("Changes the note.", async () => { - const oldNoteText = "old-note"; - const oldRevWord = mockReviewEntriesWord(); - oldRevWord.noteText = oldNoteText; - const oldFrontierWord = mockFrontierWord(); - oldFrontierWord.note = newNote(oldNoteText, Bcp47Code.Pt); - - const newNoteText = "new-note"; - const newRevWord = mockReviewEntriesWord(); - newRevWord.noteText = newNoteText; - const newFrontierWord = mockFrontierWord(); - newFrontierWord.note = newNote(newNoteText, Bcp47Code.Pt); - - mockGetWordResolve(oldFrontierWord); - await makeDispatch(newRevWord, oldRevWord); - checkResultantData(newFrontierWord); - }); - - it("Changes the flag.", async () => { - const oldFlagText = "old-flag"; - const oldRevWord = mockReviewEntriesWord(); - oldRevWord.flag = newFlag(oldFlagText); - const oldFrontierWord = mockFrontierWord(); - oldFrontierWord.flag = newFlag(oldFlagText); - - const newFlagText = "new-flag"; - const newRevWord = mockReviewEntriesWord(); - newRevWord.flag = newFlag(newFlagText); - const newFrontierWord = mockFrontierWord(); - newFrontierWord.flag = newFlag(newFlagText); - - mockGetWordResolve(oldFrontierWord); - await makeDispatch(newRevWord, oldRevWord); - checkResultantData(newFrontierWord); - }); - }); - - describe("Adds data", () => { - it("Adds a gloss to an extant sense.", async () => { - const newRevWord = mockReviewEntriesWord(); - newRevWord.senses[0].glosses.push(gloss1); - const newFrontierWord = mockFrontierWord(); - newFrontierWord.senses[0].glosses = [gloss0, gloss0Es, gloss1]; - - await makeDispatch(newRevWord, mockReviewEntriesWord()); - checkResultantData(newFrontierWord); - }); - - it("Adds a domain to an extant sense.", async () => { - const newRevWord = mockReviewEntriesWord(); - newRevWord.senses[0].domains.push(domain1); - const newFrontierWord = mockFrontierWord(); - newFrontierWord.senses[0].semanticDomains.push(domain1); - - await makeDispatch(newRevWord, mockReviewEntriesWord()); - checkResultantData(newFrontierWord); - }); - - it("Adds a new sense.", async () => { - const newRevWord = mockReviewEntriesWord(); - newRevWord.senses.push(sense1_local()); - const newFrontierWord = mockFrontierWord(); - newFrontierWord.senses.push({ - ...sense1(), - guid: expect.any(String), - }); - - await makeDispatch(newRevWord, mockReviewEntriesWord()); - checkResultantData(newFrontierWord); - }); - - it("Adds a flag.", async () => { - const newFlagText = "new-flag"; - const newRevWord = mockReviewEntriesWord(); - newRevWord.flag = newFlag(newFlagText); - const newFrontierWord = mockFrontierWord(); - newFrontierWord.flag = newFlag(newFlagText); - - await makeDispatch(newRevWord, mockReviewEntriesWord()); - checkResultantData(newFrontierWord); - }); - }); - - describe("Removes data", () => { - it("Removes a gloss from an extant sense.", async () => { - const oldRevWord = mockReviewEntriesWord(); - const oldSense = sense1_local(); - oldRevWord.senses.push({ - ...oldSense, - glosses: [...oldSense.glosses, gloss0], - }); - const newRevWord = mockReviewEntriesWord(); - newRevWord.senses.push(oldSense); - - const oldFrontierWord = mockFrontierWord(); - const oldFrontierSense = sense1(); - oldFrontierWord.senses.push({ - ...oldFrontierSense, - glosses: [...oldFrontierSense.glosses, gloss0], - }); - const newFrontierWord = mockFrontierWord(); - newFrontierWord.senses.push(oldFrontierSense); - - mockGetWordResolve(oldFrontierWord); - await makeDispatch(newRevWord, oldRevWord); - checkResultantData(newFrontierWord); - }); - - it("Removes a domain from an extant sense.", async () => { - const oldRevWord = mockReviewEntriesWord(); - const oldSense = sense1_local(); - oldRevWord.senses.push({ - ...oldSense, - domains: [...oldSense.domains, domain0], - }); - const newRevWord = mockReviewEntriesWord(); - newRevWord.senses.push(oldSense); - - const oldFrontierWord = mockFrontierWord(); - const oldFrontierSense = sense1(); - oldFrontierWord.senses.push({ - ...oldFrontierSense, - semanticDomains: [...oldFrontierSense.semanticDomains, domain0], - }); - const newFrontierWord = mockFrontierWord(); - newFrontierWord.senses.push(oldFrontierSense); - - mockGetWordResolve(oldFrontierWord); - await makeDispatch(newRevWord, oldRevWord); - checkResultantData(newFrontierWord); - }); - - it("Removes a sense.", async () => { - const oldRevWord = mockReviewEntriesWord(); - oldRevWord.senses.push(sense1_local()); - const oldFrontierWord = mockFrontierWord(); - oldFrontierWord.senses.push(sense1()); - - mockGetWordResolve(oldFrontierWord); - await makeDispatch(mockReviewEntriesWord(), oldRevWord); - checkResultantData(mockFrontierWord()); - }); - - it("Removes the flag.", async () => { - const oldFlagText = "old-flag"; - const oldRevWord = mockReviewEntriesWord(); - oldRevWord.flag = newFlag(oldFlagText); - const oldFrontierWord = mockFrontierWord(); - oldRevWord.flag = newFlag(oldFlagText); - - mockGetWordResolve(oldFrontierWord); - await makeDispatch(mockReviewEntriesWord(), oldRevWord); - checkResultantData(mockFrontierWord()); - }); - }); - - describe("Circumvents bad data", () => { - it("Restores vernacular when vernacular deleted.", async () => { - const newRevWord = mockReviewEntriesWord(""); - const oldFrontierWord = mockFrontierWord(""); - - mockGetWordResolve(oldFrontierWord); - await makeDispatch(newRevWord, mockReviewEntriesWord()); - checkResultantData(mockFrontierWord()); - }); - - it("Ignores a new sense with no glosses, domains.", async () => { - const newRevWord = mockReviewEntriesWord(); - newRevWord.senses.push({ ...sense1_local(), glosses: [], domains: [] }); - - await makeDispatch(newRevWord, mockReviewEntriesWord()); - checkResultantData(mockFrontierWord()); - }); - }); - - describe("Rejects bad, irrecoverable data", () => { - it("Rejects a vernacular which is empty and cannot be restored.", async () => { - const oldRevWord = mockReviewEntriesWord(""); - const newRevWord = mockReviewEntriesWord(""); - const oldFrontierWord = mockFrontierWord(""); - - mockGetWordResolve(oldFrontierWord); - expect( - await makeDispatch(newRevWord, oldRevWord) - .then(() => false) - .catch(() => true) - ).toBeTruthy(); - }); - - it("Rejects a new sense with no glosses.", async () => { - const newRevWord = mockReviewEntriesWord(); - newRevWord.senses.push({ - ...sense1_local(), - glosses: [], - }); - - expect( - await makeDispatch(newRevWord, mockReviewEntriesWord()) - .then(() => false) - .catch(() => true) - ).toBeTruthy(); - }); - }); - }); - - describe("getSenseFromEditSense", () => { - const oldSenses = [sense0(), sense1()]; - - it("Creates a new sense.", () => { - const expectedSense = newSense("newSense"); - const editSense = new ReviewEntriesSense(expectedSense); - expectedSense.guid = expect.any(String); - const resultSense = getSenseFromEditSense(editSense, oldSenses); - expect(resultSense).toEqual(expectedSense); - }); - - it("Updates an old sense with new domains.", () => { - const expectedSense = sense0(); - expectedSense.semanticDomains = sense1().semanticDomains; - const editSense = new ReviewEntriesSense(expectedSense); - const resultSense = getSenseFromEditSense(editSense, oldSenses); - expect(resultSense).toEqual(expectedSense); - }); - - it("Updates an old sense with new glosses.", () => { - const expectedSense = sense0(); - expectedSense.glosses = sense1().glosses; - const editSense = new ReviewEntriesSense(expectedSense); - const resultSense = getSenseFromEditSense(editSense, oldSenses); - expect(resultSense).toEqual(expectedSense); - }); - }); - - describe("getSenseError", () => { - it("By default, no-gloss triggers error.", () => { - const sense = new ReviewEntriesSense(sense0()); - expect(getSenseError(sense)).toBeUndefined(); - sense.glosses = []; - expect(getSenseError(sense)).toEqual("reviewEntries.error.gloss"); - }); - - it("By default, no-domain does not trigger error.", () => { - const sense = new ReviewEntriesSense(sense0()); - expect(getSenseError(sense)).toBeUndefined(); - sense.domains = []; - expect(getSenseError(sense)).toBeUndefined(); - }); - - it("Can allow no-gloss and error for no-domain.", () => { - const sense = new ReviewEntriesSense(sense0()); - expect(getSenseError(sense, false, true)).toBeUndefined(); - sense.glosses = []; - expect(getSenseError(sense, false, true)).toBeUndefined(); - sense.domains = []; - expect(getSenseError(sense, false, true)).toEqual( - "reviewEntries.error.domain" - ); - }); - }); -}); diff --git a/src/goals/ReviewEntries/ReviewEntriesTable/CellColumns.tsx b/src/goals/ReviewEntries/ReviewEntriesTable/CellColumns.tsx deleted file mode 100644 index 637c613906..0000000000 --- a/src/goals/ReviewEntries/ReviewEntriesTable/CellColumns.tsx +++ /dev/null @@ -1,490 +0,0 @@ -import { Column } from "@material-table/core"; -import { Input, Typography } from "@mui/material"; -import { t } from "i18next"; - -import { Pronunciation, SemanticDomain } from "api/models"; -import { - DefinitionCell, - DeleteCell, - DomainCell, - FlagCell, - GlossCell, - NoteCell, - PartOfSpeechCell, - PronunciationsCell, - SenseCell, - VernacularCell, -} from "goals/ReviewEntries/ReviewEntriesTable/CellComponents"; -import { - ColumnId, - ReviewEntriesSense, - ReviewEntriesWord, - ReviewEntriesWordField, -} from "goals/ReviewEntries/ReviewEntriesTypes"; -import { - FileWithSpeakerId, - newPronunciation, - updateSpeakerInAudio, -} from "types/word"; -import { compareFlags } from "utilities/wordUtilities"; - -export class ColumnTitle { - static Vernacular = t("reviewEntries.columns.vernacular"); - static Senses = t("reviewEntries.columns.senses"); - static Definitions = t("reviewEntries.columns.definitions"); - static Glosses = t("reviewEntries.columns.glosses"); - static PartOfSpeech = t("reviewEntries.columns.partOfSpeech"); - static Domains = t("reviewEntries.columns.domains"); - static Pronunciations = t("reviewEntries.columns.pronunciations"); - static Note = t("reviewEntries.columns.note"); - static Flag = t("reviewEntries.columns.flag"); - static Delete = t("reviewEntries.columns.delete"); -} - -function domainNumberToArray(id: string): number[] { - return id.split(".").map((digit) => parseInt(digit, 10)); -} - -function cleanRegExp(input: string): RegExp { - const cleaned = input.trim().toLowerCase(); - // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions#escaping - const escaped = cleaned.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - return new RegExp(escaped); -} - -export interface FieldParameterStandard { - rowData: ReviewEntriesWord; - value: any; - onRowDataChange?: (word: ReviewEntriesWord) => any; -} - -const columns: Column[] = [ - // Vernacular column - { - id: ColumnId.Vernacular, - title: ColumnTitle.Vernacular, - // field determines what is passed as props.value to editComponent - field: ReviewEntriesWordField.Vernacular, - render: (rowData: ReviewEntriesWord) => ( - - ), - editComponent: (props: FieldParameterStandard) => ( - - ), - }, - - // Senses column - { - id: ColumnId.Senses, - title: ColumnTitle.Senses, - // field determines what is passed as props.value to editComponent - field: ReviewEntriesWordField.Senses, - // Fix column to minimum width. - width: 0, - render: (rowData: ReviewEntriesWord) => ( - {rowData.senses.length} - ), - filterPlaceholder: "#", - customFilterAndSearch: ( - filter: string, - rowData: ReviewEntriesWord - ): boolean => { - return parseInt(filter) === rowData.senses.length; - }, - customSort: (a: ReviewEntriesWord, b: ReviewEntriesWord): number => - b.senses.length - a.senses.length, - editComponent: (props: FieldParameterStandard) => { - const deleteSense = (guid: string): void => { - if (props.onRowDataChange) { - props.onRowDataChange({ - ...props.rowData, - senses: props.rowData.senses.map((s) => - s.guid === guid ? { ...s, deleted: !s.deleted } : s - ), - }); - } - }; - return ; - }, - }, - - // Definitions column - { - id: ColumnId.Definitions, - title: ColumnTitle.Definitions, - // field determines what is passed as props.value to editComponent - field: ReviewEntriesWordField.Senses, - disableClick: true, - render: (rowData: ReviewEntriesWord) => ( - - ), - editComponent: (props: FieldParameterStandard) => ( - - ), - customFilterAndSearch: ( - term: string, - rowData: ReviewEntriesWord - ): boolean => { - const regex = cleanRegExp(term); - for (const sense of rowData.senses) { - const definitionsString = ReviewEntriesSense.definitionString(sense); - if (regex.exec(definitionsString.toLowerCase())) { - return true; - } - } - return false; - }, - customSort: (a: ReviewEntriesWord, b: ReviewEntriesWord): number => { - for ( - let count = 0; - count < a.senses.length && count < b.senses.length; - count++ - ) { - const stringA = ReviewEntriesSense.definitionString(a.senses[count]); - const stringB = ReviewEntriesSense.definitionString(b.senses[count]); - if (stringA !== stringB) { - return stringA.localeCompare(stringB); - } - } - return a.senses.length - b.senses.length; - }, - }, - - // Glosses column - { - id: ColumnId.Glosses, - title: ColumnTitle.Glosses, - // field determines what is passed as props.value to editComponent - field: ReviewEntriesWordField.Senses, - disableClick: true, - render: (rowData: ReviewEntriesWord) => ( - - ), - editComponent: (props: FieldParameterStandard) => ( - - ), - customFilterAndSearch: ( - term: string, - rowData: ReviewEntriesWord - ): boolean => { - const regex = cleanRegExp(term); - for (const sense of rowData.senses) { - const glossesString = ReviewEntriesSense.glossString(sense); - if (regex.exec(glossesString.toLowerCase())) { - return true; - } - } - return false; - }, - customSort: (a: ReviewEntriesWord, b: ReviewEntriesWord): number => { - for ( - let count = 0; - count < a.senses.length && count < b.senses.length; - count++ - ) { - const stringA = ReviewEntriesSense.glossString(a.senses[count]); - const stringB = ReviewEntriesSense.glossString(b.senses[count]); - if (stringA !== stringB) { - return stringA.localeCompare(stringB); - } - } - return a.senses.length - b.senses.length; - }, - }, - - // Part of Speech column - { - id: ColumnId.PartOfSpeech, - title: ColumnTitle.PartOfSpeech, - disableClick: true, - editable: "never", - field: ReviewEntriesWordField.Senses, - render: (rowData: ReviewEntriesWord) => ( - - ), - customFilterAndSearch: ( - term: string, - rowData: ReviewEntriesWord - ): boolean => { - const regex = cleanRegExp(term); - for (const sense of rowData.senses) { - const gramInfo = `${sense.partOfSpeech.catGroup} ${sense.partOfSpeech.grammaticalCategory}`; - if (regex.exec(gramInfo.toLowerCase())) { - return true; - } - } - return false; - }, - customSort: (a: ReviewEntriesWord, b: ReviewEntriesWord): number => { - for ( - let count = 0; - count < a.senses.length && count < b.senses.length; - count++ - ) { - const gramInfoA = a.senses[count].partOfSpeech; - const gramInfoB = b.senses[count].partOfSpeech; - if (gramInfoA.catGroup === gramInfoB.catGroup) { - return gramInfoA.grammaticalCategory.localeCompare( - gramInfoB.grammaticalCategory - ); - } - return gramInfoA.catGroup.localeCompare(gramInfoB.catGroup); - } - return a.senses.length - b.senses.length; - }, - }, - - // Semantic Domains column - { - id: ColumnId.Domains, - title: ColumnTitle.Domains, - // field determines what is passed as props.value to editComponent - field: ReviewEntriesWordField.Senses, - render: (rowData: ReviewEntriesWord) => , - editComponent: (props: FieldParameterStandard) => { - const editDomains = (guid: string, domains: SemanticDomain[]): void => { - if (props.onRowDataChange) { - props.onRowDataChange({ - ...props.rowData, - senses: props.rowData.senses.map((s) => - s.guid === guid ? { ...s, domains } : s - ), - }); - } - }; - return ; - }, - customFilterAndSearch: ( - term: string, - rowData: ReviewEntriesWord - ): boolean => { - /* - * Search term expected in one of two formats: - * 1. id (e.g., "2.1") XOR name (e.g., "bod") - * 2. id AND name, colon-separated (e.g., "2.1:ody") - * All the above examples would find entries with "2.1: Body" - * IGNORED: capitalization; whitespace around terms; 3+ terms - * e.g. " 2.1:BODY:zx:c " and "2.1 : Body " are equivalent - */ - const terms = term.split(":"); - if (terms.length === 1) { - const regex = cleanRegExp(terms[0]); - for (const sense of rowData.senses) { - for (const domain of sense.domains) { - if ( - regex.exec(domain.id)?.index === 0 || - regex.exec(domain.name.toLowerCase()) - ) { - return true; - } - } - } - } else { - const regexNumber = cleanRegExp(terms[0]); - const regexName = cleanRegExp(terms[1]); - for (const sense of rowData.senses) { - for (const domain of sense.domains) { - if ( - regexNumber.exec(domain.id)?.index === 0 && - regexName.exec(domain.name.toLowerCase()) - ) { - return true; - } - } - } - } - return false; - }, - customSort: (a: ReviewEntriesWord, b: ReviewEntriesWord): number => { - let count = 0; - let compare = 0; - - let domainsA: SemanticDomain[]; - let domainsB: SemanticDomain[]; - - let codeA: number[]; - let codeB: number[]; - - // Special case: no senses - if (!a.senses.length || !b.senses.length) { - return b.senses.length - a.senses.length; - } - - while ( - compare === 0 && - count < a.senses.length && - count < b.senses.length - ) { - domainsA = a.senses[count].domains; - domainsB = b.senses[count].domains; - - // If exactly one has no domains, it is the lower rank - if (!domainsA.length && domainsB.length) { - return 1; - } - if (domainsA.length && !domainsB.length) { - return -1; - } - - // Check the domains - for ( - let d = 0; - compare === 0 && d < domainsA.length && d < domainsB.length; - d++ - ) { - codeA = domainNumberToArray(domainsA[d].id); - codeB = domainNumberToArray(domainsB[d].id); - for ( - let i = 0; - i < codeA.length && i < codeB.length && compare === 0; - i++ - ) { - compare = codeA[i] - codeB[i]; - } - - // If the two glosses SEEM identical, sort by length - if (compare === 0) { - compare = codeA.length - codeB.length; - } - } - count++; - } - - return compare; - }, - }, - - // Audio column - { - id: ColumnId.Pronunciations, - title: ColumnTitle.Pronunciations, - // field determines what is passed as props.value to editComponent - field: ReviewEntriesWordField.Pronunciations, - filterPlaceholder: "#", - render: (rowData: ReviewEntriesWord) => ( - - ), - editComponent: (props: FieldParameterStandard) => ( - { - props.onRowDataChange && - props.onRowDataChange({ - ...props.rowData, - audioNew: [ - ...(props.rowData.audioNew ?? []), - newPronunciation(URL.createObjectURL(file), file.speakerId), - ], - }); - }, - delNewAudio: (url: string): void => { - props.onRowDataChange && - props.onRowDataChange({ - ...props.rowData, - audioNew: props.rowData.audioNew?.filter( - (a) => a.fileName !== url - ), - }); - }, - repNewAudio: (pro: Pronunciation): void => { - if (props.onRowDataChange && props.rowData.audioNew) { - const audioNew = updateSpeakerInAudio( - props.rowData.audioNew, - pro - ); - if (audioNew) { - props.onRowDataChange({ ...props.rowData, audioNew }); - } - } - }, - delOldAudio: (fileName: string): void => { - props.onRowDataChange && - props.onRowDataChange({ - ...props.rowData, - audio: props.rowData.audio.filter( - (a) => a.fileName !== fileName - ), - }); - }, - repOldAudio: (pro: Pronunciation): void => { - if (props.onRowDataChange) { - const audio = updateSpeakerInAudio(props.rowData.audio, pro); - if (audio) { - props.onRowDataChange({ ...props.rowData, audio }); - } - } - }, - }} - audio={props.rowData.audio} - audioNew={props.rowData.audioNew} - wordId={props.rowData.id} - /> - ), - customFilterAndSearch: ( - filter: string, - rowData: ReviewEntriesWord - ): boolean => { - return parseInt(filter) === rowData.audio.length; - }, - customSort: (a: ReviewEntriesWord, b: ReviewEntriesWord): number => - b.audio.length - a.audio.length, - }, - - // Note column - { - id: ColumnId.Note, - title: ColumnTitle.Note, - // field determines what is passed as props.value to editComponent - field: ReviewEntriesWordField.Note, - render: (rowData: ReviewEntriesWord) => ( - - ), - editComponent: (props: FieldParameterStandard) => , - }, - - // Flag column - { - id: ColumnId.Flag, - title: ColumnTitle.Flag, - // field determines what is passed as props.value to editComponent - field: ReviewEntriesWordField.Flag, - // Fix column to minimum width. - width: 0, - render: (rowData: ReviewEntriesWord) => ( - - ), - editComponent: (props: FieldParameterStandard) => ( - - ), - customFilterAndSearch: ( - filter: string, - rowData: ReviewEntriesWord - ): boolean => { - return rowData.flag.text.includes(filter); - }, - customSort: (a: ReviewEntriesWord, b: ReviewEntriesWord): number => - compareFlags(a.flag, b.flag), - }, - - // Delete Entry column - { - id: ColumnId.Delete, - title: ColumnTitle.Delete, - filtering: false, - sorting: false, - editable: "never", - // Fix column to minimum width. - width: 0, - render: (rowData: ReviewEntriesWord) => { - return ; - }, - }, -]; - -export default columns; diff --git a/src/goals/ReviewEntries/ReviewEntriesTable/CellComponents/AlignedList.tsx b/src/goals/ReviewEntries/ReviewEntriesTable/CellComponents/AlignedList.tsx deleted file mode 100644 index 3997cd83a3..0000000000 --- a/src/goals/ReviewEntries/ReviewEntriesTable/CellComponents/AlignedList.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import { Add } from "@mui/icons-material"; -import { Chip, Grid } from "@mui/material"; -import { ReactElement, ReactNode } from "react"; - -export const SPACER = "spacer"; -interface AlignedListProps { - contents: ReactNode[]; - listId: string; - bottomCell?: ReactNode | typeof SPACER; -} - -export default function AlignedList(props: AlignedListProps): ReactElement { - return ( - - {props.contents.map((value, index) => ( - - {value} - - ))} - {props.bottomCell && ( - - {props.bottomCell !== SPACER ? ( - props.bottomCell - ) : ( - } style={{ opacity: 0.01 }} /> - )} - - )} - - ); -} diff --git a/src/goals/ReviewEntries/ReviewEntriesTable/CellComponents/DefinitionCell.tsx b/src/goals/ReviewEntries/ReviewEntriesTable/CellComponents/DefinitionCell.tsx deleted file mode 100644 index dbff3b9328..0000000000 --- a/src/goals/ReviewEntries/ReviewEntriesTable/CellComponents/DefinitionCell.tsx +++ /dev/null @@ -1,139 +0,0 @@ -import { Typography } from "@mui/material"; -import { ReactElement } from "react"; -import { useTranslation } from "react-i18next"; -import { useSelector } from "react-redux"; - -import { Definition, WritingSystem } from "api/models"; -import Overlay from "components/Overlay"; -import { FieldParameterStandard } from "goals/ReviewEntries/ReviewEntriesTable/CellColumns"; -import AlignedList, { - SPACER, -} from "goals/ReviewEntries/ReviewEntriesTable/CellComponents/AlignedList"; -import { StoreState } from "types"; -import { newDefinition } from "types/word"; -import { - TextFieldWithFont, - TypographyWithFont, -} from "utilities/fontComponents"; - -interface DefinitionCellProps extends FieldParameterStandard { - editable?: boolean; -} - -export default function DefinitionCell( - props: DefinitionCellProps -): ReactElement { - const analysisLang = useSelector( - (state: StoreState) => - state.currentProjectState.project.analysisWritingSystems[0] - ); - - return ( - ( - - - props.onRowDataChange && - props.onRowDataChange({ - ...props.rowData, - senses: [ - ...props.rowData.senses.slice(0, index), - { - ...sense, - definitions, - }, - ...props.rowData.senses.slice(index + 1), - ], - }) - } - /> - - ))} - bottomCell={props.editable ? SPACER : undefined} - /> - ); -} - -interface DefinitionListProps { - defaultLang: WritingSystem; - definitions: Definition[]; - editable?: boolean; - idPrefix: string; - onChange: (definitions: Definition[]) => void; -} - -function DefinitionList(props: DefinitionListProps): ReactElement { - const { t } = useTranslation(); - - if (!props.editable) { - if (!props.definitions.find((d) => d.text)) { - return {t("reviewEntries.noDefinition")}; - } - return ( - <> - {props.definitions - .filter((d) => d.text) - .map((d, i) => ( - - {d.text} - - ))} - - ); - } - - const definitions = props.definitions.find( - (d) => d.language === props.defaultLang.bcp47 - ) - ? props.definitions - : [...props.definitions, newDefinition("", props.defaultLang.bcp47)]; - - return ( - <> - {definitions.map((d, i) => ( - { - const updatedDefinitions = [...definitions]; - updatedDefinitions.splice(i, 1, definition); - props.onChange(updatedDefinitions); - }} - textFieldId={`${props.idPrefix}-${i}-text`} - /> - ))} - - ); -} - -interface DefinitionFieldProps { - definition: Definition; - textFieldId: string; - onChange: (definition: Definition) => void; -} - -function DefinitionField(props: DefinitionFieldProps): ReactElement { - return ( - - props.onChange( - newDefinition(event.target.value, props.definition.language) - ) - } - /> - ); -} diff --git a/src/goals/ReviewEntries/ReviewEntriesTable/CellComponents/DeleteCell.tsx b/src/goals/ReviewEntries/ReviewEntriesTable/CellComponents/DeleteCell.tsx deleted file mode 100644 index de7c0a42bc..0000000000 --- a/src/goals/ReviewEntries/ReviewEntriesTable/CellComponents/DeleteCell.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import { ReactElement } from "react"; - -import { deleteFrontierWord as deleteFromBackend } from "backend"; -import { DeleteButtonWithDialog } from "components/Buttons"; -import { deleteWord } from "goals/ReviewEntries/Redux/ReviewEntriesActions"; -import { ReviewEntriesWord } from "goals/ReviewEntries/ReviewEntriesTypes"; -import { useAppDispatch } from "types/hooks"; - -const buttonId = (wordId: string): string => `row-${wordId}-delete`; -const buttonIdCancel = "delete-cancel"; -const buttonIdConfirm = "delete-confirm"; - -interface DeleteCellProps { - rowData: ReviewEntriesWord; -} - -export default function DeleteCell(props: DeleteCellProps): ReactElement { - const dispatch = useAppDispatch(); - - const word = props.rowData; - const disabled = word.protected || !!word.senses.find((s) => s.protected); - - async function deleteFrontierWord(): Promise { - await deleteFromBackend(word.id); - dispatch(deleteWord(word.id)); - } - - return ( - - ); -} diff --git a/src/goals/ReviewEntries/ReviewEntriesTable/CellComponents/DomainCell.tsx b/src/goals/ReviewEntries/ReviewEntriesTable/CellComponents/DomainCell.tsx deleted file mode 100644 index d0627355ed..0000000000 --- a/src/goals/ReviewEntries/ReviewEntriesTable/CellComponents/DomainCell.tsx +++ /dev/null @@ -1,147 +0,0 @@ -import { Add } from "@mui/icons-material"; -import { Chip, Dialog, Grid, IconButton } from "@mui/material"; -import { ReactElement, useState } from "react"; -import { useTranslation } from "react-i18next"; -import { useSelector } from "react-redux"; -import { toast } from "react-toastify"; - -import { SemanticDomain } from "api/models"; -import Overlay from "components/Overlay"; -import TreeView from "components/TreeView"; -import AlignedList, { - SPACER, -} from "goals/ReviewEntries/ReviewEntriesTable/CellComponents/AlignedList"; -import { - ColumnId, - ReviewEntriesSense, - ReviewEntriesWord, -} from "goals/ReviewEntries/ReviewEntriesTypes"; -import { StoreState } from "types"; -import { newSemanticDomainForMongoDB } from "types/semanticDomain"; -import { themeColors } from "types/theme"; - -interface DomainCellProps { - rowData: ReviewEntriesWord; - editDomains?: (guid: string, newDomains: SemanticDomain[]) => void; -} - -export default function DomainCell(props: DomainCellProps): ReactElement { - const [addingDomains, setAddingDomains] = useState(false); - const [senseToChange, setSenseToChange] = useState< - ReviewEntriesSense | undefined - >(); - - const sortingByThis = useSelector( - (state: StoreState) => state.reviewEntriesState.sortBy === ColumnId.Domains - ); - const selectedDomain = useSelector( - (state: StoreState) => state.treeViewState.currentDomain - ); - - function prepAddDomain(sense: ReviewEntriesSense): void { - setAddingDomains(true); - setSenseToChange(sense); - } - - function addDomain(): void { - setAddingDomains(false); - if (props.editDomains && senseToChange) { - if (!selectedDomain) { - throw new Error( - "Cannot add domain without the selectedDomain property." - ); - } - if (selectedDomain.mongoId == "") { - throw new Error("SelectedSemanticDomainTreeNode have no mongoId."); - } - if (senseToChange.domains.find((d) => d.id === selectedDomain.id)) { - toast.error( - t("reviewEntries.duplicateDomain", { val: selectedDomain.id }) - ); - return; - } - props.editDomains(senseToChange.guid, [ - ...senseToChange.domains, - newSemanticDomainForMongoDB(selectedDomain), - ]); - } - } - - function deleteDomain( - toDelete: SemanticDomain, - sense: ReviewEntriesSense - ): void { - if (props.editDomains) { - props.editDomains( - sense.guid, - sense.domains.filter((domain) => domain.id !== toDelete.id) - ); - } - } - - function getChipStyle( - senseIndex: number, - domainIndex: number - ): { backgroundColor?: string } { - return sortingByThis && senseIndex === 0 && domainIndex === 0 - ? { backgroundColor: themeColors.highlight as string } - : {}; - } - const { t } = useTranslation(); - - return ( - <> - ( - - - {sense.domains.length > 0 ? ( - sense.domains.map((domain, domainIndex) => ( - - deleteDomain(domain, sense) - : undefined - } - id={`sense-${sense.guid}-domain-${domainIndex}`} - /> - - )) - ) : ( - - - - )} - {props.editDomains && !sense.deleted && ( - prepAddDomain(sense)} - id={`sense-${sense.guid}-domain-add`} - size="large" - > - - - )} - - - ))} - bottomCell={props.editDomains ? SPACER : undefined} - /> - - setAddingDomains(false)} - returnControlToCaller={addDomain} - /> - - - ); -} diff --git a/src/goals/ReviewEntries/ReviewEntriesTable/CellComponents/FlagCell.tsx b/src/goals/ReviewEntries/ReviewEntriesTable/CellComponents/FlagCell.tsx deleted file mode 100644 index 5b907d05fa..0000000000 --- a/src/goals/ReviewEntries/ReviewEntriesTable/CellComponents/FlagCell.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import { ReactElement } from "react"; - -import { Flag } from "api/models"; -import FlagButton from "components/Buttons/FlagButton"; -import { FieldParameterStandard } from "goals/ReviewEntries/ReviewEntriesTable/CellColumns"; - -interface FlagCellProps extends FieldParameterStandard { - editable?: boolean; -} - -export default function FlagCell(props: FlagCellProps): ReactElement { - function updateFlag(flag: Flag): void { - if (props.onRowDataChange) { - props.onRowDataChange({ ...props.rowData, flag }); - } - } - return ( - - ); -} diff --git a/src/goals/ReviewEntries/ReviewEntriesTable/CellComponents/GlossCell.tsx b/src/goals/ReviewEntries/ReviewEntriesTable/CellComponents/GlossCell.tsx deleted file mode 100644 index 3889f4736f..0000000000 --- a/src/goals/ReviewEntries/ReviewEntriesTable/CellComponents/GlossCell.tsx +++ /dev/null @@ -1,132 +0,0 @@ -import { Typography } from "@mui/material"; -import { ReactElement } from "react"; -import { useTranslation } from "react-i18next"; -import { useSelector } from "react-redux"; - -import { Gloss, WritingSystem } from "api/models"; -import Overlay from "components/Overlay"; -import { FieldParameterStandard } from "goals/ReviewEntries/ReviewEntriesTable/CellColumns"; -import AlignedList, { - SPACER, -} from "goals/ReviewEntries/ReviewEntriesTable/CellComponents/AlignedList"; -import { StoreState } from "types"; -import { newGloss } from "types/word"; -import { - TextFieldWithFont, - TypographyWithFont, -} from "utilities/fontComponents"; - -interface GlossCellProps extends FieldParameterStandard { - editable?: boolean; -} - -export default function GlossCell(props: GlossCellProps): ReactElement { - const analysisLang = useSelector( - (state: StoreState) => - state.currentProjectState.project.analysisWritingSystems[0] - ); - - return ( - ( - - - props.onRowDataChange && - props.onRowDataChange({ - ...props.rowData, - senses: [ - ...props.rowData.senses.slice(0, index), - { ...sense, glosses }, - ...props.rowData.senses.slice(index + 1), - ], - }) - } - /> - - ))} - bottomCell={props.editable ? SPACER : undefined} - /> - ); -} - -interface GlossListProps { - defaultLang: WritingSystem; - editable?: boolean; - glosses: Gloss[]; - idPrefix: string; - onChange: (glosses: Gloss[]) => void; -} - -function GlossList(props: GlossListProps): ReactElement { - const { t } = useTranslation(); - - if (!props.editable) { - if (!props.glosses.find((g) => g.def)) { - return {t("reviewEntries.noGloss")}; - } - return ( - <> - {props.glosses - .filter((g) => g.def) - .map((g, i) => ( - - {g.def} - - ))} - - ); - } - - const glosses = props.glosses.find( - (g) => g.language === props.defaultLang.bcp47 - ) - ? props.glosses - : [...props.glosses, newGloss("", props.defaultLang.bcp47)]; - - return ( - <> - {glosses.map((g, i) => ( - { - const updatedGlosses = [...glosses]; - updatedGlosses.splice(i, 1, gloss); - props.onChange(updatedGlosses); - }} - textFieldId={`${props.idPrefix}-${i}-text`} - /> - ))} - - ); -} - -interface GlossFieldProps { - gloss: Gloss; - textFieldId: string; - onChange: (gloss: Gloss) => void; -} - -function GlossField(props: GlossFieldProps): ReactElement { - return ( - - props.onChange(newGloss(event.target.value, props.gloss.language)) - } - /> - ); -} diff --git a/src/goals/ReviewEntries/ReviewEntriesTable/CellComponents/NoteCell.tsx b/src/goals/ReviewEntries/ReviewEntriesTable/CellComponents/NoteCell.tsx deleted file mode 100644 index 5146fe1da2..0000000000 --- a/src/goals/ReviewEntries/ReviewEntriesTable/CellComponents/NoteCell.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import { TextField } from "@mui/material"; -import { ReactElement } from "react"; -import { useTranslation } from "react-i18next"; - -import { FieldParameterStandard } from "goals/ReviewEntries/ReviewEntriesTable/CellColumns"; - -export default function NoteCell(props: FieldParameterStandard): ReactElement { - const { t } = useTranslation(); - - return ( - - props.onRowDataChange && - props.onRowDataChange({ - ...props.rowData, - noteText: event.target.value, - }) - } - /> - ); -} diff --git a/src/goals/ReviewEntries/ReviewEntriesTable/CellComponents/PartOfSpeechCell.tsx b/src/goals/ReviewEntries/ReviewEntriesTable/CellComponents/PartOfSpeechCell.tsx deleted file mode 100644 index 803c5aff94..0000000000 --- a/src/goals/ReviewEntries/ReviewEntriesTable/CellComponents/PartOfSpeechCell.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import { Grid } from "@mui/material"; -import { ReactElement } from "react"; - -import { PartOfSpeechButton } from "components/Buttons"; -import AlignedList from "goals/ReviewEntries/ReviewEntriesTable/CellComponents/AlignedList"; -import { ReviewEntriesWord } from "goals/ReviewEntries/ReviewEntriesTypes"; - -interface PartOfSpeechCellProps { - rowData: ReviewEntriesWord; -} - -export default function PartOfSpeechCell( - props: PartOfSpeechCellProps -): ReactElement { - return ( - ( - - - - ))} - /> - ); -} diff --git a/src/goals/ReviewEntries/ReviewEntriesTable/CellComponents/PronunciationsCell.tsx b/src/goals/ReviewEntries/ReviewEntriesTable/CellComponents/PronunciationsCell.tsx deleted file mode 100644 index 6cc5dacd98..0000000000 --- a/src/goals/ReviewEntries/ReviewEntriesTable/CellComponents/PronunciationsCell.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import { ReactElement } from "react"; - -import { Pronunciation } from "api/models"; -import PronunciationsBackend from "components/Pronunciations/PronunciationsBackend"; -import PronunciationsFrontend from "components/Pronunciations/PronunciationsFrontend"; -import { - deleteAudio, - replaceAudio, - uploadAudio, -} from "goals/ReviewEntries/Redux/ReviewEntriesActions"; -import { useAppDispatch } from "types/hooks"; -import { FileWithSpeakerId } from "types/word"; - -interface PronunciationsCellProps { - audioFunctions?: { - addNewAudio: (file: FileWithSpeakerId) => void; - delNewAudio: (url: string) => void; - repNewAudio: (audio: Pronunciation) => void; - delOldAudio: (fileName: string) => void; - repOldAudio: (audio: Pronunciation) => void; - }; - audio: Pronunciation[]; - audioNew?: Pronunciation[]; - wordId: string; -} - -export default function PronunciationsCell( - props: PronunciationsCellProps -): ReactElement { - const dispatch = useAppDispatch(); - const dispatchDelete = (fileName: string): Promise => - dispatch(deleteAudio(props.wordId, fileName)); - const dispatchReplace = (audio: Pronunciation): Promise => - dispatch(replaceAudio(props.wordId, audio)); - const dispatchUpload = (file: FileWithSpeakerId): Promise => - dispatch(uploadAudio(props.wordId, file)); - - return props.audioFunctions ? ( - - } - audio={props.audioNew ?? []} - deleteAudio={props.audioFunctions.delNewAudio} - replaceAudio={props.audioFunctions.repNewAudio} - uploadAudio={props.audioFunctions.addNewAudio} - /> - ) : ( - - ); -} diff --git a/src/goals/ReviewEntries/ReviewEntriesTable/CellComponents/SenseCell.tsx b/src/goals/ReviewEntries/ReviewEntriesTable/CellComponents/SenseCell.tsx deleted file mode 100644 index b823fd07e2..0000000000 --- a/src/goals/ReviewEntries/ReviewEntriesTable/CellComponents/SenseCell.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import { Add, Delete, RestoreFromTrash } from "@mui/icons-material"; -import { Chip } from "@mui/material"; -import { ReactElement } from "react"; - -import { IconButtonWithTooltip } from "components/Buttons"; -import { FieldParameterStandard } from "goals/ReviewEntries/ReviewEntriesTable/CellColumns"; -import AlignedList from "goals/ReviewEntries/ReviewEntriesTable/CellComponents/AlignedList"; -import { ReviewEntriesSense } from "goals/ReviewEntries/ReviewEntriesTypes"; - -interface SenseCellProps extends FieldParameterStandard { - delete: (deleteIndex: string) => void; -} - -export default function SenseCell(props: SenseCellProps): ReactElement { - function addSense(): ReactElement { - const senses = [...props.rowData.senses, new ReviewEntriesSense()]; - return ( - } - onClick={() => - props.onRowDataChange && - props.onRowDataChange({ ...props.rowData, senses }) - } - /> - ); - } - - return ( - ( - : } - key={sense.guid} - onClick={ - sense.protected ? undefined : () => props.delete!(sense.guid) - } - size="small" - textId={sense.protected ? "reviewEntries.deleteDisabled" : undefined} - /> - ))} - bottomCell={addSense()} - /> - ); -} diff --git a/src/goals/ReviewEntries/ReviewEntriesTable/CellComponents/VernacularCell.tsx b/src/goals/ReviewEntries/ReviewEntriesTable/CellComponents/VernacularCell.tsx deleted file mode 100644 index c59706c91d..0000000000 --- a/src/goals/ReviewEntries/ReviewEntriesTable/CellComponents/VernacularCell.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import { ReactElement } from "react"; -import { useTranslation } from "react-i18next"; - -import { FieldParameterStandard } from "goals/ReviewEntries/ReviewEntriesTable/CellColumns"; -import { TextFieldWithFont } from "utilities/fontComponents"; - -interface VernacularCellProps extends FieldParameterStandard { - editable?: boolean; -} - -export default function VernacularCell( - props: VernacularCellProps -): ReactElement { - const { t } = useTranslation(); - - return ( - - props.onRowDataChange && - props.onRowDataChange({ - ...props.rowData, - vernacular: event.target.value, - }) - } - vernacular - /> - ); -} diff --git a/src/goals/ReviewEntries/ReviewEntriesTable/CellComponents/index.ts b/src/goals/ReviewEntries/ReviewEntriesTable/CellComponents/index.ts deleted file mode 100644 index ad76e1318e..0000000000 --- a/src/goals/ReviewEntries/ReviewEntriesTable/CellComponents/index.ts +++ /dev/null @@ -1,23 +0,0 @@ -import DefinitionCell from "goals/ReviewEntries/ReviewEntriesTable/CellComponents/DefinitionCell"; -import DeleteCell from "goals/ReviewEntries/ReviewEntriesTable/CellComponents/DeleteCell"; -import DomainCell from "goals/ReviewEntries/ReviewEntriesTable/CellComponents/DomainCell"; -import FlagCell from "goals/ReviewEntries/ReviewEntriesTable/CellComponents/FlagCell"; -import GlossCell from "goals/ReviewEntries/ReviewEntriesTable/CellComponents/GlossCell"; -import NoteCell from "goals/ReviewEntries/ReviewEntriesTable/CellComponents/NoteCell"; -import PartOfSpeechCell from "goals/ReviewEntries/ReviewEntriesTable/CellComponents/PartOfSpeechCell"; -import PronunciationsCell from "goals/ReviewEntries/ReviewEntriesTable/CellComponents/PronunciationsCell"; -import SenseCell from "goals/ReviewEntries/ReviewEntriesTable/CellComponents/SenseCell"; -import VernacularCell from "goals/ReviewEntries/ReviewEntriesTable/CellComponents/VernacularCell"; - -export { - DefinitionCell, - DeleteCell, - DomainCell, - FlagCell, - GlossCell, - NoteCell, - PartOfSpeechCell, - PronunciationsCell, - SenseCell, - VernacularCell, -}; diff --git a/src/goals/ReviewEntries/ReviewEntriesTable/CellComponents/tests/AlignedList.test.tsx b/src/goals/ReviewEntries/ReviewEntriesTable/CellComponents/tests/AlignedList.test.tsx deleted file mode 100644 index bfc9a168d7..0000000000 --- a/src/goals/ReviewEntries/ReviewEntriesTable/CellComponents/tests/AlignedList.test.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import renderer from "react-test-renderer"; - -import AlignedList from "goals/ReviewEntries/ReviewEntriesTable/CellComponents/AlignedList"; - -describe("AlignedList", () => { - it("renders without crashing", () => { - renderer.act(() => { - renderer.create( - ,
]} - listId={"testId"} - bottomCell={
} - /> - ); - }); - }); -}); diff --git a/src/goals/ReviewEntries/ReviewEntriesTable/CellComponents/tests/DefinitionCell.test.tsx b/src/goals/ReviewEntries/ReviewEntriesTable/CellComponents/tests/DefinitionCell.test.tsx deleted file mode 100644 index 67bacdc73a..0000000000 --- a/src/goals/ReviewEntries/ReviewEntriesTable/CellComponents/tests/DefinitionCell.test.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import { Provider } from "react-redux"; -import { act, create } from "react-test-renderer"; -import configureMockStore from "redux-mock-store"; - -import DefinitionCell from "goals/ReviewEntries/ReviewEntriesTable/CellComponents/DefinitionCell"; -import mockWords from "goals/ReviewEntries/tests/WordsMock"; -import { defaultWritingSystem } from "types/writingSystem"; - -// The multiline Input, TextField cause problems in the mock environment. -jest.mock("@mui/material/Input", () => "div"); -jest.mock("@mui/material/TextField", () => "div"); - -const mockStore = configureMockStore()({ - currentProjectState: { - project: { analysisWritingSystems: [defaultWritingSystem] }, - }, -}); -const mockWord = mockWords()[0]; - -describe("DefinitionCell", () => { - it("renders", async () => { - await act(async () => { - create( - - - - ); - }); - }); - - it("renders editable", async () => { - await act(async () => { - create( - - - - ); - }); - }); -}); diff --git a/src/goals/ReviewEntries/ReviewEntriesTable/CellComponents/tests/DeleteCell.test.tsx b/src/goals/ReviewEntries/ReviewEntriesTable/CellComponents/tests/DeleteCell.test.tsx deleted file mode 100644 index 483f57d1ee..0000000000 --- a/src/goals/ReviewEntries/ReviewEntriesTable/CellComponents/tests/DeleteCell.test.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import { Provider } from "react-redux"; -import { ReactTestRenderer, act, create } from "react-test-renderer"; -import configureMockStore from "redux-mock-store"; - -import { DeleteButtonWithDialog } from "components/Buttons"; -import { defaultState as reviewEntriesState } from "goals/ReviewEntries/Redux/ReviewEntriesReduxTypes"; -import DeleteCell from "goals/ReviewEntries/ReviewEntriesTable/CellComponents/DeleteCell"; -import mockWords from "goals/ReviewEntries/tests/WordsMock"; - -// Dialog uses portals, which are not supported in react-test-renderer. -jest.mock("@mui/material", () => { - const materialUiCore = jest.requireActual("@mui/material"); - return { - ...jest.requireActual("@mui/material"), - Dialog: materialUiCore.Container, - }; -}); - -jest.mock("backend", () => ({ - deleteFrontierWord: () => jest.fn(), -})); -jest.mock("types/hooks", () => { - return { - ...jest.requireActual("types/hooks"), - useAppDispatch: - () => - (...args: any[]) => - Promise.resolve(args), - }; -}); - -const mockStore = configureMockStore()({ reviewEntriesState }); -const mockWord = mockWords()[0]; - -let cellHandle: ReactTestRenderer; - -const renderDeleteCell = async (): Promise => { - await act(async () => { - cellHandle = create( - - - - ); - }); -}; - -beforeEach(async () => { - jest.clearAllMocks(); - await renderDeleteCell(); -}); - -describe("DeleteCell", () => { - it("renders", () => { - cellHandle.root.findByType(DeleteButtonWithDialog); - }); -}); diff --git a/src/goals/ReviewEntries/ReviewEntriesTable/CellComponents/tests/DomainCell.test.tsx b/src/goals/ReviewEntries/ReviewEntriesTable/CellComponents/tests/DomainCell.test.tsx deleted file mode 100644 index 164e700f09..0000000000 --- a/src/goals/ReviewEntries/ReviewEntriesTable/CellComponents/tests/DomainCell.test.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import { Provider } from "react-redux"; -import { act, create } from "react-test-renderer"; -import { Store } from "redux"; -import configureMockStore from "redux-mock-store"; - -import { defaultState as treeViewState } from "components/TreeView/Redux/TreeViewReduxTypes"; -import DomainCell from "goals/ReviewEntries/ReviewEntriesTable/CellComponents/DomainCell"; -import { ColumnId } from "goals/ReviewEntries/ReviewEntriesTypes"; -import mockWords from "goals/ReviewEntries/tests/WordsMock"; - -jest.mock("components/TreeView", () => "div"); - -const mockStore = (sortByThis?: boolean): Store => - configureMockStore()({ - reviewEntriesState: { sortBy: sortByThis ? ColumnId.Domains : undefined }, - treeViewState, - }); -const mockWord = mockWords()[0]; - -describe("DomainCell", () => { - it("renders editable", async () => { - await act(async () => { - create( - - - - ); - }); - }); - - it("renders sort-stylized", async () => { - await act(async () => { - create( - - - - ); - }); - }); -}); diff --git a/src/goals/ReviewEntries/ReviewEntriesTable/CellComponents/tests/FlagCell.test.tsx b/src/goals/ReviewEntries/ReviewEntriesTable/CellComponents/tests/FlagCell.test.tsx deleted file mode 100644 index 6b3468fb31..0000000000 --- a/src/goals/ReviewEntries/ReviewEntriesTable/CellComponents/tests/FlagCell.test.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import renderer from "react-test-renderer"; - -import FlagCell from "goals/ReviewEntries/ReviewEntriesTable/CellComponents/FlagCell"; -import mockWords from "goals/ReviewEntries/tests/WordsMock"; - -const mockWord = mockWords()[1]; - -describe("FlagCell", () => { - it("renders", () => { - renderer.act(() => { - renderer.create(); - }); - }); - - it("renders editable", () => { - renderer.act(() => { - renderer.create( - - ); - }); - }); -}); diff --git a/src/goals/ReviewEntries/ReviewEntriesTable/CellComponents/tests/GlossCell.test.tsx b/src/goals/ReviewEntries/ReviewEntriesTable/CellComponents/tests/GlossCell.test.tsx deleted file mode 100644 index 60f095960b..0000000000 --- a/src/goals/ReviewEntries/ReviewEntriesTable/CellComponents/tests/GlossCell.test.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import { Provider } from "react-redux"; -import { act, create } from "react-test-renderer"; -import configureMockStore from "redux-mock-store"; - -import GlossCell from "goals/ReviewEntries/ReviewEntriesTable/CellComponents/GlossCell"; -import mockWords from "goals/ReviewEntries/tests/WordsMock"; -import { defaultWritingSystem } from "types/writingSystem"; - -// The multiline Input, TextField cause problems in the mock environment. -jest.mock("@mui/material/Input", () => "div"); -jest.mock("@mui/material/TextField", () => "div"); - -const mockStore = configureMockStore()({ - currentProjectState: { - project: { analysisWritingSystems: [defaultWritingSystem] }, - }, -}); -const mockWord = mockWords()[0]; - -describe("GlossCell", () => { - it("renders", async () => { - await act(async () => { - create( - - - - ); - }); - }); - - it("renders editable", async () => { - await act(async () => { - create( - - - - ); - }); - }); -}); diff --git a/src/goals/ReviewEntries/ReviewEntriesTable/CellComponents/tests/NoteCell.test.tsx b/src/goals/ReviewEntries/ReviewEntriesTable/CellComponents/tests/NoteCell.test.tsx deleted file mode 100644 index 6b3db1155d..0000000000 --- a/src/goals/ReviewEntries/ReviewEntriesTable/CellComponents/tests/NoteCell.test.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import renderer from "react-test-renderer"; - -import NoteCell from "goals/ReviewEntries/ReviewEntriesTable/CellComponents/NoteCell"; -import mockWords from "goals/ReviewEntries/tests/WordsMock"; - -// The multiline TextField causes problems in the mock environment. -jest.mock("@mui/material/TextField", () => "div"); - -const mockWord = mockWords()[0]; - -describe("NoteCell", () => { - it("renders", () => { - renderer.act(() => { - renderer.create( - - ); - }); - }); -}); diff --git a/src/goals/ReviewEntries/ReviewEntriesTable/CellComponents/tests/PartOfSpeechCell.test.tsx b/src/goals/ReviewEntries/ReviewEntriesTable/CellComponents/tests/PartOfSpeechCell.test.tsx deleted file mode 100644 index 400eb3ef3a..0000000000 --- a/src/goals/ReviewEntries/ReviewEntriesTable/CellComponents/tests/PartOfSpeechCell.test.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import renderer from "react-test-renderer"; - -import PartOfSpeechCell from "goals/ReviewEntries/ReviewEntriesTable/CellComponents/PartOfSpeechCell"; -import mockWords from "goals/ReviewEntries/tests/WordsMock"; - -const mockWord = mockWords()[1]; - -describe("PartOfSpeechCell", () => { - it("renders", () => { - renderer.act(() => { - renderer.create(); - }); - }); -}); diff --git a/src/goals/ReviewEntries/ReviewEntriesTable/CellComponents/tests/PronunciationsCell.test.tsx b/src/goals/ReviewEntries/ReviewEntriesTable/CellComponents/tests/PronunciationsCell.test.tsx deleted file mode 100644 index c2a7cbc92c..0000000000 --- a/src/goals/ReviewEntries/ReviewEntriesTable/CellComponents/tests/PronunciationsCell.test.tsx +++ /dev/null @@ -1,161 +0,0 @@ -import { ThemeProvider } from "@mui/material/styles"; -import { Provider } from "react-redux"; -import { ReactTestRenderer, act, create } from "react-test-renderer"; -import configureMockStore from "redux-mock-store"; - -import { Pronunciation } from "api/models"; -import { defaultState as currentProjectState } from "components/Project/ProjectReduxTypes"; -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/ReviewEntriesTable/CellComponents/PronunciationsCell"; -import { StoreState } from "types"; -import theme from "types/theme"; -import { newPronunciation } from "types/word"; - -jest.mock("components/Project/ProjectActions", () => ({})); -// Mock the store interactions -jest.mock("goals/ReviewEntries/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 mockState: Partial = { - currentProjectState, - pronunciationsState, -}; -const mockStore = configureMockStore()(mockState); - -// Mock the functions used for the component in edit mode -const mockAddNewAudio = jest.fn(); -const mockDelNewAudio = jest.fn(); -const mockRepNewAudio = jest.fn(); -const mockDelOldAudio = jest.fn(); -const mockRepOldAudio = jest.fn(); -const mockAudioFunctions = { - addNewAudio: (...args: any[]) => mockAddNewAudio(...args), - delNewAudio: (...args: any[]) => mockDelNewAudio(...args), - repNewAudio: (...args: any[]) => mockRepNewAudio(...args), - delOldAudio: (...args: any[]) => mockDelOldAudio(...args), - repOldAudio: (...args: any[]) => mockRepOldAudio(...args), -}; - -// Render the cell component with a store and theme -let testRenderer: ReactTestRenderer; -const renderPronunciationsCell = async ( - audio: Pronunciation[], - audioNew?: Pronunciation[] -): Promise => { - await act(async () => { - testRenderer = create( - - - - - - ); - }); -}; - -beforeEach(() => { - jest.resetAllMocks(); -}); - -describe("PronunciationsCell", () => { - describe("not in edit mode", () => { - it("renders", async () => { - const mockAudio = ["1", "2", "3"].map((f) => newPronunciation(f)); - 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([newPronunciation("1")]); - await act(async () => { - testRenderer.root.findByType(AudioPlayer).props.deleteAudio(); - }); - expect(mockDeleteAudio).toHaveBeenCalled(); - expect(mockDispatch).toHaveBeenCalled(); - expect(mockDelNewAudio).not.toHaveBeenCalled(); - expect(mockDelOldAudio).not.toHaveBeenCalled(); - }); - - it("has recorder that dispatches action", async () => { - await renderPronunciationsCell([]); - await act(async () => { - testRenderer.root.findByType(AudioRecorder).props.uploadAudio(); - }); - expect(mockUploadAudio).toHaveBeenCalled(); - expect(mockDispatch).toHaveBeenCalled(); - expect(mockAddNewAudio).not.toHaveBeenCalled(); - }); - }); - - describe("in edit mode", () => { - it("renders", async () => { - const mockAudioOld = ["1", "2", "3", "4"].map((f) => newPronunciation(f)); - const mockAudioNew = ["5", "6"].map((f) => newPronunciation(f)); - 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( - [newPronunciation("old")], - [newPronunciation("new")] - ); - const playButtons = testRenderer.root.findAllByType(AudioPlayer); - - // player for audio present prior to row edit - await act(async () => { - playButtons[0].props.deleteAudio(); - }); - expect(mockDelOldAudio).toHaveBeenCalled(); - expect(mockDelNewAudio).not.toHaveBeenCalled(); - expect(mockDeleteAudio).not.toHaveBeenCalled(); - expect(mockDispatch).not.toHaveBeenCalled(); - - jest.resetAllMocks(); - - // player for audio added during row edit - await act(async () => { - playButtons[1].props.deleteAudio(); - }); - expect(mockDelNewAudio).toHaveBeenCalled(); - expect(mockDelOldAudio).not.toHaveBeenCalled(); - expect(mockDeleteAudio).not.toHaveBeenCalled(); - expect(mockDispatch).not.toHaveBeenCalled(); - }); - - it("has recorder that calls a prop function", async () => { - await renderPronunciationsCell([], []); - await act(async () => { - testRenderer.root.findByType(AudioRecorder).props.uploadAudio(); - }); - expect(mockAddNewAudio).toHaveBeenCalled(); - expect(mockUploadAudio).not.toHaveBeenCalled(); - expect(mockDispatch).not.toHaveBeenCalled(); - }); - }); -}); diff --git a/src/goals/ReviewEntries/ReviewEntriesTable/CellComponents/tests/SenseCell.test.tsx b/src/goals/ReviewEntries/ReviewEntriesTable/CellComponents/tests/SenseCell.test.tsx deleted file mode 100644 index f4ceb3d52b..0000000000 --- a/src/goals/ReviewEntries/ReviewEntriesTable/CellComponents/tests/SenseCell.test.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import renderer from "react-test-renderer"; - -import SenseCell from "goals/ReviewEntries/ReviewEntriesTable/CellComponents/SenseCell"; -import mockWords from "goals/ReviewEntries/tests/WordsMock"; - -const mockWord = mockWords()[1]; - -describe("SenseCell", () => { - it("renders", () => { - renderer.act(() => { - renderer.create( - - ); - }); - }); -}); diff --git a/src/goals/ReviewEntries/ReviewEntriesTable/CellComponents/tests/VernacularCell.test.tsx b/src/goals/ReviewEntries/ReviewEntriesTable/CellComponents/tests/VernacularCell.test.tsx deleted file mode 100644 index b06731dc98..0000000000 --- a/src/goals/ReviewEntries/ReviewEntriesTable/CellComponents/tests/VernacularCell.test.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import renderer from "react-test-renderer"; - -import VernacularCell from "goals/ReviewEntries/ReviewEntriesTable/CellComponents/VernacularCell"; -import mockWords from "goals/ReviewEntries/tests/WordsMock"; - -// The multiline TextField causes problems in the mock environment. -jest.mock("@mui/material/TextField", () => "div"); - -const mockWord = mockWords()[0]; - -describe("VernacularCell", () => { - it("renders", () => { - renderer.act(() => { - renderer.create( - - ); - }); - }); - - it("renders editable", () => { - renderer.act(() => { - renderer.create( - - ); - }); - }); -}); diff --git a/src/goals/ReviewEntries/ReviewEntriesTable/Cells/CellTypes.ts b/src/goals/ReviewEntries/ReviewEntriesTable/Cells/CellTypes.ts new file mode 100644 index 0000000000..10c1b0bc1e --- /dev/null +++ b/src/goals/ReviewEntries/ReviewEntriesTable/Cells/CellTypes.ts @@ -0,0 +1,7 @@ +import { type Word } from "api/models"; + +export interface CellProps { + delete?: (wordId: string) => void; + replace?: (oldId: string, newId: string) => Promise; + word: Word; +} diff --git a/src/goals/ReviewEntries/ReviewEntriesTable/Cells/DefinitionsCell.tsx b/src/goals/ReviewEntries/ReviewEntriesTable/Cells/DefinitionsCell.tsx new file mode 100644 index 0000000000..a549803de5 --- /dev/null +++ b/src/goals/ReviewEntries/ReviewEntriesTable/Cells/DefinitionsCell.tsx @@ -0,0 +1,47 @@ +import { type ReactElement } from "react"; + +import { type CellProps } from "goals/ReviewEntries/ReviewEntriesTable/Cells/CellTypes"; +import { TypographyWithFont } from "utilities/fontComponents"; + +export default function DefinitionsCell(props: CellProps): ReactElement { + const MAX_LENGTH = 75; + const typographies: ReactElement[] = []; + + props.word.senses.forEach((sense) => { + let text = sense.definitions + .map((d) => d.text.trim()) + .filter((t) => t) + .join("; "); + if (!text) { + return; + } + + if (text.length > MAX_LENGTH) { + text = `${text.substring(0, MAX_LENGTH)}[...]`; + } + + // Add a sense separator if this isn't the first. + if (typographies.length) { + typographies.push( + + {" | "} + + ); + } + + // Use the analysis language of the first non-empty definition, if any. + const lang = sense.definitions.find((d) => d.text.trim())?.language; + typographies.push( + + {text} + + ); + }); + + return
{typographies}
; +} diff --git a/src/goals/ReviewEntries/ReviewEntriesTable/Cells/DeleteCell.tsx b/src/goals/ReviewEntries/ReviewEntriesTable/Cells/DeleteCell.tsx new file mode 100644 index 0000000000..6373fc9e2f --- /dev/null +++ b/src/goals/ReviewEntries/ReviewEntriesTable/Cells/DeleteCell.tsx @@ -0,0 +1,35 @@ +import { type ReactElement } from "react"; + +import { Status } from "api/models"; +import { deleteFrontierWord } from "backend"; +import { DeleteButtonWithDialog } from "components/Buttons"; +import { type CellProps } from "goals/ReviewEntries/ReviewEntriesTable/Cells/CellTypes"; + +const buttonIdCancel = "delete-cancel"; +const buttonIdConfirm = "delete-confirm"; + +export default function DeleteCell(props: CellProps): ReactElement { + const { accessibility, id, senses } = props.word; + const disabled = + accessibility === Status.Protected || + senses.some((s) => s.accessibility === Status.Protected); + + async function deleteWord(): Promise { + await deleteFrontierWord(id); + if (props.delete) { + props.delete(id); + } + } + + return ( + + ); +} diff --git a/src/goals/ReviewEntries/ReviewEntriesTable/Cells/DomainsCell.tsx b/src/goals/ReviewEntries/ReviewEntriesTable/Cells/DomainsCell.tsx new file mode 100644 index 0000000000..31ca61bf49 --- /dev/null +++ b/src/goals/ReviewEntries/ReviewEntriesTable/Cells/DomainsCell.tsx @@ -0,0 +1,30 @@ +import { Chip, Grid } from "@mui/material"; +import { type ReactElement } from "react"; + +import { type SemanticDomain, type Sense } from "api/models"; +import { type CellProps } from "goals/ReviewEntries/ReviewEntriesTable/Cells/CellTypes"; + +/** Collect all distinct sense.semanticDomains entries. */ +export function gatherDomains(senses: Sense[]): SemanticDomain[] { + return senses + .flatMap((s) => s.semanticDomains) + .reduce((a, dom) => { + const { id, name } = dom; + return !id || a.some((d) => d.id === id && d.name === name) + ? a + : [...a, dom]; + }, []) + .sort((a, b) => a.id.localeCompare(b.id)); +} + +export default function DomainsCell(props: CellProps): ReactElement { + return ( + + {gatherDomains(props.word.senses).map((dom) => ( + + + + ))} + + ); +} diff --git a/src/goals/ReviewEntries/ReviewEntriesTable/Cells/EditCell/EditDialog.tsx b/src/goals/ReviewEntries/ReviewEntriesTable/Cells/EditCell/EditDialog.tsx new file mode 100644 index 0000000000..ee1d5fa16a --- /dev/null +++ b/src/goals/ReviewEntries/ReviewEntriesTable/Cells/EditCell/EditDialog.tsx @@ -0,0 +1,496 @@ +import { + Check, + Close, + CloseFullscreen, + Flag as FlagFilled, + FlagOutlined, + OpenInFull, +} from "@mui/icons-material"; +import { + Card, + CardContent, + CardHeader, + Dialog, + DialogContent, + DialogTitle, + Grid, + IconButton, + MenuItem, + Select, + TextField, + type SelectChangeEvent, +} from "@mui/material"; +import { grey, yellow } from "@mui/material/colors"; +import { + type CSSProperties, + type ReactElement, + useEffect, + useState, +} from "react"; +import { useTranslation } from "react-i18next"; +import { toast } from "react-toastify"; + +import { type Pronunciation, type Sense, Status, type Word } from "api/models"; +import { deleteAudio, updateWord } from "backend"; +import { CancelConfirmDialog } from "components/Dialogs"; +import PronunciationsBackend from "components/Pronunciations/PronunciationsBackend"; +import PronunciationsFrontend from "components/Pronunciations/PronunciationsFrontend"; +import { uploadFileFromPronunciation } from "components/Pronunciations/utilities"; +import { addEntryEditToGoal, asyncUpdateGoal } from "goals/Redux/GoalActions"; +import EditSensesCardContent from "goals/ReviewEntries/ReviewEntriesTable/Cells/EditCell/EditSensesCardContent"; +import { + cleanWord, + isSenseChanged, +} from "goals/ReviewEntries/ReviewEntriesTable/Cells/EditCell/utilities"; +import { type StoreState } from "types"; +import { type StoreStateDispatch } from "types/Redux/actions"; +import { useAppDispatch, useAppSelector } from "types/hooks"; +import { themeColors } from "types/theme"; +import { + type FileWithSpeakerId, + newPronunciation, + updateSpeakerInAudio, +} from "types/word"; +import { TextFieldWithFont } from "utilities/fontComponents"; + +/** Add word update to the current goal. */ +function asyncUpdateWord(oldId: string, newId: string) { + return async (dispatch: StoreStateDispatch) => { + dispatch(addEntryEditToGoal({ newId, oldId })); + await dispatch(asyncUpdateGoal()); + }; +} + +/** Update word in the backend */ +export async function updateFrontierWord( + newWord: Word, + newAudio: Pronunciation[], + oldAudio: Pronunciation[] +): Promise { + let newId = newWord.id; + for (const o of oldAudio) { + if (!newWord.audio.some((n) => n.fileName === o.fileName)) { + newId = await deleteAudio(newId, o.fileName); + } + } + newId = (await updateWord({ ...newWord, id: newId })).id; + for (const pro of newAudio) { + newId = await uploadFileFromPronunciation(newId, pro); + } + return newId; +} + +export enum EditDialogId { + ButtonCancel = "edit-dialog-cancel-button", + ButtonCancelDialogCancel = "edit-dialog-cancel-dialog-cancel-button", + ButtonCancelDialogConfirm = "edit-dialog-cancel-dialog-confirm-button", + ButtonSave = "edit-dialog-save-button", + ButtonSensesViewToggle = "edit-dialog-senses-view-toggle-button", + TextFieldFlag = "edit-dialog-flag-textfield", + TextFieldNote = "edit-dialog-note-textfield", + TextFieldVernacular = "edit-dialog-vernacular-textfield", +} + +enum EditField { + Flag, + Note, + Pronunciations, + Senses, + Vernacular, +} + +type EditFieldChanged = Record; +const defaultEditFieldChanged: EditFieldChanged = { + [EditField.Flag]: false, + [EditField.Note]: false, + [EditField.Pronunciations]: false, + [EditField.Senses]: false, + [EditField.Vernacular]: false, +}; + +interface EditDialogProps { + close: () => void; + confirm: (newId: string) => Promise; + word: Word; +} + +export default function EditDialog(props: EditDialogProps): ReactElement { + const dispatch = useAppDispatch(); + + const analysisWritingSystems = useAppSelector( + (state: StoreState) => + state.currentProjectState.project.analysisWritingSystems + ); + const vernLang = useAppSelector( + (state: StoreState) => + state.currentProjectState.project.vernacularWritingSystem.bcp47 + ); + + const [cancelDialog, setCancelDialog] = useState(false); + const [changes, setChanges] = useState(defaultEditFieldChanged); + const [newAudio, setNewAudio] = useState([]); + const [newWord, setNewWord] = useState(props.word); + const [showSenses, setShowSenses] = useState(true); + + useEffect(() => { + setChanges({ + [EditField.Flag]: + newWord.flag.active !== props.word.flag.active || + (newWord.flag.active && + newWord.flag.text.trim() !== props.word.flag.text.trim()), + [EditField.Note]: + newWord.note.text.trim() !== props.word.note.text.trim() || + (newWord.note.text.trim().length > 0 && + newWord.note.language !== props.word.note.language), + [EditField.Pronunciations]: + newAudio.length > 0 || + newWord.audio.length !== props.word.audio.length || + newWord.audio.some((n) => + props.word.audio.some( + (o) => n.fileName === o.fileName && n.speakerId !== o.speakerId + ) + ), + [EditField.Senses]: + newWord.senses.length !== props.word.senses.length || + newWord.senses.some((s, i) => isSenseChanged(props.word.senses[i], s)), + [EditField.Vernacular]: + newWord.vernacular.trim() !== props.word.vernacular.trim(), + }); + }, [newAudio, newWord, props.word]); + + const bgStyle = (field: EditField): CSSProperties => ({ + backgroundColor: changes[field] ? yellow[50] : grey[200], + }); + + // Functions for handling senses is the edit state. + const moveSense = (from: number, to: number): void => { + if (from < 0 || to < 0 || from === to) { + return; + } + setNewWord((prev) => { + if (from >= prev.senses.length || to >= prev.senses.length) { + return prev; + } + const senses = [...prev.senses]; + senses.splice(to, 0, senses.splice(from, 1)[0]); + return { ...prev, senses }; + }); + }; + const toggleDeleted = (s: Sense): Sense => ({ + ...s, + accessibility: + s.accessibility === Status.Deleted ? Status.Active : Status.Deleted, + }); + const toggleSenseDeleted = (index: number): void => { + setNewWord((prev) => { + if (index < 0 || index >= prev.senses.length) { + console.error("Sense doesn't exist."); + return prev; + } + if (prev.senses[index].accessibility === Status.Protected) { + console.error("Sense is protected."); + return prev; + } + + return { + ...prev, + senses: prev.senses.map((s, i) => (i === index ? toggleDeleted(s) : s)), + }; + }); + }; + const updateOrAddSense = (sense: Sense): void => { + setNewWord((prev) => { + const oldSense = prev.senses.find((s) => s.guid === sense.guid); + + if (oldSense && oldSense.accessibility !== sense.accessibility) { + console.error("Cannot change a sense status with this method."); + return prev; + } + + return { + ...prev, + senses: oldSense + ? prev.senses.map((s) => (s.guid === sense.guid ? sense : s)) + : [...prev.senses, sense], + }; + }); + }; + + // Functions for handling pronunciations in the edit state. + const delOldAudio = (fileName: string): void => + setNewWord((prev) => ({ + ...prev, + audio: prev.audio.filter((pro) => pro.fileName !== fileName), + })); + const repOldAudio = (pro: Pronunciation): void => + setNewWord((prev) => { + const audio = updateSpeakerInAudio(prev.audio, pro); + return audio ? { ...prev, audio } : prev; + }); + const delNewAudio = (url: string): void => + setNewAudio((prev) => prev.filter((pro) => pro.fileName !== url)); + const repNewAudio = (pro: Pronunciation): void => + setNewAudio((prev) => updateSpeakerInAudio(prev, pro) ?? prev); + const uplNewAudio = (file: FileWithSpeakerId): void => + setNewAudio((prev) => [ + ...prev, + newPronunciation(URL.createObjectURL(file), file.speakerId), + ]); + + // Functions for handling the note in the edit state. + const updateNoteLang = (language: string): void => { + setNewWord((prev) => ({ + ...prev, + note: { ...prev.note, language }, + })); + }; + const updateNoteText = (text: string): void => { + setNewWord((prev) => ({ + ...prev, + note: { + language: prev.note.language || analysisWritingSystems[0].bcp47, + text, + }, + })); + }; + + // Functions for handling the flag in the edit state. + const toggleFlag = (): void => { + setNewWord((prev) => ({ + ...prev, + flag: { ...prev.flag, active: !prev.flag.active }, + })); + }; + const updateFlag = (text: string): void => { + setNewWord((prev) => ({ + ...prev, + flag: { active: prev.flag.active || !!text, text }, + })); + }; + + /** Clean up the edited word and update it backend and frontend. */ + const saveAndClose = async (): Promise => { + // If no changes, just close + if (Object.values(changes).every((change) => !change)) { + cancelAndClose(); + return; + } + + // Remove empty/deleted senses; confirm nonempty vernacular and senses + const cleanedWord = cleanWord(newWord, true); + if (typeof cleanedWord === "string") { + toast.error(t(cleanedWord)); + return Promise.reject(t(cleanedWord)); + } + + // Update in backend + const newId = await updateFrontierWord( + cleanedWord, + newAudio, + props.word.audio + ); + + // Update in goal + await dispatch(asyncUpdateWord(props.word.id, newId)); + + // Update in ReviewEntries state + await props.confirm(newId); + + // Close + props.close(); + }; + + /** Open dialog to ask to discard changes, or close if no changes. */ + const conditionalCancel = (): void => { + if (Object.values(changes).some((change) => change)) { + setCancelDialog(true); + } else { + cancelAndClose(); + } + }; + + /** Undo all edits and close the edit dialog. */ + const cancelAndClose = (): void => { + setNewAudio([]); + setNewWord(props.word); + setCancelDialog(false); + props.close(); + }; + + const { t } = useTranslation(); + + const noteLangSelect = ( + + ); + + return ( + <> + setCancelDialog(false)} + handleConfirm={cancelAndClose} + open={cancelDialog} + text="reviewEntries.discardChanges" + /> + + + + + {t("reviewEntries.columns.edit")} + {" : "} + {props.word.vernacular} + + + + + + + + + + + + + + {/* Vernacular */} + + + + + + setNewWord((prev) => ({ + ...prev, + vernacular: e.target.value, + })) + } + value={newWord.vernacular} + vernacular + /> + + + + + {/* Senses */} + + + 1 && ( + setShowSenses((prev) => !prev)} + > + {showSenses ? ( + + ) : ( + + )} + + ) + } + title={t("reviewEntries.columns.senses")} + /> + + + + + {/* Pronunciations */} + + + + + + } + audio={newAudio} + deleteAudio={delNewAudio} + replaceAudio={repNewAudio} + uploadAudio={uplNewAudio} + /> + + + + + {/* Note */} + + + + + updateNoteText(e.target.value)} + value={newWord.note.text} + /> + + + + + {/* Flag */} + + + + + + {newWord.flag.active ? ( + + ) : ( + + )} + + updateFlag(e.target.value)} + value={newWord.flag.active ? newWord.flag.text : ""} + > + + + + + + + + ); +} diff --git a/src/goals/ReviewEntries/ReviewEntriesTable/Cells/EditCell/EditSenseDialog.tsx b/src/goals/ReviewEntries/ReviewEntriesTable/Cells/EditCell/EditSenseDialog.tsx new file mode 100644 index 0000000000..cb5f387770 --- /dev/null +++ b/src/goals/ReviewEntries/ReviewEntriesTable/Cells/EditCell/EditSenseDialog.tsx @@ -0,0 +1,471 @@ +import { Add, Check, Close } from "@mui/icons-material"; +import { + Card, + CardContent, + CardHeader, + Chip, + Dialog, + DialogContent, + DialogTitle, + Grid, + IconButton, + Typography, +} from "@mui/material"; +import { grey, yellow } from "@mui/material/colors"; +import { + type CSSProperties, + type ReactElement, + useEffect, + useState, +} from "react"; +import { useTranslation } from "react-i18next"; +import { toast } from "react-toastify"; + +import { + type Definition, + type Gloss, + GramCatGroup, + type SemanticDomain, + type Sense, + type WritingSystem, +} from "api/models"; +import { PartOfSpeechButton } from "components/Buttons"; +import { CancelConfirmDialog } from "components/Dialogs"; +import TreeView from "components/TreeView"; +import { + areDefinitionsSame, + areDomainsSame, + areGlossesSame, + cleanSense, +} from "goals/ReviewEntries/ReviewEntriesTable/Cells/EditCell/utilities"; +import { type StoreState } from "types"; +import { useAppSelector } from "types/hooks"; +import { newSemanticDomainForMongoDB } from "types/semanticDomain"; +import { newDefinition, newGloss } from "types/word"; +import { TextFieldWithFont } from "utilities/fontComponents"; + +export enum EditSenseDialogId { + ButtonCancel = "edit-sense-dialog-cancel-button", + ButtonCancelDialogCancel = "edit-sense-dialog-cancel-dialog-cancel-button", + ButtonCancelDialogConfirm = "edit-sense-dialog-cancel-dialog-confirm-button", + ButtonPartOfSpeech = "edit-sense-dialog-part-of-speech-button", + ButtonSave = "edit-sense-dialog-save-button", + ButtonSemDomAdd = "edit-sense-add-semantic-domain-button", + ButtonSemDomDeletePrefix = "edit-sense-delete-semantic-domain-button-", + TextFieldDefinitionPrefix = "edit-sense-definition-textfield-", + TextFieldGlossPrefix = "edit-sense-gloss-textfield-", +} + +export enum EditSenseField { + Definitions, + Glosses, + GrammaticalInfo, + SemanticDomains, +} + +type EditSenseFieldChanged = Record; +const defaultEditSenseFieldChanged: EditSenseFieldChanged = { + [EditSenseField.Definitions]: false, + [EditSenseField.GrammaticalInfo]: false, + [EditSenseField.Glosses]: false, + [EditSenseField.SemanticDomains]: false, +}; + +interface EditSenseDialogProps { + close: () => void; + isOpen: boolean; + save: (sense: Sense) => void; + sense: Sense; +} + +export default function EditSenseDialog( + props: EditSenseDialogProps +): ReactElement { + const analysisWritingSystems = useAppSelector( + (state: StoreState) => + state.currentProjectState.project.analysisWritingSystems + ); + const showDefinitions = useAppSelector( + (state: StoreState) => state.currentProjectState.project.definitionsEnabled + ); + const showGrammaticalInfo = useAppSelector( + (state: StoreState) => + state.currentProjectState.project.grammaticalInfoEnabled + ); + + const [newSense, setNewSense] = useState(props.sense); + const [cancelDialog, setCancelDialog] = useState(false); + const [changes, setChanges] = useState(defaultEditSenseFieldChanged); + + const { t } = useTranslation(); + + useEffect(() => { + setChanges({ + [EditSenseField.Definitions]: !areDefinitionsSame( + newSense.definitions, + props.sense.definitions + ), + [EditSenseField.Glosses]: !areGlossesSame( + newSense.glosses, + props.sense.glosses + ), + [EditSenseField.GrammaticalInfo]: false, // not editable + [EditSenseField.SemanticDomains]: !areDomainsSame( + newSense.semanticDomains, + props.sense.semanticDomains + ), + }); + }, [newSense, props.sense]); + + const bgStyle = (field: EditSenseField): CSSProperties => ({ + backgroundColor: changes[field] ? yellow[50] : grey[200], + }); + + // Functions for updating the edit sense state. + const updateDefinitions = (definitions: Definition[]): void => + setNewSense((prev) => ({ ...prev, definitions })); + const updateGlosses = (glosses: Gloss[]): void => + setNewSense((prev) => ({ ...prev, glosses })); + const updateDomains = (semanticDomains: SemanticDomain[]): void => + setNewSense((prev) => ({ ...prev, semanticDomains })); + + /** Clean up the edited senses and update the parent state. */ + const saveAndClose = (): void => { + // If no changes, just close + if (Object.values(changes).every((change) => !change)) { + cancelAndClose(); + return; + } + + // Confirm nonempty senses + const cleanedSense = cleanSense(newSense); + if (!cleanedSense || typeof cleanedSense === "string") { + toast.error(t(cleanedSense ?? "")); + return; + } + + // Update in ReviewEntries state + props.save(cleanedSense); + + // Close + props.close(); + }; + + /** Open dialog to ask to discard changes, or close if no changes. */ + const conditionalCancel = (): void => { + if (Object.values(changes).some((change) => change)) { + setCancelDialog(true); + } else { + cancelAndClose(); + } + }; + + /** Undo all edits and close dialogs. */ + const cancelAndClose = (): void => { + setNewSense(props.sense); + setCancelDialog(false); + props.close(); + }; + + return ( + <> + setCancelDialog(false)} + handleConfirm={cancelAndClose} + open={cancelDialog} + text="reviewEntries.discardChanges" + /> + + + + {t("reviewEntries.editSense")} + + + t.palette.success.main }} /> + + + t.palette.error.main }} /> + + + + + + + {/* Definitions */} + {showDefinitions && ( + + + + + + + + + )} + + {/* Glosses */} + + + + + + + + + + {/* Part of Speech */} + {showGrammaticalInfo && ( + + + + + {newSense.grammaticalInfo.catGroup === + GramCatGroup.Unspecified ? ( + + {t("grammaticalCategory.group.Unspecified")} + + ) : ( + + )} + + + + )} + + {/* Semantic Domains */} + + + + + + + + + + + + + ); +} + +interface DefinitionListProps { + defaultLang: WritingSystem; + definitions: Definition[]; + onChange: (definitions: Definition[]) => void; + textFieldIdPrefix: string; +} + +function DefinitionList(props: DefinitionListProps): ReactElement { + const definitions = props.definitions.some( + (d) => d.language === props.defaultLang.bcp47 + ) + ? props.definitions + : [...props.definitions, newDefinition("", props.defaultLang.bcp47)]; + + return ( + <> + {definitions.map((d, i) => ( + { + const updated = [...definitions]; + updated.splice(i, 1, definition); + props.onChange(updated); + }} + textFieldId={`${props.textFieldIdPrefix}${i}`} + /> + ))} + + ); +} + +interface DefinitionTextFieldProps { + definition: Definition; + textFieldId: string; + onChange: (definition: Definition) => void; +} + +function DefinitionTextField(props: DefinitionTextFieldProps): ReactElement { + return ( + + props.onChange( + newDefinition(event.target.value, props.definition.language) + ) + } + value={props.definition.text} + variant="outlined" + /> + ); +} + +interface GlossListProps { + defaultLang: WritingSystem; + glosses: Gloss[]; + onChange: (glosses: Gloss[]) => void; + textFieldIdPrefix: string; +} + +function GlossList(props: GlossListProps): ReactElement { + const glosses = props.glosses.some( + (g) => g.language === props.defaultLang.bcp47 + ) + ? props.glosses + : [...props.glosses, newGloss("", props.defaultLang.bcp47)]; + + return ( + <> + {glosses.map((g, i) => ( + { + const updated = [...glosses]; + updated.splice(i, 1, gloss); + props.onChange(updated); + }} + textFieldId={`${props.textFieldIdPrefix}${i}`} + /> + ))} + + ); +} + +interface GlossTextFieldProps { + gloss: Gloss; + textFieldId: string; + onChange: (gloss: Gloss) => void; +} + +function GlossTextField(props: GlossTextFieldProps): ReactElement { + return ( + + props.onChange(newGloss(event.target.value, props.gloss.language)) + } + value={props.gloss.def} + variant="outlined" + /> + ); +} + +interface DomainListProps { + buttonIdAdd: string; + buttonIdPrefixDelete: string; + domains: SemanticDomain[]; + onChange: (domains: SemanticDomain[]) => void; +} + +function DomainList(props: DomainListProps): ReactElement { + const selectedDom = useAppSelector( + (state: StoreState) => state.treeViewState.currentDomain + ); + const [addingDom, setAddingDom] = useState(false); + const { t } = useTranslation(); + + function addDomain(): void { + setAddingDom(false); + if (!selectedDom) { + throw new Error("Cannot add domain without the selectedDomain property."); + } + if (selectedDom.mongoId == "") { + throw new Error("SelectedSemanticDomainTreeNode have no mongoId."); + } + if (props.domains.some((d) => d.id === selectedDom.id)) { + toast.error(t("reviewEntries.duplicateDomain", { val: selectedDom.id })); + return; + } + props.onChange([ + ...props.domains, + newSemanticDomainForMongoDB(selectedDom), + ]); + } + + function deleteDomain(domId: string): void { + props.onChange(props.domains.filter((d) => d.id !== domId)); + } + + return ( + <> + + {props.domains.length > 0 ? ( + props.domains.map((domain, index) => ( + + deleteDomain(domain.id)} + /> + + )) + ) : ( + + + + )} + setAddingDom(true)} + size="large" + > + + + + + setAddingDom(false)} + returnControlToCaller={addDomain} + /> + + + ); +} diff --git a/src/goals/ReviewEntries/ReviewEntriesTable/Cells/EditCell/EditSensesCardContent.tsx b/src/goals/ReviewEntries/ReviewEntriesTable/Cells/EditCell/EditSensesCardContent.tsx new file mode 100644 index 0000000000..8093a53d1a --- /dev/null +++ b/src/goals/ReviewEntries/ReviewEntriesTable/Cells/EditCell/EditSensesCardContent.tsx @@ -0,0 +1,197 @@ +import { + Add, + ArrowDownward, + ArrowUpward, + Delete, + Edit, + RestoreFromTrash, +} from "@mui/icons-material"; +import { CardContent, Divider, Grid, Icon } from "@mui/material"; +import { grey, yellow } from "@mui/material/colors"; +import { type ReactElement, useEffect, useState } from "react"; + +import { type Sense, Status } from "api/models"; +import { IconButtonWithTooltip } from "components/Buttons"; +import SenseCard from "components/WordCard/SenseCard"; +import SummarySenseCard from "components/WordCard/SummarySenseCard"; +import EditSenseDialog from "goals/ReviewEntries/ReviewEntriesTable/Cells/EditCell/EditSenseDialog"; +import { isSenseChanged } from "goals/ReviewEntries/ReviewEntriesTable/Cells/EditCell/utilities"; +import { newSense } from "types/word"; + +enum EditSensesId { + ButtonSenseAdd = "add-sense-button", + ButtonSenseBumpDownPrefix = "bump-up-sense-button-", + ButtonSenseBumpUpPrefix = "bump-down-sense-button-", + ButtonSenseDeletePrefix = "delete-sense-button-", + ButtonSenseEditPrefix = "edit-sense-button-", + ButtonSenseRestorePrefix = "restore-sense-button-", +} + +interface EditSensesCardContentProps { + moveSense: (from: number, to: number) => void; + newSenses: Sense[]; + oldSenses: Sense[]; + showSenses: boolean; + toggleSenseDeleted: (index: number) => void; + updateOrAddSense: (sense: Sense) => void; +} + +export default function EditSensesCardContent( + props: EditSensesCardContentProps +): ReactElement { + const [addSense, setAddSense] = useState(false); + const [changes, setChanges] = useState([]); + + useEffect(() => { + setChanges( + props.newSenses.map((sense, index) => + index < props.oldSenses.length + ? isSenseChanged(sense, props.oldSenses[index]) + : true + ) + ); + }, [props.newSenses, props.oldSenses]); + + return ( + + {props.showSenses ? ( + <> + {props.newSenses.map((s, i) => ( + props.moveSense(i, i + 1) + : undefined + } + bumpSenseUp={i ? () => props.moveSense(i, i - 1) : undefined} + edited={changes[i]} + key={s.guid} + sense={s} + toggleSenseDeleted={() => props.toggleSenseDeleted(i)} + updateSense={props.updateOrAddSense} + /> + ))} + } + onClick={() => setAddSense(true)} + size="small" + /> + setAddSense(false)} + isOpen={addSense} + save={props.updateOrAddSense} + sense={newSense()} + /> + + ) : ( + s.accessibility !== Status.Deleted + )} + /> + )} + + ); +} + +interface EditSenseProps { + edited?: boolean; + sense: Sense; + bumpSenseDown?: () => void; + bumpSenseUp?: () => void; + toggleSenseDeleted: () => void; + updateSense: (sense: Sense) => void; +} + +export function EditSense(props: EditSenseProps): ReactElement { + const sense = props.sense; + const deleted = sense.accessibility === Status.Deleted; + const [editing, setEditing] = useState(false); + + return ( + <> + + {props.bumpSenseDown || props.bumpSenseUp ? ( + + + + : } + onClick={props.bumpSenseUp} + size="small" + /> + + + : } + onClick={props.bumpSenseDown} + size="small" + /> + + + + ) : null} + + + {deleted ? ( + + } + onClick={props.toggleSenseDeleted} + size="small" + /> + + ) : ( + <> + + } + onClick={ + sense.accessibility === Status.Protected + ? undefined + : props.toggleSenseDeleted + } + size="small" + textId={ + sense.accessibility === Status.Protected + ? "reviewEntries.deleteDisabled" + : undefined + } + /> + + + } + onClick={() => setEditing(true)} + size="small" + /> + + + )} + + + + + + setEditing(false)} + isOpen={editing} + save={props.updateSense} + sense={sense} + /> + + + + ); +} diff --git a/src/goals/ReviewEntries/ReviewEntriesTable/Cells/EditCell/index.tsx b/src/goals/ReviewEntries/ReviewEntriesTable/Cells/EditCell/index.tsx new file mode 100644 index 0000000000..d9090fb802 --- /dev/null +++ b/src/goals/ReviewEntries/ReviewEntriesTable/Cells/EditCell/index.tsx @@ -0,0 +1,40 @@ +import { Edit } from "@mui/icons-material"; +import { type ReactElement, useState } from "react"; + +import { IconButtonWithTooltip } from "components/Buttons"; +import { type CellProps } from "goals/ReviewEntries/ReviewEntriesTable/Cells/CellTypes"; +import EditDialog from "goals/ReviewEntries/ReviewEntriesTable/Cells/EditCell/EditDialog"; + +const buttonId = (wordId: string): string => `row-${wordId}-edit`; + +export default function EditCell(props: CellProps): ReactElement { + const [open, setOpen] = useState(false); + + const handleConfirm = async (newId: string): Promise => { + if (props.replace) { + await props.replace(props.word.id, newId); + } + }; + + return ( + <> + } + onClick={() => setOpen(true)} + textId={"reviewEntries.columns.edit"} + /> + { + /* Only render EditDialog when `open` is `true` + * to ensure that its `word` prop is not stale from a previous edit. */ + open && ( + setOpen(false)} + confirm={handleConfirm} + word={props.word} + /> + ) + } + + ); +} diff --git a/src/goals/ReviewEntries/ReviewEntriesTable/Cells/EditCell/tests/EditDialog.test.tsx b/src/goals/ReviewEntries/ReviewEntriesTable/Cells/EditCell/tests/EditDialog.test.tsx new file mode 100644 index 0000000000..28db6f023f --- /dev/null +++ b/src/goals/ReviewEntries/ReviewEntriesTable/Cells/EditCell/tests/EditDialog.test.tsx @@ -0,0 +1,267 @@ +import { Delete, RestoreFromTrash } from "@mui/icons-material"; +import { type ChangeEvent } from "react"; +import { Provider } from "react-redux"; +import { type ReactTestRenderer, act, create } from "react-test-renderer"; +import configureMockStore from "redux-mock-store"; + +import { GramCatGroup, Status, type Word } from "api/models"; +import { type CurrentProjectState } from "components/Project/ProjectReduxTypes"; +import SummarySenseCard from "components/WordCard/SummarySenseCard"; +import EditDialog, { + EditDialogId, +} from "goals/ReviewEntries/ReviewEntriesTable/Cells/EditCell/EditDialog"; +import EditSensesCardContent, { + EditSense, +} from "goals/ReviewEntries/ReviewEntriesTable/Cells/EditCell/EditSensesCardContent"; +import { newProject } from "types/project"; +import { newSemanticDomain } from "types/semanticDomain"; +import { newDefinition, newSense, newWord } from "types/word"; +import { defaultWritingSystem } from "types/writingSystem"; + +// Container uses Portal, not supported in react-test-renderer +jest.mock("@mui/material/Dialog", () => + jest.requireActual("@mui/material/Container") +); +// Textfield with multiline not supported in react-test-renderer +jest.mock("@mui/material/TextField", () => "div"); + +jest.mock("backend", () => ({ + deleteAudio: (...args: any[]) => mockDeleteAudio(...args), + updateWord: (word: Word) => mockUpdateWord(word), +})); +jest.mock("components/Pronunciations/AudioRecorder"); +jest.mock( + "goals/ReviewEntries/ReviewEntriesTable/Cells/EditCell/EditSenseDialog" +); +jest.mock("i18n", () => ({})); +jest.mock("types/hooks", () => ({ + ...jest.requireActual("types/hooks"), + useAppDispatch: () => mockDispatch, +})); + +const mockClose = jest.fn(); +const mockConfirm = jest.fn(); +const mockDeleteAudio = jest.fn(); +const mockDispatch = jest.fn(); +const mockUpdateWord = jest.fn(); + +const mockTextFieldEvent = ( + value: string +): ChangeEvent => + ({ target: { value } }) as any; +const mockWord = (): Word => ({ + ...newWord("vernacular"), + senses: [ + { + ...newSense("gloss 1"), + definitions: [newDefinition("def A", "aa"), newDefinition("def B", "bb")], + }, + { + ...newSense("gloss 2"), + semanticDomains: [newSemanticDomain("2.2", "two-point-two")], + }, + { ...newSense("gloss 3"), accessibility: Status.Protected }, + { + ...newSense("gloss 4"), + grammaticalInfo: { + catGroup: GramCatGroup.Verb, + grammaticalCategory: "vt", + }, + }, + ], +}); + +const currentProjectState: Partial = { + project: { + ...newProject(), + analysisWritingSystems: [defaultWritingSystem], + definitionsEnabled: true, + grammaticalInfoEnabled: true, + vernacularWritingSystem: defaultWritingSystem, + }, +}; +const mockStore = configureMockStore()({ currentProjectState }); + +let renderer: ReactTestRenderer; + +const renderEditDialog = async (): Promise => + await act(async () => { + renderer = create( + + + + ); + }); + +beforeEach(async () => { + jest.clearAllMocks(); + mockUpdateWord.mockImplementation((w: Word) => + Promise.resolve({ ...w, id: `${w.id}++` }) + ); + await renderEditDialog(); +}); + +describe("EditDialog", () => { + describe("cancel and save buttons", () => { + test("cancel button closes if no changes", async () => { + // Click the cancel button + const cancelButton = renderer.root.findByProps({ + id: EditDialogId.ButtonCancel, + }); + await act(async () => { + cancelButton.props.onClick(); + }); + + // Ensure a close without saving + expect(mockClose).toHaveBeenCalledTimes(1); + expect(mockConfirm).not.toHaveBeenCalled(); + expect(mockUpdateWord).not.toHaveBeenCalled(); + expect(mockDispatch).not.toHaveBeenCalled(); + }); + + test("cancel button opens dialog if changes", async () => { + // Make a change + const noteTextField = renderer.root.findByProps({ + id: EditDialogId.TextFieldNote, + }); + const newFlagText = "New note!"; + await act(async () => { + noteTextField.props.onChange(mockTextFieldEvent(newFlagText)); + }); + + // Click the cancel button and cancel the cancel + const cancelButton = renderer.root.findByProps({ + id: EditDialogId.ButtonCancel, + }); + await act(async () => { + cancelButton.props.onClick(); + }); + const cancelButNoButton = renderer.root.findByProps({ + id: EditDialogId.ButtonCancelDialogCancel, + }); + await act(async () => { + cancelButNoButton.props.onClick(); + }); + + // Ensure nothing happened + expect(mockClose).not.toHaveBeenCalled(); + expect(mockConfirm).not.toHaveBeenCalled(); + expect(mockUpdateWord).not.toHaveBeenCalled(); + expect(mockDispatch).not.toHaveBeenCalled(); + + // Click the cancel button and confirm the cancel + await act(async () => { + cancelButton.props.onClick(); + }); + const cancelAndYesButton = renderer.root.findByProps({ + id: EditDialogId.ButtonCancelDialogConfirm, + }); + await act(async () => { + cancelAndYesButton.props.onClick(); + }); + + // Ensure a close without saving + expect(mockClose).toHaveBeenCalledTimes(1); + expect(mockConfirm).not.toHaveBeenCalled(); + expect(mockUpdateWord).not.toHaveBeenCalled(); + expect(mockDispatch).not.toHaveBeenCalled(); + }); + + test("save button closes if no changes", async () => { + // Click the save button + const saveButton = renderer.root.findByProps({ + id: EditDialogId.ButtonSave, + }); + await act(async () => { + saveButton.props.onClick(); + }); + + // Ensure a close without saving + expect(mockClose).toHaveBeenCalledTimes(1); + expect(mockConfirm).not.toHaveBeenCalled(); + expect(mockUpdateWord).not.toHaveBeenCalled(); + expect(mockDispatch).not.toHaveBeenCalled(); + }); + + test("save button saves changes and closes", async () => { + // Make a change + const flagTextField = renderer.root.findByProps({ + id: EditDialogId.TextFieldFlag, + }); + const newFlagText = "New flag!"; + await act(async () => { + flagTextField.props.onChange(mockTextFieldEvent(newFlagText)); + }); + + // Click the save button + const saveButton = renderer.root.findByProps({ + id: EditDialogId.ButtonSave, + }); + await act(async () => { + saveButton.props.onClick(); + }); + + // Ensure save and close occurred, with dispatch up update goal + expect(mockClose).toHaveBeenCalledTimes(1); + expect(mockConfirm).toHaveBeenCalledTimes(1); + expect(mockUpdateWord).toHaveBeenCalledTimes(1); + const updatedWord: Word = mockUpdateWord.mock.calls[0][0]; + expect(updatedWord.flag.text).toEqual(newFlagText); + expect(mockDispatch).toHaveBeenCalled(); + }); + }); + + describe("senses", () => { + test("sense view toggle", async () => { + // Not summary view by default + expect(renderer.root.findAllByType(SummarySenseCard)).toHaveLength(0); + + // Click to turn on summary view + const button = renderer.root.findByProps({ + id: EditDialogId.ButtonSensesViewToggle, + }); + await act(async () => { + button.props.onClick(); + }); + expect(renderer.root.findAllByType(SummarySenseCard)).toHaveLength(1); + + // Click again to turn off summary view + await act(async () => { + button.props.onClick(); + }); + expect(renderer.root.findAllByType(SummarySenseCard)).toHaveLength(0); + }); + + test("add a sense", async () => { + expect(renderer.root.findAllByType(EditSense)).toHaveLength(4); + const senses = renderer.root.findByType(EditSensesCardContent); + await act(async () => { + senses.props.updateOrAddSense(newSense("new gloss")); + }); + expect(renderer.root.findAllByType(EditSense)).toHaveLength(5); + }); + + test("delete/restore a sense", async () => { + expect(renderer.root.findAllByType(EditSense)).toHaveLength(4); + expect(renderer.root.findAllByType(Delete)).toHaveLength(4); + expect(renderer.root.findAllByType(RestoreFromTrash)).toHaveLength(0); + const senses = renderer.root.findByType(EditSensesCardContent); + + // Delete the first sense + await act(async () => { + senses.props.toggleSenseDeleted(0); + }); + expect(renderer.root.findAllByType(EditSense)).toHaveLength(4); + expect(renderer.root.findAllByType(Delete)).toHaveLength(3); + expect(renderer.root.findAllByType(RestoreFromTrash)).toHaveLength(1); + + // Restore the first sense + await act(async () => { + senses.props.toggleSenseDeleted(0); + }); + expect(renderer.root.findAllByType(EditSense)).toHaveLength(4); + expect(renderer.root.findAllByType(Delete)).toHaveLength(4); + expect(renderer.root.findAllByType(RestoreFromTrash)).toHaveLength(0); + }); + }); +}); diff --git a/src/goals/ReviewEntries/ReviewEntriesTable/Cells/EditCell/tests/EditSenseDialog.test.tsx b/src/goals/ReviewEntries/ReviewEntriesTable/Cells/EditCell/tests/EditSenseDialog.test.tsx new file mode 100644 index 0000000000..3de945432b --- /dev/null +++ b/src/goals/ReviewEntries/ReviewEntriesTable/Cells/EditCell/tests/EditSenseDialog.test.tsx @@ -0,0 +1,213 @@ +import { type ChangeEvent } from "react"; +import { Provider } from "react-redux"; +import { type ReactTestRenderer, act, create } from "react-test-renderer"; +import configureMockStore from "redux-mock-store"; + +import { Project, type Sense } from "api/models"; +import { defaultState } from "components/App/DefaultState"; +import EditSenseDialog, { + EditSenseDialogId, +} from "goals/ReviewEntries/ReviewEntriesTable/Cells/EditCell/EditSenseDialog"; +import { type StoreState } from "types"; +import { newSense } from "types/word"; + +// Container uses Portal, not supported in react-test-renderer +jest.mock("@mui/material/Dialog", () => + jest.requireActual("@mui/material/Container") +); +// Textfield with multiline not supported in react-test-renderer +jest.mock("@mui/material/TextField", () => "div"); + +jest.mock("components/TreeView", () => "div"); +jest.mock("types/hooks", () => ({ + ...jest.requireActual("types/hooks"), + useAppDispatch: () => jest.fn(), +})); + +const mockClose = jest.fn(); +const mockSave = jest.fn(); + +const mockTextFieldEvent = ( + value: string +): ChangeEvent => + ({ target: { value } }) as any; + +const mockState = ( + definitionsEnabled = false, + grammaticalInfoEnabled = false +): StoreState => { + const project: Project = { + ...defaultState.currentProjectState.project, + definitionsEnabled, + grammaticalInfoEnabled, + }; + return { + ...defaultState, + currentProjectState: { ...defaultState.currentProjectState, project }, + }; +}; + +let renderer: ReactTestRenderer; + +const renderEditSenseDialog = async ( + definitionsEnabled = false, + grammaticalInfoEnabled = false +): Promise => { + const mockStore = configureMockStore()( + mockState(definitionsEnabled, grammaticalInfoEnabled) + ); + await act(async () => { + renderer = create( + + + + ); + }); +}; + +beforeEach(async () => { + jest.clearAllMocks(); +}); + +describe("EditSenseDialog", () => { + describe("cancel and save buttons", () => { + beforeEach(async () => { + await renderEditSenseDialog(); + }); + + test("cancel button closes if no changes", async () => { + // Click the cancel button + const cancelButton = renderer.root.findByProps({ + id: EditSenseDialogId.ButtonCancel, + }); + await act(async () => { + cancelButton.props.onClick(); + }); + + // Ensure a close without saving + expect(mockClose).toHaveBeenCalledTimes(1); + expect(mockSave).not.toHaveBeenCalled(); + }); + + test("cancel button opens dialog if changes", async () => { + // Make a change + const glossTextField = renderer.root.findByProps({ + id: `${EditSenseDialogId.TextFieldGlossPrefix}0`, + }); + const newGlossText = "New gloss!"; + await act(async () => { + glossTextField.props.onChange(mockTextFieldEvent(newGlossText)); + }); + + // Click the cancel button and cancel the cancel + const cancelButton = renderer.root.findByProps({ + id: EditSenseDialogId.ButtonCancel, + }); + await act(async () => { + cancelButton.props.onClick(); + }); + const cancelButNoButton = renderer.root.findByProps({ + id: EditSenseDialogId.ButtonCancelDialogCancel, + }); + await act(async () => { + cancelButNoButton.props.onClick(); + }); + + // Ensure nothing happened + expect(mockClose).not.toHaveBeenCalled(); + expect(mockSave).not.toHaveBeenCalled(); + + // Click the cancel button and confirm the cancel + await act(async () => { + cancelButton.props.onClick(); + }); + const cancelAndYesButton = renderer.root.findByProps({ + id: EditSenseDialogId.ButtonCancelDialogConfirm, + }); + await act(async () => { + cancelAndYesButton.props.onClick(); + }); + + // Ensure a close without saving + expect(mockClose).toHaveBeenCalledTimes(1); + expect(mockSave).not.toHaveBeenCalled(); + }); + + test("save button closes if no changes", async () => { + // Click the save button + const saveButton = renderer.root.findByProps({ + id: EditSenseDialogId.ButtonSave, + }); + await act(async () => { + saveButton.props.onClick(); + }); + + // Ensure a close without saving + expect(mockClose).toHaveBeenCalledTimes(1); + expect(mockSave).not.toHaveBeenCalled(); + }); + + test("save button saves changes and closes", async () => { + // Make a change + const glossTextField = renderer.root.findByProps({ + id: `${EditSenseDialogId.TextFieldGlossPrefix}0`, + }); + const newGlossText = "New gloss!"; + await act(async () => { + glossTextField.props.onChange(mockTextFieldEvent(newGlossText)); + }); + + // Click the save button + const saveButton = renderer.root.findByProps({ + id: EditSenseDialogId.ButtonSave, + }); + await act(async () => { + saveButton.props.onClick(); + }); + + // Ensure save and close occurred + expect(mockClose).toHaveBeenCalledTimes(1); + expect(mockSave).toHaveBeenCalledTimes(1); + const updatedSense: Sense = mockSave.mock.calls[0][0]; + expect(updatedSense.glosses[0].def).toEqual(newGlossText); + }); + }); + + describe("definitionsEnabled & grammaticalInfoEnabled", () => { + const definitionsTitle = "reviewEntries.columns.definitions"; + const partOfSpeechTitle = "reviewEntries.columns.partOfSpeech"; + + test("show definitions when definitionsEnabled is true", async () => { + await renderEditSenseDialog(true, false); + expect( + renderer.root.findAllByProps({ + title: definitionsTitle, + }) + ).toHaveLength(1); + expect( + renderer.root.findAllByProps({ + title: partOfSpeechTitle, + }) + ).toHaveLength(0); + }); + + test("show part of speech when grammaticalInfoEnabled is true", async () => { + await renderEditSenseDialog(false, true); + expect( + renderer.root.findAllByProps({ + title: definitionsTitle, + }) + ).toHaveLength(0); + expect( + renderer.root.findAllByProps({ + title: partOfSpeechTitle, + }) + ).toHaveLength(1); + }); + }); +}); diff --git a/src/goals/ReviewEntries/ReviewEntriesTable/Cells/EditCell/tests/utilities.test.ts b/src/goals/ReviewEntries/ReviewEntriesTable/Cells/EditCell/tests/utilities.test.ts new file mode 100644 index 0000000000..0b1c83f8fa --- /dev/null +++ b/src/goals/ReviewEntries/ReviewEntriesTable/Cells/EditCell/tests/utilities.test.ts @@ -0,0 +1,280 @@ +import { GramCatGroup, GrammaticalInfo, Sense, Status, Word } from "api/models"; +import { + areDefinitionsSame, + areDomainsSame, + areGlossesSame, + cleanSense, + cleanWord, + isSenseChanged, +} from "goals/ReviewEntries/ReviewEntriesTable/Cells/EditCell/utilities"; +import { newSemanticDomain } from "types/semanticDomain"; +import { + newDefinition, + newFlag, + newGloss, + newNote, + newSense, + newWord, +} from "types/word"; + +describe("areDefinitionsSame", () => { + it("trims whitespace", () => { + const helloA = newDefinition(" \tHello", "en"); + const helloB = newDefinition("Hello", "en"); + const worldA = newDefinition("World", "qaa"); + const worldB = newDefinition(" World \n", "qaa"); + expect(areDefinitionsSame([helloA, worldA], [helloB, worldB])).toBeTruthy(); + }); + + it("ignores order and empty text", () => { + const hello = newDefinition("Hello", "en"); + const world = newDefinition("World", "qaa"); + const space = newDefinition(" ", "sp"); + const tab = newDefinition("\t\t", "ta"); + const newline = newDefinition("\n\n\n\n", "ne"); + expect( + areDefinitionsSame([hello, space, world], [tab, newline, world, hello]) + ).toBeTruthy(); + }); + + it("is case sensitive", () => { + const helloA = newDefinition("Hello", "en"); + const helloB = newDefinition("Hello", "En"); + const worldA = newDefinition("World", "qaa"); + const worldB = newDefinition("world", "qaa"); + expect(areDefinitionsSame([helloA, worldA], [helloB, worldA])).toBeFalsy(); + expect(areDefinitionsSame([helloA, worldA], [helloA, worldB])).toBeFalsy(); + }); +}); + +describe("areGlossesSame", () => { + it("trims whitespace", () => { + const helloA = newGloss(" \tHello", "en"); + const helloB = newGloss("Hello", "en"); + const worldA = newGloss("World", "qaa"); + const worldB = newGloss(" World \n", "qaa"); + expect(areGlossesSame([helloA, worldA], [helloB, worldB])).toBeTruthy(); + }); + + it("ignores order and empty text", () => { + const hello = newGloss("Hello", "en"); + const world = newGloss("World", "qaa"); + const space = newGloss(" ", "sp"); + const tab = newGloss("\t\t", "ta"); + const newline = newGloss("\n\n\n\n", "ne"); + expect( + areGlossesSame([hello, space, world], [tab, newline, world, hello]) + ).toBeTruthy(); + }); + + it("is case sensitive", () => { + const helloA = newGloss("Hello", "en"); + const helloB = newGloss("Hello", "En"); + expect(areGlossesSame([helloA], [helloB])).toBeFalsy(); + + const worldA = newGloss("World", "qaa"); + const worldB = newGloss("world", "qaa"); + expect(areGlossesSame([worldA], [worldB])).toBeFalsy(); + }); +}); + +describe("areDomainsSame", () => { + it("ignores order and multiplicity", () => { + const one = newSemanticDomain("1"); + const oneTwo = newSemanticDomain("1.2"); + const oneTwoThree = newSemanticDomain("1.2.3"); + expect( + areDomainsSame( + [one, oneTwo, oneTwo, oneTwoThree, oneTwoThree, oneTwoThree], + [oneTwoThree, oneTwo, one] + ) + ).toBeTruthy(); + }); + + it("ignores name and lang", () => { + const en = newSemanticDomain("1", "", "en"); + const es = newSemanticDomain("1", "", "es"); + const one = newSemanticDomain("1", "one"); + const uno = newSemanticDomain("1", "uno"); + expect(areDomainsSame([en], [es])).toBeTruthy(); + expect(areDomainsSame([one], [uno])).toBeTruthy(); + }); + + it("matches id strings exactly", () => { + const oneTwo = newSemanticDomain("12"); + const oneDotTwo = newSemanticDomain("1.2"); + const oneDotTwoSpace = newSemanticDomain("1.2 "); + expect(areDomainsSame([oneTwo], [oneDotTwo])).toBeFalsy(); + expect(areDomainsSame([oneDotTwo], [oneDotTwoSpace])).toBeFalsy(); + }); +}); + +describe("isSenseChanged", () => { + it("checks guid", () => { + const senseA = newSense("gloss"); + const senseB: Sense = { ...senseA, guid: "new-guid" }; + expect(isSenseChanged(senseA, senseB)).toBeTruthy(); + }); + + it("checks accessibility", () => { + const senseA = newSense("gloss"); + const senseB: Sense = { ...senseA, accessibility: Status.Duplicate }; + expect(isSenseChanged(senseA, senseB)).toBeTruthy(); + }); + + it("checks grammaticalInfo", () => { + const gi: GrammaticalInfo = { + catGroup: GramCatGroup.Adverb, + grammaticalCategory: "a", + }; + const senseA: Sense = { ...newSense("gloss"), grammaticalInfo: gi }; + const senseB: Sense = { ...senseA }; + senseB.grammaticalInfo = { ...gi, catGroup: GramCatGroup.Verb }; + expect(isSenseChanged(senseA, senseB)).toBeTruthy(); + senseB.grammaticalInfo = { ...gi, grammaticalCategory: "b" }; + expect(isSenseChanged(senseA, senseB)).toBeTruthy(); + }); + + it("checks definitions", () => { + const hello = newDefinition("Hello"); + const world = newDefinition("World"); + const ds = [hello, world]; + const senseA: Sense = { ...newSense(), definitions: ds }; + const senseB: Sense = { ...senseA, definitions: [hello] }; + expect(isSenseChanged(senseA, senseB)).toBeTruthy(); + senseB.definitions = [world, newDefinition(), hello]; + expect(isSenseChanged(senseA, senseB)).toBeFalsy(); + }); + + it("checks glosses", () => { + const hello = newGloss("Hello"); + const world = newGloss("World"); + const gs = [hello, world]; + const senseA: Sense = { ...newSense(), glosses: gs }; + const senseB: Sense = { ...senseA, glosses: [hello] }; + expect(isSenseChanged(senseA, senseB)).toBeTruthy(); + senseB.glosses = [world, newGloss(), hello]; + expect(isSenseChanged(senseA, senseB)).toBeFalsy(); + }); + + it("checks semantic domains", () => { + const hello = newSemanticDomain("3.5.1.4.3", "Greet"); + const world = newSemanticDomain("1.2", "World"); + const sd = [hello, world]; + const senseA: Sense = { ...newSense(), semanticDomains: sd }; + const senseB: Sense = { ...senseA, semanticDomains: [hello] }; + expect(isSenseChanged(senseA, senseB)).toBeTruthy(); + senseB.semanticDomains = [hello, hello, world, world]; + expect(isSenseChanged(senseA, senseB)).toBeFalsy(); + }); +}); + +describe("cleanSense", () => { + it("returns undefined for deleted sense", () => { + const sense = newSense("gloss"); + sense.accessibility = Status.Deleted; + expect(cleanSense(sense)).toBeUndefined(); + }); + + it("removes empty definitions", () => { + const sense = newSense("gloss"); + const hello = newDefinition("hello"); + const world = newDefinition("world"); + sense.definitions = [hello, newDefinition(), world]; + const cleaned = cleanSense(sense) as Sense; + expect(cleaned.definitions).toEqual([hello, world]); + }); + + it("removes empty glosses", () => { + const sense = newSense(); + const hello = newGloss("hello"); + const world = newGloss("world"); + sense.glosses = [hello, newGloss(), world]; + const cleaned = cleanSense(sense) as Sense; + expect(cleaned.glosses).toEqual([hello, world]); + }); + + it("removes domains with duplicate ids", () => { + const sense = newSense("gloss"); + const hello = newSemanticDomain("3.5.1.4.3", "Greet"); + const world = newSemanticDomain("1.2", "World"); + sense.semanticDomains = [hello, hello, world, world]; + const doms = (cleanSense(sense) as Sense).semanticDomains; + expect(doms).toHaveLength(2); + expect(doms).toContain(hello); + expect(doms).toContain(world); + }); + + it("returns undefined for empty sense (unless protected and exempted)", () => { + const sense = newSense(); + sense.guid = "guid-does-not-matter"; + sense.accessibility = Status.Protected; + expect(cleanSense(sense)).toBeUndefined(); + expect(typeof cleanSense(sense, true)).toEqual("object"); + }); + + it("returns error string for non-empty sense without gloss (unless protected and exempted)", () => { + const sense = newSense(); + sense.grammaticalInfo.catGroup = GramCatGroup.Noun; + sense.accessibility = Status.Protected; + expect(typeof cleanSense(sense)).toEqual("string"); + expect(typeof cleanSense(sense, true)).toEqual("object"); + }); +}); + +describe("cleanWord", () => { + it("trims vernacular", () => { + const vern = "Hello, World!"; + const word = newWord(` \n${vern} \t `); + word.senses.push(newSense("gloss")); + expect((cleanWord(word) as Word).vernacular).toEqual(vern); + }); + + it("returns error string for empty vernacular", () => { + const word = newWord(" \n \t "); + word.senses.push(newSense("gloss")); + expect(typeof cleanWord(word)).toEqual("string"); + }); + + it("returns error string for no senses (unless protected and exempted)", () => { + const word = newWord("vern"); + const sense = newSense(" \n"); + sense.accessibility = Status.Protected; + word.senses.push(sense); + expect(typeof cleanWord(word)).toEqual("string"); + expect(typeof cleanWord(word, true)).toEqual("object"); + }); + + it("cleans up note", () => { + const word = newWord("vern"); + word.senses.push(newSense("gloss")); + const helloWorld = "Hello, World!"; + const lang = "en"; + + // Trims whitespace + word.note = newNote(` ${helloWorld}`, lang); + let cleaned = cleanWord(word) as Word; + expect(cleaned.note).toEqual(newNote(helloWorld, lang)); + + // Clears lang if note text empty + word.note.text = "\t "; + cleaned = cleanWord(word) as Word; + expect(cleaned.note).toEqual(newNote()); + }); + + it("cleans up flag", () => { + const word = newWord("vern"); + word.senses.push(newSense("gloss")); + const helloWorld = "Hello, World!"; + + // Trims whitespace + word.flag = newFlag(`${helloWorld}\n`); + let cleaned = cleanWord(word) as Word; + expect(cleaned.flag).toEqual(newFlag(helloWorld)); + + // Clears text if active is false + word.flag.active = false; + cleaned = cleanWord(word) as Word; + expect(cleaned.flag).toEqual(newFlag()); + }); +}); diff --git a/src/goals/ReviewEntries/ReviewEntriesTable/Cells/EditCell/utilities.ts b/src/goals/ReviewEntries/ReviewEntriesTable/Cells/EditCell/utilities.ts new file mode 100644 index 0000000000..d38712d502 --- /dev/null +++ b/src/goals/ReviewEntries/ReviewEntriesTable/Cells/EditCell/utilities.ts @@ -0,0 +1,161 @@ +import { + type Definition, + type Gloss, + GramCatGroup, + type SemanticDomain, + type Sense, + Status, + type Word, +} from "api/models"; +import { newFlag, newNote } from "types/word"; + +/** Trim whitespace off all definition texts, then remove those with empty text. */ +function trimDefinitions(definitions: Definition[]): Definition[] { + return definitions + .map((d) => ({ ...d, text: d.text.trim() })) + .filter((d) => d.text.length); +} + +/** Trim whitespace off all gloss defs, then remove those with empty def. */ +function trimGlosses(glosses: Gloss[]): Gloss[] { + return glosses + .map((g) => ({ ...g, def: g.def.trim() })) + .filter((g) => g.def.length); +} + +/** Check if two definition arrays have the same content. */ +export function areDefinitionsSame(a: Definition[], b: Definition[]): boolean { + a = trimDefinitions(a); + b = trimDefinitions(b); + return ( + a.length === b.length && + a.every((ad) => + b.some((bd) => ad.language === bd.language && ad.text === bd.text) + ) + ); +} + +/** Check if two gloss arrays have the same content. */ +export function areGlossesSame(a: Gloss[], b: Gloss[]): boolean { + a = trimGlosses(a); + b = trimGlosses(b); + return ( + a.length === b.length && + a.every((ag) => + b.some((bg) => ag.language === bg.language && ag.def === bg.def) + ) + ); +} + +/** Check if two semantic domain arrays have the same content. */ +export function areDomainsSame( + a: SemanticDomain[], + b: SemanticDomain[] +): boolean { + return ( + a.every((ad) => b.some((bd) => ad.id === bd.id)) && + b.every((bd) => a.some((ad) => ad.id === bd.id)) + ); +} + +/** Check whether a sense is substantively different. */ +export function isSenseChanged(oldSense: Sense, newSense: Sense): boolean { + return ( + oldSense.guid !== newSense.guid || + oldSense.accessibility !== newSense.accessibility || + oldSense.grammaticalInfo.catGroup !== newSense.grammaticalInfo.catGroup || + oldSense.grammaticalInfo.grammaticalCategory !== + newSense.grammaticalInfo.grammaticalCategory || + !areDefinitionsSame(oldSense.definitions, newSense.definitions) || + !areGlossesSame(oldSense.glosses, newSense.glosses) || + !areDomainsSame(oldSense.semanticDomains, newSense.semanticDomains) + ); +} + +/** Return a cleaned sense ready to be saved: + * - If a sense is marked as deleted or is utterly blank, return undefined + * - If a sense lacks gloss, return error string + * + * (If `exemptProtected = true`, protected senses are allowed to be without gloss.) */ +export function cleanSense( + newSense: Sense, + exemptProtected = false +): Sense | string | undefined { + // Ignore deleted senses. + if (newSense.accessibility === Status.Deleted) { + return; + } + + // Remove empty definitions, empty glosses, and duplicate domains. + newSense.definitions = trimDefinitions(newSense.definitions); + newSense.glosses = trimGlosses(newSense.glosses); + const domainIds = [...new Set(newSense.semanticDomains.map((dom) => dom.id))]; + domainIds.sort(); + newSense.semanticDomains = domainIds.map( + (id) => newSense.semanticDomains.find((dom) => dom.id === id)! + ); + + // Bypass the following checks on protected senses. + if (exemptProtected && newSense.accessibility === Status.Protected) { + return newSense; + } + + // Skip empty senses. + if ( + newSense.definitions.length === 0 && + newSense.glosses.length === 0 && + newSense.grammaticalInfo.catGroup === GramCatGroup.Unspecified && + newSense.semanticDomains.length === 0 + ) { + return; + } + + // Don't allow senses without a gloss. + if (!newSense.glosses.length) { + return "reviewEntries.error.gloss"; + } + + return newSense; +} + +/** Clean a word. Return error string id if: + * - the vernacular field is empty + * - all senses are empty/deleted + * + * (If `exemptProtected = true`, protected senses are allowed to be empty.) */ +export function cleanWord(word: Word, exemptProtected = false): Word | string { + // Make sure vernacular isn't empty. + const vernacular = word.vernacular.trim(); + if (!vernacular.length) { + return "reviewEntries.error.vernacular"; + } + + // Clean senses and check for problems. + const senses: Sense[] = []; + for (const sense of word.senses) { + const cleanedSense = cleanSense(sense, exemptProtected); + // Skip deleted or empty senses. + if (!cleanedSense) { + continue; + } + // Don't allow senses without a gloss. + if (typeof cleanedSense === "string") { + return cleanedSense; + } + senses.push(sense); + } + if (!senses.length) { + return "reviewEntries.error.senses"; + } + + // Clear note language if text empty. + const noteText = word.note.text.trim(); + const note = newNote(noteText, noteText ? word.note.language : ""); + + // Clear flag text if flag not active. + const flagActive = word.flag.active; + const flag = newFlag(flagActive ? word.flag.text.trim() : undefined); + flag.active = flagActive; + + return { ...word, flag, note, senses, vernacular }; +} diff --git a/src/goals/ReviewEntries/ReviewEntriesTable/Cells/FlagCell.tsx b/src/goals/ReviewEntries/ReviewEntriesTable/Cells/FlagCell.tsx new file mode 100644 index 0000000000..65873058ca --- /dev/null +++ b/src/goals/ReviewEntries/ReviewEntriesTable/Cells/FlagCell.tsx @@ -0,0 +1,10 @@ +import { type ReactElement } from "react"; + +import FlagButton from "components/Buttons/FlagButton"; +import { type CellProps } from "goals/ReviewEntries/ReviewEntriesTable/Cells/CellTypes"; + +export default function FlagCell(props: CellProps): ReactElement { + return ( + + ); +} diff --git a/src/goals/ReviewEntries/ReviewEntriesTable/Cells/GlossesCell.tsx b/src/goals/ReviewEntries/ReviewEntriesTable/Cells/GlossesCell.tsx new file mode 100644 index 0000000000..0856393633 --- /dev/null +++ b/src/goals/ReviewEntries/ReviewEntriesTable/Cells/GlossesCell.tsx @@ -0,0 +1,47 @@ +import { type ReactElement } from "react"; + +import { type CellProps } from "goals/ReviewEntries/ReviewEntriesTable/Cells/CellTypes"; +import { TypographyWithFont } from "utilities/fontComponents"; + +export default function GlossesCell(props: CellProps): ReactElement { + const MAX_LENGTH = 50; + const typographies: ReactElement[] = []; + + props.word.senses.forEach((sense) => { + let text = sense.glosses + .map((g) => g.def.trim()) + .filter((t) => t) + .join("; "); + if (!text) { + return; + } + + if (text.length > MAX_LENGTH) { + text = `${text.substring(0, MAX_LENGTH)}[...]`; + } + + // Add a sense separator if this isn't the first. + if (typographies.length) { + typographies.push( + + {" | "} + + ); + } + + // Use the analysis language of the first non-empty gloss, if any. + const lang = sense.glosses.find((g) => g.def.trim())?.language; + typographies.push( + + {text} + + ); + }); + + return
{typographies}
; +} diff --git a/src/goals/ReviewEntries/ReviewEntriesTable/Cells/NoteCell.tsx b/src/goals/ReviewEntries/ReviewEntriesTable/Cells/NoteCell.tsx new file mode 100644 index 0000000000..3c03378e0f --- /dev/null +++ b/src/goals/ReviewEntries/ReviewEntriesTable/Cells/NoteCell.tsx @@ -0,0 +1,21 @@ +import { type ReactElement } from "react"; + +import { type CellProps } from "goals/ReviewEntries/ReviewEntriesTable/Cells/CellTypes"; +import { TypographyWithFont } from "utilities/fontComponents"; + +export default function NoteCell(props: CellProps): ReactElement { + const MAX_LENGTH = 100; + let text = props.word.note.text; + if (text.length > MAX_LENGTH) { + text = `${text.substring(0, MAX_LENGTH)}[...]`; + } + return ( + + {text} + + ); +} diff --git a/src/goals/ReviewEntries/ReviewEntriesTable/Cells/PartOfSpeechCell.tsx b/src/goals/ReviewEntries/ReviewEntriesTable/Cells/PartOfSpeechCell.tsx new file mode 100644 index 0000000000..df0840d02a --- /dev/null +++ b/src/goals/ReviewEntries/ReviewEntriesTable/Cells/PartOfSpeechCell.tsx @@ -0,0 +1,29 @@ +import { Grid } from "@mui/material"; +import { type ReactElement } from "react"; + +import { type GrammaticalInfo, type Sense } from "api/models"; +import { PartOfSpeechButton } from "components/Buttons"; +import { type CellProps } from "goals/ReviewEntries/ReviewEntriesTable/Cells/CellTypes"; + +/** Collect all distinct sense.grammaticalInfo values. */ +function gatherGramInfo(senses: Sense[]): GrammaticalInfo[] { + return senses.reduce((a, sense) => { + const cg = sense.grammaticalInfo.catGroup; + const gc = sense.grammaticalInfo.grammaticalCategory; + return a.some((gi) => gi.catGroup === cg && gi.grammaticalCategory === gc) + ? a + : [...a, sense.grammaticalInfo]; + }, []); +} + +export default function PartOfSpeechCell(props: CellProps): ReactElement { + return ( + + {gatherGramInfo(props.word.senses).map((gi) => ( + + + + ))} + + ); +} diff --git a/src/goals/ReviewEntries/ReviewEntriesTable/Cells/PronunciationsCell.tsx b/src/goals/ReviewEntries/ReviewEntriesTable/Cells/PronunciationsCell.tsx new file mode 100644 index 0000000000..67c9e74a38 --- /dev/null +++ b/src/goals/ReviewEntries/ReviewEntriesTable/Cells/PronunciationsCell.tsx @@ -0,0 +1,49 @@ +import { type ReactElement } from "react"; + +import { type Pronunciation } from "api/models"; +import { deleteAudio, getWord, updateWord, uploadAudio } from "backend"; +import PronunciationsBackend from "components/Pronunciations/PronunciationsBackend"; +import { type CellProps } from "goals/ReviewEntries/ReviewEntriesTable/Cells/CellTypes"; +import { type FileWithSpeakerId, updateSpeakerInAudio } from "types/word"; + +export default function PronunciationsCell(props: CellProps): ReactElement { + const wordId = props.word.id; + + const deleteAudioHandle = async (fileName: string): Promise => { + const newId = await deleteAudio(wordId, fileName); + if (props.replace) { + await props.replace(wordId, newId); + } + }; + + const replaceAudioHandle = async (pro: Pronunciation): Promise => { + const word = await getWord(wordId); + const audio = updateSpeakerInAudio(word.audio, pro); + if (audio) { + const newId = (await updateWord({ ...word, audio })).id; + if (props.replace) { + await props.replace(wordId, newId); + } + } + }; + + const uploadAudioHandle = async (file: FileWithSpeakerId): Promise => { + const newId = await uploadAudio(wordId, file); + if (props.replace) { + await props.replace(wordId, newId); + } + }; + + return ( + // The div container allows the audio icons to wrap in the table cell. +
+ +
+ ); +} diff --git a/src/goals/ReviewEntries/ReviewEntriesTable/Cells/VernacularCell.tsx b/src/goals/ReviewEntries/ReviewEntriesTable/Cells/VernacularCell.tsx new file mode 100644 index 0000000000..4ebe55d949 --- /dev/null +++ b/src/goals/ReviewEntries/ReviewEntriesTable/Cells/VernacularCell.tsx @@ -0,0 +1,12 @@ +import { type ReactElement } from "react"; + +import { type CellProps } from "goals/ReviewEntries/ReviewEntriesTable/Cells/CellTypes"; +import { TypographyWithFont } from "utilities/fontComponents"; + +export default function VernacularCell(props: CellProps): ReactElement { + return ( + + {props.word.vernacular} + + ); +} diff --git a/src/goals/ReviewEntries/ReviewEntriesTable/Cells/index.ts b/src/goals/ReviewEntries/ReviewEntriesTable/Cells/index.ts new file mode 100644 index 0000000000..a9a86cb587 --- /dev/null +++ b/src/goals/ReviewEntries/ReviewEntriesTable/Cells/index.ts @@ -0,0 +1,10 @@ +export { default as Definitions } from "goals/ReviewEntries/ReviewEntriesTable/Cells/DefinitionsCell"; +export { default as Delete } from "goals/ReviewEntries/ReviewEntriesTable/Cells/DeleteCell"; +export { default as Domains } from "goals/ReviewEntries/ReviewEntriesTable/Cells/DomainsCell"; +export { default as Edit } from "goals/ReviewEntries/ReviewEntriesTable/Cells/EditCell"; +export { default as Flag } from "goals/ReviewEntries/ReviewEntriesTable/Cells/FlagCell"; +export { default as Glosses } from "goals/ReviewEntries/ReviewEntriesTable/Cells/GlossesCell"; +export { default as Note } from "goals/ReviewEntries/ReviewEntriesTable/Cells/NoteCell"; +export { default as PartOfSpeech } from "goals/ReviewEntries/ReviewEntriesTable/Cells/PartOfSpeechCell"; +export { default as Pronunciations } from "goals/ReviewEntries/ReviewEntriesTable/Cells/PronunciationsCell"; +export { default as Vernacular } from "goals/ReviewEntries/ReviewEntriesTable/Cells/VernacularCell"; diff --git a/src/goals/ReviewEntries/ReviewEntriesTable/filterFn.ts b/src/goals/ReviewEntries/ReviewEntriesTable/filterFn.ts new file mode 100644 index 0000000000..42f7f7ae26 --- /dev/null +++ b/src/goals/ReviewEntries/ReviewEntriesTable/filterFn.ts @@ -0,0 +1,102 @@ +import { type MRT_FilterFn } from "material-react-table"; + +import { + type Gloss, + type Definition, + type Word, + type SemanticDomain, + type Pronunciation, + type Flag, +} from "api/models"; +import { type Hash } from "types/hash"; + +/* Custom `filterFn` functions for `MaterialReactTable` columns. + * (Can always assume that `filterValue` will be truthy.) */ + +/** Requires the accessor return type to be `Dictionary[]`. */ +export const filterFnDefinitions: MRT_FilterFn = ( + row, + id, + filterValue: string +) => { + const definitions = row.getValue(id); + const filter = filterValue.trim().toLowerCase(); + return definitions.some((d) => d.text.toLowerCase().includes(filter)); +}; + +/** Requires the accessor return type to be `Gloss[]`. */ +export const filterFnGlosses: MRT_FilterFn = ( + row, + id, + filterValue: string +) => { + const glosses = row.getValue(id); + const filter = filterValue.trim().toLowerCase(); + return glosses.some((g) => g.def.toLowerCase().includes(filter)); +}; + +/** Requires the accessor return type to be `SemanticDomain[]`. */ +export const filterFnDomains: MRT_FilterFn = ( + row, + id, + filterValue: string +) => { + /* Numeric id filter expected (periods between digits are optional). + * Test for exact id match if no final period. + * Test for initial substring match if final period. */ + const doms = row.getValue(id); + if (!doms.length) { + // A filter has been typed and there are no domains + return false; + } + if (!filterValue.trim()) { + // The typed filter is whitespace + return true; + } + let filter = filterValue.replace(/[^0-9\.]/g, ""); + if (!filter) { + // The typed filter has no digits or periods + return false; + } + // Check if the filter ends with a period rather than a digit + const finalPeriod = filter.slice(-1) === "."; + // Remove all periods and put periods between digits + filter = filter.replace(/\./g, "").split("").join("."); + if (finalPeriod) { + // There is a period after the final digit (or no digits) + return doms.some((d) => d.id.startsWith(filter)); + } else { + // There is not a period after the final digit + return doms.some((d) => d.id === filter); + } +}; + +/** Takes a speaker dictionary with ids as keys and names as values. + * Returns a function that requires the accessor return type to be `Pronunciation[]`. */ +export const filterFnPronunciations = + (speakers: Hash): MRT_FilterFn => + (row, id, filterValue: string) => { + /* Match either number of pronunciations or a speaker name. + * (Whitespace will match all audio, even without a speaker.) */ + const audio = row.getValue(id); + const filter = filterValue.trim().toLocaleLowerCase(); + return ( + audio.length === parseInt(filter) || + audio.some((p) => !filter || speakers[p.speakerId]?.includes(filter)) + ); + }; + +/** Requires the accessor return type to be `Flag`. */ +export const filterFnFlag: MRT_FilterFn = ( + row, + id, + filterValue: string +) => { + const flag = row.getValue(id); + if (!flag.active) { + // A filter has been typed and the word isn't flagged + return false; + } + const filter = filterValue.trim().toLowerCase(); + return flag.text.toLowerCase().includes(filter); +}; diff --git a/src/goals/ReviewEntries/ReviewEntriesTable/icons.tsx b/src/goals/ReviewEntries/ReviewEntriesTable/icons.tsx deleted file mode 100644 index 4b03847b96..0000000000 --- a/src/goals/ReviewEntries/ReviewEntriesTable/icons.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import AddBox from "@mui/icons-material/AddBox"; -import ArrowUpward from "@mui/icons-material/ArrowUpward"; -import Check from "@mui/icons-material/Check"; -import ChevronLeft from "@mui/icons-material/ChevronLeft"; -import ChevronRight from "@mui/icons-material/ChevronRight"; -import Clear from "@mui/icons-material/Clear"; -import DeleteOutline from "@mui/icons-material/DeleteOutline"; -import Edit from "@mui/icons-material/Edit"; -import FilterList from "@mui/icons-material/FilterList"; -import FirstPage from "@mui/icons-material/FirstPage"; -import LastPage from "@mui/icons-material/LastPage"; -import Remove from "@mui/icons-material/Remove"; -import SaveAlt from "@mui/icons-material/SaveAlt"; -import Search from "@mui/icons-material/Search"; -import ViewColumn from "@mui/icons-material/ViewColumn"; -import { forwardRef, Ref } from "react"; - -/* eslint-disable react/display-name */ -const tableIcons = { - Add: forwardRef((props: any, ref: Ref) => ( - - )), - Check: forwardRef((props: any, ref: Ref) => ( - - )), - Clear: forwardRef((props: any, ref: Ref) => ( - - )), - Delete: forwardRef((props: any, ref: Ref) => ( - - )), - DetailPanel: forwardRef((props: any, ref: Ref) => ( - - )), - Edit: forwardRef((props: any, ref: Ref) => ( - - )), - Export: forwardRef((props: any, ref: Ref) => ( - - )), - Filter: forwardRef((props: any, ref: Ref) => ( - - )), - FirstPage: forwardRef((props: any, ref: Ref) => ( - - )), - LastPage: forwardRef((props: any, ref: Ref) => ( - - )), - NextPage: forwardRef((props: any, ref: Ref) => ( - - )), - PreviousPage: forwardRef((props: any, ref: Ref) => ( - - )), - ResetSearch: forwardRef((props: any, ref: Ref) => ( - - )), - Search: forwardRef((props: any, ref: Ref) => ( - - )), - SortArrow: forwardRef((props: any, ref: Ref) => ( - - )), - ThirdStateCheck: forwardRef((props: any, ref: Ref) => ( - - )), - ViewColumn: forwardRef((props: any, ref: Ref) => ( - - )), -}; - -export default tableIcons; diff --git a/src/goals/ReviewEntries/ReviewEntriesTable/index.tsx b/src/goals/ReviewEntries/ReviewEntriesTable/index.tsx index c9bed9189d..9714f89043 100644 --- a/src/goals/ReviewEntries/ReviewEntriesTable/index.tsx +++ b/src/goals/ReviewEntries/ReviewEntriesTable/index.tsx @@ -1,186 +1,353 @@ -import MaterialTable, { OrderByCollection } from "@material-table/core"; +import { + FiberManualRecord, + Flag as FlagIcon, + PlayArrow, +} from "@mui/icons-material"; import { Typography } from "@mui/material"; -import { createSelector } from "@reduxjs/toolkit"; -import { enqueueSnackbar } from "notistack"; -import React, { ReactElement, createRef, useEffect, useState } from "react"; +import { + MaterialReactTable, + type MRT_Localization, + type MRT_PaginationState, + type MRT_Row, + type MRT_RowVirtualizer, + createMRTColumnHelper, + useMaterialReactTable, +} from "material-react-table"; +import { type ReactElement, useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; -import { useSelector } from "react-redux"; -import columns from "goals/ReviewEntries/ReviewEntriesTable/CellColumns"; -import tableIcons from "goals/ReviewEntries/ReviewEntriesTable/icons"; -import { - ColumnId, - ReviewEntriesWord, -} from "goals/ReviewEntries/ReviewEntriesTypes"; -import { StoreState } from "types"; - -interface ReviewEntriesTableProps { - onRowUpdate: ( - newData: ReviewEntriesWord, - oldData?: ReviewEntriesWord - ) => Promise; - onSort: (columnId?: ColumnId) => void; -} +import { GramCatGroup, type GrammaticalInfo, type Word } from "api/models"; +import { getAllSpeakers, getFrontierWords, getWord } from "backend"; +import { topBarHeight } from "components/LandingPage/TopBar"; +import * as Cell from "goals/ReviewEntries/ReviewEntriesTable/Cells"; +import * as ff from "goals/ReviewEntries/ReviewEntriesTable/filterFn"; +import * as sf from "goals/ReviewEntries/ReviewEntriesTable/sortingFn"; +import { type StoreState } from "types"; +import { type Hash } from "types/hash"; +import { useAppSelector } from "types/hooks"; -interface PageState { - pageSize: number; - pageSizeOptions: number[]; +/** Import `material-react-table` localization for given `lang`. + * (See https://www.material-react-table.com/docs/guides/localization.) */ +async function getLocalization( + lang?: string +): Promise { + switch (lang) { + case "ar": + return (await import("material-react-table/locales/ar")) + .MRT_Localization_AR; + case "es": + return (await import("material-react-table/locales/es")) + .MRT_Localization_ES; + case "fr": + return (await import("material-react-table/locales/fr")) + .MRT_Localization_FR; + case "pt": + return (await import("material-react-table/locales/pt")) + .MRT_Localization_PT; + case "zh": + return (await import("material-react-table/locales/zh-Hans")) + .MRT_Localization_ZH_HANS; + default: + return; + } } -// Remove the duplicates from an array -function removeDuplicates(array: T[]): T[] { - return [...new Set(array)]; -} +// Constants for custom column/header sizing. +const BaselineColumnSize = 180; +const HeaderActionsWidth = 60; // Assumes initial state density "compact" +const IconColumnSize = 50; // Baseline for a column with a single icon as row content +const IconHeaderHeight = 22; // Height for a small icon as Header +const IconHeaderPaddingTop = "2px"; // Vertical offset for a small icon as Header +const IconHeaderWidth = 20; // Width for a small icon as Header +const SensesHeaderWidth = 15; // Width for # as Header -function getPageSizeOptions(max: number): number[] { - return removeDuplicates(ROWS_PER_PAGE.map((num) => Math.min(max, num))); +// Constants for pagination state. +const rowsPerPage = [10, 100]; +const initPaginationState: MRT_PaginationState = { + pageIndex: 0, + pageSize: rowsPerPage[0], +}; +interface RowsPerPageOption { + label: string; + value: number; } -function getPageState(wordCount: number): PageState { - const pageSizeOptions = getPageSizeOptions(wordCount); - return { pageSize: pageSizeOptions[0], pageSizeOptions }; -} - -// Constants -const ROWS_PER_PAGE = [10, 50, 200]; -const tableRef: React.RefObject = createRef(); - -export default function ReviewEntriesTable( - props: ReviewEntriesTableProps -): ReactElement { - // https://redux.js.org/usage/deriving-data-selectors#optimizing-selectors-with-memoization - const wordsSelector = createSelector( - [(state: StoreState) => state.reviewEntriesState.words], - (words) => words.map((w) => new ReviewEntriesWord(w)) - ); - const allWords = useSelector(wordsSelector); - const showDefinitions = useSelector( +/** Table for reviewing all entries, built with `material-react-table`. */ +export default function ReviewEntriesTable(props: { + disableVirtualization?: boolean; +}): ReactElement { + const showDefinitions = useAppSelector( (state: StoreState) => state.currentProjectState.project.definitionsEnabled ); - const showGrammaticalInfo = useSelector( + const showGrammaticalInfo = useAppSelector( (state: StoreState) => state.currentProjectState.project.grammaticalInfoEnabled ); - const { t } = useTranslation(); - const [maxRows, setMaxRows] = useState(allWords.length); - const [pageState, setPageState] = useState(getPageState(allWords.length)); - const [scrollToTop, setScrollToTop] = useState(false); - - const updateMaxRows = (): void => { - if (tableRef.current) { - const tableRows = tableRef.current.state.data.length; - if (tableRows !== maxRows) { - setMaxRows(tableRows); - } - } - }; + + const rowVirtualizerInstanceRef = useRef(null); + + const [data, setData] = useState([]); + const [enablePagination, setEnablePagination] = useState(false); + const [isLoading, setIsLoading] = useState(true); + const [localization, setLocalization] = useState< + MRT_Localization | undefined + >(); + const [pagination, setPagination] = useState(initPaginationState); + const [speakers, setSpeakers] = useState>({}); + + const { i18n, t } = useTranslation(); useEffect(() => { - setPageState((prevState) => { - const options = getPageSizeOptions(maxRows); - let i = 0; - while (i < options.length - 1 && options[i] < prevState.pageSize) { - i++; - } - return { pageSize: options[i], pageSizeOptions: options }; + getAllSpeakers().then((list) => + setSpeakers( + Object.fromEntries(list.map((s) => [s.id, s.name.toLocaleLowerCase()])) + ) + ); + getFrontierWords().then((frontier) => { + setData(frontier); + setEnablePagination(frontier.length > rowsPerPage[0]); + setIsLoading(false); }); - }, [maxRows]); + }, []); useEffect(() => { - // onRowsPerPageChange={() => window.scrollTo({ top: 0 })} doesn't work. - // This useEffect on an intermediate state triggers scrolling at the right time. - if (scrollToTop) { - window.scrollTo({ behavior: "smooth", top: 0 }); - setScrollToTop(false); - } - }, [scrollToTop]); + getLocalization(i18n.resolvedLanguage).then(setLocalization); + }, [i18n.resolvedLanguage]); - const activeColumns = columns.filter( - (c) => - (showDefinitions || c.id !== ColumnId.Definitions) && - (showGrammaticalInfo || c.id !== ColumnId.PartOfSpeech) - ); + /** Removes word with given `id` from the state. */ + const deleteWord = (id: string): void => { + setData((prev) => prev.filter((w) => w.id !== id)); + }; - const onOrderCollectionChange = (order: OrderByCollection[]): void => { - if (!order.length) { - props.onSort(undefined); - } else { - props.onSort(activeColumns[order[0].orderBy].id as ColumnId); + /** Replaces word (`.id === oldId`) in the state + * with word (`.id === newId`) fetched from the backend. */ + const replaceWord = async (oldId: string, newId: string): Promise => { + const newWord = await getWord(newId); + setData((prev) => prev.map((w) => (w.id === oldId ? newWord : w))); + }; + + /** Checks if there are any entries and, if so, scrolls to the top of the current page. */ + const scrollToTop = (): void => { + const virtualizer = rowVirtualizerInstanceRef.current; + if (virtualizer?.getTotalSize()) { + virtualizer.scrollToIndex(0); } }; - const materialTableLocalization = { - body: { - editRow: { - cancelTooltip: t("buttons.cancel"), - saveTooltip: t("buttons.save"), + const rowsPerPageOptions: RowsPerPageOption[] = rowsPerPage + .filter((value, i) => i === 0 || value < data.length) + .map((value) => ({ label: `${value}`, value })); + if (enablePagination) { + rowsPerPageOptions.push({ + label: t("reviewEntries.allEntriesPerPageOption"), + value: data.length, + }); + } + + const columnHelper = createMRTColumnHelper(); + + type CellProps = { row: MRT_Row }; + + const columns = [ + // Edit column + columnHelper.display({ + Cell: ({ row }: CellProps) => ( + + ), + Header: "", + header: t("reviewEntries.columns.edit"), + size: IconColumnSize, + visibleInShowHideMenu: false, + }), + + // Vernacular column + columnHelper.accessor("vernacular", { + Cell: ({ row }: CellProps) => , + enableColumnOrdering: false, + enableHiding: false, + header: t("reviewEntries.columns.vernacular"), + size: BaselineColumnSize - 40, + }), + + // Senses column + columnHelper.accessor((w) => w.senses.length, { + enableFilterMatchHighlighting: false, + filterFn: "equals", + Header: #, + header: t("reviewEntries.columns.sensesCount"), + id: "senses", + muiTableHeadCellProps: { + sx: { + "& .Mui-TableHeadCell-Content-Wrapper": { + minWidth: SensesHeaderWidth, + width: SensesHeaderWidth, + }, + }, }, - editTooltip: t("reviewEntries.materialTable.body.edit"), - emptyDataSourceMessage: t( - "reviewEntries.materialTable.body.emptyDataSourceMessage" + size: SensesHeaderWidth + HeaderActionsWidth, + }), + + // Definitions column + columnHelper.accessor((w) => w.senses.flatMap((s) => s.definitions), { + Cell: ({ row }: CellProps) => , + filterFn: ff.filterFnDefinitions, + header: t("reviewEntries.columns.definitions"), + id: "definitions", + size: BaselineColumnSize + 20, + sortingFn: sf.sortingFnDefinitions, + visibleInShowHideMenu: showDefinitions, + }), + + // Glosses column + columnHelper.accessor((w) => w.senses.flatMap((s) => s.glosses), { + Cell: ({ row }: CellProps) => , + filterFn: ff.filterFnGlosses, + header: t("reviewEntries.columns.glosses"), + id: "glosses", + sortingFn: sf.sortingFnGlosses, + }), + + // Part of Speech column + columnHelper.accessor((w) => w.senses.map((s) => s.grammaticalInfo), { + Cell: ({ row }: CellProps) => , + filterFn: (row, id, filterValue: GramCatGroup) => + row + .getValue(id) + .some((gi) => gi.catGroup === filterValue), + filterSelectOptions: Object.values(GramCatGroup).map((g) => ({ + text: t(`grammaticalCategory.group.${g}`), + value: g, + })), + filterVariant: "select", + header: t("reviewEntries.columns.partOfSpeech"), + id: "partOfSpeech", + sortingFn: sf.sortingFnPartOfSpeech, + visibleInShowHideMenu: showGrammaticalInfo, + }), + + // Domains column + columnHelper.accessor((w) => w.senses.flatMap((s) => s.semanticDomains), { + Cell: ({ row }: CellProps) => , + filterFn: ff.filterFnDomains, + header: t("reviewEntries.columns.domains"), + id: "domains", + sortingFn: sf.sortingFnDomains, + }), + + // Pronunciations column + columnHelper.accessor((w) => w.audio, { + Cell: ({ row }: CellProps) => ( + ), - filterRow: { - filterTooltip: t("reviewEntries.materialTable.body.filter"), + filterFn: ff.filterFnPronunciations(speakers), + Header: ( + <> + t.palette.error.main }} + /> + t.palette.success.main }} + /> + + ), + header: t("reviewEntries.columns.pronunciations"), + id: "pronunciations", + muiTableHeadCellProps: { + sx: { + "& .Mui-TableHeadCell-Content-Wrapper": { + height: IconHeaderHeight, + minWidth: 2 * IconHeaderWidth, + paddingTop: IconHeaderPaddingTop, + width: 2 * IconHeaderWidth, + }, + }, }, - }, - header: { - actions: t("reviewEntries.materialTable.body.edit"), - }, - pagination: { - labelDisplayedRows: t( - "reviewEntries.materialTable.pagination.labelDisplayedRows" + size: 3 * IconColumnSize, + }), + + // Note column + columnHelper.accessor((w) => w.note.text || undefined, { + Cell: ({ row }: CellProps) => , + header: t("reviewEntries.columns.note"), + id: "note", + size: BaselineColumnSize - 40, + }), + + // Flag column + columnHelper.accessor("flag", { + Cell: ({ row }: CellProps) => , + filterFn: ff.filterFnFlag, + Header: ( + t.palette.error.main }} + /> ), - labelRows: t("reviewEntries.materialTable.pagination.labelRows"), - labelRowsPerPage: t( - "reviewEntries.materialTable.pagination.labelRowsPerPage" + header: t("reviewEntries.columns.flag"), + muiTableHeadCellProps: { + sx: { + "& .Mui-TableHeadCell-Content-Wrapper": { + height: IconHeaderHeight, + minWidth: IconHeaderWidth, + paddingTop: IconHeaderPaddingTop, + width: IconHeaderWidth, + }, + }, + }, + size: IconHeaderWidth + HeaderActionsWidth, + sortingFn: sf.sortingFnFlag, + }), + + // Delete column + columnHelper.display({ + Cell: ({ row }: CellProps) => ( + ), - firstAriaLabel: t("reviewEntries.materialTable.pagination.first"), - firstTooltip: t("reviewEntries.materialTable.pagination.first"), - lastAriaLabel: t("reviewEntries.materialTable.pagination.last"), - lastTooltip: t("reviewEntries.materialTable.pagination.last"), - nextAriaLabel: t("reviewEntries.materialTable.pagination.next"), - nextTooltip: t("reviewEntries.materialTable.pagination.next"), - previousAriaLabel: t("reviewEntries.materialTable.pagination.previous"), - previousTooltip: t("reviewEntries.materialTable.pagination.previous"), + Header: "", + header: t("reviewEntries.columns.delete"), + size: IconColumnSize, + visibleInShowHideMenu: false, + }), + ]; + + const table = useMaterialReactTable({ + columns, + data, + columnFilterDisplayMode: "popover", + enableColumnActions: false, + enableColumnDragging: false, + enableColumnOrdering: true, + enableDensityToggle: false, + enableFullScreenToggle: false, + enableGlobalFilter: false, + enablePagination, + enableRowVirtualization: !props.disableVirtualization, + initialState: { + columnVisibility: { + definitions: showDefinitions, + partOfSpeech: showGrammaticalInfo, + }, + density: "compact", }, - toolbar: { - searchAriaLabel: t("reviewEntries.materialTable.toolbar.search"), - searchPlaceholder: t("reviewEntries.materialTable.toolbar.search"), - searchTooltip: t("reviewEntries.materialTable.toolbar.search"), + localization, + muiPaginationProps: { rowsPerPageOptions }, + // Override whiteSpace: "nowrap" from having density: "compact" + muiTableBodyCellProps: { sx: { whiteSpace: "normal" } }, + // Keep the table from going below the bottom of the page + muiTableContainerProps: { + sx: { maxHeight: `calc(100vh - ${enablePagination ? 180 : 130}px)` }, }, - }; + muiTablePaperProps: { sx: { height: `calc(100vh - ${topBarHeight}px)` } }, + onPaginationChange: (updater) => { + setPagination(updater); + scrollToTop(); + }, + rowVirtualizerInstanceRef, + sortDescFirst: false, + state: { isLoading, pagination }, + }); - return ( - - tableRef={tableRef} - icons={tableIcons} - title={ - - {t("reviewEntries.title")} - - } - columns={activeColumns} - data={allWords} - onFilterChange={updateMaxRows} - onOrderCollectionChange={onOrderCollectionChange} - onRowsPerPageChange={() => setScrollToTop(true)} - editable={{ - onRowUpdate: ( - newData: ReviewEntriesWord, - oldData?: ReviewEntriesWord - ) => - new Promise(async (resolve, reject) => { - await props - .onRowUpdate(newData, oldData) - .then(resolve) - .catch((reason) => { - enqueueSnackbar(t(reason)); - reject(reason); - }); - }), - }} - options={{ draggable: false, filtering: true, ...pageState }} - localization={materialTableLocalization} - /> - ); + return ; } diff --git a/src/goals/ReviewEntries/ReviewEntriesTable/sortingFn.ts b/src/goals/ReviewEntries/ReviewEntriesTable/sortingFn.ts new file mode 100644 index 0000000000..83a329b156 --- /dev/null +++ b/src/goals/ReviewEntries/ReviewEntriesTable/sortingFn.ts @@ -0,0 +1,92 @@ +import { type MRT_SortingFn } from "material-react-table"; + +import { + GramCatGroup, + type GrammaticalInfo, + type SemanticDomain, + type Sense, + type Word, +} from "api/models"; +import { gatherDomains } from "goals/ReviewEntries/ReviewEntriesTable/Cells/DomainsCell"; +import { compareFlags } from "utilities/wordUtilities"; + +/* Text-joining functions for definitions and glosses. */ + +function joinNonEmpties(texts: string[]): string { + return texts + .map((t) => t.trim()) + .filter((t) => t) + .join("; "); +} + +function definitionString(s: Sense[]): string { + return joinNonEmpties(s.flatMap((s) => s.definitions.map((d) => d.text))); +} + +function glossesString(s: Sense[]): string { + return joinNonEmpties(s.flatMap((s) => s.glosses.map((g) => g.def))); +} + +/* Comparison functions for grammatical info. */ + +function compareGramInfo(a: GrammaticalInfo, b: GrammaticalInfo): number { + return a.catGroup === b.catGroup + ? a.grammaticalCategory.localeCompare(b.grammaticalCategory) + : a.catGroup === GramCatGroup.Unspecified + ? 1 + : b.catGroup === GramCatGroup.Unspecified + ? -1 + : a.catGroup.localeCompare(b.catGroup); +} + +function compareSensesGramInfo(a: Sense[], b: Sense[]): number { + let compare = 0; + for (let i = 0; compare === 0 && i < a.length && i < b.length; i++) { + compare = compareGramInfo(a[i].grammaticalInfo, b[i].grammaticalInfo); + } + return compare || a.length - b.length; +} + +/* Comparison functions for semantic domains. */ + +function compareDomains(a: SemanticDomain[], b: SemanticDomain[]): number { + // Special case: no domains sorted to last + if (!a.length || !b.length) { + return b.length - a.length; + } + // Compare the domains + let compare = 0; + for (let i = 0; compare === 0 && i < a.length && i < b.length; i++) { + compare = a[i].id.localeCompare(b[i].id); + } + return compare || a.length - b.length; +} + +/* Custom `sortingFn` functions for `MaterialReactTable` columns. */ + +/** Concatenates all sense definition texts for each word, then compares strings. */ +export const sortingFnDefinitions: MRT_SortingFn = (a, b) => + definitionString(a.original.senses).localeCompare( + definitionString(b.original.senses) + ); + +/** Concatenates all sense gloss defs for each word, then compares strings. */ +export const sortingFnGlosses: MRT_SortingFn = (a, b) => + glossesString(a.original.senses).localeCompare( + glossesString(b.original.senses) + ); + +/** Compares grammatical info of `.senses[i]` of the words, starting at `i = 0`. */ +export const sortingFnPartOfSpeech: MRT_SortingFn = (a, b) => + compareSensesGramInfo(a.original.senses, b.original.senses); + +/** Compares semantic domains with the lowest id of all the senses of each word. */ +export const sortingFnDomains: MRT_SortingFn = (a, b) => + compareDomains( + gatherDomains(a.original.senses), + gatherDomains(b.original.senses) + ); + +/** Compares flags: `.active = true` before `= false`, then `.text` alphabetically. */ +export const sortingFnFlag: MRT_SortingFn = (a, b) => + compareFlags(a.original.flag, b.original.flag); diff --git a/src/goals/ReviewEntries/ReviewEntriesTable/tests/CellColumns.test.tsx b/src/goals/ReviewEntries/ReviewEntriesTable/tests/CellColumns.test.tsx deleted file mode 100644 index 2c140bf809..0000000000 --- a/src/goals/ReviewEntries/ReviewEntriesTable/tests/CellColumns.test.tsx +++ /dev/null @@ -1,334 +0,0 @@ -import { GramCatGroup, GrammaticalInfo } from "api/models"; -import columns, { - ColumnTitle, -} from "goals/ReviewEntries/ReviewEntriesTable/CellColumns"; -import { - ReviewEntriesSense, - ReviewEntriesWord, -} from "goals/ReviewEntries/ReviewEntriesTypes"; -import { newSemanticDomain } from "types/semanticDomain"; -import { newDefinition, newFlag, newGloss } from "types/word"; -import { Bcp47Code } from "types/writingSystem"; - -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 }; -}); - -const LANG = Bcp47Code.En; -const DEFINITION = newDefinition("groovy", LANG); -const GLOSS = newGloss("hoovy", LANG); -const DOMAIN = newSemanticDomain("1.1.3.5", "Storm"); -const DOMAIN2 = newSemanticDomain("1", "Universe"); -const DOMAIN_BAD = newSemanticDomain("0.0", "Joke"); -const FLAG = newFlag("movie"); -const PART_OF_SPEECH: GrammaticalInfo = { - catGroup: GramCatGroup.Verb, - grammaticalCategory: "vt", -}; -const WORD: ReviewEntriesWord = { - ...new ReviewEntriesWord(), - id: "id", - vernacular: "pootis", - senses: [ - { - ...new ReviewEntriesSense(), - guid: "sense0", - definitions: [DEFINITION], - glosses: [ - newGloss("meaning of life", LANG), - newGloss("life's meaning", LANG), - ], - domains: [DOMAIN2, DOMAIN_BAD], - }, - { - ...new ReviewEntriesSense(), - guid: "sense1", - glosses: [newGloss("heavy noise", LANG), GLOSS], - domains: [DOMAIN, DOMAIN_BAD], - partOfSpeech: PART_OF_SPEECH, - }, - ], - flag: FLAG, -}; - -// Last sort sense is the sense that should bubble to the end, first sort sense to the front -const SENSES: Partial[] = [ - { - guid: "", - definitions: [newDefinition("defC")], - glosses: [newGloss("glossD")], - partOfSpeech: PART_OF_SPEECH, - domains: [newSemanticDomain("7.7.6")], - deleted: false, - }, - { - guid: "", - definitions: [newDefinition("defA")], - glosses: [newGloss("glossB")], - partOfSpeech: { catGroup: GramCatGroup.Noun, grammaticalCategory: "ger" }, - domains: [newSemanticDomain("9.9.9.9.9")], - deleted: false, - }, - { - guid: "", - definitions: [newDefinition("defD")], - glosses: [newGloss("glossC")], - partOfSpeech: { catGroup: GramCatGroup.Noun, grammaticalCategory: "n" }, - domains: [newSemanticDomain("0.0.0.0.0")], - deleted: false, - }, - { - guid: "", - definitions: [newDefinition("defB")], - glosses: [newGloss("glossA")], - partOfSpeech: { catGroup: GramCatGroup.Other, grammaticalCategory: "x" }, - domains: [newSemanticDomain("7.7.7")], - deleted: false, - }, -]; - -const WORD0 = { - senses: [SENSES[0]], - flag: { ...newFlag(), active: true }, -} as ReviewEntriesWord; -const WORD1 = { senses: [SENSES[1]], flag: newFlag("Zz") } as ReviewEntriesWord; -const WORD2 = { senses: [SENSES[2]], flag: newFlag("Aa") } as ReviewEntriesWord; -const WORD3 = { senses: [SENSES[3]], flag: newFlag() } as ReviewEntriesWord; - -const SORT_BY_DEFINIS = [WORD1, WORD3, WORD0, WORD2]; -const SORT_BY_GLOSSES = [WORD3, WORD1, WORD2, WORD0]; -const SORT_BY_PARTOFS = [WORD1, WORD2, WORD3, WORD0]; -const SORT_BY_DOMAINS = [WORD2, WORD0, WORD3, WORD1]; -const SORT_BY_FLAGIES = [WORD0, WORD2, WORD1, WORD3]; - -describe("CellColumns filter and sort functions", () => { - describe("Sense column", () => { - const col = columns.find((c) => c.title === ColumnTitle.Senses); - - it("properly sorts a list by sense count", () => { - if (col?.customSort) { - const firstWord = [...SORT_BY_GLOSSES, WORD].sort((a, b) => - col.customSort!(a, b, "row") - )[0]; - // The one with two sense comes first, since the rest have only one. - expect(firstWord).toEqual(WORD); - } else { - fail(); - } - }); - }); - - describe("Definitions column", () => { - const col = columns.find((c) => c.title === ColumnTitle.Definitions); - - it("returns true when searching a word for an extant definition", () => { - if (col?.customFilterAndSearch) { - expect( - col.customFilterAndSearch(DEFINITION.text, WORD, {}) - ).toBeTruthy(); - } else { - fail(); - } - }); - - it("returns false when searching a word for a nonexistent definition", () => { - if (col?.customFilterAndSearch) { - expect(col.customFilterAndSearch("c67ig-8", WORD, {})).toBeFalsy(); - } else { - fail(); - } - }); - - it("properly sorts a list by definitions", () => { - if (col?.customSort) { - expect( - [...SORT_BY_DOMAINS].sort((a, b) => col.customSort!(a, b, "row")) - ).toEqual(SORT_BY_DEFINIS); - } else { - fail(); - } - }); - }); - - describe("Glosses column", () => { - const col = columns.find((c) => c.title === ColumnTitle.Glosses); - - it("returns true when searching a word for an extant gloss", () => { - if (col?.customFilterAndSearch) { - expect(col.customFilterAndSearch(GLOSS.def, WORD, {})).toBeTruthy(); - } else { - fail(); - } - }); - - it("returns false when searching a word for a nonexistent gloss", () => { - if (col?.customFilterAndSearch) { - expect(col.customFilterAndSearch("76yu*9", WORD, {})).toBeFalsy(); - } else { - fail(); - } - }); - - it("properly sorts a list by glosses", () => { - if (col?.customSort) { - expect( - [...SORT_BY_DOMAINS].sort((a, b) => col.customSort!(a, b, "row")) - ).toEqual(SORT_BY_GLOSSES); - } else { - fail(); - } - }); - }); - - describe("Part of Speech column", () => { - const col = columns.find((c) => c.title === ColumnTitle.PartOfSpeech); - - it("can find by group", () => { - if (col?.customFilterAndSearch) { - expect( - col.customFilterAndSearch(PART_OF_SPEECH.catGroup, WORD, {}) - ).toBeTruthy(); - } else { - fail(); - } - }); - - it("can find by category", () => { - if (col?.customFilterAndSearch) { - expect( - col.customFilterAndSearch( - PART_OF_SPEECH.grammaticalCategory, - WORD, - {} - ) - ).toBeTruthy(); - } else { - fail(); - } - }); - - it("finds nothing when nothing should match", () => { - if (col?.customFilterAndSearch) { - expect(col.customFilterAndSearch("j098#", WORD, {})).toBeFalsy(); - } else { - fail(); - } - }); - - it("properly sorts a list of by part of speech", () => { - if (col?.customSort) { - expect( - [...SORT_BY_DOMAINS].sort((a, b) => col.customSort!(a, b, "row")) - ).toEqual(SORT_BY_PARTOFS); - } else { - fail(); - } - }); - }); - - describe("Semantic Domains column", () => { - const col = columns.find((c) => c.title === ColumnTitle.Domains); - - it("returns true when searching a word for an extant domain", () => { - if (col?.customFilterAndSearch) { - expect(col.customFilterAndSearch(DOMAIN.id, WORD, {})).toBeTruthy(); - expect(col.customFilterAndSearch(DOMAIN.name, WORD, {})).toBeTruthy(); - } else { - fail(); - } - }); - - it("returns true when searching for start of domain number but not end", () => { - if (col?.customFilterAndSearch) { - expect( - col.customFilterAndSearch(DOMAIN.id.substring(0, 3), WORD, {}) - ).toBeTruthy(); - expect( - col.customFilterAndSearch(DOMAIN.id.substring(2), WORD, {}) - ).toBeFalsy(); - } else { - fail(); - } - }); - - it("returns true when searching a word for an extant domain id:name", () => { - if (col?.customFilterAndSearch) { - const filter1 = `${DOMAIN.id}:${DOMAIN.name}`; - expect(col.customFilterAndSearch(filter1, WORD, {})).toBeTruthy(); - const filter2 = ` ${DOMAIN.id} : ${DOMAIN.name.toUpperCase()} `; - expect(col.customFilterAndSearch(filter2, WORD, {})).toBeTruthy(); - } else { - fail(); - } - }); - - it("handles extra whitespace and different capitalization", () => { - if (col?.customFilterAndSearch) { - const filter = ` ${DOMAIN.id} : ${DOMAIN.name.toUpperCase()} `; - expect(col.customFilterAndSearch(filter, WORD, {})).toBeTruthy(); - } else { - fail(); - } - }); - - it("returns false when searching a word for a nonexistent domain", () => { - if (col?.customFilterAndSearch) { - expect(col.customFilterAndSearch("asdfghjkl", WORD, {})).toBeFalsy(); - } else { - fail(); - } - }); - - it("returns false when searching for domain id:name that don't occur together", () => { - if (col?.customFilterAndSearch) { - const filter = `${DOMAIN.id}:${DOMAIN2.name}`; - expect(col.customFilterAndSearch(filter, WORD, {})).toBeFalsy(); - } else { - fail(); - } - }); - - it("properly sorts a list by domains", () => { - if (col?.customSort) { - expect( - [...SORT_BY_GLOSSES].sort((a, b) => col.customSort!(a, b, "row")) - ).toEqual(SORT_BY_DOMAINS); - } else { - fail(); - } - }); - }); - - describe("Flag column", () => { - const col = columns.find((c) => c.title === ColumnTitle.Flag); - - it("returns true when searching a word for extant flag-text", () => { - if (col?.customFilterAndSearch) { - expect(col.customFilterAndSearch(FLAG.text, WORD, {})).toBeTruthy(); - } else { - fail(); - } - }); - - it("returns false when searching a word for nonexistent flag-text", () => { - if (col?.customFilterAndSearch) { - expect(col.customFilterAndSearch("asdfghjkl", WORD, {})).toBeFalsy(); - } else { - fail(); - } - }); - - it("properly sorts a list by flags", () => { - if (col?.customSort) { - expect( - [...SORT_BY_GLOSSES].sort((a, b) => col.customSort!(a, b, "row")) - ).toEqual(SORT_BY_FLAGIES); - } else { - fail(); - } - }); - }); -}); diff --git a/src/goals/ReviewEntries/ReviewEntriesTable/tests/WordsMock.ts b/src/goals/ReviewEntries/ReviewEntriesTable/tests/WordsMock.ts new file mode 100644 index 0000000000..ea88df4254 --- /dev/null +++ b/src/goals/ReviewEntries/ReviewEntriesTable/tests/WordsMock.ts @@ -0,0 +1,106 @@ +import { GramCatGroup, type Sense, type Word } from "api/models"; +import { newSemanticDomain } from "types/semanticDomain"; +import { + newDefinition, + newFlag, + newNote, + newPronunciation, + newSense, + newWord, +} from "types/word"; + +// Define senses for count sorted order [1, 0, 2, 3] +// and for gloss sorted order [0, 1, 3, 2] +const senses: Sense[][] = [ + [newSense("Echo"), newSense("E2")], + [newSense("Foxtrot")], + [newSense("Hotel"), newSense("H2"), newSense("H3")], + [newSense("Golf"), newSense("G2"), newSense("G3"), newSense("G4")], +]; + +// Define sense grammatical info for sorted order [1, 2, 0, 3] +senses[1][0].grammaticalInfo.catGroup = GramCatGroup.Noun; +senses[1][0].grammaticalInfo.grammaticalCategory = "a"; +senses[2][0].grammaticalInfo.catGroup = GramCatGroup.Noun; +senses[2][0].grammaticalInfo.grammaticalCategory = "b"; +senses[2][1].grammaticalInfo.catGroup = GramCatGroup.Adverb; +senses[0][0].grammaticalInfo.catGroup = GramCatGroup.Noun; +senses[0][0].grammaticalInfo.grammaticalCategory = "b"; +senses[0][1].grammaticalInfo.catGroup = GramCatGroup.Verb; +// (leave senses[3] grammatical info Unspecified) + +// Define sense definitions for sorted order [2, 0, 3, 1] +// (leave senses[2] definitions empty) +// (give senses[0] concatenated definition "a") +senses[0][1].definitions = [newDefinition("a", "a")]; +// (give senses[3] concatenated definition "b; c") +senses[3][0].definitions.push(newDefinition("", "e"), newDefinition("b", "b")); +senses[3][3].definitions.push(newDefinition("c", "c")); +// (give senses[1] concatenated definition "b; d") +senses[1][0].definitions.push(newDefinition("b", "b"), newDefinition("d", "d")); + +// Define semantic domains for sorted order [2, 3, 0, 1] +// (give senses[2] domains with id "1", "2", "9") +senses[2][1].semanticDomains.push(newSemanticDomain("9")); +senses[2][1].semanticDomains.push(newSemanticDomain("2")); +senses[2][2].semanticDomains.push(newSemanticDomain("1")); +// (give senses[3] domains with id "1", "4") +senses[3][1].semanticDomains.push(newSemanticDomain("4")); +senses[3][1].semanticDomains.push(newSemanticDomain("1")); +// (give senses[3] domain with id "3") +senses[0][0].semanticDomains.push(newSemanticDomain("3")); +// (leave senses[1] without semantic domains) + +// Defined words for vernaculars sorted order [0, 2, 3, 1] +// and for pronunciations sorted order [3, 2, 1, 0] +// and for flags sorted order [1, 0, 3, 2] +// and for notes sorted order [0, 3, 2, 1] +/** Returns an array of 4 words specially designed to test every column's `sortingFn`. */ +export function mockWords(): Word[] { + return [ + { + ...newWord("Alfa"), + audio: [newPronunciation(), newPronunciation(), newPronunciation()], + flag: newFlag("India"), + id: "0", + note: newNote("Kilo"), + senses: senses[0], + }, + { + ...newWord("Delta"), + audio: [newPronunciation(), newPronunciation()], + flag: { active: true, text: "" }, // (active flag with empty text is sorted to first) + id: "1", + // (empty note text is sorted to last) + senses: senses[1], + }, + { + ...newWord("Bravo"), + audio: [newPronunciation()], + // (flag with `active = false` is sorted to last) + id: "2", + note: newNote("Mike"), + senses: senses[2], + }, + { + ...newWord("Charlie"), + flag: newFlag("Juliett"), + id: "3", + note: newNote("Lima"), + senses: senses[3], + }, + ]; +} + +/** `sortOrder[i]` gives the order of `mockWords()` when sorted by column `i`. */ +export const sortOrder = [ + [0, 2, 3, 1], // Vernacular (alphabetical) + [1, 0, 2, 3], // Senses (count) + [2, 0, 3, 1], // Definitions (concatenated, alphabetical) + [0, 1, 3, 2], // Glosses (concatenated, alphabetical) + [1, 2, 0, 3], // PartOfSpeech (compare sense-by-sense) + [2, 3, 0, 1], // Domains (compare ids across all senses, with none last) + [3, 2, 1, 0], // Pronunciations (count) + [0, 3, 2, 1], // Note (text, alphabetical, with empty last) + [1, 0, 3, 2], // Flag (text, alphabetical, with active=false last) +]; diff --git a/src/goals/ReviewEntries/ReviewEntriesTable/tests/filterFn.test.ts b/src/goals/ReviewEntries/ReviewEntriesTable/tests/filterFn.test.ts new file mode 100644 index 0000000000..28731cc322 --- /dev/null +++ b/src/goals/ReviewEntries/ReviewEntriesTable/tests/filterFn.test.ts @@ -0,0 +1,103 @@ +import * as ff from "goals/ReviewEntries/ReviewEntriesTable/filterFn"; +import { newSemanticDomain } from "types/semanticDomain"; +import { newDefinition, newFlag, newGloss, newPronunciation } from "types/word"; + +const mockGetValue = jest.fn(); +const mockId = "id"; +const mockRow = { getValue: mockGetValue }; + +describe("filterFn", () => { + describe("filterFnDefinitions", () => { + const filterFn = ff.filterFnDefinitions as any; + it("trims whitespace and isn't case sensitive", () => { + mockGetValue.mockReturnValue([ + newDefinition("hello"), + newDefinition("WORLD"), + ]); + expect(filterFn(mockRow, mockId, " WoRlD\t")).toBeTruthy(); + }); + }); + + describe("filterFnGlosses", () => { + const filterFn = ff.filterFnGlosses as any; + it("trims whitespace and isn't case sensitive", () => { + mockGetValue.mockReturnValue([newGloss("hello"), newGloss("WORLD")]); + expect(filterFn(mockRow, mockId, " WoRlD\t")).toBeTruthy(); + }); + }); + + describe("filterFnDomains", () => { + const filterFn = ff.filterFnDomains as any; + + it("whitespace matches everything with a domain", () => { + mockGetValue.mockReturnValue([]); + expect(filterFn(mockRow, mockId, " ")).toBeFalsy(); + + mockGetValue.mockReturnValue([newSemanticDomain()]); + expect(filterFn(mockRow, mockId, " ")).toBeTruthy(); + }); + + it("non-whitespace without . or digits matches nothing", () => { + const filterValue = "just letters!"; + mockGetValue.mockReturnValue([ + newSemanticDomain("1.1"), + newSemanticDomain("9.9.9", filterValue), + newSemanticDomain(), + ]); + expect(filterFn(mockRow, mockId, filterValue)).toBeFalsy(); + }); + + it("ignores characters aside from . and digits", () => { + mockGetValue.mockReturnValue([newSemanticDomain("2")]); + expect(filterFn(mockRow, mockId, "2: Person")).toBeTruthy(); + expect(filterFn(mockRow, mockId, "abc2de")).toBeTruthy(); + }); + + it("periods are optional", () => { + mockGetValue.mockReturnValue([newSemanticDomain("2.2.2")]); + expect(filterFn(mockRow, mockId, "..22.....2")).toBeTruthy(); + }); + + it("final periods allow for initial id matches", () => { + mockGetValue.mockReturnValue([newSemanticDomain("1.2.3")]); + expect(filterFn(mockRow, mockId, ".")).toBeTruthy(); + expect(filterFn(mockRow, mockId, "1.")).toBeTruthy(); + expect(filterFn(mockRow, mockId, "12.")).toBeTruthy(); + expect(filterFn(mockRow, mockId, "2.")).toBeFalsy(); + }); + }); + + describe("filterFnPronunciations", () => { + const speakerId = "speaker-id"; + const speakers = { [speakerId]: "name with number 2" }; + // filterFnPronunciations returns a filter function when given a speaker dictionary + const filterFn = (ff.filterFnPronunciations as any)(speakers); + + it("matches number of pronunciations", () => { + mockGetValue.mockReturnValue([newPronunciation(), newPronunciation()]); + expect(filterFn(mockRow, mockId, " 2")).toBeTruthy(); + expect(filterFn(mockRow, mockId, "2.0")).toBeTruthy(); + expect(filterFn(mockRow, mockId, "1")).toBeFalsy(); + }); + + it("matches speaker name", () => { + mockGetValue.mockReturnValue([newPronunciation("filename", speakerId)]); + expect(filterFn(mockRow, mockId, "2")).toBeTruthy(); + expect(filterFn(mockRow, mockId, " NAME\t\t")).toBeTruthy(); + expect(filterFn(mockRow, mockId, "other person")).toBeFalsy(); + }); + }); + + describe("filterFnFlag", () => { + const filterFn = ff.filterFnFlag as any; + it("trims whitespace and isn't case sensitive", () => { + mockGetValue.mockReturnValue(newFlag("hello, WORLD")); + expect(filterFn(mockRow, mockId, " WoRlD\t")).toBeTruthy(); + }); + + it("doesn't match if flag not active", () => { + mockGetValue.mockReturnValue({ active: false, text: "hi" }); + expect(filterFn(mockRow, mockId, " ")).toBeFalsy(); + }); + }); +}); diff --git a/src/goals/ReviewEntries/ReviewEntriesTable/tests/index.test.tsx b/src/goals/ReviewEntries/ReviewEntriesTable/tests/index.test.tsx new file mode 100644 index 0000000000..f4644412fd --- /dev/null +++ b/src/goals/ReviewEntries/ReviewEntriesTable/tests/index.test.tsx @@ -0,0 +1,203 @@ +import { TableSortLabel } from "@mui/material"; +import { MRT_TableBodyRow, MRT_TableHeadCell } from "material-react-table"; +import { Provider } from "react-redux"; +import { type ReactTestRenderer, act, create } from "react-test-renderer"; +import configureMockStore from "redux-mock-store"; + +import { defaultState } from "components/Project/ProjectReduxTypes"; +import ReviewEntriesTable from "goals/ReviewEntries/ReviewEntriesTable"; +import VernacularCell from "goals/ReviewEntries/ReviewEntriesTable/Cells/VernacularCell"; +import { + mockWords, + sortOrder, +} from "goals/ReviewEntries/ReviewEntriesTable/tests/WordsMock"; +import { type StoreState } from "types"; + +// With `columnFilterDisplayMode: "popover",`, it is necessary to mock out `Grow`. +// To access filter `TextField`s, replace both `Grow`, `Modal` with `div`. +// However, using a `TextField`'s `.props.onChange()` doesn't activate a filter. +jest.mock("@mui/material/Grow", () => "div"); + +// Intercept i18n to set the resolvedLanguage for localization testing. +jest.mock("react-i18next", () => ({ + ...jest.requireActual("react-i18next"), + useTranslation: () => mockUseTranslation(), +})); +const mockUseTranslation = jest.fn(); +const setMockUseTranslation = (resolvedLanguage: string): void => { + mockUseTranslation.mockReturnValue({ + i18n: { resolvedLanguage }, + t: (str: string) => str, + }); +}; + +jest.mock("backend", () => ({ + getAllSpeakers: (projectId: string) => mockGetAllSpeakers(projectId), + getFrontierWords: (...args: any[]) => mockGetFrontierWords(...args), + getWord: (wordId: string) => mockGetWord(wordId), +})); +jest.mock("components/Pronunciations/PronunciationsBackend"); +jest.mock("i18n", () => ({})); +jest.mock("types/hooks", () => ({ + ...jest.requireActual("types/hooks"), + useAppDispatch: () => jest.fn(), +})); + +const mockClickEvent = { stopPropagation: jest.fn() }; +const mockGetAllSpeakers = jest.fn(); +const mockGetFrontierWords = jest.fn(); +const mockGetWord = jest.fn(); +const mockState = ( + definitionsEnabled = false, + grammaticalInfoEnabled = false +): Partial => ({ + currentProjectState: { + ...defaultState, + project: { + ...defaultState.project, + definitionsEnabled, + grammaticalInfoEnabled, + }, + }, +}); + +let renderer: ReactTestRenderer; + +const renderReviewEntriesTable = async ( + definitionsEnabled = false, + grammaticalInfoEnabled = false, + i18nLang = "" +): Promise => { + setMockUseTranslation(i18nLang); + const mockStore = configureMockStore()( + mockState(definitionsEnabled, grammaticalInfoEnabled) + ); + await act(async () => { + renderer = create( + + + + ); + }); +}; + +function setMockFunctions(): void { + jest.clearAllMocks(); + mockGetAllSpeakers.mockResolvedValue([]); + mockGetFrontierWords.mockResolvedValue(mockWords()); +} + +beforeEach(() => { + setMockFunctions(); +}); + +describe("ReviewEntriesTable", () => { + test("initial render fetches frontier and loads data", async () => { + await renderReviewEntriesTable(); + expect(mockGetFrontierWords).toHaveBeenCalled(); + expect(renderer.root.findAllByType(MRT_TableBodyRow)).toHaveLength(4); + }); + + describe("table sort", () => { + beforeEach(async () => { + await renderReviewEntriesTable(true, true); + }); + + /** Checks if the WordsMock.tsx words have been sorted by the given column. */ + const checkRowOrder = (col: number, dir: "asc" | "desc"): void => { + const rowIds = renderer.root + .findAllByType(VernacularCell) + .map((cell) => cell.props.word.id); + const order = [...sortOrder[col]]; + if (dir === "desc") { + order.reverse(); + } + order.forEach((id, index) => { + expect(rowIds[index]).toEqual(`${id}`); + }); + }; + + /** The accessor columns in default order. */ + const cols = [ + "Vernacular", + "Senses", + "Definitions", + "Glosses", + "PartOfSpeech", + "Domains", + "Pronunciations", + "Note", + "Flag", + ]; + + cols.forEach((col, i) => { + test(`sorting by ${col} column`, async () => { + const button = renderer.root.findAllByType(TableSortLabel)[i]; + expect(button.props.direction).toBeUndefined(); + await act(async () => { + button.props.onClick(mockClickEvent); + }); + expect(button.props.direction).toEqual("asc"); + checkRowOrder(i, "asc"); + await act(async () => { + button.props.onClick(mockClickEvent); + }); + expect(button.props.direction).toEqual("desc"); + checkRowOrder(i, "desc"); + await act(async () => { + button.props.onClick(mockClickEvent); + }); + expect(button.props.direction).toBeUndefined(); + }); + }); + }); + + describe("definitionsEnabled & grammaticalInfoEnabled", () => { + const definitionsId = "definitions"; + const partOfSpeechId = "partOfSpeech"; + + test("show definitions when definitionsEnabled is true", async () => { + await renderReviewEntriesTable(true, false); + const colIds = renderer.root + .findAllByType(MRT_TableHeadCell) + .map((col) => col.props.header.id); + expect(colIds).toContain(definitionsId); + expect(colIds).not.toContain(partOfSpeechId); + }); + + test("show part of speech when grammaticalInfoEnabled is true", async () => { + await renderReviewEntriesTable(false, true); + const colIds = renderer.root + .findAllByType(MRT_TableHeadCell) + .map((col) => col.props.header.id); + expect(colIds).not.toContain(definitionsId); + expect(colIds).toContain(partOfSpeechId); + }); + }); + + describe("localization", () => { + /** A hover-text phrase from MRT's localization. */ + const localizedText = { + ar: "إظهار / إخفاء الأعمدة", + en: "Show/Hide columns", + es: "Mostrar/ocultar columnas", + fr: "Afficher/Masquer les colonnes", + pt: "Mostrar/Ocultar colunas", + zh: "显示/隐藏 列", + }; + + test("defaults to en", async () => { + await renderReviewEntriesTable(); + // Throws error if no component found with specified `title` prop + renderer.root.findByProps({ title: localizedText["en"] }); + }); + + Object.entries(localizedText).forEach(([lang, text]) => { + test(lang, async () => { + await renderReviewEntriesTable(false, false, lang); + // Throws error if no component found with specified `title` prop + renderer.root.findByProps({ title: text }); + }); + }); + }); +}); diff --git a/src/goals/ReviewEntries/ReviewEntriesTypes.ts b/src/goals/ReviewEntries/ReviewEntriesTypes.ts index faac50dc82..d51fefbfdd 100644 --- a/src/goals/ReviewEntries/ReviewEntriesTypes.ts +++ b/src/goals/ReviewEntries/ReviewEntriesTypes.ts @@ -1,30 +1,4 @@ -import { - Definition, - Flag, - Gloss, - GrammaticalInfo, - Pronunciation, - SemanticDomain, - Sense, - Status, - Word, -} from "api/models"; import { Goal, GoalName, GoalType } from "types/goals"; -import { newNote, newSense, newWord } from "types/word"; -import { cleanDefinitions, cleanGlosses } from "utilities/wordUtilities"; - -export enum ColumnId { - Vernacular, - Senses, - Definitions, - Glosses, - PartOfSpeech, - Domains, - Pronunciations, - Note, - Flag, - Delete, -} export class ReviewEntries extends Goal { constructor() { @@ -40,112 +14,3 @@ export type EntryEdit = { export interface EntriesEdited { entryEdits: EntryEdit[]; } - -// These must match the ReviewEntriesWord fields for use in ReviewEntriesTable -export enum ReviewEntriesWordField { - Id = "id", - Vernacular = "vernacular", - Senses = "senses", - Pronunciations = "audio", - Note = "noteText", - Flag = "flag", -} - -export class ReviewEntriesWord { - id: string; - vernacular: string; - senses: ReviewEntriesSense[]; - audio: Pronunciation[]; - audioNew?: Pronunciation[]; - noteText: string; - flag: Flag; - protected: boolean; - - /** Construct a ReviewEntriesWord from a Word. - * Important: Some things (e.g., note language) aren't preserved! */ - constructor(word?: Word, analysisLang?: string) { - word ??= newWord(); - this.id = word.id; - this.vernacular = word.vernacular; - this.senses = word.senses.map( - (s) => new ReviewEntriesSense(s, analysisLang) - ); - this.audio = word.audio; - this.noteText = word.note.text; - this.flag = word.flag; - this.protected = word.accessibility === Status.Protected; - } -} - -export class ReviewEntriesSense { - guid: string; - definitions: Definition[]; - glosses: Gloss[]; - partOfSpeech: GrammaticalInfo; - domains: SemanticDomain[]; - deleted: boolean; - protected: boolean; - - /** Construct a ReviewEntriesSense from a Sense. - * Important: Some things aren't preserved! - * (E.g., distinct glosses with the same language are combined.) */ - constructor(sense?: Sense, analysisLang?: string) { - sense ??= newSense(); - this.guid = sense.guid; - this.definitions = analysisLang - ? sense.definitions.filter((d) => d.language === analysisLang) - : sense.definitions; - this.definitions = cleanDefinitions(this.definitions); - this.glosses = analysisLang - ? sense.glosses.filter((g) => g.language === analysisLang) - : sense.glosses; - this.glosses = cleanGlosses(this.glosses); - this.partOfSpeech = sense.grammaticalInfo; - this.domains = [...sense.semanticDomains]; - this.deleted = sense.accessibility === Status.Deleted; - this.protected = sense.accessibility === Status.Protected; - } - - private static SEPARATOR = "; "; - static definitionString(sense: ReviewEntriesSense): string { - return sense.definitions - .map((d) => d.text) - .join(ReviewEntriesSense.SEPARATOR); - } - static glossString(sense: ReviewEntriesSense): string { - return sense.glosses.map((g) => g.def).join(ReviewEntriesSense.SEPARATOR); - } -} - -/** Reverse map of the ReviewEntriesSense constructor. - * Important: Some things aren't preserved! - * (E.g., distinct glosses with the same language may have been combined.) */ -function senseFromReviewEntriesSense(revSense: ReviewEntriesSense): Sense { - return { - ...newSense(), - accessibility: revSense.protected - ? Status.Protected - : revSense.deleted - ? Status.Deleted - : Status.Active, - definitions: revSense.definitions.map((d) => ({ ...d })), - glosses: revSense.glosses.map((g) => ({ ...g })), - grammaticalInfo: revSense.partOfSpeech, - guid: revSense.guid, - semanticDomains: revSense.domains.map((dom) => ({ ...dom })), - }; -} - -/** Reverse map of the ReviewEntriesWord constructor. - * Important: Some things (e.g., note language) aren't preserved! */ -export function wordFromReviewEntriesWord(revWord: ReviewEntriesWord): Word { - return { - ...newWord(revWord.vernacular), - accessibility: revWord.protected ? Status.Protected : Status.Active, - audio: [...revWord.audio], - id: revWord.id, - flag: { ...revWord.flag }, - note: newNote(revWord.noteText), - senses: revWord.senses.map(senseFromReviewEntriesSense), - }; -} diff --git a/src/goals/ReviewEntries/index.tsx b/src/goals/ReviewEntries/index.tsx index f51880fe3c..fb0b874e2e 100644 --- a/src/goals/ReviewEntries/index.tsx +++ b/src/goals/ReviewEntries/index.tsx @@ -1,46 +1,12 @@ -import { ReactElement, useEffect, useState } from "react"; +import { type ReactElement } from "react"; -import { getFrontierWords } from "backend"; -import { - setAllWords, - setSortBy, - updateFrontierWord, -} from "goals/ReviewEntries/Redux/ReviewEntriesActions"; import ReviewEntriesCompleted from "goals/ReviewEntries/ReviewEntriesCompleted"; import ReviewEntriesTable from "goals/ReviewEntries/ReviewEntriesTable"; -import { - ColumnId, - ReviewEntriesWord, -} from "goals/ReviewEntries/ReviewEntriesTypes"; -import { useAppDispatch } from "types/hooks"; interface ReviewEntriesProps { completed: boolean; } export default function ReviewEntries(props: ReviewEntriesProps): ReactElement { - const dispatch = useAppDispatch(); - const [loaded, setLoaded] = useState(false); - - useEffect(() => { - if (!props.completed) { - getFrontierWords().then((frontier) => { - dispatch(setAllWords(frontier)); - setLoaded(true); - }); - } - }, [dispatch, props.completed]); - - return props.completed ? ( - - ) : loaded ? ( - - dispatch(updateFrontierWord(newData, oldData)) - } - onSort={(columnId?: ColumnId) => dispatch(setSortBy(columnId))} - /> - ) : ( -
- ); + return props.completed ? : ; } diff --git a/src/goals/ReviewEntries/tests/WordsMock.ts b/src/goals/ReviewEntries/tests/WordsMock.ts deleted file mode 100644 index 45d65b8ecc..0000000000 --- a/src/goals/ReviewEntries/tests/WordsMock.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { GramCatGroup } from "api/models"; -import { - ReviewEntriesSense, - ReviewEntriesWord, -} from "goals/ReviewEntries/ReviewEntriesTypes"; -import { newSemanticDomain } from "types/semanticDomain"; -import { newDefinition, newFlag, newGloss } from "types/word"; -import { Bcp47Code } from "types/writingSystem"; - -export default function mockWords(): ReviewEntriesWord[] { - return [ - { - ...new ReviewEntriesWord(), - id: "0", - vernacular: "toad", - senses: [ - { - ...new ReviewEntriesSense(), - guid: "0.0", - glosses: [ - newGloss("bup", Bcp47Code.En), - newGloss("AHH", Bcp47Code.Es), - ], - definitions: [ - newDefinition("bup-bup", Bcp47Code.Ar), - newDefinition("AHH-AHH", Bcp47Code.Fr), - ], - domains: [newSemanticDomain("number", "domain")], - }, - ], - noteText: "first word", - }, - { - ...new ReviewEntriesWord(), - id: "1", - vernacular: "vern", - senses: [ - { - ...new ReviewEntriesSense(), - guid: "1.1", - glosses: [newGloss("gloss", Bcp47Code.En)], - domains: [newSemanticDomain("number", "domain")], - partOfSpeech: { - catGroup: GramCatGroup.Other, - grammaticalCategory: "wxyz", - }, - }, - ], - flag: newFlag("second word"), - }, - ]; -} diff --git a/src/goals/ReviewEntries/tests/index.test.tsx b/src/goals/ReviewEntries/tests/index.test.tsx deleted file mode 100644 index c6582f9ea6..0000000000 --- a/src/goals/ReviewEntries/tests/index.test.tsx +++ /dev/null @@ -1,99 +0,0 @@ -import { Fragment } from "react"; -import { Provider } from "react-redux"; -import { act, create } from "react-test-renderer"; -import configureMockStore from "redux-mock-store"; - -import ReviewEntries from "goals/ReviewEntries"; -import * as actions from "goals/ReviewEntries/Redux/ReviewEntriesActions"; -import { wordFromReviewEntriesWord } from "goals/ReviewEntries/ReviewEntriesTypes"; -import mockWords from "goals/ReviewEntries/tests/WordsMock"; -import { defaultWritingSystem } from "types/writingSystem"; - -const mockGetFrontierWords = jest.fn(); -const mockMaterialTable = jest.fn(); -const mockUuid = jest.fn(); - -// To deal with the table not wanting to behave in testing. -jest.mock("@material-table/core", () => ({ - __esModule: true, - default: () => mockMaterialTable(), -})); -// Standard dialog mock-out. -jest.mock("@mui/material", () => { - const material = jest.requireActual("@mui/material"); - return { - ...material, - Dialog: material.Container, - }; -}); - -jest.mock("notistack", () => ({ - ...jest.requireActual("notistack"), - enqueueSnackbar: jest.fn(), -})); -jest.mock("uuid", () => ({ v4: () => mockUuid() })); -jest.mock("backend", () => ({ - getFrontierWords: (...args: any[]) => mockGetFrontierWords(...args), -})); -jest.mock("components/TreeView", () => "div"); -jest.mock("goals/Redux/GoalActions", () => ({})); -jest.mock("types/hooks", () => ({ - useAppDispatch: () => jest.fn(), -})); - -const setAllWordsSpy = jest.spyOn(actions, "setAllWords"); - -// Mock store + axios -const mockReviewEntryWords = mockWords(); -const state = { - currentProjectState: { - project: { - analysisWritingSystems: [defaultWritingSystem], - definitionsEnabled: true, - vernacularWritingSystem: defaultWritingSystem, - }, - }, - reviewEntriesState: { - words: mockReviewEntryWords.map(wordFromReviewEntriesWord), - }, - treeViewState: { - open: false, - currentDomain: { id: "number", name: "domain", subdomains: [] }, - }, -}; -const mockStore = configureMockStore()(state); - -function setMockFunctions(): void { - jest.clearAllMocks(); - mockGetFrontierWords.mockResolvedValue( - mockReviewEntryWords.map(wordFromReviewEntriesWord) - ); - mockMaterialTable.mockReturnValue(Fragment); -} - -beforeEach(async () => { - // Prep for component creation - setMockFunctions(); - for (const word of mockReviewEntryWords) { - for (const sense of word.senses) { - mockUuid.mockImplementationOnce(() => sense.guid); - } - } - - await act(async () => { - create( - - - - ); - }); -}); - -describe("ReviewEntries", () => { - it("Initializes correctly", () => { - expect(setAllWordsSpy).toHaveBeenCalled(); - const wordIds = setAllWordsSpy.mock.calls[0][0].map((w) => w.id); - expect(wordIds).toHaveLength(mockReviewEntryWords.length); - mockReviewEntryWords.forEach((w) => expect(wordIds).toContain(w.id)); - }); -}); diff --git a/src/rootReducer.ts b/src/rootReducer.ts index 44f7357bea..65787d8458 100644 --- a/src/rootReducer.ts +++ b/src/rootReducer.ts @@ -1,4 +1,4 @@ -import { combineReducers, Reducer } from "redux"; +import { type Reducer, combineReducers } from "redux"; import loginReducer from "components/Login/Redux/LoginReducer"; import projectReducer from "components/Project/ProjectReducer"; @@ -8,8 +8,7 @@ import treeViewReducer from "components/TreeView/Redux/TreeViewReducer"; import characterInventoryReducer from "goals/CharacterInventory/Redux/CharacterInventoryReducer"; import mergeDupStepReducer from "goals/MergeDuplicates/Redux/MergeDupsReducer"; import goalsReducer from "goals/Redux/GoalReducer"; -import reviewEntriesReducer from "goals/ReviewEntries/Redux/ReviewEntriesReducer"; -import { StoreState } from "types"; +import { type StoreState } from "types"; import analyticsReducer from "types/Redux/analytics"; export const rootReducer: Reducer = combineReducers({ @@ -22,7 +21,6 @@ export const rootReducer: Reducer = combineReducers({ //data entry and review entries goal treeViewState: treeViewReducer, - reviewEntriesState: reviewEntriesReducer, pronunciationsState: pronunciationsReducer, //goal timeline and current goal diff --git a/src/types/index.ts b/src/types/index.ts index 3a90814fb3..7f7ff64ae4 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -6,7 +6,6 @@ import { TreeViewState } from "components/TreeView/Redux/TreeViewReduxTypes"; import { CharacterInventoryState } from "goals/CharacterInventory/Redux/CharacterInventoryReduxTypes"; import { MergeTreeState } from "goals/MergeDuplicates/Redux/MergeDupsReduxTypes"; import { GoalsState } from "goals/Redux/GoalReduxTypes"; -import { ReviewEntriesState } from "goals/ReviewEntries/Redux/ReviewEntriesReduxTypes"; import { AnalyticsState } from "types/Redux/analyticsReduxTypes"; //root store structure @@ -20,7 +19,6 @@ export interface StoreState { //data entry and review entries treeViewState: TreeViewState; - reviewEntriesState: ReviewEntriesState; pronunciationsState: PronunciationsState; //goal timeline and current goal