diff --git a/.dockerignore b/.dockerignore index 74a21c55e0..709fd21975 100644 --- a/.dockerignore +++ b/.dockerignore @@ -2,15 +2,16 @@ ** # Except the following. +!docs/user_guide !nginx !public !src -!docs/user_guide !.env +!.eslintrc.cjs !dev-requirements.txt !package*.json -!tsconfig.json !tox.ini +!tsconfig.json # Ignore user guide build directory. docs/user_guide/site diff --git a/.eslintrc.cjs b/.eslintrc.cjs new file mode 100644 index 0000000000..fe11647d20 --- /dev/null +++ b/.eslintrc.cjs @@ -0,0 +1,69 @@ +module.exports = { + env: { + browser: true, + jest: true, + }, + extends: [ + "plugin:react/recommended", + "plugin:react/jsx-runtime", + "plugin:react-hooks/recommended", + "plugin:import/recommended", + ], + ignorePatterns: ["*.dic.js"], + parser: "@typescript-eslint/parser", + parserOptions: { + ecmaFeatures: { jsx: true }, + project: "./tsconfig.json", + }, + plugins: ["@typescript-eslint", "react", "unused-imports"], + root: true, + rules: { + "import/first": "warn", + "import/newline-after-import": "warn", + "import/no-duplicates": "warn", + "import/no-named-as-default": "off", + "import/no-named-as-default-member": "off", + "import/order": [ + "warn", + { + alphabetize: { order: "asc" }, + groups: [ + "builtin", + "external", + ["internal", "parent", "sibling", "index", "object", "type"], + ], + "newlines-between": "always", + }, + ], + "no-undef": "off", + "prefer-const": "warn", + "react/jsx-boolean-value": "warn", + "unused-imports/no-unused-imports": "warn", + }, + settings: { + react: { version: "detect" }, + "import/resolver": { + typescript: { alwaysTryTypes: true }, + }, + }, + overrides: [ + { + files: ["*.ts", "*.tsx"], + extends: [ + "plugin:@typescript-eslint/recommended", + "plugin:import/typescript", + ], + rules: { + "@typescript-eslint/explicit-function-return-type": [ + "warn", + { allowExpressions: true }, + ], + "@typescript-eslint/no-empty-interface": "warn", + "@typescript-eslint/no-explicit-any": "off", + "@typescript-eslint/no-inferrable-types": "warn", + "@typescript-eslint/no-unused-vars": "warn", + "@typescript-eslint/switch-exhaustiveness-check": "warn", + }, + }, + ], +}; diff --git a/.vscode/settings.json b/.vscode/settings.json index baa9e6cdb4..e80e35b6ff 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -86,6 +86,7 @@ "thecombine", "upsert", "venv", + "verns", "wordlist", "wordlists" ], diff --git a/Backend.Tests/Controllers/LiftControllerTests.cs b/Backend.Tests/Controllers/LiftControllerTests.cs index a2c7c5ccf6..cab3e9b365 100644 --- a/Backend.Tests/Controllers/LiftControllerTests.cs +++ b/Backend.Tests/Controllers/LiftControllerTests.cs @@ -225,6 +225,38 @@ private static async Task DownloadAndReadLift(LiftController liftControl return liftText; } + [Test] + public void TestUploadLiftFileNoPermission() + { + _liftController.ControllerContext.HttpContext = PermissionServiceMock.UnauthorizedHttpContext(); + var result = _liftController.UploadLiftFile(_projId, new FileUpload()).Result; + Assert.That(result, Is.InstanceOf()); + } + + [Test] + public void TestUploadLiftFileInvalidProjectId() + { + var result = _liftController.UploadLiftFile("../hack", new FileUpload()).Result; + Assert.That(result, Is.InstanceOf()); + } + + [Test] + public void TestUploadLiftFileAlreadyImported() + { + var projId = _projRepo.Create(new Project { Name = "already has import", LiftImported = true }).Result!.Id; + var result = _liftController.UploadLiftFile(projId, new FileUpload()).Result; + Assert.That(result, Is.InstanceOf()); + Assert.That(((BadRequestObjectResult)result).Value, Contains.Substring("LIFT")); + } + + [Test] + public void TestUploadLiftFileBadFile() + { + var result = _liftController.UploadLiftFile(_projId, new FileUpload()).Result; + Assert.That(result, Is.InstanceOf()); + Assert.That(((BadRequestObjectResult)result).Value, Is.InstanceOf()); + } + [Test] public void TestUploadLiftFileAndGetWritingSystems() { @@ -271,6 +303,21 @@ public void TestFinishUploadLiftFileNothingToFinish() Assert.That(_liftService.RetrieveImport(UserId), Is.Null); } + [Test] + public void TestFinishUploadLiftFileNoPermission() + { + _liftController.ControllerContext.HttpContext = PermissionServiceMock.UnauthorizedHttpContext(); + var result = _liftController.FinishUploadLiftFile(_projId).Result; + Assert.That(result, Is.InstanceOf()); + } + + [Test] + public void TestFinishUploadLiftFileInvalidProjectId() + { + var result = _liftController.FinishUploadLiftFile("../hack", UserId).Result; + Assert.That(result, Is.InstanceOf()); + } + [Test] public async Task TestModifiedTimeExportsToLift() { @@ -285,6 +332,35 @@ public async Task TestModifiedTimeExportsToLift() Assert.That(liftContents, Does.Contain("dateModified=\"2000-01-01T00:00:00Z\"")); } + [Test] + public void TestExportLiftFileNoPermission() + { + _liftController.ControllerContext.HttpContext = PermissionServiceMock.UnauthorizedHttpContext(); + var result = _liftController.ExportLiftFile(_projId).Result; + Assert.That(result, Is.InstanceOf()); + } + + [Test] + public void TestExportLiftFileInvalidProjectId() + { + var result = _liftController.ExportLiftFile("../hack").Result; + Assert.That(result, Is.InstanceOf()); + } + + [Test] + public void TestExportLiftFileNoProject() + { + var result = _liftController.ExportLiftFile("non-existent-project").Result; + Assert.That(result, Is.InstanceOf()); + } + + [Test] + public void TestExportLiftFileNoWordsInProject() + { + var result = _liftController.ExportLiftFile(_projId).Result; + Assert.That(result, Is.InstanceOf()); + } + [Test] public void TestExportInvalidProjectId() { @@ -294,6 +370,47 @@ public void TestExportInvalidProjectId() Throws.TypeOf()); } + [Test] + public void TestDownloadLiftFileNoPermission() + { + _liftController.ControllerContext.HttpContext = PermissionServiceMock.UnauthorizedHttpContext(); + var result = _liftController.DownloadLiftFile(_projId).Result; + Assert.That(result, Is.InstanceOf()); + } + + [Test] + public void TestCanUploadLiftNoPermission() + { + _liftController.ControllerContext.HttpContext = PermissionServiceMock.UnauthorizedHttpContext(); + var result = _liftController.CanUploadLift(_projId).Result; + Assert.That(result, Is.InstanceOf()); + } + + [Test] + public void TestCanUploadLiftInvalidProjectId() + { + var result = _liftController.CanUploadLift("../hack").Result; + Assert.That(result, Is.InstanceOf()); + } + + [Test] + public void TestCanUploadLiftFalse() + { + var projId = _projRepo.Create(new Project { Name = "has import", LiftImported = true }).Result!.Id; + var result = _liftController.CanUploadLift(projId).Result; + Assert.That(result, Is.InstanceOf()); + Assert.That(((OkObjectResult)result).Value, Is.False); + } + + [Test] + public void TestCanUploadLiftTrue() + { + var projId = _projRepo.Create(new Project { Name = "has no import", LiftImported = false }).Result!.Id; + var result = _liftController.CanUploadLift(projId).Result; + Assert.That(result, Is.InstanceOf()); + Assert.That(((OkObjectResult)result).Value, Is.True); + } + /// /// Create three words and delete one. Ensure that the deleted word is still exported to Lift format and marked /// as deleted. diff --git a/Backend.Tests/Mocks/ProjectRepositoryMock.cs b/Backend.Tests/Mocks/ProjectRepositoryMock.cs index b38a8b0b6e..0b3e77628b 100644 --- a/Backend.Tests/Mocks/ProjectRepositoryMock.cs +++ b/Backend.Tests/Mocks/ProjectRepositoryMock.cs @@ -95,7 +95,8 @@ public Task Update(string projectId, Project project) public Task CanImportLift(string projectId) { - return Task.FromResult(true); + var project = _projects.Find(p => p.Id == projectId); + return Task.FromResult(project?.LiftImported != true); } } } diff --git a/Backend/Repositories/WordRepository.cs b/Backend/Repositories/WordRepository.cs index 8a6a9f2a7b..18fcdf2803 100644 --- a/Backend/Repositories/WordRepository.cs +++ b/Backend/Repositories/WordRepository.cs @@ -20,21 +20,43 @@ public WordRepository(IWordContext collectionSettings) _wordDatabase = collectionSettings; } - /// Finds all s with specified projectId + /// + /// Creates a mongo filter for all words in a specified project (and optionally with specified vernacular). + /// Since a variant in FLEx can export as an entry without any senses, filters out 0-sense words. + /// + private static FilterDefinition GetAllProjectWordsFilter(string projectId, string? vernacular = null) + { + var filterDef = new FilterDefinitionBuilder(); + return (vernacular is null) + ? filterDef.And(filterDef.Eq(w => w.ProjectId, projectId), filterDef.SizeGt(w => w.Senses, 0)) + : filterDef.And(filterDef.Eq(w => w.ProjectId, projectId), filterDef.SizeGt(w => w.Senses, 0), + filterDef.Eq(w => w.Vernacular, vernacular)); + } + + /// Creates a mongo filter for words in a specified project with specified wordId. + private static FilterDefinition GetProjectWordFilter(string projectId, string wordId) + { + var filterDef = new FilterDefinitionBuilder(); + return filterDef.And(filterDef.Eq(w => w.ProjectId, projectId), filterDef.Eq(w => w.Id, wordId)); + } + + /// Creates a mongo filter for words in a specified project with specified wordIds. + private static FilterDefinition GetProjectWordsFilter(string projectId, List wordIds) + { + var filterDef = new FilterDefinitionBuilder(); + return filterDef.And(filterDef.Eq(w => w.ProjectId, projectId), filterDef.In(w => w.Id, wordIds)); + } + + /// Finds all s with specified projectId public async Task> GetAllWords(string projectId) { - return await _wordDatabase.Words.Find(w => w.ProjectId == projectId).ToListAsync(); + return await _wordDatabase.Words.Find(GetAllProjectWordsFilter(projectId)).ToListAsync(); } /// Finds with specified wordId and projectId public async Task GetWord(string projectId, string wordId) { - var filterDef = new FilterDefinitionBuilder(); - var filter = filterDef.And( - filterDef.Eq(x => x.ProjectId, projectId), - filterDef.Eq(x => x.Id, wordId)); - - var wordList = await _wordDatabase.Words.FindAsync(filter); + var wordList = await _wordDatabase.Words.FindAsync(GetProjectWordFilter(projectId, wordId)); try { return await wordList.FirstAsync(); @@ -127,29 +149,26 @@ public async Task Add(Word word) /// Checks if Frontier is nonempty for specified public async Task IsFrontierNonempty(string projectId) { - var word = await _wordDatabase.Frontier.Find(w => w.ProjectId == projectId).FirstOrDefaultAsync(); + var word = await _wordDatabase.Frontier.Find(GetAllProjectWordsFilter(projectId)).FirstOrDefaultAsync(); return word is not null; } /// Checks if specified word is in Frontier for specified public async Task IsInFrontier(string projectId, string wordId) { - var word = await _wordDatabase.Frontier - .Find(w => w.ProjectId == projectId && w.Id == wordId).FirstOrDefaultAsync(); - return word is not null; + return (await _wordDatabase.Frontier.CountDocumentsAsync(GetProjectWordFilter(projectId, wordId))) > 0; } /// Finds all s in the Frontier for specified public async Task> GetFrontier(string projectId) { - return await _wordDatabase.Frontier.Find(w => w.ProjectId == projectId).ToListAsync(); + return await _wordDatabase.Frontier.Find(GetAllProjectWordsFilter(projectId)).ToListAsync(); } /// Finds all s in Frontier of specified project with specified vern public async Task> GetFrontierWithVernacular(string projectId, string vernacular) { - return await _wordDatabase.Frontier.Find( - w => w.ProjectId == projectId && w.Vernacular == vernacular).ToListAsync(); + return await _wordDatabase.Frontier.Find(GetAllProjectWordsFilter(projectId, vernacular)).ToListAsync(); } /// Adds a only to the Frontier @@ -174,12 +193,7 @@ public async Task> AddFrontier(List words) /// A bool: success of operation public async Task DeleteFrontier(string projectId, string wordId) { - var filterDef = new FilterDefinitionBuilder(); - var filter = filterDef.And( - filterDef.Eq(x => x.ProjectId, projectId), - filterDef.Eq(x => x.Id, wordId)); - - var deleted = await _wordDatabase.Frontier.DeleteOneAsync(filter); + var deleted = await _wordDatabase.Frontier.DeleteOneAsync(GetProjectWordFilter(projectId, wordId)); return deleted.DeletedCount > 0; } @@ -187,29 +201,8 @@ public async Task DeleteFrontier(string projectId, string wordId) /// Number of words deleted public async Task DeleteFrontier(string projectId, List wordIds) { - var filterDef = new FilterDefinitionBuilder(); - var filter = filterDef.And( - filterDef.Eq(x => x.ProjectId, projectId), - filterDef.In(x => x.Id, wordIds)); - var deleted = await _wordDatabase.Frontier.DeleteManyAsync(filter); + var deleted = await _wordDatabase.Frontier.DeleteManyAsync(GetProjectWordsFilter(projectId, wordIds)); return deleted.DeletedCount; } - - /// Updates in the Frontier collection with same wordId and projectId - /// A bool: success of operation - public async Task UpdateFrontier(Word word) - { - var filterDef = new FilterDefinitionBuilder(); - var filter = filterDef.And( - filterDef.Eq(x => x.ProjectId, word.ProjectId), - filterDef.Eq(x => x.Id, word.Id)); - - var deleted = (await _wordDatabase.Frontier.DeleteOneAsync(filter)).DeletedCount > 0; - if (deleted) - { - await AddFrontier(word); - } - return deleted; - } } } diff --git a/Backend/Services/LiftService.cs b/Backend/Services/LiftService.cs index 183b5bff91..c8a29d71a9 100644 --- a/Backend/Services/LiftService.cs +++ b/Backend/Services/LiftService.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.IO; using System.IO.Compression; using System.Linq; @@ -944,16 +945,17 @@ public void ProcessRangeElement(string range, string id, string guid, string par // The following are unused and are not implemented, but may still be called by the Lexicon Merger // They may be useful later if we need to add more complex attributes to words in The Combine + [ExcludeFromCodeCoverage] public LiftExample GetOrMakeExample(LiftSense sense, Extensible info) { return new LiftExample { Content = new LiftMultiText() }; } - + [ExcludeFromCodeCoverage] public LiftObject GetOrMakeParentReversal(LiftObject parent, LiftMultiText contents, string type) { return new LiftReversal(); } - + [ExcludeFromCodeCoverage] public LiftSense GetOrMakeSubsense(LiftSense sense, Extensible info, string rawXml) { return new LiftSense(info, new Guid(), sense) @@ -962,35 +964,40 @@ public LiftSense GetOrMakeSubsense(LiftSense sense, Extensible info, string rawX Gloss = new LiftMultiText() }; } - + [ExcludeFromCodeCoverage] public LiftObject MergeInEtymology(LiftEntry entry, string source, string type, LiftMultiText form, - LiftMultiText gloss, string rawXml) + LiftMultiText gloss, string rawXml) { return new LiftEtymology(); } - + [ExcludeFromCodeCoverage] public LiftObject MergeInReversal( - LiftSense sense, LiftObject parent, LiftMultiText contents, string type, string rawXml) + LiftSense sense, LiftObject parent, LiftMultiText contents, string type, string rawXml) { return new LiftReversal(); } - + [ExcludeFromCodeCoverage] public LiftObject MergeInVariant(LiftEntry entry, LiftMultiText contents, string rawXml) { return new LiftVariant(); } - + [ExcludeFromCodeCoverage] public void EntryWasDeleted(Extensible info, DateTime dateDeleted) { } + [ExcludeFromCodeCoverage] public void MergeInExampleForm(LiftExample example, LiftMultiText multiText) { } - + [ExcludeFromCodeCoverage] public void MergeInPicture(LiftSense sense, string href, LiftMultiText caption) { } + [ExcludeFromCodeCoverage] public void MergeInRelation( - LiftObject extensible, string relationTypeName, string targetId, string rawXml) + LiftObject extensible, string relationTypeName, string targetId, string rawXml) { } + [ExcludeFromCodeCoverage] public void MergeInSource(LiftExample example, string source) { } + [ExcludeFromCodeCoverage] public void MergeInTranslationForm( - LiftExample example, string type, LiftMultiText multiText, string rawXml) + LiftExample example, string type, LiftMultiText multiText, string rawXml) { } + [ExcludeFromCodeCoverage] public void ProcessFieldDefinition(string tag, LiftMultiText description) { } } } diff --git a/README.md b/README.md index 58908a172c..21994cb538 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ [github-actions]: https://github.com/sillsdev/TheCombine/actions [localization-sd-badge]: https://img.shields.io/badge/Semantic%20Domains-Ar%20En%20Es%20Fr%20Hi%20Ml%20My%20Pt%20Ru%20Sw%20Zh-blue -[localization-ug-badge]: https://img.shields.io/badge/User%20Guide-En%20Es-blue +[localization-ug-badge]: https://img.shields.io/badge/User%20Guide-En%20Es%20Zh-blue [localization-crowdin-combine]: https://crowdin.com/project/the-combine [localization-crowdin-flex]: https://crowdin.com/project/fieldworks [github-version-badge]: https://img.shields.io/github/package-json/v/sillsdev/TheCombine diff --git a/docs/user_guide/docs/account.zh.md b/docs/user_guide/docs/account.zh.md new file mode 100644 index 0000000000..5a6fa69f69 --- /dev/null +++ b/docs/user_guide/docs/account.zh.md @@ -0,0 +1,43 @@ +# 账号 + +如何注册、登录并编辑用户账号 + +![登陆](../images/login.png){.center} + +## 注册 + +要创建新账户,请点击登录页面上的 [注册](../../sign-up) 按钮。 + +!!! 笔记 + + 电子邮件地址用于重新设置用户密码,因此,每个电子邮件地址只能有一个账号。 + +## 登录 + +[登录](../../login) 到 The Combine 请键入注册时提供的用户名和密码, + +如果用户想换密码,点击“忘记密码?”链接即可 按照指示去操作,系统会将一套密码重新设置流程发送到与用户账号有关的电子邮件地 +址上。 + +!!! 重要警告 + + 用户名和电子邮件地址不需要区分大小写。 密码必须要区分大小写。 + +## 设置 + +登陆后,沿着 The Combine 的顶部有一个蓝色应用框(App Bar)。 点击应用框右端的头像图标,以打开用户菜单 选“用户设置”就可更 +改账号或简况设置 + +![用户菜单](../images/userMenu.png){.center} + +用户可添加或更新 + +- • 头像图标; +- • 姓名; +- • 电话号码; +- • 电子邮件地址; +- • 用户界面语言; + +!!! 笔记 + + 注册时用到的用户名具有永久性,且无法更改。 diff --git a/docs/user_guide/docs/admin.zh.md b/docs/user_guide/docs/admin.zh.md new file mode 100644 index 0000000000..a8e495ef2f --- /dev/null +++ b/docs/user_guide/docs/admin.zh.md @@ -0,0 +1,32 @@ +# 管理/网站设置 + +网站管理员在用户菜单中还有一个选项:“网站设置”。 + +![用户菜单 - 管理](../images/userMenuAdmin.png){.center} + +## 项目管理 + +管理员可以输出、归档及存储任何项目。 项目一旦归档,所有用户都将无法再访问,甚至对项目创建者也不例外;但任何管理员都可以 +重新恢复该项目。 目前还没有一种方法可以永久删除一个项目。 + +## 用户管理 + +管理员可以删除任何非管理员用户账户。 需添加或移除管理员用户,请与网站所有者联系。 + +## 横幅 + +管理员可通过配置横幅,定义自己的 The Combine 实例。 + +### 通知横幅 + +当用户访问 The Combine 时,通知横幅将一条醒目的横幅摊开在页面顶部。 这些横幅旨在向用户显示那些跟 The Combine 实例有关的 +重要、简短的信息。 信息可能包括定时升级、计划的停机时间,或即将发生的服务器更改。 + +![通知横幅](../images/announcementBanner.png){.center} + +### 登录横幅 + +登陆横幅将一条信息置于登陆页面底部。 该横幅旨在告诉用户需要了解 The Combine 的实例特定信息,例如有关支持、版本升级、数据 +库备份策略的联系人是谁。 + +![登录横幅](../images/loginBanner.png){width=400 .center} diff --git a/docs/user_guide/docs/dataEntry.zh.md b/docs/user_guide/docs/dataEntry.zh.md new file mode 100644 index 0000000000..5bec686c7e --- /dev/null +++ b/docs/user_guide/docs/dataEntry.zh.md @@ -0,0 +1,62 @@ +# 词条输入 + +## 语义域树 + +浏览或查询感兴趣的领域。 + +!!! 提示 + + 为快速查询某个域名,The Combine 会在用户键入一连串连续数字时自动插入“.”符号。 例如,“1234‘就会自动变为“1.2.3.4”。 如果键入任何非数字的字符,此现象则不会发生。 + +## 新词条 + +### 土语 + +出现在土语语言中的词,它通常按照语音拼写或根据当地的正字写出。 + +### 注释 + +一个词条可有多种词义或注释,在开始创建词条时,只能添加一个。 + +### 笔记 + +每个词条可以有一个笔记。 对一个词·条的词义、注释、语义域等的任何注释,都可以加进该词条的笔记中 + +### 录音 + +用户可为每一词条添加多重录音(如男声或女声)。 跟笔记部分一样,音频录音与词条相关联,而非其词义。 + +要录制音频时,有一个红色的圆键。 每一条录好的音频,都有一个绿色三角键 + +**用鼠标** 点击并按住红色圆圈开始录制。 点击绿色三角键播放音频,或按住 移位键并单击以删除其录音。 + +**用触屏** 点击并按住红色圆圈开始录制。 轻触绿色三角键播放其音频,或者下压并按住,以拉出菜单栏(带播放或删除选项)。 + +## 含重复土语形式的新词条 {#new-entry-with-duplicate-vernacular-form} + +如果用户提交的新词条与土语现有的词条有相同的形式和注释,该词条将被更新,不会创建一个新词条。 举例说明:如果用户在域值 +2.1.3.1(手臂) 提交了[土语: dedo; 注释: 手指] 后,又在域值2.1.3.3(手指,脚趾) 提交,结果 “dedo”这词条、含注释为“手指” 单 +个词义及有两个域值。 + +The Combine 有一个可选功能,便于输入项目中已经存在的词条,但 这词条在新语义域中再次被收集。 该功能可以在 +[项目设置 > 自动完成](project.md#autocomplete)打开和关闭。 当该功能打开后,用户在数据输入中键入土语时,就会出现一条下拉 +菜单,而菜单里列出项目中现存完全相同或类似的土语词条。 如果用户看到正在键入的单词出现在该项目中,可以点击 列出的词条,而 +不必键入该单词的其余部分。 当功能关闭后,词条必须完整地输入;不会推荐任何现有的词条。 + +![含重复土语形式的新词条](../images/data-entry-dup-vern.png){.center} + +无论您键入的词条是与项目中的现有词条一样,还是选择 The Combine 提供的建议,都会弹出一个提供选项的方框。 (如果自动完成设 +置处于关闭状态,或者输入的土语词条在项目中不存在,则此方框不会弹出)。 在弹出的方框中,用户可以看到所有类似的词条,然后可 +以选择更新其中一个词条或创建一个新词条。 + +![新词条重复土语的词条](../images/data-entry-dup-vern-select-entry.png){.center} + +如果用户选择创建新词条,弹出的方框就会消失,这时就可以键入新词条的注释。 + +!!! 笔记 + + 尽管用户选择创建新词条,如果键入的注释与土语中另一条完全相同的词条一样,新词条就不会被创建,而是该词条会被更新。 + +如果用户选择更新已有的其中一个词条,屏幕就会显示另一个方框 此时用户可选择更新词条的词义,或者将新的词义添加到该词条。 + +![新词条重复土语的词义。](../images/data-entry-dup-vern-select-sense.png){.center} diff --git a/docs/user_guide/docs/goals.zh.md b/docs/user_guide/docs/goals.zh.md new file mode 100644 index 0000000000..da1b5329db --- /dev/null +++ b/docs/user_guide/docs/goals.zh.md @@ -0,0 +1,173 @@ +# 数据清理/目标 + +## 检阅词条 {#review-entries} + +检查词条表显示出一个项目内所有的词条。 + +### 排序和筛选列 + +这些列包括:编辑,土语,词义,注释,领域,发音,笔记,标记,和删除。 + +![审查词条列标题](../images/reviewEntriesColumns.png) + +主要是文字内容(土语,注释,领域,笔记,或标记) 的列的顶部,用户可根据字母顺序分类,或者采用文本搜素的方式进行筛选 + +在词义和发音列的顶部,用户可根据词条的词义数量或录音数量进行分类或筛选。 + +由于快速字词收集(Rapid Word Collection)自身带有的特性,The Combine 中的 [词条输入](dataEntry.md) 不支持添加定义或词性 +的功能。 但是,如果该项目已输入的数据中已有定义或词类,审查词条表中会自动添加额外的列来表达该信息。 + +### 编辑词条行 + +用户可以使用“发音”列中的图标来录制、播放或删除词条的录音。 用户可以使用删除列中的图标删除整个词条。 + +要编辑词条的语形、词义(含注释和领域)、笔记、标记时,只需点击编辑列的图标。 + +## 合并重复项 {#merge-duplicates} + +该工具会自动查找可能重复的词条(每组最多 5 个词条,每次最多 12 组)。 首先,该工具会找出有相同形式的土语。 接着,它会找出 +类似土语形式或有相同注释(或定义) 的土语。 + +![合并两个重复的词条](../images/mergeTwo.png) + +每个词条都显示在一列中,并且该词条的每种词义都以卡片的形式展现,这样可方便用户点击并拖动。 用户可对每一个词义采取三种基 +本的处理方式:移除、将其与另一个词义合并,或者删除。 + +### 迁移词义 + +当用户点击并按住词义卡时,它会变成绿色。 用户可将词义卡拖放至同一列中的不同位置,以重新排列该词条的各项词义。 或者,也可 +将此卡拖放至不同列,以将该词义移至另一词条。 + +![Merge Duplicates moving a sense](../images/mergeMove.png) + +如果用户想将含有多种词义的词条拆分为多个词条,可将其中一张词义卡拖至右侧空的附加列中。 + +### 合并词义 + +如果将一张词义卡拖至另一张词义卡上,另一张词义卡也会变成绿色。 + +![Merge Duplicates merging a sense](../images/mergeMerge.png) + +将一张词义卡拖放到另一张词义卡上(当它们同时显为绿色时),它们的词义就合并。 这会导致在右侧出现一个蓝色侧边栏,显示出哪些 +词义被合并。 + +![合并重复的词条和合并其词义](../images/mergeSidebar.png) + +!!! 重要警告 + + 当多种词义被合并时,所有语义域得以保留,但“只有侧边栏顶部的词义”保留其注释(和定义)。 + +用户可将词义卡拖放至侧栏,或者从侧栏拖放至词义卡,来决定哪个词义被合并。 或者在侧栏内,用户可将不同的词义移至顶部(以保留 +其注释)。 + +![Merge Duplicates moving a sidebar sense](../images/mergeSidebarMove.png) + +点击右角括号(>) 可关闭或打开蓝色侧边栏。 + +### 删除词义 + +要完全删除一个词义,请将其卡片拖至左下角垃圾桶的图标内。 当词义卡变为红色时,松开鼠标。 + +![合并重复,删除一个词义](../images/mergeDelete.png) + +如果用户删除列中仅剩的一条词义,则整个列将消失,并且在保存& 时,整个词条将被删除。 + +![合并重复的词条,删除词义内容](../images/mergeDeleted.png) + +### 标记词条 + +每列的右上角(位于土语形式右侧) 都有一个旗标图标。 + +![合并重复,标记该词条](../images/mergeFlag.png){.center} + +用户可点击旗标来标记该词条,以用于将来检查或编辑。 (用户可以在[检阅词条](#review-entries)中对标记的词条进行排序)。 当用 +户标记词条时,可选择添加文本。 + +![Merge Duplicates adding or editing a flag](../images/mergeEditFlag.png){.center} + +无论是否有任何文本被键入,用户会知晓该词条已被标记,因旗标图标会变为红色。 如果用户添加了文本,可将鼠标悬停在旗标图标上 +方来查看文本。 + +![Merge Duplicates a flagged entry](../images/mergeFlagged.png){.center} + +点击红色旗标图标来编辑文本,或者移除标记。 + +### 完成一组 + +在底部的位置有两个键,可用于完成目前这一组潜在重复词条有关的工作,并继续下一组的工作:“保存& 继续”和“推迟”。 + +#### 保存 & 继续 + +![合并重复保存并继续按钮](../images/mergeSaveAndContinue.png) + +蓝色的“保存与继续”键有两个功能。 第一,它将所有更改都保存下来(例如,所有已被移除、合并或被删除的词义),并更新数据库中的 +词。 第二,它将任何未合并的词保存为非重复词。 + +!!! 提示 + + 可能的重复是真的重复吗? 用户只需点击 保存& 继续, 即可告诉 The Combine 不再显示该组的资料。 + +!!! 笔记 + + 如果有意不合并词组中有一个被修改(例如,在检阅条目),则该组词会再次显示为可能重复词。 + +#### 推迟 + +![合并重复推迟按钮](../images/mergeDefer.png) + +灰色”推迟”键会重置潜在重复词组所做的任何修改 可通过 "审查被推迟的重复词组 "重新访问被推迟的词组。 。 + +### 与输入的数据合并 + +#### 定义和词性 + +尽管定义与词性无法在词条输入时添加,但它们可以出现在输入的词条中。 出现在“合并重复项”词义卡上的内容: + +- 分析语言内的任何定义都将被显示在该语言的注释下方。 +- 左上角的彩色六边形表示词性 颜色对应其一般类别(例如名词或动词)。 用户将鼠标悬停在六边形上可查看该词的具体语法类别(例 + 如,专有名词或及物动词)。 + +![将重复词义与定义和语性合并](../images/mergeSenseDefinitionsPartOfSpeech.png){.center} + +#### 受保护的词条与词义 + +如果导入的词条或词义项包含 The Combine 不支持的资料(例如词源或词义反转),它将被保护以防止删除。 受保护的词义卡会有一个黄 +色背景。它无法被删除,也无法(被合并)放入另一张词义卡。 如果整个词条受保护,其列将具有黄色标题(位于土语和旗标位置)。 当受 +保护的词条只有一个词义时,该词义卡就无法被移动。 + +## 创建字符库存 + +创建字符工具仅供项目管理员使用。 + +_创建字符库存_" 提供了该项目词条土语形式出现的每个符编码标准字符的概览。 这会使用户识别出该语言中经常用到的字符,并将它 +们“接受”为该语言字符库存的一部分。 字符库属于项目土语的LDML文档一部分,在项目 [导出](project.md#export) 时会包含该文件。 +接受字符后,就能用统一的字符编码标准、在民族语及其他语言资源中表达, + +*创建字符库存*的另一个用途,即为识别和替换那些在键入土语字形时被误用的字符。 + +土语词条中的每个统一编码标准的的字符都有一个图块。 每个图块显示字符、其编码 Unicode "U+" 值,以及它在词条土语形中出现的 +次数并其指定名称(默认:有待决定)。 + +![字符库字符图标](../images/characterInventoryTiles.png) + +### 管理单个字符 + +点击一个字符图块可打开该字符的面板。 + +!!! 提示 + + 用户可能需要滚动才能看到面板。 如果用户电脑可视窗户足够宽,窗的右边会有空白边距;而面板将位于其顶部。 如果电脑可视窗户狭窄,图块就会一直填充到窗户右侧;该面板将位于底部,即所有图块的下方。 + +![字符库字符面板](../images/characterInventoryPanel.png){.center} + +面板中间最多会显示该字符总共出现的5个土语形示例,并突出显示每次出现时的字符。 + +面板顶部有三个键,用于指定该字符是否应含在土语的字符库中。它们是:“接受”、“待定”、“拒绝”。 按其中任何一个键都会更新位于 +字符图块底部的指定。 (这些对字符库所做的修改只有在点击“保存”键后才会被保存到项目。“保存”键在页面底部) + +面板底部是“查找和替换”工具。 如果该字符在*每次*时都应替换成其他字符,只需在“替换为”框中键入替换字符或字符串,然后点击“应 +用”键即可。 + +!!! 重要警告 + + “查找和替换 ”操作更改的是词条,而不是字符库。 **它无法被撤销!** diff --git a/docs/user_guide/docs/index.zh.md b/docs/user_guide/docs/index.zh.md new file mode 100644 index 0000000000..dd32c30da8 --- /dev/null +++ b/docs/user_guide/docs/index.zh.md @@ -0,0 +1,17 @@ +# 概览 + +The Combine 是一个支持 +[快速字词收集](https://www.sil.org/dictionaries-lexicography/rapid-word-collection-methodology)(RWC) 工作坊和后工作坊数 +据清理的工具。 + +## 开始 + +关于如何创建与管理账户,请参看我们 [账户](account.md) 页面。 + +关于如何创建与管理项目,请参看我们 [项目](project.md) 页面。 + +## 收集与组织数据 + +关于如何收集数据,请参阅我们的 [词条输入](dataEntry.md) 页面。 + +关于编辑和整理所收集数据的工具,请参阅我们的 [数据清理](goals.md) 页面。 diff --git a/docs/user_guide/docs/project.zh.md b/docs/user_guide/docs/project.zh.md new file mode 100644 index 0000000000..d704661c96 --- /dev/null +++ b/docs/user_guide/docs/project.zh.md @@ -0,0 +1,167 @@ +# 项目 + +一个项目针对一个土语。 + +## 创建项目 + +创建项目时,用户可选项启动一个空项目,或者输入已有的词典数据。 + +![创建项目 - Tzotzil](../images/projectCreateTzotzil.png){.center} + +### 输入已有数据 + +如果用户已经有词典数据在 [LIFT](https://software.sil.org/lifttools) 文档(很可能是从 The Combine, +[WeSay](https://software.sil.org/wesay), [FLEx](https://software.sil.org/fieldworks), 或 +[Lexique Pro](https://software.sil.org/lexiquepro) 导出的) ,只要点击“下载已有数据?”旁的“浏览”键, 就可将数据输入自己的 +项目。 + +如果用户选择在创建项目时不输入数据,可留待以后再操作(参见[下面](#import))。 + +### 土语语言 + +*土语*指的是字词被收集的语言 这种语言通常是指具有地方性的、本土性的、少数民族的、传统的及面临濒危的一种语言或方言。 项目 +一旦被创建,该土语就无法被更改。 + +如果用户在创建项目时选用LIFT 文档来输入,则会出现一个下拉菜单,用户可以从导入的 LDML 文档所有语言中选择项目的土语。 + +### 分析语言 + +*分析语言*是该土语被译成的语言。 这通常是指使用该土语所在地的一种地区性的、国家性的、官方的或主要的语言。 可在创建项目后 +添加其他分析语言(参 [以下](#project-languages))。 + +如果用户在创建项目的过程中选用LIFT文档来输入,定义或注释中使用的语将会自动添加到项目中作为分析语言 + +## 管理项目 + +一旦创建或选择了一个项目,它就变成活动项目。用户可在 The Combine 顶部应用框的中间位置看到一个齿轮图标或该项目名。 点击齿 +轮图标或项目名,拉出“项目设置”以管理该项目。 具有足够权限的项目用户可以使用以下设置。 + +### 基本设置 + +![基本设置](../images/projectSettings1Basic.png){width=750 .center} + +#### 项目名称 + +建议使用一个区别性和描述性的名称。 [导出](#export) 项目时,项目名称是文件名的一部分。 + +#### 自动完成 {#autocomplete} + +设置默认为开启:当用户用土语输入新词条,此设置会提供现有的类似的词条作为建议,允许用户选择现有词条并为该词条添加新词义, +而不是创建一个(可能是) 重复的词条。 详情请参阅 [词条输入](dataEntry.md#new-entry-with-duplicate-vernacular-form)。 + +(这不影响对注释的拼写建议,因为这些建议是基于独立于现有项目数据的字典的)。 + +#### 存档项目 + +这只有项目所有者才能获取。 将项目存档后,所有用户都无法访问该项目。 只有网站管理员才能取消这一操作。 如果用户希望从服务 +器上完全清除该项目,请与网站管理员联系。 + +### 项目语言 {#project-languages} + +![语言](../images/projectSettings2Langs.png){width=750 .center} + +![项目语言 - Tzotzil](../images/projectLanguagesTzotzil.png){.center} + +创建项目时指定的*土语*是固定的。 + +与项目有关的*分析语言*可能会有很多种,但只有列表中最上面的一种可以输入新词条。 + +!!! 笔记 + + 如果项目有多种语言的注释,则那些语言必须被添加至此处,这样所有注释就可以显示在[数据清理](goals.md). 点击放大镜图标,就能看到呈现在项目中的所有语言代码。 + +*语义领域语言*决定[词条输入](./dataEntry.md)中表达语义领域标题和描述的语言。 + +### 项目用户 + +![用户](../images/projectSettings3Users.png){width=750 .center} + +#### 当前用户 + +在每位项目用户的旁边都有一个带三个垂直点的图标。 如果用户是项目所有者,可点击此处,打开一个用户管理菜单,它里面含有以下 +选项: + +
+    从项目中移除
+更改项目角色:
+收集者
+编辑
+管理员
+成为项目所有者
+[仅对项目所有者改项目管理员时用]
+
+ +_收集者_ 可以进行 [词条输入](./dataEntry.md) ,但不能进行 [数据清理](./goals.md)。 在项目设置中,其他人可以看到 项目语言 +和工作坊时间表,但不能做任何更改。 + +*编辑*具有与*收集者*相同的权限,还可 +以[审阅词条](./goals.md#review-entries)、[合并重复词条](./goals.md#merge-duplicates)和[导出](#export)。 + +_管理员_ 拥有 _编辑_ 的所有权限,还可以修改大多数项目设置和 用户。 + +!!! 重要警告 + + 每个项目只有一个所有者。 如果您将另一个用户成为项目所有者,您将自动从项目所有者变为 + 管理员,而且您将无法再存档项目或为其他用户设置/删除管理员。 + +#### 添加用户 + +可搜素已有的用户(用搜素词显示所有用户的姓名、用户名,或电子邮件地址), 也可以电子邮件的方式邀请新用户(他们在通过邀请创建 +帐户后将自动添加到项目中). + +### 导入/导出 + +![导入/导出](../images/projectSettings4Port.png){width=750 .center} + +#### 导入 {#import} + +!!! 笔记 + + 目前,可导入的 LIFT 文件不可超过 100MB。 + +!!! 笔记 + + 目前,每个项目只能导入一个 LIFT 文件。 + +#### 导出 {#export} + +单击 "导出 "按钮后,在数据准备下载时,用户可以到网站的其他部分浏览。 当导出内容可供下载时,应用程序栏中会出现一个下载图 +标。 默认文件名是项目名称加上下载时间。 + +!!! 重要警告 + + 导出一个数百 MB 大小的项目文件可能需要好几分钟时间。 + +### 工作坊日程 {#workshop-schedule} + +![工作坊日程](../images/projectSettings5Sched.png){width=750 .center} + +只有项目所有人可以使用该功能,以便为快速文字工作坊设定时间。 点击第一个键可选择工作坊日期范围。 点击中间键可添加或移除具 +体日期。 点击最后的键可清除日程表。 + +![工作坊日程](../images/projectSchedule.png){.center} + +## 项目统计数据 + +如果用户是项目所有者,在 The Combine 顶部应用栏齿轮图标的旁边会有另一个图标。 这将打开项目中关于字词的统计数据。 + +![项目统计数据](../images/projectStatsButton.png){.center} + +在这些统计数据中, _词_ 指的是一对词义-词域:例如,一个词条有 3 个词义,每个词义属于2 个语义域,则该词条将被算作 6 个 +词。 + +### 每个用户的单词词数 + +一张表格列出了每个项目用户的词数和独特的语义域。 导入的单词没有相关的用户,将计入 "未知用户"行。 + +### 每个语义域的单词 + +列出每个语义域中单词数量的表格。 + +### 每天字数 + +线形图显示在 [工作坊时间表](#workshop-schedule)中指定的日子里收集到的词语。 + +### 工作坊进度 + +线型图显示了 [工作坊日程](#workshop-schedule)多天所累积的词以及对余下时间的预测。 diff --git a/docs/user_guide/mkdocs.yml b/docs/user_guide/mkdocs.yml index 796c951aae..29f0f225d1 100644 --- a/docs/user_guide/mkdocs.yml +++ b/docs/user_guide/mkdocs.yml @@ -41,8 +41,18 @@ plugins: Data Entry: Entrada de datos Data Cleanup: Depuración de datos Admin: Administración + - locale: zh + name: 中文 + build: true + nav_translations: + Overview: 概览 + Account: 账号 + Projects: 项目 + Data Entry: 词条输入 + Data Cleanup: 数据清理 + Admin: 管理 - search: - lang: [en, es] + lang: [en, es, zh] # This plugin is used to validate URLs (including some anchors). # Uncomment to build the docs with this validation enabled. # This is commented out by default because this requires network I/O to validate URLs, so it is @@ -62,6 +72,9 @@ extra: - name: Español link: /es/ lang: es + - name: 中文 + link: /zh/ + lang: zh social: - icon: fontawesome/solid/globe link: https://www.sil.org/ diff --git a/package.json b/package.json index 67e5a699bc..ff4b70113d 100644 --- a/package.json +++ b/package.json @@ -125,89 +125,6 @@ "source-map-explorer": "^2.5.3", "typescript": "4.9.5" }, - "eslintConfig": { - "extends": [ - "plugin:@typescript-eslint/recommended", - "plugin:react/recommended", - "plugin:react/jsx-runtime", - "plugin:react-hooks/recommended", - "plugin:import/recommended", - "plugin:import/typescript" - ], - "env": { - "browser": true, - "jest": true - }, - "ignorePatterns": [ - "*.dic.js" - ], - "rules": { - "@typescript-eslint/explicit-function-return-type": [ - "warn", - { - "allowExpressions": true - } - ], - "@typescript-eslint/no-empty-interface": "warn", - "@typescript-eslint/no-explicit-any": "off", - "@typescript-eslint/no-inferrable-types": "warn", - "@typescript-eslint/no-unused-vars": "warn", - "@typescript-eslint/switch-exhaustiveness-check": "warn", - "import/first": "warn", - "import/newline-after-import": "warn", - "import/no-duplicates": "warn", - "import/no-named-as-default": "off", - "import/no-named-as-default-member": "off", - "import/order": [ - "warn", - { - "groups": [ - "builtin", - "external", - [ - "internal", - "parent", - "sibling", - "index", - "object", - "type" - ] - ], - "alphabetize": { - "order": "asc" - }, - "newlines-between": "always" - } - ], - "no-undef": "off", - "prefer-const": "warn", - "react/jsx-boolean-value": "warn", - "unused-imports/no-unused-imports": "warn" - }, - "parser": "@typescript-eslint/parser", - "parserOptions": { - "ecmaFeatures": { - "jsx": true - }, - "project": "./tsconfig.json" - }, - "plugins": [ - "@typescript-eslint", - "react", - "unused-imports" - ], - "root": true, - "settings": { - "react": { - "version": "detect" - }, - "import/resolver": { - "typescript": { - "alwaysTryTypes": true - } - } - } - }, "jest": { "coverageReporters": [ "cobertura", diff --git a/public/locales/zh/translation.json b/public/locales/zh/translation.json index a35fb61eb8..b45bf874ff 100644 --- a/public/locales/zh/translation.json +++ b/public/locales/zh/translation.json @@ -130,7 +130,13 @@ }, "projectSettings": { "project": "项目:", - "tab": { "export": "导出", "languages": "语言", "users": "用户" }, + "tab": { + "basic": "基本设置", + "export": "导出", + "importExport": "导入/导出", + "languages": "语言", + "users": "用户" + }, "language": { "header": "语言", "vernacular": "土语", @@ -234,7 +240,8 @@ }, "createStrWordInv": { "title": "创建结构词库" }, "handleFlags": { "title": "处理警告标志" }, - "spellCheckGloss": { "title": "拼写检查词汇表" }, + "reviewDeferredDups": {}, + "spellCheckGloss": { "title": "拼写检查注释" }, "validateChars": { "title": "验证字符" }, "validateStrWords": { "title": "验证结构词" }, "reviewEntries": { @@ -334,7 +341,7 @@ "dups": "把重复项拖动到这里", "sense": "把新词义拖动到这里", "saveAndContinue": "保存更改并加载一组新单词", - "skip": "放弃更改并加载一组新单词", + "defer": "放弃更改并加载一组新单词", "list": "把这个词拖往右边与其他词合并", "noDups": "没有什么可合并的。", "delete": "删除词义", @@ -366,6 +373,7 @@ "cancel": "取消", "clearText": "清除文本", "confirm": "确认", + "defer": "推迟", "delete": "删除", "deletePermanently": "确定永久删除吗?", "done": "完成", @@ -379,7 +387,6 @@ "restore": "还原", "save": "保存", "saveAndContinue": "保存并继续", - "skip": "跳过", "undecided": "未定", "upload": "上传" }, diff --git a/scripts/setupMongo.ts b/scripts/setupMongo.ts index 862f00bf9d..7f8f85a906 100644 --- a/scripts/setupMongo.ts +++ b/scripts/setupMongo.ts @@ -2,7 +2,7 @@ import * as makeDir from "make-dir"; const directory = "./mongo_database"; -const makeMongoDirectory = async () => { +const makeMongoDirectory = async (): Promise => { await makeDir(directory); }; diff --git a/src/components/App/DefaultState.ts b/src/components/App/DefaultState.ts index 5e981553ad..26df9445ac 100644 --- a/src/components/App/DefaultState.ts +++ b/src/components/App/DefaultState.ts @@ -4,20 +4,20 @@ import { defaultState as currentProjectState } from "components/Project/ProjectR import { defaultState as exportProjectState } from "components/ProjectExport/Redux/ExportProjectReduxTypes"; import { defaultState as pronunciationsState } from "components/Pronunciations/Redux/PronunciationsReduxTypes"; import { defaultState as treeViewState } from "components/TreeView/Redux/TreeViewReduxTypes"; -import { defaultState as characterInventoryState } from "goals/CharacterInventory/Redux/CharacterInventoryReducer"; +import { defaultState as characterInventoryState } from "goals/CharacterInventory/Redux/CharacterInventoryReduxTypes"; import { defaultState as mergeDuplicateGoal } from "goals/MergeDuplicates/Redux/MergeDupsReducer"; import { defaultState as reviewEntriesState } from "goals/ReviewEntries/ReviewEntriesComponent/Redux/ReviewEntriesReduxTypes"; import { defaultState as analyticsState } from "types/Redux/analyticsReduxTypes"; export const defaultState = { - //login + //login and signup loginState: { ...loginState }, //project currentProjectState: { ...currentProjectState }, exportProjectState: { ...exportProjectState }, - //data entry and review entries + //data entry and review entries goal treeViewState: { ...treeViewState }, reviewEntriesState: { ...reviewEntriesState }, pronunciationsState: { ...pronunciationsState }, @@ -25,7 +25,7 @@ export const defaultState = { //goal timeline and current goal goalsState: { ...goalTimelineState }, - //merge duplicates goal + //merge duplicates goal and review deferred duplicates goal mergeDuplicateGoal: { ...mergeDuplicateGoal }, //character inventory goal diff --git a/src/components/DataEntry/DataEntryTable/NewEntry/SenseDialog.tsx b/src/components/DataEntry/DataEntryTable/NewEntry/SenseDialog.tsx index 11132c97f4..14df7766a1 100644 --- a/src/components/DataEntry/DataEntryTable/NewEntry/SenseDialog.tsx +++ b/src/components/DataEntry/DataEntryTable/NewEntry/SenseDialog.tsx @@ -1,8 +1,9 @@ +import { Close } from "@mui/icons-material"; import { Dialog, DialogContent, - Divider, Grid, + IconButton, MenuList, Typography, } from "@mui/material"; @@ -50,7 +51,7 @@ export default function SenseDialog(props: SenseDialogProps): ReactElement { interface SenseListProps { selectedWord: Word; - closeDialog: (gloss: string) => void; + closeDialog: (gloss?: string) => void; analysisLang: string; } @@ -95,11 +96,7 @@ export function SenseList(props: SenseListProps): ReactElement { ); }; - const menuItems: ReactElement[] = []; - for (const s of props.selectedWord.senses) { - menuItems.push(menuItem(s)); - menuItems.push(); - } + const menuItems = props.selectedWord.senses.map(menuItem); menuItems.push( props.closeDialog("")}> {t("addWords.newSenseFor")} @@ -109,7 +106,16 @@ export function SenseList(props: SenseListProps): ReactElement { return ( <> + {/* Cancel button */} + props.closeDialog()} + style={{ position: "absolute", right: 0, top: 0 }} + > + + + {/* Header */} {t("addWords.selectSense")} + {/* Sense options */} {menuItems} ); diff --git a/src/components/DataEntry/DataEntryTable/NewEntry/StyledMenuItem.ts b/src/components/DataEntry/DataEntryTable/NewEntry/StyledMenuItem.ts index 0410fedf5b..2cb3895f4f 100644 --- a/src/components/DataEntry/DataEntryTable/NewEntry/StyledMenuItem.ts +++ b/src/components/DataEntry/DataEntryTable/NewEntry/StyledMenuItem.ts @@ -4,6 +4,9 @@ import { withStyles } from "@mui/styles"; // Copied from customized menus at https://material-ui.com/components/menus/ const StyledMenuItem = withStyles((theme) => ({ root: { + border: "1px solid gray", + borderRadius: "8px", + marginTop: "8px", "&:focus": { backgroundColor: theme.palette.primary.main, "& .MuiListItemIcon-root, & .MuiListItemText-primary": { diff --git a/src/components/DataEntry/DataEntryTable/NewEntry/VernDialog.tsx b/src/components/DataEntry/DataEntryTable/NewEntry/VernDialog.tsx index 77e96dc084..b489398dff 100644 --- a/src/components/DataEntry/DataEntryTable/NewEntry/VernDialog.tsx +++ b/src/components/DataEntry/DataEntryTable/NewEntry/VernDialog.tsx @@ -1,8 +1,9 @@ +import { Close } from "@mui/icons-material"; import { Dialog, DialogContent, - Divider, Grid, + IconButton, MenuList, Typography, } from "@mui/material"; @@ -50,7 +51,7 @@ export default function VernDialog(props: vernDialogProps): ReactElement { interface VernListProps { vernacularWords: Word[]; - closeDialog: (wordId: string) => void; + closeDialog: (wordId?: string) => void; analysisLang?: string; } @@ -96,11 +97,7 @@ export function VernList(props: VernListProps): ReactElement { ); }; - const menuItems: ReactElement[] = []; - for (const w of props.vernacularWords) { - menuItems.push(menuItem(w)); - menuItems.push(); - } + const menuItems = props.vernacularWords.map(menuItem); menuItems.push( props.closeDialog("")}> {t("addWords.newEntryFor")} @@ -110,7 +107,16 @@ export function VernList(props: VernListProps): ReactElement { return ( <> + {/* Cancel button */} + props.closeDialog()} + style={{ position: "absolute", right: 0, top: 0 }} + > + + + {/* Header */} {t("addWords.selectEntry")} + {/* Entry options */} {menuItems} ); diff --git a/src/components/DataEntry/index.tsx b/src/components/DataEntry/index.tsx index 6b37c8e8d0..8a03c5a48b 100644 --- a/src/components/DataEntry/index.tsx +++ b/src/components/DataEntry/index.tsx @@ -15,10 +15,7 @@ import DataEntryTable from "components/DataEntry/DataEntryTable"; import ExistingDataTable from "components/DataEntry/ExistingDataTable"; import { filterWordsByDomain } from "components/DataEntry/utilities"; import TreeView from "components/TreeView"; -import { - closeTreeAction, - openTreeAction, -} from "components/TreeView/Redux/TreeViewActions"; +import { closeTree, openTree } from "components/TreeView/Redux/TreeViewActions"; import { StoreState } from "types"; import { useAppDispatch, useAppSelector } from "types/hooks"; import { newSemanticDomain } from "types/semanticDomain"; @@ -73,7 +70,7 @@ export default function DataEntry(): ReactElement { // On first render, open tree. useLayoutEffect(() => { - dispatch(openTreeAction()); + dispatch(openTree()); }, [dispatch]); // When window width changes, check if there's space for the sidebar. @@ -99,43 +96,46 @@ export default function DataEntry(): ReactElement { setDomainWords( filterWordsByDomain(await getFrontierWords(), id, analysisLang) ); - dispatch(closeTreeAction()); + dispatch(closeTree()); }, [analysisLang, dispatch, id]); return ( - - - - + {!open && !!domain.guid && ( + + + + + + 0} + hideQuestions={() => setQuestionsVisible(false)} + isTreeOpen={open} + openTree={() => dispatch(openTree())} + semanticDomain={currentDomain} + showExistingData={() => setDrawerOpen(true)} + updateHeight={updateHeight} + /> + + + - - 0} - hideQuestions={() => setQuestionsVisible(false)} - isTreeOpen={open} - openTree={() => dispatch(openTreeAction())} - semanticDomain={currentDomain} - showExistingData={() => setDrawerOpen(true)} - updateHeight={updateHeight} + domainWords={domainWords} + drawerOpen={drawerOpen} + height={height} + toggleDrawer={setDrawerOpen} + typeDrawer={isSmallScreen} /> - - - - - + + )} + - + ); } diff --git a/src/components/DataEntry/tests/index.test.tsx b/src/components/DataEntry/tests/index.test.tsx index 85a4ec1aff..a459dc7ef9 100644 --- a/src/components/DataEntry/tests/index.test.tsx +++ b/src/components/DataEntry/tests/index.test.tsx @@ -9,11 +9,8 @@ import DataEntry, { treeViewDialogId, } from "components/DataEntry"; import { defaultState as currentProjectState } from "components/Project/ProjectReduxTypes"; -import { openTreeAction } from "components/TreeView/Redux/TreeViewActions"; -import { - TreeViewAction, - TreeViewState, -} from "components/TreeView/Redux/TreeViewReduxTypes"; +import { openTree } from "components/TreeView/Redux/TreeViewActions"; +import { TreeViewState } from "components/TreeView/Redux/TreeViewReduxTypes"; import { newSemanticDomainTreeNode } from "types/semanticDomain"; import * as useWindowSize from "utilities/useWindowSize"; @@ -39,7 +36,7 @@ jest.mock("types/hooks", () => { }; }); -const mockDispatch = jest.fn((action: TreeViewAction) => action); +const mockDispatch = jest.fn((action: any) => action); const mockDomain = newSemanticDomainTreeNode("mockId", "mockName", "mockLang"); const mockGetSemanticDomainFull = jest.fn(); const mockStore = createMockStore(); @@ -72,7 +69,7 @@ describe("DataEntry", () => { it("dispatches to open the tree", async () => { await renderDataEntry({ currentDomain: mockDomain }); - expect(mockDispatch).toHaveBeenCalledWith(openTreeAction()); + expect(mockDispatch).toHaveBeenCalledWith(openTree()); }); it("fetches domain", async () => { diff --git a/src/components/Project/ProjectActions.ts b/src/components/Project/ProjectActions.ts index 70bd79f323..ba4348c53c 100644 --- a/src/components/Project/ProjectActions.ts +++ b/src/components/Project/ProjectActions.ts @@ -1,31 +1,35 @@ +import { Action, PayloadAction } from "@reduxjs/toolkit"; + import { Project, User } from "api/models"; import { getAllProjectUsers, updateProject } from "backend"; import { setProjectId } from "backend/localStorage"; import { - ProjectAction, - ProjectActionType, -} from "components/Project/ProjectReduxTypes"; + resetAction, + setProjectAction, + setUsersAction, +} from "components/Project/ProjectReducer"; import { StoreStateDispatch } from "types/Redux/actions"; +import { newProject } from "types/project"; -export function setCurrentProject(payload?: Project): ProjectAction { - return { - type: ProjectActionType.SET_CURRENT_PROJECT, - payload, - }; +// Action Creation Functions + +export function resetCurrentProject(): Action { + return resetAction(); } -export function setCurrentProjectUsers(payload?: User[]): ProjectAction { - return { - type: ProjectActionType.SET_CURRENT_PROJECT_USERS, - payload, - }; +export function setCurrentProject(project?: Project): PayloadAction { + return setProjectAction(project ?? newProject()); } -export function clearCurrentProject() { - return (dispatch: StoreStateDispatch) => { - setProjectId(); - dispatch(setCurrentProject()); - dispatch(setCurrentProjectUsers()); +export function setCurrentProjectUsers(users?: User[]): PayloadAction { + return setUsersAction(users ?? []); +} + +// Dispatch Functions + +export function asyncRefreshProjectUsers(projectId: string) { + return async (dispatch: StoreStateDispatch) => { + dispatch(setCurrentProjectUsers(await getAllProjectUsers(projectId))); }; } @@ -36,9 +40,10 @@ export function asyncUpdateCurrentProject(project: Project) { }; } -export function asyncRefreshProjectUsers(projectId: string) { - return async (dispatch: StoreStateDispatch) => { - dispatch(setCurrentProjectUsers(await getAllProjectUsers(projectId))); +export function clearCurrentProject() { + return (dispatch: StoreStateDispatch) => { + setProjectId(); + dispatch(resetCurrentProject()); }; } diff --git a/src/components/Project/ProjectReducer.ts b/src/components/Project/ProjectReducer.ts index 13aa66a8f1..7670d31c74 100644 --- a/src/components/Project/ProjectReducer.ts +++ b/src/components/Project/ProjectReducer.ts @@ -1,27 +1,28 @@ -import { - CurrentProjectState, - defaultState, - ProjectAction, - ProjectActionType, -} from "components/Project/ProjectReduxTypes"; -import { StoreAction, StoreActionTypes } from "rootActions"; -import { newProject } from "types/project"; +import { createSlice } from "@reduxjs/toolkit"; -export const projectReducer = ( - state = defaultState, - action: ProjectAction | StoreAction -): CurrentProjectState => { - switch (action.type) { - case ProjectActionType.SET_CURRENT_PROJECT: - if (action.payload?.id === state.project.id) { - return { ...state, project: action.payload }; +import { defaultState } from "components/Project/ProjectReduxTypes"; +import { StoreActionTypes } from "rootActions"; + +const projectSlice = createSlice({ + name: "currentProjectState", + initialState: defaultState, + reducers: { + resetAction: () => defaultState, + setProjectAction: (state, action) => { + if (state.project.id !== action.payload.id) { + state.users = []; } - return { project: action.payload ?? newProject(), users: [] }; - case ProjectActionType.SET_CURRENT_PROJECT_USERS: - return { ...state, users: action.payload ?? [] }; - case StoreActionTypes.RESET: - return defaultState; - default: - return state; - } -}; + state.project = action.payload; + }, + setUsersAction: (state, action) => { + state.users = action.payload; + }, + }, + extraReducers: (builder) => + builder.addCase(StoreActionTypes.RESET, () => defaultState), +}); + +export const { resetAction, setProjectAction, setUsersAction } = + projectSlice.actions; + +export default projectSlice.reducer; diff --git a/src/components/Project/ProjectReduxTypes.ts b/src/components/Project/ProjectReduxTypes.ts index 0fb02750e5..1ba4450825 100644 --- a/src/components/Project/ProjectReduxTypes.ts +++ b/src/components/Project/ProjectReduxTypes.ts @@ -1,25 +1,6 @@ import { Project, User } from "api/models"; import { newProject } from "types/project"; -export enum ProjectActionType { - SET_CURRENT_PROJECT = "SET_CURRENT_PROJECT", - SET_CURRENT_PROJECT_USERS = "SET_CURRENT_PROJECT_USERS", -} - -export interface SetCurrentProjectAction { - type: ProjectActionType.SET_CURRENT_PROJECT; - payload?: Project; -} - -export interface SetCurrentProjectUsersAction { - type: ProjectActionType.SET_CURRENT_PROJECT_USERS; - payload?: User[]; -} - -export type ProjectAction = - | SetCurrentProjectAction - | SetCurrentProjectUsersAction; - export interface CurrentProjectState { project: Project; users: User[]; diff --git a/src/components/Project/tests/ProjectActions.test.tsx b/src/components/Project/tests/ProjectActions.test.tsx new file mode 100644 index 0000000000..b8fbd72593 --- /dev/null +++ b/src/components/Project/tests/ProjectActions.test.tsx @@ -0,0 +1,104 @@ +import { PreloadedState } from "redux"; + +import { Project } from "api/models"; +import { defaultState } from "components/App/DefaultState"; +import { + asyncRefreshProjectUsers, + asyncUpdateCurrentProject, + clearCurrentProject, + setNewCurrentProject, +} from "components/Project/ProjectActions"; +import { RootState, setupStore } from "store"; +import { newProject } from "types/project"; +import { newUser } from "types/user"; + +jest.mock("backend", () => ({ + getAllProjectUsers: (...args: any[]) => mockGetAllProjectUsers(...args), + updateProject: (...args: any[]) => mockUpdateProject(...args), +})); + +const mockGetAllProjectUsers = jest.fn(); +const mockUpdateProject = jest.fn(); +const mockProjId = "project-id"; + +// Preloaded values for store when testing +const persistedDefaultState: PreloadedState = { + ...defaultState, + _persist: { version: 1, rehydrated: false }, +}; + +describe("ProjectActions", () => { + describe("asyncUpdateCurrentProject", () => { + it("updates the backend and correctly affects state for different id", async () => { + const proj: Project = { ...newProject(), id: mockProjId }; + const store = setupStore({ + ...persistedDefaultState, + currentProjectState: { project: proj, users: [newUser()] }, + }); + const id = "new-id"; + await store.dispatch(asyncUpdateCurrentProject({ ...proj, id })); + expect(mockUpdateProject).toBeCalledTimes(1); + const { project, users } = store.getState().currentProjectState; + expect(project.id).toEqual(id); + expect(users).toHaveLength(0); + }); + + it("updates the backend and correctly affects state for same id", async () => { + const proj: Project = { ...newProject(), id: mockProjId }; + const store = setupStore({ + ...persistedDefaultState, + currentProjectState: { project: proj, users: [newUser()] }, + }); + const name = "new-name"; + await store.dispatch(asyncUpdateCurrentProject({ ...proj, name })); + expect(mockUpdateProject).toBeCalledTimes(1); + const { project, users } = store.getState().currentProjectState; + expect(project.name).toEqual(name); + expect(users).toHaveLength(1); + }); + }); + + describe("asyncRefreshProjectUsers", () => { + it("correctly affects state", async () => { + const proj: Project = { ...newProject(), id: mockProjId }; + const store = setupStore({ + ...persistedDefaultState, + currentProjectState: { project: proj, users: [] }, + }); + const mockUsers = [newUser(), newUser(), newUser()]; + mockGetAllProjectUsers.mockResolvedValueOnce(mockUsers); + await store.dispatch(asyncRefreshProjectUsers("mockProjId")); + const { project, users } = store.getState().currentProjectState; + expect(project.id).toEqual(mockProjId); + expect(users).toHaveLength(mockUsers.length); + }); + }); + + describe("clearCurrentProject", () => { + it("correctly affects state", () => { + const nonDefaultState = { + project: { ...newProject(), id: "nonempty-string" }, + users: [newUser()], + }; + const store = setupStore({ + ...persistedDefaultState, + currentProjectState: nonDefaultState, + }); + store.dispatch(clearCurrentProject()); + const { project, users } = store.getState().currentProjectState; + expect(project.id).toEqual(""); + expect(users).toHaveLength(0); + }); + }); + + describe("setNewCurrentProject", () => { + it("correctly affects state and doesn't update the backend", () => { + const proj: Project = { ...newProject(), id: mockProjId }; + const store = setupStore(); + store.dispatch(setNewCurrentProject(proj)); + expect(mockUpdateProject).not.toBeCalled(); + const { project } = store.getState().currentProjectState; + expect(project.id).toEqual(mockProjId); + }); + }); +}); diff --git a/src/components/Project/tests/ProjectReducer.test.tsx b/src/components/Project/tests/ProjectReducer.test.tsx deleted file mode 100644 index 1c4cee05bc..0000000000 --- a/src/components/Project/tests/ProjectReducer.test.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import { projectReducer } from "components/Project/ProjectReducer"; -import { - CurrentProjectState, - defaultState, - ProjectAction, - ProjectActionType, -} from "components/Project/ProjectReduxTypes"; -import { StoreAction, StoreActionTypes } from "rootActions"; -import { newProject } from "types/project"; -import { newUser } from "types/user"; - -describe("ProjectReducer", () => { - it("returns default state when passed reset action", () => { - const action: StoreAction = { - type: StoreActionTypes.RESET, - }; - - expect(projectReducer({} as CurrentProjectState, action)).toEqual( - defaultState - ); - }); - - describe("SetCurrentProject", () => { - it("Preserves users when project id is the same", () => { - const id = "same"; - const action: ProjectAction = { - type: ProjectActionType.SET_CURRENT_PROJECT, - payload: { ...newProject(), id }, - }; - - const user = newUser(); - expect( - projectReducer( - { project: { id }, users: [user] } as CurrentProjectState, - action - ) - ).toEqual({ project: action.payload, users: [user] }); - }); - - it("Resets users when project id changes", () => { - const action: ProjectAction = { - type: ProjectActionType.SET_CURRENT_PROJECT, - payload: newProject(), - }; - - expect( - projectReducer( - { - project: { id: "different" }, - users: [newUser()], - } as CurrentProjectState, - action - ) - ).toEqual({ project: action.payload, users: [] }); - }); - }); - - describe("SetCurrentProjectUsers", () => { - it("Updates users; preserves project.", () => { - const action: ProjectAction = { - type: ProjectActionType.SET_CURRENT_PROJECT_USERS, - payload: [], - }; - - const id = "unique"; - expect( - projectReducer( - { project: { id }, users: [newUser()] } as CurrentProjectState, - action - ) - ).toEqual({ project: { id }, users: [] }); - }); - }); -}); diff --git a/src/components/ProjectScreen/index.tsx b/src/components/ProjectScreen/index.tsx index 67af430b19..363b8d88d2 100644 --- a/src/components/ProjectScreen/index.tsx +++ b/src/components/ProjectScreen/index.tsx @@ -4,7 +4,7 @@ import { ReactElement, useEffect } from "react"; import { clearCurrentProject } from "components/Project/ProjectActions"; import ChooseProject from "components/ProjectScreen/ChooseProject"; import CreateProject from "components/ProjectScreen/CreateProject"; -import { resetTreeAction } from "components/TreeView/Redux/TreeViewActions"; +import { resetTree } from "components/TreeView/Redux/TreeViewActions"; import { useAppDispatch } from "types/hooks"; /** Where users create a project or choose an existing one */ @@ -13,7 +13,7 @@ export default function ProjectScreen(): ReactElement { /* Disable Data Entry, Data Cleanup, Project Settings until a project is selected or created. */ useEffect(() => { dispatch(clearCurrentProject()); - dispatch(resetTreeAction()); + dispatch(resetTree()); }, [dispatch]); return ( diff --git a/src/components/Pronunciations/AudioPlayer.tsx b/src/components/Pronunciations/AudioPlayer.tsx index fae2ac34d5..421b05bd5a 100644 --- a/src/components/Pronunciations/AudioPlayer.tsx +++ b/src/components/Pronunciations/AudioPlayer.tsx @@ -14,7 +14,7 @@ import { useTranslation } from "react-i18next"; import { ButtonConfirmation } from "components/Dialogs"; import { playing, - reset, + resetPronunciations, } from "components/Pronunciations/Redux/PronunciationsActions"; import { PronunciationsStatus } from "components/Pronunciations/Redux/PronunciationsReduxTypes"; import { StoreState } from "types"; @@ -38,8 +38,8 @@ const useStyles = makeStyles((theme: Theme) => export default function AudioPlayer(props: PlayerProps): ReactElement { const isPlaying = useAppSelector( (state: StoreState) => - state.pronunciationsState.payload === props.fileName && - state.pronunciationsState.type === PronunciationsStatus.Playing + state.pronunciationsState.fileName === props.fileName && + state.pronunciationsState.status === PronunciationsStatus.Playing ); const [audio] = useState(new Audio(props.pronunciationUrl)); @@ -48,7 +48,10 @@ export default function AudioPlayer(props: PlayerProps): ReactElement { const classes = useStyles(); const dispatch = useAppDispatch(); - const dispatchReset = useCallback(() => dispatch(reset()), [dispatch]); + const dispatchReset = useCallback( + () => dispatch(resetPronunciations()), + [dispatch] + ); const { t } = useTranslation(); useEffect(() => { diff --git a/src/components/Pronunciations/RecorderIcon.tsx b/src/components/Pronunciations/RecorderIcon.tsx index b19e04a872..c7aeafb629 100644 --- a/src/components/Pronunciations/RecorderIcon.tsx +++ b/src/components/Pronunciations/RecorderIcon.tsx @@ -6,7 +6,7 @@ import { useTranslation } from "react-i18next"; import { recording, - reset, + resetPronunciations, } from "components/Pronunciations/Redux/PronunciationsActions"; import { PronunciationsStatus } from "components/Pronunciations/Redux/PronunciationsReduxTypes"; import { StoreState } from "types"; @@ -23,9 +23,12 @@ interface RecorderIconProps { } export default function RecorderIcon(props: RecorderIconProps): ReactElement { - const pronunciationsState = useAppSelector( - (state: StoreState) => state.pronunciationsState + const isRecording = useAppSelector( + (state: StoreState) => + state.pronunciationsState.status === PronunciationsStatus.Recording && + state.pronunciationsState.wordId === props.wordId ); + const dispatch = useAppDispatch(); const { t } = useTranslation(); @@ -43,7 +46,7 @@ export default function RecorderIcon(props: RecorderIconProps): ReactElement { } function toggleIsRecordingToFalse(): void { props.stopRecording(); - dispatch(reset()); + dispatch(resetPronunciations()); } function handleTouchStart(): void { @@ -77,12 +80,7 @@ export default function RecorderIcon(props: RecorderIconProps): ReactElement { tabIndex={-1} > diff --git a/src/components/Pronunciations/Redux/PronunciationsActions.ts b/src/components/Pronunciations/Redux/PronunciationsActions.ts index 89a44d5ebb..4e63d7a14a 100644 --- a/src/components/Pronunciations/Redux/PronunciationsActions.ts +++ b/src/components/Pronunciations/Redux/PronunciationsActions.ts @@ -1,22 +1,21 @@ +import { Action, PayloadAction } from "@reduxjs/toolkit"; + import { - PronunciationsAction, - PronunciationsStatus, -} from "components/Pronunciations/Redux/PronunciationsReduxTypes"; + resetAction, + setPlayingAction, + setRecordingAction, +} from "components/Pronunciations/Redux/PronunciationsReducer"; + +// Action Creation Functions -export function playing(payload: string): PronunciationsAction { - return { - type: PronunciationsStatus.Playing, - payload, - }; +export function playing(fileName: string): PayloadAction { + return setPlayingAction(fileName); } -export function recording(payload: string): PronunciationsAction { - return { - type: PronunciationsStatus.Recording, - payload, - }; +export function recording(wordId: string): PayloadAction { + return setRecordingAction(wordId); } -export function reset(): PronunciationsAction { - return { type: PronunciationsStatus.Default }; +export function resetPronunciations(): Action { + return resetAction(); } diff --git a/src/components/Pronunciations/Redux/PronunciationsReducer.ts b/src/components/Pronunciations/Redux/PronunciationsReducer.ts index e8c7e40b98..a37e046ae2 100644 --- a/src/components/Pronunciations/Redux/PronunciationsReducer.ts +++ b/src/components/Pronunciations/Redux/PronunciationsReducer.ts @@ -1,25 +1,32 @@ +import { createSlice } from "@reduxjs/toolkit"; + import { defaultState, - PronunciationsAction, PronunciationsStatus, - PronunciationsState, } from "components/Pronunciations/Redux/PronunciationsReduxTypes"; -import { StoreAction, StoreActionTypes } from "rootActions"; +import { StoreActionTypes } from "rootActions"; + +const pronunciationsSlice = createSlice({ + name: "pronunciationsState", + initialState: defaultState, + reducers: { + resetAction: () => defaultState, + setPlayingAction: (state, action) => { + state.fileName = action.payload; + state.status = PronunciationsStatus.Playing; + state.wordId = ""; + }, + setRecordingAction: (state, action) => { + state.fileName = ""; + state.status = PronunciationsStatus.Recording; + state.wordId = action.payload; + }, + }, + extraReducers: (builder) => + builder.addCase(StoreActionTypes.RESET, () => defaultState), +}); + +export const { resetAction, setPlayingAction, setRecordingAction } = + pronunciationsSlice.actions; -export const pronunciationsReducer = ( - state: PronunciationsState = defaultState, - action: StoreAction | PronunciationsAction -): PronunciationsState => { - switch (action.type) { - case PronunciationsStatus.Playing: - return { ...defaultState, ...action }; - case PronunciationsStatus.Recording: - return { ...defaultState, ...action }; - case PronunciationsStatus.Default: - return defaultState; - case StoreActionTypes.RESET: - return defaultState; - default: - return state; - } -}; +export default pronunciationsSlice.reducer; diff --git a/src/components/Pronunciations/Redux/PronunciationsReduxTypes.ts b/src/components/Pronunciations/Redux/PronunciationsReduxTypes.ts index e4804d86ca..d0d1357012 100644 --- a/src/components/Pronunciations/Redux/PronunciationsReduxTypes.ts +++ b/src/components/Pronunciations/Redux/PronunciationsReduxTypes.ts @@ -1,20 +1,17 @@ export enum PronunciationsStatus { - Default = "DEFAULT", + Inactive = "INACTIVE", Playing = "PLAYING", Recording = "RECORDING", } -export interface PronunciationsAction { - type: PronunciationsStatus; - payload?: string; -} - export interface PronunciationsState { - type: PronunciationsStatus; - payload: string; + fileName: string; + status: PronunciationsStatus; + wordId: string; } export const defaultState: PronunciationsState = { - type: PronunciationsStatus.Default, - payload: "", + fileName: "", + status: PronunciationsStatus.Inactive, + wordId: "", }; diff --git a/src/components/Pronunciations/tests/AudioRecorder.test.tsx b/src/components/Pronunciations/tests/AudioRecorder.test.tsx index 1c6bedbbfd..4d59607eb1 100644 --- a/src/components/Pronunciations/tests/AudioRecorder.test.tsx +++ b/src/components/Pronunciations/tests/AudioRecorder.test.tsx @@ -1,6 +1,6 @@ import { StyledEngineProvider, ThemeProvider } from "@mui/material/styles"; import { Provider } from "react-redux"; -import renderer from "react-test-renderer"; +import { ReactTestRenderer, act, create } from "react-test-renderer"; import configureMockStore from "redux-mock-store"; import "tests/reactI18nextMock"; @@ -11,32 +11,31 @@ import RecorderIcon, { recordIconId, } from "components/Pronunciations/RecorderIcon"; import { - PronunciationsState, defaultState as pronunciationsState, PronunciationsStatus, } from "components/Pronunciations/Redux/PronunciationsReduxTypes"; +import { StoreState } from "types"; import theme from "types/theme"; jest.mock("components/Pronunciations/Recorder"); -let testRenderer: renderer.ReactTestRenderer; +let testRenderer: ReactTestRenderer; const createMockStore = configureMockStore(); const mockStore = createMockStore({ pronunciationsState }); -function mockRecordingState(wordId: string): { - pronunciationsState: Partial; -} { +function mockRecordingState(wordId: string): Partial { return { pronunciationsState: { - type: PronunciationsStatus.Recording, - payload: wordId, + fileName: "", + status: PronunciationsStatus.Recording, + wordId, }, }; } beforeAll(() => { - renderer.act(() => { - testRenderer = renderer.create( + act(() => { + testRenderer = create( @@ -52,8 +51,8 @@ describe("Pronunciations", () => { test("pointerDown and pointerUp", () => { const mockStartRecording = jest.fn(); const mockStopRecording = jest.fn(); - renderer.act(() => { - testRenderer = renderer.create( + act(() => { + testRenderer = create( @@ -78,8 +77,8 @@ describe("Pronunciations", () => { }); test("default style is iconRelease", () => { - renderer.act(() => { - testRenderer = renderer.create( + act(() => { + testRenderer = create( @@ -96,8 +95,8 @@ describe("Pronunciations", () => { test("style depends on pronunciations state", () => { const wordId = "1"; const mockStore2 = createMockStore(mockRecordingState(wordId)); - renderer.act(() => { - testRenderer = renderer.create( + act(() => { + testRenderer = create( diff --git a/src/components/TreeView/Redux/TreeViewActions.ts b/src/components/TreeView/Redux/TreeViewActions.ts index 808adf7b83..f193907f47 100644 --- a/src/components/TreeView/Redux/TreeViewActions.ts +++ b/src/components/TreeView/Redux/TreeViewActions.ts @@ -1,60 +1,57 @@ +import { Action, PayloadAction } from "@reduxjs/toolkit"; + import { SemanticDomain, SemanticDomainTreeNode } from "api/models"; import { getSemanticDomainTreeNode } from "backend"; import { - TreeActionType, - TreeViewAction, -} from "components/TreeView/Redux/TreeViewReduxTypes"; + resetTreeAction, + setCurrentDomainAction, + setDomainLanguageAction, + setTreeOpenAction, +} from "components/TreeView/Redux/TreeViewReducer"; import { StoreState } from "types"; import { StoreStateDispatch } from "types/Redux/actions"; -export function closeTreeAction(): TreeViewAction { - return { type: TreeActionType.CLOSE_TREE }; +// Action Creation Functions + +export function closeTree(): PayloadAction { + return setTreeOpenAction(false); } -export function openTreeAction(): TreeViewAction { - return { type: TreeActionType.OPEN_TREE }; +export function openTree(): PayloadAction { + return setTreeOpenAction(true); } -export function setDomainAction( - domain: SemanticDomainTreeNode -): TreeViewAction { - return { type: TreeActionType.SET_CURRENT_DOMAIN, domain }; +export function resetTree(): Action { + return resetTreeAction(); } -export function setDomainLanguageAction(language: string): TreeViewAction { - return { type: TreeActionType.SET_DOMAIN_LANGUAGE, language }; +export function setCurrentDomain( + domain: SemanticDomainTreeNode +): PayloadAction { + return setCurrentDomainAction(domain); } -export function resetTreeAction(): TreeViewAction { - return { type: TreeActionType.RESET_TREE }; +export function setDomainLanguage(language: string): PayloadAction { + return setDomainLanguageAction(language); } +// Dispatch Functions + export function traverseTree(domain: SemanticDomain) { return async (dispatch: StoreStateDispatch) => { - if (domain) { - await getSemanticDomainTreeNode(domain.id, domain.lang).then( - (response) => { - if (response) { - dispatch(setDomainAction(response)); - } - } - ); - } - }; -} - -export function updateTreeLanguage(language: string) { - return (dispatch: StoreStateDispatch) => { - if (language) { - dispatch(setDomainLanguageAction(language)); + if (domain.id) { + const dom = await getSemanticDomainTreeNode(domain.id, domain.lang); + if (dom) { + dispatch(setCurrentDomain(dom)); + } } }; } -export function initTreeDomain(language = "") { +export function initTreeDomain(lang = "") { return async (dispatch: StoreStateDispatch, getState: () => StoreState) => { - const currentDomain = getState().treeViewState.currentDomain; - currentDomain.lang = language; - await dispatch(traverseTree(currentDomain)); + await dispatch( + traverseTree({ ...getState().treeViewState.currentDomain, lang }) + ); }; } diff --git a/src/components/TreeView/Redux/TreeViewReducer.ts b/src/components/TreeView/Redux/TreeViewReducer.ts index 148fa5c314..8de4cdedb7 100644 --- a/src/components/TreeView/Redux/TreeViewReducer.ts +++ b/src/components/TreeView/Redux/TreeViewReducer.ts @@ -1,39 +1,33 @@ -import { - TreeViewAction, - TreeActionType, - TreeViewState, - defaultState, -} from "components/TreeView/Redux/TreeViewReduxTypes"; -import { StoreAction, StoreActionTypes } from "rootActions"; +import { createSlice } from "@reduxjs/toolkit"; -export const treeViewReducer = ( - state: TreeViewState = defaultState, - action: StoreAction | TreeViewAction -): TreeViewState => { - switch (action.type) { - case TreeActionType.CLOSE_TREE: - return { ...state, open: false }; - case TreeActionType.OPEN_TREE: - return { ...state, open: true }; - case TreeActionType.RESET_TREE: - return defaultState; - case TreeActionType.SET_DOMAIN_LANGUAGE: - if (!action.language) { - throw new Error("Cannot set domain language to undefined."); - } - return { - ...state, - currentDomain: { ...state.currentDomain, lang: action.language }, - language: action.language, - }; - case TreeActionType.SET_CURRENT_DOMAIN: - if (!action.domain) { - throw new Error("Cannot set the current domain to undefined."); - } - return { ...state, currentDomain: action.domain }; - case StoreActionTypes.RESET: - return defaultState; - default: - return state; - } -}; +import { defaultState } from "components/TreeView/Redux/TreeViewReduxTypes"; +import { StoreActionTypes } from "rootActions"; + +const treeViewSlice = createSlice({ + name: "treeViewState", + initialState: defaultState, + reducers: { + resetTreeAction: () => defaultState, + setCurrentDomainAction: (state, action) => { + state.currentDomain = action.payload; + }, + setDomainLanguageAction: (state, action) => { + state.currentDomain.lang = action.payload; + state.language = action.payload; + }, + setTreeOpenAction: (state, action) => { + state.open = action.payload; + }, + }, + extraReducers: (builder) => + builder.addCase(StoreActionTypes.RESET, () => defaultState), +}); + +export const { + resetTreeAction, + setCurrentDomainAction, + setDomainLanguageAction, + setTreeOpenAction, +} = treeViewSlice.actions; + +export default treeViewSlice.reducer; diff --git a/src/components/TreeView/Redux/TreeViewReduxTypes.tsx b/src/components/TreeView/Redux/TreeViewReduxTypes.tsx index b210d50f51..922a2e62b8 100644 --- a/src/components/TreeView/Redux/TreeViewReduxTypes.tsx +++ b/src/components/TreeView/Redux/TreeViewReduxTypes.tsx @@ -1,20 +1,6 @@ import { SemanticDomainTreeNode } from "api/models"; import { newSemanticDomainTreeNode } from "types/semanticDomain"; -export enum TreeActionType { - CLOSE_TREE = "CLOSE_TREE", - OPEN_TREE = "OPEN_TREE", - RESET_TREE = "RESET_TREE", - SET_DOMAIN_LANGUAGE = "SET_DOMAIN_LANGUAGE", - SET_CURRENT_DOMAIN = "SET_CURRENT_DOMAIN", -} - -export interface TreeViewAction { - type: TreeActionType; - domain?: SemanticDomainTreeNode; - language?: string; -} - export interface TreeViewState { currentDomain: SemanticDomainTreeNode; language: string; diff --git a/src/components/TreeView/Redux/tests/TreeViewActions.test.tsx b/src/components/TreeView/Redux/tests/TreeViewActions.test.tsx index d00025db16..19eebad99b 100644 --- a/src/components/TreeView/Redux/tests/TreeViewActions.test.tsx +++ b/src/components/TreeView/Redux/tests/TreeViewActions.test.tsx @@ -1,19 +1,19 @@ -import configureMockStore from "redux-mock-store"; -import thunk from "redux-thunk"; +import { PreloadedState } from "redux"; +import { defaultState } from "components/App/DefaultState"; import { - setDomainLanguageAction, + initTreeDomain, + setDomainLanguage, traverseTree, } from "components/TreeView/Redux/TreeViewActions"; +import { RootState, setupStore } from "store"; import { - defaultState, - TreeActionType, -} from "components/TreeView/Redux/TreeViewReduxTypes"; -import { newSemanticDomainTreeNode } from "types/semanticDomain"; + newSemanticDomain, + newSemanticDomainTreeNode, +} from "types/semanticDomain"; jest.mock("backend", () => ({ - getSemanticDomainTreeNode: (id: string, lang: string) => - mockGetSemDomTreeNode(id, lang), + getSemanticDomainTreeNode: (...args: any[]) => mockGetSemDomTreeNode(...args), })); const mockGetSemDomTreeNode = jest.fn(); @@ -21,41 +21,58 @@ const mockGetSemDomTreeNode = jest.fn(); // Mock the track and identify methods of segment analytics. global.analytics = { identify: jest.fn(), track: jest.fn() } as any; -const createMockStore = configureMockStore([thunk]); -const mockState = defaultState; - -describe("TraverseTreeAction", () => { - it("SetDomainLanguage returns correct action", async () => { - const language = "lang"; - const action = { - type: TreeActionType.SET_DOMAIN_LANGUAGE, - language, - }; - const mockStore = createMockStore(mockState); - await mockStore.dispatch(setDomainLanguageAction("lang")); - expect(mockStore.getActions()).toEqual([action]); - }); +const mockId = "id"; +const mockLang = "lang"; + +// Preloaded values for store when testing +const persistedDefaultState: PreloadedState = { + ...defaultState, + _persist: { version: 1, rehydrated: false }, +}; - it("TraverseTreeAction dispatches on successful", async () => { - const mockDomainReturned = newSemanticDomainTreeNode("id", "name"); - mockGetSemDomTreeNode.mockResolvedValue(mockDomainReturned); - const domain = { id: "id", name: "name", guid: "", lang: "" }; - const action = { - type: TreeActionType.SET_CURRENT_DOMAIN, - domain: mockDomainReturned, - }; - const mockStore = createMockStore(mockState); - - await mockStore.dispatch(traverseTree(domain)); - expect(mockStore.getActions()).toEqual([action]); +describe("TreeViewActions", () => { + describe("setDomainLanguage", () => { + it("correctly affects state", async () => { + const store = setupStore(); + store.dispatch(setDomainLanguage(mockLang)); + const { currentDomain, language } = store.getState().treeViewState; + expect(currentDomain.lang).toEqual(mockLang); + expect(language).toEqual(mockLang); + }); }); - it("TraverseTreeAction does not dispatch on null return", async () => { - mockGetSemDomTreeNode.mockResolvedValue(undefined); - const domain = { id: "id", name: "name", guid: "", lang: "" }; - const mockStore = createMockStore(mockState); + describe("traverseTree", () => { + it("dispatches on successful", async () => { + const store = setupStore(); + const dom = newSemanticDomain(mockId); + mockGetSemDomTreeNode.mockResolvedValue(dom); + await store.dispatch(traverseTree(dom)); + const { currentDomain } = store.getState().treeViewState; + expect(currentDomain.id).toEqual(mockId); + }); + + it("does not dispatch on undefined", async () => { + const store = setupStore(); + mockGetSemDomTreeNode.mockResolvedValue(undefined); + await store.dispatch(traverseTree(newSemanticDomain(mockId))); + const { currentDomain } = store.getState().treeViewState; + expect(currentDomain.id).not.toEqual(mockId); + }); + }); - await mockStore.dispatch(traverseTree(domain)); - expect(mockStore.getActions()).toEqual([]); + describe("initTreeDomain", () => { + it("changes domain lang but not id", async () => { + const nonDefaultState = { + currentDomain: newSemanticDomainTreeNode(mockId), + language: "", + open: true, + }; + const store = setupStore({ + ...persistedDefaultState, + treeViewState: nonDefaultState, + }); + await store.dispatch(initTreeDomain(mockLang)); + expect(mockGetSemDomTreeNode).toBeCalledWith(mockId, mockLang); + }); }); }); diff --git a/src/components/TreeView/Redux/tests/TreeViewReducer.test.tsx b/src/components/TreeView/Redux/tests/TreeViewReducer.test.tsx deleted file mode 100644 index 58b03da1f7..0000000000 --- a/src/components/TreeView/Redux/tests/TreeViewReducer.test.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import { treeViewReducer } from "components/TreeView/Redux/TreeViewReducer"; -import { - defaultState, - TreeActionType, - TreeViewAction, - TreeViewState, -} from "components/TreeView/Redux/TreeViewReduxTypes"; -import { StoreAction, StoreActionTypes } from "rootActions"; -import { newSemanticDomainTreeNode } from "types/semanticDomain"; - -describe("Test the TreeViewReducer", () => { - it("Returns defaultState when passed undefined", () => { - expect(treeViewReducer(undefined, {} as TreeViewAction)).toEqual( - defaultState - ); - }); - - it("Returns default state when tree reset action is passed", () => { - const action: TreeViewAction = { type: TreeActionType.RESET_TREE }; - expect(treeViewReducer({} as TreeViewState, action)).toEqual(defaultState); - }); - - it("Returns default state when store reset action is passed", () => { - const action: StoreAction = { type: StoreActionTypes.RESET }; - expect(treeViewReducer({} as TreeViewState, action)).toEqual(defaultState); - }); - - it("Returns state passed in when passed an invalid action", () => { - const badAction = { type: "Nothing" } as any as TreeViewAction; - expect(treeViewReducer({ ...defaultState, open: true }, badAction)).toEqual( - { ...defaultState, open: true } - ); - }); - - it("Closes the tree when requested", () => { - expect( - treeViewReducer( - { ...defaultState, open: true }, - { type: TreeActionType.CLOSE_TREE } - ) - ).toEqual({ ...defaultState, open: false }); - }); - - it("Opens the tree when requested", () => { - expect( - treeViewReducer( - { ...defaultState, open: false }, - { type: TreeActionType.OPEN_TREE } - ) - ).toEqual({ ...defaultState, open: true }); - }); - - it("Returns state with a new SemanticDomain when requested to change this value", () => { - const payload = newSemanticDomainTreeNode("testId", "testName"); - expect( - treeViewReducer(defaultState, { - type: TreeActionType.SET_CURRENT_DOMAIN, - domain: payload, - }) - ).toEqual({ ...defaultState, currentDomain: payload }); - }); -}); diff --git a/src/components/TreeView/TreeSearch.tsx b/src/components/TreeView/TreeSearch.tsx index 9372cc1c5b..34d8d6dd46 100644 --- a/src/components/TreeView/TreeSearch.tsx +++ b/src/components/TreeView/TreeSearch.tsx @@ -18,9 +18,22 @@ export const testId = "testSearch"; export default function TreeSearch(props: TreeSearchProps): ReactElement { const { t } = useTranslation(); - const { input, handleChange, searchAndSelectDomain, searchError } = + const { input, handleChange, searchAndSelectDomain, searchError, setInput } = useTreeSearch(props); + const handleOnKeyUp = (event: React.KeyboardEvent): void => { + event.bubbles = false; + if (event.key === Key.Enter) { + // Use onKeyUp so that this fires after onChange, to facilitate + // error state clearing. + event.stopPropagation(); + searchAndSelectDomain(event); + } else if (event.key === Key.Backspace) + if (input && input[input.length - 1] === ".") { + setInput(input.slice(0, input.length - 1)); + } + }; + return ( ) => void; searchAndSelectDomain: (event: React.KeyboardEvent) => void; searchError: boolean; + setInput: (text: string) => void; } // Exported for unit testing only @@ -96,26 +108,20 @@ export function useTreeSearch(props: TreeSearchProps): TreeSearchState { async function searchAndSelectDomain( event: React.KeyboardEvent ): Promise { - event.bubbles = false; - - if (event.key === Key.Enter) { - event.preventDefault(); - - // Search for domain - let domain: SemanticDomainTreeNode | undefined; - if (!isNaN(parseInt(input))) { - domain = await getSemanticDomainTreeNode(input, lang); - } else { - domain = await searchDomainByName(input); - } - if (domain) { - animateSuccessfulSearch(domain, event); - // Return to indicate success and skip setting error state. - return; - } - // Did not find a domain through either numerical or textual search. - setSearchError(true); + // Search for domain + let domain: SemanticDomainTreeNode | undefined; + if (!isNaN(parseInt(input))) { + domain = await getSemanticDomainTreeNode(input, lang); + } else { + domain = await searchDomainByName(input); + } + if (domain) { + animateSuccessfulSearch(domain, event); + // Return to indicate success and skip setting error state. + return; } + // Did not find a domain through either numerical or textual search. + setSearchError(true); } // Change the input on typing @@ -131,6 +137,7 @@ export function useTreeSearch(props: TreeSearchProps): TreeSearchState { handleChange, searchAndSelectDomain, searchError, + setInput, }; } diff --git a/src/components/TreeView/index.tsx b/src/components/TreeView/index.tsx index 09602f8d7f..1527363fcf 100644 --- a/src/components/TreeView/index.tsx +++ b/src/components/TreeView/index.tsx @@ -9,8 +9,8 @@ import { SemanticDomain, WritingSystem } from "api"; import { IconButtonWithTooltip } from "components/Buttons"; import { initTreeDomain, + setDomainLanguage, traverseTree, - updateTreeLanguage, } from "components/TreeView/Redux/TreeViewActions"; import { defaultTreeNode } from "components/TreeView/Redux/TreeViewReduxTypes"; import TreeDepiction from "components/TreeView/TreeDepiction"; @@ -57,7 +57,7 @@ export default function TreeView(props: TreeViewProps): ReactElement { const newLang = getSemDomWritingSystem(semDomWritingSystem)?.bcp47 ?? resolvedLanguage; if (newLang && newLang !== semDomLanguage) { - dispatch(updateTreeLanguage(newLang)); + dispatch(setDomainLanguage(newLang)); } dispatch(initTreeDomain(newLang)); }, [semDomLanguage, semDomWritingSystem, dispatch, resolvedLanguage]); diff --git a/src/goals/CharacterInventory/CharInv/CharacterDetail/tests/index.test.tsx b/src/goals/CharacterInventory/CharInv/CharacterDetail/tests/index.test.tsx index a49b42547e..12b4f41014 100644 --- a/src/goals/CharacterInventory/CharInv/CharacterDetail/tests/index.test.tsx +++ b/src/goals/CharacterInventory/CharInv/CharacterDetail/tests/index.test.tsx @@ -16,7 +16,7 @@ import { buttonIdSubmit, } from "goals/CharacterInventory/CharInv/CharacterDetail/FindAndReplace"; import CharacterReplaceDialog from "goals/CharacterInventory/CharInv/CharacterDetail/FindAndReplace/CharacterReplaceDialog"; -import { defaultState } from "goals/CharacterInventory/Redux/CharacterInventoryReducer"; +import { defaultState } from "goals/CharacterInventory/Redux/CharacterInventoryReduxTypes"; import { StoreState } from "types"; // Dialog uses portals, which are not supported in react-test-renderer. diff --git a/src/goals/CharacterInventory/CharInv/CharacterList/index.tsx b/src/goals/CharacterInventory/CharInv/CharacterList/index.tsx index 6cae3c7e17..b25f18bb31 100644 --- a/src/goals/CharacterInventory/CharInv/CharacterList/index.tsx +++ b/src/goals/CharacterInventory/CharInv/CharacterList/index.tsx @@ -36,8 +36,9 @@ export default function CharacterList(): ReactElement { }; useEffect(() => { - setOrderedChars(sortBy(allChars, sortOrder)); - }, [allChars, setOrderedChars, sortOrder]); + // Spread allChars to not mutate the Redux state. + setOrderedChars(sortBy([...allChars], sortOrder)); + }, [allChars, sortOrder]); return ( <> diff --git a/src/goals/CharacterInventory/CharInv/CharacterList/tests/index.test.tsx b/src/goals/CharacterInventory/CharInv/CharacterList/tests/index.test.tsx index 8f56dad1f5..e501972fbd 100644 --- a/src/goals/CharacterInventory/CharInv/CharacterList/tests/index.test.tsx +++ b/src/goals/CharacterInventory/CharInv/CharacterList/tests/index.test.tsx @@ -1,24 +1,26 @@ import { Provider } from "react-redux"; -import renderer from "react-test-renderer"; +import { ReactTestRenderer, act, create } from "react-test-renderer"; import configureMockStore from "redux-mock-store"; import "tests/reactI18nextMock"; import CharacterList from "goals/CharacterInventory/CharInv/CharacterList"; import CharacterCard from "goals/CharacterInventory/CharInv/CharacterList/CharacterCard"; -import { defaultState } from "goals/CharacterInventory/Redux/CharacterInventoryReducer"; -import { newCharacterSetEntry } from "goals/CharacterInventory/Redux/CharacterInventoryReduxTypes"; +import { + defaultState, + newCharacterSetEntry, +} from "goals/CharacterInventory/Redux/CharacterInventoryReduxTypes"; const characterSet = ["q", "w", "e", "r", "t", "y"].map(newCharacterSetEntry); const mockStore = configureMockStore()({ characterInventoryState: { ...defaultState, characterSet }, }); -let testRenderer: renderer.ReactTestRenderer; +let testRenderer: ReactTestRenderer; beforeEach(async () => { - await renderer.act(async () => { - testRenderer = renderer.create( + await act(async () => { + testRenderer = create( diff --git a/src/goals/CharacterInventory/CharInv/index.tsx b/src/goals/CharacterInventory/CharInv/index.tsx index 83376299a7..34a853cc27 100644 --- a/src/goals/CharacterInventory/CharInv/index.tsx +++ b/src/goals/CharacterInventory/CharInv/index.tsx @@ -18,7 +18,7 @@ import CharacterSetHeader from "goals/CharacterInventory/CharInv/CharacterSetHea import { exit, loadCharInvData, - resetInState, + resetCharInv, setSelectedCharacter, uploadInventory, } from "goals/CharacterInventory/Redux/CharacterInventoryActions"; @@ -54,7 +54,7 @@ export default function CharacterInventory(): ReactElement { dispatch(loadCharInvData()); // Call when component unmounts. - () => dispatch(resetInState()); + () => dispatch(resetCharInv()); }, [dispatch]); const save = async (): Promise => { diff --git a/src/goals/CharacterInventory/CharInv/tests/index.test.tsx b/src/goals/CharacterInventory/CharInv/tests/index.test.tsx index 88a6106331..c8ed1d0c98 100644 --- a/src/goals/CharacterInventory/CharInv/tests/index.test.tsx +++ b/src/goals/CharacterInventory/CharInv/tests/index.test.tsx @@ -11,7 +11,7 @@ import CharInv, { dialogButtonIdYes, dialogIdCancel, } from "goals/CharacterInventory/CharInv"; -import { defaultState as characterInventoryState } from "goals/CharacterInventory/Redux/CharacterInventoryReducer"; +import { defaultState as characterInventoryState } from "goals/CharacterInventory/Redux/CharacterInventoryReduxTypes"; // Replace Dialog with something that doesn't create portals, // because react-test-renderer does not support portals. @@ -27,7 +27,7 @@ jest.mock("goals/CharacterInventory/CharInv/CharacterDetail", () => "div"); jest.mock("goals/CharacterInventory/Redux/CharacterInventoryActions", () => ({ exit: () => mockExit(), loadCharInvData: () => mockLoadCharInvData(), - resetInState: () => jest.fn(), + reset: () => jest.fn(), setSelectedCharacter: () => mockSetSelectedCharacter(), uploadInventory: () => mockUploadInventory(), })); diff --git a/src/goals/CharacterInventory/Redux/CharacterInventoryActions.ts b/src/goals/CharacterInventory/Redux/CharacterInventoryActions.ts index fc75d252d6..c14ac5bc33 100644 --- a/src/goals/CharacterInventory/Redux/CharacterInventoryActions.ts +++ b/src/goals/CharacterInventory/Redux/CharacterInventoryActions.ts @@ -1,3 +1,5 @@ +import { Action, PayloadAction } from "@reduxjs/toolkit"; + import { Project } from "api/models"; import { getFrontierWords } from "backend"; import router from "browserRouter"; @@ -10,84 +12,59 @@ import { CharacterStatus, CharacterChange, } from "goals/CharacterInventory/CharacterInventoryTypes"; +import { + addRejectedCharacterAction, + addValidCharacterAction, + resetCharInvAction, + setAllWordsAction, + setCharacterSetAction, + setRejectedCharactersAction, + setSelectedCharacterAction, + setValidCharactersAction, +} from "goals/CharacterInventory/Redux/CharacterInventoryReducer"; import { CharacterInventoryState, CharacterSetEntry, - CharacterInventoryAction, - CharacterInventoryType, getCharacterStatus, } from "goals/CharacterInventory/Redux/CharacterInventoryReduxTypes"; import { StoreState } from "types"; import { StoreStateDispatch } from "types/Redux/actions"; import { Path } from "types/path"; -// Action Creators +// Action Creation Functions -export function addToValidCharacters( - chars: string[] -): CharacterInventoryAction { - return { - type: CharacterInventoryType.ADD_TO_VALID_CHARACTERS, - payload: chars, - }; +export function addRejectedCharacter(char: string): PayloadAction { + return addRejectedCharacterAction(char); } -export function addToRejectedCharacters( - chars: string[] -): CharacterInventoryAction { - return { - type: CharacterInventoryType.ADD_TO_REJECTED_CHARACTERS, - payload: chars, - }; +export function addValidCharacter(char: string): PayloadAction { + return addValidCharacterAction(char); } -export function setValidCharacters(chars: string[]): CharacterInventoryAction { - return { - type: CharacterInventoryType.SET_VALID_CHARACTERS, - payload: chars, - }; +export function resetCharInv(): Action { + return resetCharInvAction(); } -export function setRejectedCharacters( - chars: string[] -): CharacterInventoryAction { - return { - type: CharacterInventoryType.SET_REJECTED_CHARACTERS, - payload: chars, - }; +export function setAllWords(words: string[]): PayloadAction { + return setAllWordsAction(words); } -export function setAllWords(words: string[]): CharacterInventoryAction { - return { - type: CharacterInventoryType.SET_ALL_WORDS, - payload: words, - }; +export function setCharacterSet( + characterSet: CharacterSetEntry[] +): PayloadAction { + return setCharacterSetAction(characterSet); } -export function setSelectedCharacter( - character: string -): CharacterInventoryAction { - return { - type: CharacterInventoryType.SET_SELECTED_CHARACTER, - payload: [character], - }; +export function setRejectedCharacters(chars: string[]): PayloadAction { + return setRejectedCharactersAction(chars); } -export function setCharacterSet( - characterSet: CharacterSetEntry[] -): CharacterInventoryAction { - return { - type: CharacterInventoryType.SET_CHARACTER_SET, - payload: [], - characterSet, - }; +export function setSelectedCharacter(character: string): PayloadAction { + return setSelectedCharacterAction(character); } -export function resetInState(): CharacterInventoryAction { - return { - type: CharacterInventoryType.RESET, - payload: [], - }; +export function setValidCharacters(chars: string[]): PayloadAction { + return setValidCharactersAction(chars); } // Dispatch Functions @@ -96,10 +73,10 @@ export function setCharacterStatus(character: string, status: CharacterStatus) { return (dispatch: StoreStateDispatch, getState: () => StoreState) => { switch (status) { case CharacterStatus.Accepted: - dispatch(addToValidCharacters([character])); + dispatch(addValidCharacter(character)); break; case CharacterStatus.Rejected: - dispatch(addToRejectedCharacters([character])); + dispatch(addRejectedCharacter(character)); break; case CharacterStatus.Undecided: const state = getState().characterInventoryState; @@ -120,17 +97,23 @@ export function setCharacterStatus(character: string, status: CharacterStatus) { }; } -// Sends the character inventory to the server. +/** Sends the in-state character inventory to the server. */ export function uploadInventory() { return async (dispatch: StoreStateDispatch, getState: () => StoreState) => { - const state = getState(); - const changes = getChangesFromState(state); + const charInvState = getState().characterInventoryState; + const project = getState().currentProjectState.project; + const changes = getChanges(project, charInvState); if (!changes.length) { exit(); return; } - const updatedProject = updateCurrentProject(state); - await dispatch(asyncUpdateCurrentProject(updatedProject)); + await dispatch( + asyncUpdateCurrentProject({ + ...project, + rejectedCharacters: charInvState.rejectedCharacters, + validCharacters: charInvState.validCharacters, + }) + ); dispatch(addCharInvChangesToGoal(changes)); await dispatch(asyncUpdateGoal()); exit(); @@ -146,28 +129,17 @@ export function fetchWords() { export function getAllCharacters() { return async (dispatch: StoreStateDispatch, getState: () => StoreState) => { - const state = getState(); - const words = await getFrontierWords(); - const charactersWithDuplicates: string[] = []; - words.forEach((word) => charactersWithDuplicates.push(...word.vernacular)); - const characters = [...new Set(charactersWithDuplicates)]; - - const characterSet: CharacterSetEntry[] = []; - characters.forEach((letter) => { - characterSet.push({ - character: letter, - occurrences: countCharacterOccurrences( - letter, - words.map((word) => word.vernacular) - ), - status: getCharacterStatus( - letter, - state.currentProjectState.project.validCharacters, - state.currentProjectState.project.rejectedCharacters - ), - }); - }); - dispatch(setCharacterSet(characterSet)); + const allWords = getState().characterInventoryState.allWords; + const characters = new Set(); + allWords.forEach((w) => [...w].forEach((c) => characters.add(c))); + const { rejectedCharacters, validCharacters } = + getState().currentProjectState.project; + const entries: CharacterSetEntry[] = [...characters].map((c) => ({ + character: c, + occurrences: countOccurrences(c, allWords), + status: getCharacterStatus(c, validCharacters, rejectedCharacters), + })); + dispatch(setCharacterSet(entries)); }; } @@ -188,7 +160,10 @@ export function exit(): void { router.navigate(Path.Goals); } -function countCharacterOccurrences(char: string, words: string[]): number { +function countOccurrences(char: string, words: string[]): number { + if (char.length !== 1) { + console.error(`countOccurrences expects length 1 char, but got: ${char}`); + } let count = 0; for (const word of words) { for (const letter of word) { @@ -200,19 +175,13 @@ function countCharacterOccurrences(char: string, words: string[]): number { return count; } -function getChangesFromState(state: StoreState): CharacterChange[] { - const proj = state.currentProjectState.project; - const charInvState = state.characterInventoryState; - return getChanges(proj, charInvState); -} - export function getChanges( - proj: Project, + project: Project, charInvState: CharacterInventoryState ): CharacterChange[] { - const oldAcc = proj.validCharacters; + const oldAcc = project.validCharacters; const newAcc = charInvState.validCharacters; - const oldRej = proj.rejectedCharacters; + const oldRej = project.rejectedCharacters; const newRej = charInvState.rejectedCharacters; const allCharacters = [ ...new Set([...oldAcc, ...newAcc, ...oldRej, ...newRej]), @@ -261,10 +230,3 @@ function getChange( } return undefined; } - -function updateCurrentProject(state: StoreState): Project { - const project = state.currentProjectState.project; - project.validCharacters = state.characterInventoryState.validCharacters; - project.rejectedCharacters = state.characterInventoryState.rejectedCharacters; - return project; -} diff --git a/src/goals/CharacterInventory/Redux/CharacterInventoryReducer.ts b/src/goals/CharacterInventory/Redux/CharacterInventoryReducer.ts index afba0b6f91..f40f7b0ce6 100644 --- a/src/goals/CharacterInventory/Redux/CharacterInventoryReducer.ts +++ b/src/goals/CharacterInventory/Redux/CharacterInventoryReducer.ts @@ -1,119 +1,111 @@ +import { createSlice } from "@reduxjs/toolkit"; + import { - CharacterInventoryAction, - CharacterInventoryType, - CharacterInventoryState, - CharacterSetEntry, getCharacterStatus, + defaultState, } from "goals/CharacterInventory/Redux/CharacterInventoryReduxTypes"; -import { StoreAction, StoreActionTypes } from "rootActions"; +import { StoreActionTypes } from "rootActions"; -export const defaultState: CharacterInventoryState = { - validCharacters: [], - rejectedCharacters: [], - allWords: [], - selectedCharacter: "", - characterSet: [], -}; +const characterInventorySlice = createSlice({ + name: "characterInventoryState", + initialState: defaultState, + reducers: { + addRejectedCharacterAction: (state, action) => { + if (!state.rejectedCharacters.includes(action.payload)) { + state.rejectedCharacters.push(action.payload); + } -export const characterInventoryReducer = ( - state: CharacterInventoryState = defaultState, - action: StoreAction | CharacterInventoryAction -): CharacterInventoryState => { - let validCharacters: string[]; - let rejectedCharacters: string[]; - let characterSet: CharacterSetEntry[]; - switch (action.type) { - case CharacterInventoryType.SET_VALID_CHARACTERS: - // Set prevents duplicate characters - validCharacters = [...new Set(action.payload)]; - rejectedCharacters = state.rejectedCharacters.filter( - (char) => !validCharacters.includes(char) - ); + const index = state.validCharacters.findIndex((c) => c == action.payload); + if (index !== -1) { + state.validCharacters.splice(index, 1); + } - // Set status of characters in character set - characterSet = state.characterSet.map((entry) => { + const entry = state.characterSet.find( + (e) => e.character === action.payload + ); + if (entry) { entry.status = getCharacterStatus( entry.character, - validCharacters, - rejectedCharacters + state.validCharacters, + state.rejectedCharacters ); - return entry; - }); - return { ...state, validCharacters, rejectedCharacters, characterSet }; + } + }, + addValidCharacterAction: (state, action) => { + if (!state.validCharacters.includes(action.payload)) { + state.validCharacters.push(action.payload); + } - case CharacterInventoryType.SET_REJECTED_CHARACTERS: - rejectedCharacters = [...new Set(action.payload)]; - validCharacters = state.validCharacters.filter( - (char) => !rejectedCharacters.includes(char) + const index = state.rejectedCharacters.findIndex( + (c) => c == action.payload ); + if (index !== -1) { + state.rejectedCharacters.splice(index, 1); + } - // Set status of characters in character set - characterSet = state.characterSet.map((entry) => { + const entry = state.characterSet.find( + (e) => e.character === action.payload + ); + if (entry) { entry.status = getCharacterStatus( entry.character, - validCharacters, - rejectedCharacters + state.validCharacters, + state.rejectedCharacters ); - return entry; - }); - return { ...state, validCharacters, rejectedCharacters, characterSet }; - - case CharacterInventoryType.ADD_TO_VALID_CHARACTERS: - validCharacters = [ - ...new Set(state.validCharacters.concat(action.payload)), - ]; - rejectedCharacters = state.rejectedCharacters.filter( - (char) => !validCharacters.includes(char) + } + }, + resetCharInvAction: () => defaultState, + setAllWordsAction: (state, action) => { + state.allWords = action.payload; + }, + setCharacterSetAction: (state, action) => { + if (action.payload) { + state.characterSet = action.payload; + } + }, + setRejectedCharactersAction: (state, action) => { + state.rejectedCharacters = [...new Set(action.payload as string[])]; + state.validCharacters = state.validCharacters.filter( + (char) => !state.rejectedCharacters.includes(char) ); - - // Set status of characters in character set - characterSet = state.characterSet.map((entry) => { + for (const entry of state.characterSet) { entry.status = getCharacterStatus( entry.character, - validCharacters, - rejectedCharacters + state.validCharacters, + state.rejectedCharacters ); - return entry; - }); - return { ...state, validCharacters, rejectedCharacters, characterSet }; - - case CharacterInventoryType.ADD_TO_REJECTED_CHARACTERS: - rejectedCharacters = [ - ...new Set(state.rejectedCharacters.concat(action.payload)), - ]; - validCharacters = state.validCharacters.filter( - (char) => !rejectedCharacters.includes(char) + } + }, + setSelectedCharacterAction: (state, action) => { + state.selectedCharacter = action.payload; + }, + setValidCharactersAction: (state, action) => { + state.validCharacters = [...new Set(action.payload as string[])]; + state.rejectedCharacters = state.rejectedCharacters.filter( + (char) => !state.validCharacters.includes(char) ); - - // Set status of characters in character set - characterSet = state.characterSet.map((entry) => { + for (const entry of state.characterSet) { entry.status = getCharacterStatus( entry.character, - validCharacters, - rejectedCharacters + state.validCharacters, + state.rejectedCharacters ); - return entry; - }); - return { ...state, validCharacters, rejectedCharacters, characterSet }; - - case CharacterInventoryType.SET_SELECTED_CHARACTER: - return { ...state, selectedCharacter: action.payload[0] }; - - case CharacterInventoryType.SET_ALL_WORDS: - return { ...state, allWords: action.payload }; - - case CharacterInventoryType.SET_CHARACTER_SET: - return action.characterSet - ? { ...state, characterSet: action.characterSet } - : state; - - case CharacterInventoryType.RESET: - return defaultState; + } + }, + }, + extraReducers: (builder) => + builder.addCase(StoreActionTypes.RESET, () => defaultState), +}); - case StoreActionTypes.RESET: - return defaultState; +export const { + addRejectedCharacterAction, + addValidCharacterAction, + resetCharInvAction, + setAllWordsAction, + setCharacterSetAction, + setRejectedCharactersAction, + setSelectedCharacterAction, + setValidCharactersAction, +} = characterInventorySlice.actions; - default: - return state; - } -}; +export default characterInventorySlice.reducer; diff --git a/src/goals/CharacterInventory/Redux/CharacterInventoryReduxTypes.ts b/src/goals/CharacterInventory/Redux/CharacterInventoryReduxTypes.ts index f8850c9256..d1b7cdbe5f 100644 --- a/src/goals/CharacterInventory/Redux/CharacterInventoryReduxTypes.ts +++ b/src/goals/CharacterInventory/Redux/CharacterInventoryReduxTypes.ts @@ -1,17 +1,6 @@ import { CharacterStatus } from "goals/CharacterInventory/CharacterInventoryTypes"; -export enum CharacterInventoryType { - SET_VALID_CHARACTERS = "SET_VALID_CHARACTERS", - SET_REJECTED_CHARACTERS = "SET_REJECTED_CHARACTERS", - ADD_TO_VALID_CHARACTERS = "ADD_TO_VALID_CHARACTERS", - ADD_TO_REJECTED_CHARACTERS = "ADD_TO_REJECTED_CHARACTERS", - SET_ALL_WORDS = "SET_ALL_WORDS", - SET_SELECTED_CHARACTER = "SET_SELECTED_CHARACTER", - SET_CHARACTER_SET = "SET_CHARACTER_SET", - RESET = "CHAR_INV_RESET", -} - -// Utility function for returning a CharacterStatus from arrays of character data +/** Utility function for returning a CharacterStatus from arrays of character data */ export function getCharacterStatus( char: string, validChars: string[], @@ -26,12 +15,6 @@ export function getCharacterStatus( return CharacterStatus.Undecided; } -export interface CharacterInventoryAction { - type: CharacterInventoryType; - payload: string[]; - characterSet?: CharacterSetEntry[]; -} - export interface CharacterInventoryState { validCharacters: string[]; rejectedCharacters: string[]; @@ -40,8 +23,15 @@ export interface CharacterInventoryState { characterSet: CharacterSetEntry[]; } -/** A character with its occurrences and status, - * for sorting and filtering in a list */ +export const defaultState: CharacterInventoryState = { + validCharacters: [], + rejectedCharacters: [], + allWords: [], + selectedCharacter: "", + characterSet: [], +}; + +/** A character with its occurrences and status, for sorting and filtering in a list */ export interface CharacterSetEntry { character: string; occurrences: number; diff --git a/src/goals/CharacterInventory/Redux/tests/CharacterInventoryActions.test.tsx b/src/goals/CharacterInventory/Redux/tests/CharacterInventoryActions.test.tsx index eddf0a64f8..1151c04b77 100644 --- a/src/goals/CharacterInventory/Redux/tests/CharacterInventoryActions.test.tsx +++ b/src/goals/CharacterInventory/Redux/tests/CharacterInventoryActions.test.tsx @@ -1,129 +1,248 @@ -import { Action } from "redux"; -import configureMockStore from "redux-mock-store"; -import thunk from "redux-thunk"; +import { Action, PreloadedState } from "redux"; import { Project } from "api/models"; -import { updateProject } from "backend"; -import { ProjectActionType } from "components/Project/ProjectReduxTypes"; +import { defaultState } from "components/App/DefaultState"; import { CharacterStatus, CharacterChange, } from "goals/CharacterInventory/CharacterInventoryTypes"; -import * as Actions from "goals/CharacterInventory/Redux/CharacterInventoryActions"; -import { defaultState } from "goals/CharacterInventory/Redux/CharacterInventoryReducer"; import { + fetchWords, + getAllCharacters, + getChanges, + loadCharInvData, + setCharacterStatus, + uploadInventory, +} from "goals/CharacterInventory/Redux/CharacterInventoryActions"; +import { + defaultState as defaultCharInvState, CharacterInventoryState, CharacterSetEntry, - CharacterInventoryType, - newCharacterSetEntry, } from "goals/CharacterInventory/Redux/CharacterInventoryReduxTypes"; -import { StoreState } from "types"; +import { RootState, setupStore } from "store"; import { newProject } from "types/project"; -import { newUser } from "types/user"; - -const VALID_DATA: string[] = ["a", "b"]; -const REJECT_DATA: string[] = ["y", "z"]; -const CHARACTER_SET_DATA: CharacterSetEntry[] = [ - { ...newCharacterSetEntry("a"), status: CharacterStatus.Accepted }, - { ...newCharacterSetEntry("b"), status: CharacterStatus.Accepted }, - { ...newCharacterSetEntry("y"), status: CharacterStatus.Rejected }, - { ...newCharacterSetEntry("z"), status: CharacterStatus.Rejected }, - { ...newCharacterSetEntry("m"), status: CharacterStatus.Undecided }, -]; - -const characterInventoryState: Partial = { - characterSet: CHARACTER_SET_DATA, - rejectedCharacters: REJECT_DATA, - validCharacters: VALID_DATA, -}; -const project: Partial = { - rejectedCharacters: [], - validCharacters: [], -}; -const MOCK_STATE = { - characterInventoryState, - currentProjectState: { project }, - goalsState: { currentGoal: { changes: {} } }, -}; - -const mockProjectId = "123"; -const mockUserEditId = "456"; -const mockUserId = "789"; -const mockUser = newUser(); -mockUser.id = mockUserId; -mockUser.workedProjects[mockProjectId] = mockUserEditId; +import { newWord } from "types/word"; -jest.mock("backend"); +jest.mock("backend", () => ({ + getFrontierWords: (...args: any[]) => mockGetFrontierWords(...args), +})); jest.mock("browserRouter"); jest.mock("components/GoalTimeline/Redux/GoalActions", () => ({ asyncUpdateGoal: (...args: any[]) => mockAsyncUpdateGoal(...args), - addCharInvChangesToGoal: (...args: any[]) => mockAddCharInvChanges(...args), + addCharInvChangesToGoal: (...args: any[]) => + mockAddCharInvChangesToGoal(...args), +})); +jest.mock("components/Project/ProjectActions", () => ({ + asyncUpdateCurrentProject: (...args: any[]) => + mockAsyncUpdateCurrentProject(...args), })); + +const mockAddCharInvChangesToGoal = jest.fn(); +const mockAsyncUpdateCurrentProject = jest.fn(); const mockAsyncUpdateGoal = jest.fn(); -const mockAddCharInvChanges = jest.fn(); +const mockGetFrontierWords = jest.fn(); -const createMockStore = configureMockStore([thunk]); +// Preloaded values for store when testing +const persistedDefaultState: PreloadedState = { + ...defaultState, + _persist: { version: 1, rehydrated: false }, +}; beforeEach(() => { jest.resetAllMocks(); }); describe("CharacterInventoryActions", () => { - test("setInventory yields correct action", () => { - expect(Actions.setValidCharacters(VALID_DATA)).toEqual({ - type: CharacterInventoryType.SET_VALID_CHARACTERS, - payload: VALID_DATA, + describe("setCharacterStatus", () => { + const character = "C"; + const mockState = (status: CharacterStatus): PreloadedState => { + const entry: CharacterSetEntry = { character, occurrences: 0, status }; + const rej = status === CharacterStatus.Rejected ? [character] : []; + const val = status === CharacterStatus.Accepted ? [character] : []; + return { + ...persistedDefaultState, + characterInventoryState: { + ...persistedDefaultState.characterInventoryState, + characterSet: [entry], + rejectedCharacters: rej, + validCharacters: val, + }, + }; + }; + + it("changes character from Rejected to Accepted", () => { + const store = setupStore(mockState(CharacterStatus.Rejected)); + store.dispatch(setCharacterStatus(character, CharacterStatus.Accepted)); + const state = store.getState().characterInventoryState; + expect(state.characterSet[0].status).toEqual(CharacterStatus.Accepted); + expect(state.rejectedCharacters).toHaveLength(0); + expect(state.validCharacters).toHaveLength(1); + expect(state.validCharacters[0]).toEqual(character); + }); + + it("changes character from Accepted to Undecided", () => { + const store = setupStore(mockState(CharacterStatus.Accepted)); + store.dispatch(setCharacterStatus(character, CharacterStatus.Undecided)); + const state = store.getState().characterInventoryState; + expect(state.characterSet[0].status).toEqual(CharacterStatus.Undecided); + expect(state.rejectedCharacters).toHaveLength(0); + expect(state.validCharacters).toHaveLength(0); + }); + + it("changes character from Undecided to Rejected", () => { + const store = setupStore(mockState(CharacterStatus.Undecided)); + store.dispatch(setCharacterStatus(character, CharacterStatus.Rejected)); + const state = store.getState().characterInventoryState; + expect(state.characterSet[0].status).toEqual(CharacterStatus.Rejected); + expect(state.rejectedCharacters).toHaveLength(1); + expect(state.rejectedCharacters[0]).toEqual(character); + expect(state.validCharacters).toHaveLength(0); + }); + }); + + describe("uploadInventory", () => { + it("dispatches no actions if there are no changes", async () => { + const store = setupStore(); + await store.dispatch(uploadInventory()); + expect(mockAddCharInvChangesToGoal).not.toHaveBeenCalled(); + expect(mockAsyncUpdateCurrentProject).not.toHaveBeenCalled(); + expect(mockAsyncUpdateGoal).not.toHaveBeenCalled(); + }); + + it("dispatches correct action if there are changes", async () => { + // Mock data with distinct characters + const rejectedCharacters = ["r", "e", "j"]; + const validCharacters = ["v", "a", "l", "i", "d"]; + const store = setupStore({ + ...persistedDefaultState, + characterInventoryState: { + ...persistedDefaultState.characterInventoryState, + rejectedCharacters, + validCharacters, + }, + }); + + // Mock the dispatch functions called by uploadInventory. + const mockAction: Action = { type: null }; + mockAddCharInvChangesToGoal.mockReturnValue(mockAction); + mockAsyncUpdateCurrentProject.mockReturnValue(mockAction); + mockAsyncUpdateGoal.mockReturnValue(mockAction); + + await store.dispatch(uploadInventory()); + expect(mockAddCharInvChangesToGoal).toHaveBeenCalledTimes(1); + expect(mockAddCharInvChangesToGoal.mock.calls[0][0]).toHaveLength( + rejectedCharacters.length + validCharacters.length + ); + expect(mockAsyncUpdateCurrentProject).toHaveBeenCalledTimes(1); + const proj: Project = mockAsyncUpdateCurrentProject.mock.calls[0][0]; + expect(proj.rejectedCharacters).toHaveLength(rejectedCharacters.length); + rejectedCharacters.forEach((c) => + expect(proj.rejectedCharacters).toContain(c) + ); + expect(proj.validCharacters).toHaveLength(validCharacters.length); + validCharacters.forEach((c) => expect(proj.validCharacters).toContain(c)); + expect(mockAsyncUpdateGoal).toHaveBeenCalledTimes(1); }); }); - test("uploadInventory dispatches correct action", async () => { - // Mock out the goal-related things called by uploadInventory. - const mockAction: Action = { type: null }; - mockAsyncUpdateGoal.mockReturnValue(mockAction); - mockAddCharInvChanges.mockReturnValue(mockAction); - - const mockStore = createMockStore(MOCK_STATE); - const mockUpload = Actions.uploadInventory(); - await mockUpload( - mockStore.dispatch, - mockStore.getState as () => StoreState - ); - expect(updateProject).toHaveBeenCalledTimes(1); - expect(mockStore.getActions()).toContainEqual({ - type: ProjectActionType.SET_CURRENT_PROJECT, - payload: project, + describe("fetchWords", () => { + it("correctly affects state", async () => { + const store = setupStore(); + const verns = ["v1", "v2", "v3", "v4"]; + mockGetFrontierWords.mockResolvedValueOnce(verns.map((v) => newWord(v))); + await store.dispatch(fetchWords()); + const { allWords } = store.getState().characterInventoryState; + expect(allWords).toHaveLength(verns.length); + verns.forEach((v) => expect(allWords).toContain(v)); }); }); - test("getChanges returns correct changes", () => { - const accAcc = "accepted"; - const accRej = "accepted->rejected"; - const accUnd = "accepted->undecided"; - const rejAcc = "rejected->accepted"; - const rejRej = "rejected"; - const rejUnd = "rejected->undecided"; - const undAcc = "undecided->accepted"; - const undRej = "undecided->rejected"; - const oldProj = { - ...newProject(), - validCharacters: [accAcc, accRej, accUnd], - rejectedCharacters: [rejAcc, rejRej, rejUnd], - }; - const charInvState: CharacterInventoryState = { - ...defaultState, - validCharacters: [accAcc, rejAcc, undAcc], - rejectedCharacters: [accRej, rejRej, undRej], - }; - const expectedChanges: CharacterChange[] = [ - [accRej, CharacterStatus.Accepted, CharacterStatus.Rejected], - [accUnd, CharacterStatus.Accepted, CharacterStatus.Undecided], - [rejAcc, CharacterStatus.Rejected, CharacterStatus.Accepted], - [rejUnd, CharacterStatus.Rejected, CharacterStatus.Undecided], - [undAcc, CharacterStatus.Undecided, CharacterStatus.Accepted], - [undRej, CharacterStatus.Undecided, CharacterStatus.Rejected], - ]; - const changes = Actions.getChanges(oldProj, charInvState); - expect(changes.length).toEqual(expectedChanges.length); - expectedChanges.forEach((ch) => expect(changes).toContainEqual(ch)); + describe("getAllCharacters", () => { + it("correctly affects state", async () => { + const store = setupStore({ + ...persistedDefaultState, + characterInventoryState: { + ...persistedDefaultState.characterInventoryState, + // Words containing the characters 1 through 9 + allWords: ["123", "45246", "735111189"], + }, + }); + await store.dispatch(getAllCharacters()); + const { characterSet } = store.getState().characterInventoryState; + expect(characterSet).toHaveLength(9); + const chars = characterSet.map((char) => char.character); + [..."123456789"].forEach((c) => expect(chars).toContain(c)); + }); + }); + + describe("loadCharInvData", () => { + it("correctly affects state", async () => { + // Mock data with distinct characters + const mockVern = "1234"; + const rejectedCharacters = ["r", "e", "j"]; + const validCharacters = ["v", "a", "l", "i", "d"]; + + const store = setupStore({ + ...persistedDefaultState, + currentProjectState: { + ...persistedDefaultState.currentProjectState, + project: { ...newProject(), rejectedCharacters, validCharacters }, + }, + }); + mockGetFrontierWords.mockResolvedValueOnce([newWord(mockVern)]); + await store.dispatch(loadCharInvData()); + const state = store.getState().characterInventoryState; + + expect(state.allWords).toHaveLength(1); + expect(state.allWords[0]).toEqual(mockVern); + + expect(state.characterSet).toHaveLength(mockVern.length); + const chars = state.characterSet.map((char) => char.character); + [...mockVern].forEach((c) => expect(chars).toContain(c)); + + expect(state.rejectedCharacters).toHaveLength(rejectedCharacters.length); + rejectedCharacters.forEach((c) => + expect(state.rejectedCharacters).toContain(c) + ); + + expect(state.validCharacters).toHaveLength(validCharacters.length); + validCharacters.forEach((c) => + expect(state.validCharacters).toContain(c) + ); + }); + }); + + describe("getChanges", () => { + it("returns correct changes", () => { + const accAcc = "accepted"; + const accRej = "accepted->rejected"; + const accUnd = "accepted->undecided"; + const rejAcc = "rejected->accepted"; + const rejRej = "rejected"; + const rejUnd = "rejected->undecided"; + const undAcc = "undecided->accepted"; + const undRej = "undecided->rejected"; + const oldProj = { + ...newProject(), + validCharacters: [accAcc, accRej, accUnd], + rejectedCharacters: [rejAcc, rejRej, rejUnd], + }; + const charInvState: CharacterInventoryState = { + ...defaultCharInvState, + validCharacters: [accAcc, rejAcc, undAcc], + rejectedCharacters: [accRej, rejRej, undRej], + }; + const expectedChanges: CharacterChange[] = [ + [accRej, CharacterStatus.Accepted, CharacterStatus.Rejected], + [accUnd, CharacterStatus.Accepted, CharacterStatus.Undecided], + [rejAcc, CharacterStatus.Rejected, CharacterStatus.Accepted], + [rejUnd, CharacterStatus.Rejected, CharacterStatus.Undecided], + [undAcc, CharacterStatus.Undecided, CharacterStatus.Accepted], + [undRej, CharacterStatus.Undecided, CharacterStatus.Rejected], + ]; + const changes = getChanges(oldProj, charInvState); + expect(changes.length).toEqual(expectedChanges.length); + expectedChanges.forEach((ch) => expect(changes).toContainEqual(ch)); + }); }); }); diff --git a/src/goals/CharacterInventory/Redux/tests/CharacterInventoryReducer.test.tsx b/src/goals/CharacterInventory/Redux/tests/CharacterInventoryReducer.test.tsx deleted file mode 100644 index ee94d75858..0000000000 --- a/src/goals/CharacterInventory/Redux/tests/CharacterInventoryReducer.test.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import { - characterInventoryReducer, - defaultState, -} from "goals/CharacterInventory/Redux/CharacterInventoryReducer"; -import { - CharacterInventoryState, - CharacterInventoryAction, - CharacterInventoryType, -} from "goals/CharacterInventory/Redux/CharacterInventoryReduxTypes"; -import { StoreAction, StoreActionTypes } from "rootActions"; - -const DATA: string[] = ["a", "b"]; -const BAD_RESP: string[] = ["If", "this", "appears", "there's", "an", "issue"]; - -describe("Test Character Inventory Reducer", () => { - it("Returns default state when passed no state", () => { - expect( - characterInventoryReducer(undefined, { - type: "" as CharacterInventoryType.SET_VALID_CHARACTERS, - payload: BAD_RESP, - } as CharacterInventoryAction) - ).toEqual(defaultState); - }); - - it("Returns a state with a specified inventory when passed an inventory", () => { - expect( - characterInventoryReducer(undefined, { - type: CharacterInventoryType.SET_VALID_CHARACTERS, - payload: DATA, - } as CharacterInventoryAction) - ).toEqual({ - validCharacters: DATA, - allWords: [], - characterSet: [], - rejectedCharacters: [], - selectedCharacter: "", - }); - }); - - it("Returns state passed in when passed an undefined action", () => { - const inv = { - validCharacters: DATA, - allWords: [], - characterSet: [], - rejectedCharacters: [], - selectedCharacter: "", - }; - expect( - characterInventoryReducer(inv, { - type: "" as CharacterInventoryType.SET_VALID_CHARACTERS, - payload: BAD_RESP, - } as CharacterInventoryAction) - ).toEqual(inv); - }); - - it("Returns default state when passed reset action", () => { - const action: StoreAction = { type: StoreActionTypes.RESET }; - - expect( - characterInventoryReducer({} as CharacterInventoryState, action) - ).toEqual(defaultState); - }); -}); diff --git a/src/goals/MergeDuplicates/MergeDupsStep/MergeDragDrop/DropWord.tsx b/src/goals/MergeDuplicates/MergeDupsStep/MergeDragDrop/DropWord.tsx index 4fd9e7c98b..07aeddae21 100644 --- a/src/goals/MergeDuplicates/MergeDupsStep/MergeDragDrop/DropWord.tsx +++ b/src/goals/MergeDuplicates/MergeDupsStep/MergeDragDrop/DropWord.tsx @@ -43,7 +43,7 @@ export default function DropWord(props: DropWordProps): ReactElement { // reset vern if not in vern list if (treeWord && !verns.includes(treeWord.vern)) { - dispatch(setVern(props.wordId, verns[0] || "")); + dispatch(setVern({ wordId: props.wordId, vern: verns[0] || "" })); } return ( @@ -69,7 +69,12 @@ export default function DropWord(props: DropWordProps): ReactElement { variant="standard" value={treeWord.vern} onChange={(e) => - dispatch(setVern(props.wordId, e.target.value as string)) + dispatch( + setVern({ + wordId: props.wordId, + vern: e.target.value as string, + }) + ) } > {verns.map((vern) => ( @@ -94,7 +99,7 @@ export default function DropWord(props: DropWordProps): ReactElement { { - dispatch(flagWord(props.wordId, newFlag)); + dispatch(flagWord({ wordId: props.wordId, flag: newFlag })); }} buttonId={`word-${props.wordId}-flag`} /> diff --git a/src/goals/MergeDuplicates/MergeDupsStep/MergeDragDrop/index.tsx b/src/goals/MergeDuplicates/MergeDupsStep/MergeDragDrop/index.tsx index 3889aa5ac1..be213702a9 100644 --- a/src/goals/MergeDuplicates/MergeDupsStep/MergeDragDrop/index.tsx +++ b/src/goals/MergeDuplicates/MergeDupsStep/MergeDragDrop/index.tsx @@ -55,7 +55,13 @@ export default function MergeDragDrop(): ReactElement { // Case 2a: Cannot merge a protected sense into another sense. if (sourceId !== res.combine.droppableId) { // The target sense is in a different word, so move instead of combine. - dispatch(moveSense(senseRef, res.combine.droppableId, 0)); + dispatch( + moveSense({ + ref: senseRef, + destWordId: res.combine.droppableId, + destOrder: 0, + }) + ); } return; } @@ -66,7 +72,7 @@ export default function MergeDragDrop(): ReactElement { // Case 2b: If the target is a sidebar sub-sense, it cannot receive a combine. return; } - dispatch(combineSense(senseRef, combineRef)); + dispatch(combineSense({ src: senseRef, dest: combineRef })); } else if (res.destination) { const destId = res.destination.droppableId; // Case 3: The sense was dropped in a droppable. @@ -77,7 +83,13 @@ export default function MergeDragDrop(): ReactElement { return; } // Move the sense to the dest MergeWord. - dispatch(moveSense(senseRef, destId, res.destination.index)); + dispatch( + moveSense({ + ref: senseRef, + destWordId: destId, + destOrder: res.destination.index, + }) + ); } else { // Case 3b: The source & dest droppables are the same, so we reorder, not move. const order = res.destination.index; @@ -90,7 +102,7 @@ export default function MergeDragDrop(): ReactElement { // If the sense wasn't moved or was moved within the sidebar above a protected sense, do nothing. return; } - dispatch(orderSense(senseRef, order)); + dispatch(orderSense({ ref: senseRef, order: order })); } } } diff --git a/src/goals/MergeDuplicates/MergeDupsTreeTypes.ts b/src/goals/MergeDuplicates/MergeDupsTreeTypes.ts index 9d86a37c2c..f333343797 100644 --- a/src/goals/MergeDuplicates/MergeDupsTreeTypes.ts +++ b/src/goals/MergeDuplicates/MergeDupsTreeTypes.ts @@ -55,12 +55,12 @@ export function newMergeTreeWord( } export function convertSenseToMergeTreeSense( - sense?: Sense, + sense: Sense, srcWordId = "", order = 0 ): MergeTreeSense { return { - ...(sense ?? newSense()), + ...sense, srcWordId, order, protected: sense?.accessibility === Status.Protected, diff --git a/src/goals/MergeDuplicates/Redux/MergeDupsActions.ts b/src/goals/MergeDuplicates/Redux/MergeDupsActions.ts index 31b8b06706..88a697a4ce 100644 --- a/src/goals/MergeDuplicates/Redux/MergeDupsActions.ts +++ b/src/goals/MergeDuplicates/Redux/MergeDupsActions.ts @@ -1,12 +1,6 @@ -import { - Definition, - Flag, - GramCatGroup, - MergeSourceWord, - MergeWords, - Status, - Word, -} from "api/models"; +import { Action, PayloadAction } from "@reduxjs/toolkit"; + +import { Word } from "api/models"; import * as backend from "backend"; import { addCompletedMergeToGoal, @@ -15,216 +9,94 @@ import { import { defaultSidebar, MergeTreeReference, - MergeTreeSense, Sidebar, } from "goals/MergeDuplicates/MergeDupsTreeTypes"; import { MergeDups, MergeStepData, ReviewDeferredDups, - newMergeWords, } from "goals/MergeDuplicates/MergeDupsTypes"; import { - ClearTreeMergeAction, - CombineSenseMergeAction, - DeleteSenseMergeAction, - FlagWord, - MergeTreeActionTypes, - MergeTreeState, - MoveDuplicateMergeAction, - MoveSenseMergeAction, - OrderDuplicateMergeAction, - OrderSenseMergeAction, - SetDataMergeAction, - SetSidebarMergeAction, - SetVernacularMergeAction, + clearMergeWordsAction, + clearTreeAction, + combineSenseAction, + deleteSenseAction, + flagWordAction, + getMergeWordsAction, + moveDuplicateAction, + moveSenseAction, + orderDuplicateAction, + orderSenseAction, + setDataAction, + setSidebarAction, + setVernacularAction, +} from "goals/MergeDuplicates/Redux/MergeDupsReducer"; +import { + CombineSenseMergePayload, + FlagWordPayload, + MoveSensePayload, + OrderSensePayload, + SetVernacularPayload, } from "goals/MergeDuplicates/Redux/MergeDupsReduxTypes"; import { StoreState } from "types"; import { StoreStateDispatch } from "types/Redux/actions"; -import { Hash } from "types/hash"; -import { compareFlags } from "utilities/wordUtilities"; -// Action Creators +// Action Creation Functions -export function clearTree(): ClearTreeMergeAction { - return { type: MergeTreeActionTypes.CLEAR_TREE }; +export function clearMergeWords(): Action { + return clearMergeWordsAction(); } -export function combineSense( - src: MergeTreeReference, - dest: MergeTreeReference -): CombineSenseMergeAction { - return { type: MergeTreeActionTypes.COMBINE_SENSE, payload: { src, dest } }; +export function clearTree(): Action { + return clearTreeAction(); } -export function deleteSense(src: MergeTreeReference): DeleteSenseMergeAction { - return { type: MergeTreeActionTypes.DELETE_SENSE, payload: { src } }; +export function combineSense(payload: CombineSenseMergePayload): PayloadAction { + return combineSenseAction(payload); } -export function flagWord(wordId: string, flag: Flag): FlagWord { - return { type: MergeTreeActionTypes.FLAG_WORD, payload: { wordId, flag } }; +export function deleteSense(payload: MergeTreeReference): PayloadAction { + return deleteSenseAction(payload); } -export function moveSense( - ref: MergeTreeReference, - destWordId: string, - destOrder: number -): MoveDuplicateMergeAction | MoveSenseMergeAction { - if (ref.order === undefined) { - return { - type: MergeTreeActionTypes.MOVE_SENSE, - payload: { ...ref, destWordId, destOrder }, - }; +export function flagWord(payload: FlagWordPayload): PayloadAction { + return flagWordAction(payload); +} + +export function getMergeWords(): Action { + return getMergeWordsAction(); +} + +export function moveSense(payload: MoveSensePayload): PayloadAction { + if (payload.ref.order === undefined) { + return moveSenseAction(payload); + } else { + return moveDuplicateAction(payload); } - // If ref.order is defined, the sense is being moved out of the sidebar. - return { - type: MergeTreeActionTypes.MOVE_DUPLICATE, - payload: { ref, destWordId, destOrder }, - }; } -export function orderSense( - ref: MergeTreeReference, - order: number -): OrderDuplicateMergeAction | OrderSenseMergeAction { - if (ref.order === undefined) { - return { - type: MergeTreeActionTypes.ORDER_SENSE, - payload: { ...ref, order }, - }; +export function orderSense(payload: OrderSensePayload): PayloadAction { + if (payload.ref.order === undefined) { + return orderSenseAction(payload); + } else { + return orderDuplicateAction(payload); } - // If ref.order is defined, the sense is being ordered within the sidebar. - return { - type: MergeTreeActionTypes.ORDER_DUPLICATE, - payload: { ref, order }, - }; } -export function setSidebar(sidebar?: Sidebar): SetSidebarMergeAction { - return { - type: MergeTreeActionTypes.SET_SIDEBAR, - payload: sidebar ?? defaultSidebar, - }; +export function setSidebar(sidebar?: Sidebar): PayloadAction { + return setSidebarAction(sidebar ?? defaultSidebar); } -export function setWordData(words: Word[]): SetDataMergeAction { - return { type: MergeTreeActionTypes.SET_DATA, payload: words }; +export function setData(words: Word[]): PayloadAction { + return setDataAction(words); } -export function setVern( - wordId: string, - vern: string -): SetVernacularMergeAction { - return { - type: MergeTreeActionTypes.SET_VERNACULAR, - payload: { wordId, vern }, - }; +export function setVern(payload: SetVernacularPayload): PayloadAction { + return setVernacularAction(payload); } // Dispatch Functions -// Given a wordId, constructs from the state the corresponding MergeWords. -// Returns the MergeWords, or undefined if the parent and child are identical. -function getMergeWords( - wordId: string, - mergeTree: MergeTreeState -): MergeWords | undefined { - // Find and build MergeSourceWord[]. - const word = mergeTree.tree.words[wordId]; - if (word) { - const data = mergeTree.data; - - // List of all non-deleted senses. - const nonDeleted = Object.values(mergeTree.tree.words).flatMap((w) => - Object.values(w.sensesGuids).flatMap((s) => s) - ); - - // Create list of all senses and add merge type tags slit by src word. - const senses: Hash = {}; - - // Build senses array. - for (const senseGuids of Object.values(word.sensesGuids)) { - for (const guid of senseGuids) { - const senseData = data.senses[guid]; - const wordId = senseData.srcWordId; - - if (!senses[wordId]) { - const dbWord = data.words[wordId]; - - // Add each sense into senses as separate or deleted. - senses[wordId] = []; - for (const sense of dbWord.senses) { - senses[wordId].push({ - ...sense, - srcWordId: wordId, - order: senses[wordId].length, - accessibility: nonDeleted.includes(sense.guid) - ? Status.Separate - : Status.Deleted, - protected: sense.accessibility === Status.Protected, - }); - } - } - } - } - - // Set sense and duplicate senses. - Object.values(word.sensesGuids).forEach((guids) => { - const sensesToCombine = guids - .map((g) => data.senses[g]) - .map((s) => senses[s.srcWordId][s.order]); - combineIntoFirstSense(sensesToCombine); - }); - - // Clean order of senses in each src word to reflect backend order. - Object.values(senses).forEach((wordSenses) => { - wordSenses = wordSenses.sort((a, b) => a.order - b.order); - senses[wordSenses[0].srcWordId] = wordSenses; - }); - - // Don't return empty merges: when the only child is the parent word - // and has the same number of senses as parent (all Active/Protected) - // and has the same flag. - if (Object.values(senses).length === 1) { - const onlyChild = Object.values(senses)[0]; - if ( - onlyChild[0].srcWordId === wordId && - onlyChild.length === data.words[wordId].senses.length && - !onlyChild.find( - (s) => ![Status.Active, Status.Protected].includes(s.accessibility) - ) && - compareFlags(word.flag, data.words[wordId].flag) === 0 - ) { - return undefined; - } - } - - // Construct parent and children. - const parent: Word = { ...data.words[wordId], senses: [], flag: word.flag }; - if (!parent.vernacular) { - parent.vernacular = word.vern; - } - const children: MergeSourceWord[] = Object.values(senses).map((sList) => { - sList.forEach((sense) => { - if ([Status.Active, Status.Protected].includes(sense.accessibility)) { - parent.senses.push({ - guid: sense.guid, - definitions: sense.definitions, - glosses: sense.glosses, - semanticDomains: sense.semanticDomains, - accessibility: sense.accessibility, - grammaticalInfo: sense.grammaticalInfo, - }); - } - }); - const getAudio = !sList.find((s) => s.accessibility === Status.Separate); - return { srcWordId: sList[0].srcWordId, getAudio }; - }); - - return newMergeWords(parent, children); - } -} - export function deferMerge() { return async (_: StoreStateDispatch, getState: () => StoreState) => { const mergeTree = getState().mergeDuplicateGoal; @@ -239,27 +111,11 @@ export function mergeAll() { // Add to blacklist. await backend.blacklistAdd(Object.keys(mergeTree.data.words)); - // Handle words with all senses deleted. - const possibleWords = Object.values(mergeTree.data.words); - const nonDeletedSenses = Object.values(mergeTree.tree.words).flatMap((w) => - Object.values(w.sensesGuids).flatMap((s) => s) - ); - const deletedWords = possibleWords.filter( - (w) => - !w.senses.map((s) => s.guid).find((g) => nonDeletedSenses.includes(g)) - ); - const mergeWordsArray = deletedWords.map((w) => - newMergeWords(w, [{ srcWordId: w.id, getAudio: false }], true) - ); - // Merge words. - const wordIds = Object.keys(mergeTree.tree.words); - wordIds.forEach((id) => { - const wordsToMerge = getMergeWords(id, mergeTree); - if (wordsToMerge) { - mergeWordsArray.push(wordsToMerge); - } - }); + dispatch(getMergeWords()); + + const mergeWordsArray = [...getState().mergeDuplicateGoal.mergeWords]; + dispatch(clearMergeWords()); if (mergeWordsArray.length) { const parentIds = await backend.mergeWords(mergeWordsArray); const childIds = [ @@ -268,10 +124,8 @@ export function mergeAll() { ), ]; const completedMerge = { childIds, parentIds }; - if (getState().goalsState.currentGoal) { - dispatch(addCompletedMergeToGoal(completedMerge)); - await dispatch(asyncUpdateGoal()); - } + dispatch(addCompletedMergeToGoal(completedMerge)); + await dispatch(asyncUpdateGoal()); } }; } @@ -283,59 +137,7 @@ export function dispatchMergeStepData(goal: MergeDups | ReviewDeferredDups) { const stepData = goal.steps[goal.currentStep] as MergeStepData; if (stepData) { const stepWords = stepData.words ?? []; - dispatch(setWordData(stepWords)); + dispatch(setData(stepWords)); } }; } - -/** Modifies the mutable input sense list. */ -export function combineIntoFirstSense(senses: MergeTreeSense[]): void { - // Set the first sense to be merged as Active/Protected. - // This was the top sense when the sidebar was opened. - const mainSense = senses[0]; - mainSense.accessibility = mainSense.protected - ? Status.Protected - : Status.Active; - - // Merge the rest as duplicates. - // These were senses dropped into another sense. - senses.slice(1).forEach((dupSense) => { - dupSense.accessibility = Status.Duplicate; - // Put the duplicate's definitions in the main sense. - dupSense.definitions.forEach((def) => - mergeDefinitionIntoSense(mainSense, def) - ); - // Use the duplicate's part of speech if not specified in the main sense. - if (mainSense.grammaticalInfo.catGroup === GramCatGroup.Unspecified) { - mainSense.grammaticalInfo = { ...dupSense.grammaticalInfo }; - } - // Put the duplicate's domains in the main sense. - dupSense.semanticDomains.forEach((dom) => { - if (!mainSense.semanticDomains.find((d) => d.id === dom.id)) { - mainSense.semanticDomains.push({ ...dom }); - } - }); - }); -} - -/** Modifies the mutable input sense. */ -export function mergeDefinitionIntoSense( - sense: MergeTreeSense, - def: Definition, - sep = ";" -): void { - if (!def.text.length) { - return; - } - const defIndex = sense.definitions.findIndex( - (d) => d.language === def.language - ); - if (defIndex === -1) { - sense.definitions.push({ ...def }); - } else { - const oldText = sense.definitions[defIndex].text; - if (!oldText.split(sep).includes(def.text)) { - sense.definitions[defIndex].text = `${oldText}${sep}${def.text}`; - } - } -} diff --git a/src/goals/MergeDuplicates/Redux/MergeDupsReducer.ts b/src/goals/MergeDuplicates/Redux/MergeDupsReducer.ts index 90974b1d9d..9334e402db 100644 --- a/src/goals/MergeDuplicates/Redux/MergeDupsReducer.ts +++ b/src/goals/MergeDuplicates/Redux/MergeDupsReducer.ts @@ -1,76 +1,76 @@ +import { createSlice } from "@reduxjs/toolkit"; import { v4 } from "uuid"; -import { Word } from "api/models"; +import { + GramCatGroup, + MergeSourceWord, + MergeWords, + Status, + Word, +} from "api/models"; import { convertSenseToMergeTreeSense, convertWordToMergeTreeWord, defaultSidebar, defaultTree, - MergeTree, + MergeData, MergeTreeSense, MergeTreeWord, newMergeTreeWord, } from "goals/MergeDuplicates/MergeDupsTreeTypes"; -import { - MergeTreeAction, - MergeTreeActionTypes, - MergeTreeState, -} from "goals/MergeDuplicates/Redux/MergeDupsReduxTypes"; -import { StoreAction, StoreActionTypes } from "rootActions"; +import { newMergeWords } from "goals/MergeDuplicates/MergeDupsTypes"; +import { MergeTreeState } from "goals/MergeDuplicates/Redux/MergeDupsReduxTypes"; +import { StoreActionTypes } from "rootActions"; import { Hash } from "types/hash"; +import { compareFlags } from "utilities/wordUtilities"; const defaultData = { words: {}, senses: {} }; export const defaultState: MergeTreeState = { data: defaultData, tree: defaultTree, + mergeWords: [], }; -export const mergeDupStepReducer = ( - state: MergeTreeState = defaultState, //createStore() calls each reducer with undefined state - action: MergeTreeAction | StoreAction -): MergeTreeState => { - switch (action.type) { - case MergeTreeActionTypes.CLEAR_TREE: { +const mergeDuplicatesSlice = createSlice({ + name: "mergeDupStepReducer", + initialState: defaultState, + reducers: { + clearMergeWordsAction: (state) => { + state.mergeWords = []; + }, + clearTreeAction: () => { return defaultState; - } - - case MergeTreeActionTypes.COMBINE_SENSE: { + }, + combineSenseAction: (state, action) => { const srcRef = action.payload.src; const destRef = action.payload.dest; // Ignore dropping a sense (or one of its sub-senses) into itself. - if (srcRef.mergeSenseId === destRef.mergeSenseId) { - return state; - } - - const words: Hash = JSON.parse( - JSON.stringify(state.tree.words) - ); - const srcWordId = srcRef.wordId; - const srcGuids = words[srcWordId].sensesGuids[srcRef.mergeSenseId]; - const destGuids: string[] = []; - if (srcRef.order === undefined || srcGuids.length === 1) { - destGuids.push(...srcGuids); - delete words[srcWordId].sensesGuids[srcRef.mergeSenseId]; - if (!Object.keys(words[srcWordId].sensesGuids).length) { - delete words[srcWordId]; + if (srcRef.mergeSenseId !== destRef.mergeSenseId) { + const words = state.tree.words; + const srcWordId = srcRef.wordId; + const srcGuids = words[srcWordId].sensesGuids[srcRef.mergeSenseId]; + const destGuids: string[] = []; + if (srcRef.order === undefined || srcGuids.length === 1) { + destGuids.push(...srcGuids); + delete words[srcWordId].sensesGuids[srcRef.mergeSenseId]; + if (!Object.keys(words[srcWordId].sensesGuids).length) { + delete words[srcWordId]; + } + } else { + destGuids.push(srcGuids.splice(srcRef.order, 1)[0]); } - } else { - destGuids.push(srcGuids.splice(srcRef.order, 1)[0]); - } - - words[destRef.wordId].sensesGuids[destRef.mergeSenseId].push( - ...destGuids - ); - return { ...state, tree: { ...state.tree, words } }; - } - - case MergeTreeActionTypes.DELETE_SENSE: { - const srcRef = action.payload.src; + words[destRef.wordId].sensesGuids[destRef.mergeSenseId].push( + ...destGuids + ); + state.tree.words = words; + } + }, + deleteSenseAction: (state, action) => { + const srcRef = action.payload; const srcWordId = srcRef.wordId; - const tree: MergeTree = JSON.parse(JSON.stringify(state.tree)); - const words = tree.words; + const words = state.tree.words; const sensesGuids = words[srcWordId].sensesGuids; if (srcRef.order !== undefined) { @@ -85,216 +85,365 @@ export const mergeDupStepReducer = ( delete words[srcWordId]; } - let sidebar = tree.sidebar; + const sidebar = state.tree.sidebar; + // If the sense is being deleted from the words column + // and the sense is also shown in the sidebar, + // then reset the sidebar. if ( sidebar.wordId === srcRef.wordId && sidebar.mergeSenseId === srcRef.mergeSenseId && srcRef.order === undefined ) { - sidebar = defaultSidebar; + state.tree.sidebar = defaultSidebar; } - - return { ...state, tree: { ...state.tree, words, sidebar } }; - } - - case MergeTreeActionTypes.FLAG_WORD: { - const words: Hash = JSON.parse( - JSON.stringify(state.tree.words) + }, + flagWordAction: (state, action) => { + state.tree.words[action.payload.wordId].flag = action.payload.flag; + }, + getMergeWordsAction: (state) => { + // Handle words with all senses deleted. + const possibleWords = Object.values(state.data.words); + // List of all non-deleted senses. + const nonDeletedSenses = Object.values(state.tree.words).flatMap((w) => + Object.values(w.sensesGuids).flatMap((s) => s) ); - words[action.payload.wordId].flag = action.payload.flag; - return { ...state, tree: { ...state.tree, words } }; - } - - case MergeTreeActionTypes.MOVE_DUPLICATE: { - const srcRef = action.payload.ref; - const destWordId = action.payload.destWordId; - const words: Hash = JSON.parse( - JSON.stringify(state.tree.words) + const deletedWords = possibleWords.filter( + (w) => + !w.senses.map((s) => s.guid).find((g) => nonDeletedSenses.includes(g)) + ); + state.mergeWords = deletedWords.map((w) => + newMergeWords(w, [{ srcWordId: w.id, getAudio: false }], true) ); - const srcWordId = srcRef.wordId; - let mergeSenseId = srcRef.mergeSenseId; - - // Get guid of sense being restored from the sidebar. - if (srcRef.order === undefined) { - return state; + for (const wordId in state.tree.words) { + const mergeWord = state.tree.words[wordId]; + const mergeSenses = buildSenses( + mergeWord.sensesGuids, + state.data, + nonDeletedSenses + ); + const mergeWords = createMergeWords( + wordId, + mergeWord, + mergeSenses, + state.data.words[wordId] + ); + if (mergeWords) { + state.mergeWords.push(mergeWords); + } } - const srcGuids = words[srcWordId].sensesGuids[mergeSenseId]; - const guid = srcGuids.splice(srcRef.order, 1)[0]; + }, + moveSenseAction: (state, action) => { + const srcWordId = action.payload.ref.wordId; + const destWordId = action.payload.destWordId; + const srcOrder = action.payload.ref.order; + if (srcOrder === undefined && srcWordId !== destWordId) { + const mergeSenseId = action.payload.ref.mergeSenseId; + + const words = state.tree.words; + + // Check if dropping the sense into a new word. + if (words[destWordId] === undefined) { + if (Object.keys(words[srcWordId].sensesGuids).length === 1) { + return; + } + words[destWordId] = newMergeTreeWord(); + } - // Check if dropping the sense into a new word. - if (words[destWordId] === undefined) { - words[destWordId] = newMergeTreeWord(); - } + // Update the destWord. + const guids = words[srcWordId].sensesGuids[mergeSenseId]; + const sensesPairs = Object.entries(words[destWordId].sensesGuids); + sensesPairs.splice(action.payload.destOrder, 0, [mergeSenseId, guids]); + const newSensesGuids: Hash = {}; + sensesPairs.forEach(([key, value]) => (newSensesGuids[key] = value)); + words[destWordId].sensesGuids = newSensesGuids; - if (srcGuids.length === 0) { - // If there are no guids left, this is a full move. - if (srcWordId === destWordId) { - return state; - } + // Cleanup the srcWord. delete words[srcWordId].sensesGuids[mergeSenseId]; if (!Object.keys(words[srcWordId].sensesGuids).length) { delete words[srcWordId]; } - } else { - // Otherwise, create a new sense in the destWord. - mergeSenseId = v4(); } + }, + moveDuplicateAction: (state, action) => { + const srcRef = action.payload.ref; + // Verify that the ref.order field is defined + if (srcRef.order !== undefined) { + const destWordId = action.payload.destWordId; + const words = state.tree.words; - // Update the destWord. - const sensesPairs = Object.entries(words[destWordId].sensesGuids); - sensesPairs.splice(action.payload.destOrder, 0, [mergeSenseId, [guid]]); - const newSensesGuids: Hash = {}; - sensesPairs.forEach(([key, value]) => (newSensesGuids[key] = value)); - words[destWordId].sensesGuids = newSensesGuids; - - return { ...state, tree: { ...state.tree, words } }; - } - - case MergeTreeActionTypes.MOVE_SENSE: { - const srcWordId = action.payload.wordId; - const mergeSenseId = action.payload.mergeSenseId; - const destWordId = action.payload.destWordId; + const srcWordId = srcRef.wordId; + let mergeSenseId = srcRef.mergeSenseId; - if (srcWordId === destWordId) { - return state; - } - const words: Hash = JSON.parse( - JSON.stringify(state.tree.words) - ); + // Get guid of sense being restored from the sidebar. + const srcGuids = words[srcWordId].sensesGuids[mergeSenseId]; + const guid = srcGuids.splice(srcRef.order, 1)[0]; - // Check if dropping the sense into a new word. - if (words[destWordId] === undefined) { - if (Object.keys(words[srcWordId].sensesGuids).length === 1) { - return state; + // Check if dropping the sense into a new word. + if (words[destWordId] === undefined) { + words[destWordId] = newMergeTreeWord(); } - words[destWordId] = newMergeTreeWord(); - } - // Update the destWord. - const guids = [...words[srcWordId].sensesGuids[mergeSenseId]]; - const sensesPairs = Object.entries(words[destWordId].sensesGuids); - sensesPairs.splice(action.payload.destOrder, 0, [mergeSenseId, guids]); - const newSensesGuids: Hash = {}; - sensesPairs.forEach(([key, value]) => (newSensesGuids[key] = value)); - words[destWordId].sensesGuids = newSensesGuids; + if (srcGuids.length === 0) { + // If there are no guids left, this is a full move. + if (srcWordId === destWordId) { + return; + } + delete words[srcWordId].sensesGuids[mergeSenseId]; + if (!Object.keys(words[srcWordId].sensesGuids).length) { + delete words[srcWordId]; + } + } else { + // Otherwise, create a new sense in the destWord. + mergeSenseId = v4(); + } - // Cleanup the srcWord. - delete words[srcWordId].sensesGuids[mergeSenseId]; - if (!Object.keys(words[srcWordId].sensesGuids).length) { - delete words[srcWordId]; + // Update the destWord. + const sensesPairs = Object.entries(words[destWordId].sensesGuids); + sensesPairs.splice(action.payload.destOrder, 0, [mergeSenseId, [guid]]); + const newSensesGuids: Hash = {}; + sensesPairs.forEach(([key, value]) => (newSensesGuids[key] = value)); + words[destWordId].sensesGuids = newSensesGuids; } - - return { ...state, tree: { ...state.tree, words } }; - } - - case MergeTreeActionTypes.ORDER_DUPLICATE: { + }, + orderDuplicateAction: (state, action) => { const ref = action.payload.ref; const oldOrder = ref.order; const newOrder = action.payload.order; // Ensure the reorder is valid. - if (oldOrder === undefined || oldOrder === newOrder) { - return state; - } - - // Move the guid. - const oldSensesGuids = state.tree.words[ref.wordId].sensesGuids; - const guids = [...oldSensesGuids[ref.mergeSenseId]]; - const guid = guids.splice(oldOrder, 1)[0]; - guids.splice(newOrder, 0, guid); - - // - const sensesGuids = { ...oldSensesGuids }; - sensesGuids[ref.mergeSenseId] = guids; - - const word: MergeTreeWord = { - ...state.tree.words[ref.wordId], - sensesGuids, - }; - - const words = { ...state.tree.words }; - words[ref.wordId] = word; + if (oldOrder !== undefined && oldOrder !== newOrder) { + // Move the guid. + const oldSensesGuids = state.tree.words[ref.wordId].sensesGuids; + const guids = [...oldSensesGuids[ref.mergeSenseId]]; + const guid = guids.splice(oldOrder, 1)[0]; + guids.splice(newOrder, 0, guid); - return { ...state, tree: { ...state.tree, words } }; - } + const sensesGuids = { ...oldSensesGuids }; + sensesGuids[ref.mergeSenseId] = guids; - case MergeTreeActionTypes.ORDER_SENSE: { - const word: MergeTreeWord = JSON.parse( - JSON.stringify(state.tree.words[action.payload.wordId]) - ); + state.tree.words[ref.wordId].sensesGuids = sensesGuids; + } + }, + orderSenseAction: (state, action) => { + const word = state.tree.words[action.payload.ref.wordId]; // Convert the Hash to an array to expose the order. const sensePairs = Object.entries(word.sensesGuids); - const mergeSenseId = action.payload.mergeSenseId; + const mergeSenseId = action.payload.ref.mergeSenseId; const oldOrder = sensePairs.findIndex((p) => p[0] === mergeSenseId); const newOrder = action.payload.order; // Ensure the move is valid. - if (oldOrder === -1 || newOrder === undefined || oldOrder === newOrder) { - return state; - } - - // Move the sense pair to its new place. - const pair = sensePairs.splice(oldOrder, 1)[0]; - sensePairs.splice(newOrder, 0, pair); + if (oldOrder !== -1 && newOrder !== undefined && oldOrder !== newOrder) { + // Move the sense pair to its new place. + const pair = sensePairs.splice(oldOrder, 1)[0]; + sensePairs.splice(newOrder, 0, pair); + + // Rebuild the Hash. + word.sensesGuids = {}; + for (const [key, value] of sensePairs) { + word.sensesGuids[key] = value; + } - // Rebuild the Hash. - word.sensesGuids = {}; - for (const [key, value] of sensePairs) { - word.sensesGuids[key] = value; + state.tree.words[action.payload.ref.wordId] = word; } - - const words = { ...state.tree.words }; - words[action.payload.wordId] = word; - - return { ...state, tree: { ...state.tree, words } }; - } - - case MergeTreeActionTypes.SET_DATA: { + }, + setSidebarAction: (state, action) => { + state.tree.sidebar = action.payload; + }, + setDataAction: (state, action) => { if (action.payload.length === 0) { - return defaultState; - } - const words: Hash = {}; - const senses: Hash = {}; - const wordsTree: Hash = {}; - action.payload.forEach((word) => { - words[word.id] = JSON.parse(JSON.stringify(word)); - word.senses.forEach((s, order) => { - senses[s.guid] = convertSenseToMergeTreeSense(s, word.id, order); + state = defaultState; + } else { + const words: Hash = {}; + const senses: Hash = {}; + const wordsTree: Hash = {}; + action.payload.forEach((word: Word) => { + words[word.id] = JSON.parse(JSON.stringify(word)); + word.senses.forEach((s, order) => { + senses[s.guid] = convertSenseToMergeTreeSense(s, word.id, order); + }); + wordsTree[word.id] = convertWordToMergeTreeWord(word); }); - wordsTree[word.id] = convertWordToMergeTreeWord(word); - }); - return { - ...state, - tree: { ...state.tree, words: wordsTree }, - data: { senses, words }, - }; - } - - case MergeTreeActionTypes.SET_SIDEBAR: { - const sidebar = action.payload; - return { ...state, tree: { ...state.tree, sidebar } }; + state.tree.words = wordsTree; + state.data = { senses, words }; + state.mergeWords = []; + } + }, + setVernacularAction: (state, action) => { + state.tree.words[action.payload.wordId].vern = action.payload.vern; + }, + }, + extraReducers: (builder) => + builder.addCase(StoreActionTypes.RESET, () => defaultState), +}); + +// Helper Functions + +/** Create hash of senses keyed by id of src word. */ +function buildSenses( + sensesGuids: Hash, + data: MergeData, + nonDeletedSenses: string[] +): Hash { + const senses: Hash = {}; + for (const senseGuids of Object.values(sensesGuids)) { + for (const guid of senseGuids) { + const senseData = data.senses[guid]; + const wordId = senseData.srcWordId; + + if (!senses[wordId]) { + const dbWord = data.words[wordId]; + + // Add each sense into senses as separate or deleted. + senses[wordId] = []; + for (const sense of dbWord.senses) { + senses[wordId].push({ + ...sense, + srcWordId: wordId, + order: senses[wordId].length, + accessibility: nonDeletedSenses.includes(sense.guid) + ? Status.Separate + : Status.Deleted, + protected: sense.accessibility === Status.Protected, + }); + } + } } + } - case MergeTreeActionTypes.SET_VERNACULAR: { - const word = { ...state.tree.words[action.payload.wordId] }; - word.vern = action.payload.vern; - - const words = { ...state.tree.words }; - words[action.payload.wordId] = word; - - return { ...state, tree: { ...state.tree, words } }; + // Set sense and duplicate senses. + Object.values(sensesGuids).forEach((guids) => { + const sensesToCombine = guids + .map((g) => data.senses[g]) + .map((s) => senses[s.srcWordId][s.order]); + combineIntoFirstSense(sensesToCombine); + }); + + // Clean order of senses in each src word to reflect backend order. + Object.values(senses).forEach((wordSenses) => { + wordSenses = wordSenses.sort((a, b) => a.order - b.order); + senses[wordSenses[0].srcWordId] = wordSenses; + }); + + return senses; +} + +function createMergeWords( + wordId: string, + mergeWord: MergeTreeWord, + mergeSenses: Hash, + word: Word +): MergeWords | undefined { + // Don't return empty merges: when the only child is the parent word + // and has the same number of senses as parent (all Active/Protected) + // and has the same flag. + if (Object.values(mergeSenses).length === 1) { + const onlyChild = Object.values(mergeSenses)[0]; + if ( + onlyChild[0].srcWordId === wordId && + onlyChild.length === word.senses.length && + !onlyChild.find( + (s) => ![Status.Active, Status.Protected].includes(s.accessibility) + ) && + compareFlags(mergeWord.flag, word.flag) === 0 + ) { + return; } + } - case StoreActionTypes.RESET: { - return defaultState; + // Construct parent and children. + const parent: Word = { + ...word, + senses: [], + flag: mergeWord.flag, + }; + if (!parent.vernacular) { + parent.vernacular = mergeWord.vern; + } + const children: MergeSourceWord[] = Object.values(mergeSenses).map( + (sList) => { + sList.forEach((sense) => { + if ([Status.Active, Status.Protected].includes(sense.accessibility)) { + parent.senses.push({ + guid: sense.guid, + definitions: sense.definitions, + glosses: sense.glosses, + semanticDomains: sense.semanticDomains, + accessibility: sense.accessibility, + grammaticalInfo: sense.grammaticalInfo, + }); + } + }); + const getAudio = !sList.find((s) => s.accessibility === Status.Separate); + return { srcWordId: sList[0].srcWordId, getAudio }; } - - default: { - return state; + ); + + return newMergeWords(parent, children); +} + +function combineIntoFirstSense(senses: MergeTreeSense[]): void { + // Set the first sense to be merged as Active/Protected. + // This was the top sense when the sidebar was opened. + const mainSense = senses[0]; + mainSense.accessibility = mainSense.protected + ? Status.Protected + : Status.Active; + + // Merge the rest as duplicates. + // These were senses dropped into another sense. + senses.slice(1).forEach((dupSense) => { + dupSense.accessibility = Status.Duplicate; + // Put the duplicate's definitions in the main sense. + const sep = ";"; + dupSense.definitions.forEach((def) => { + if (def.text.length) { + const defIndex = mainSense.definitions.findIndex( + (d) => d.language === def.language + ); + if (defIndex === -1) { + mainSense.definitions.push({ ...def }); + } else { + const oldText = mainSense.definitions[defIndex].text; + if (!oldText.split(sep).includes(def.text)) { + mainSense.definitions[ + defIndex + ].text = `${oldText}${sep}${def.text}`; + } + } + } + }); + // Use the duplicate's part of speech if not specified in the main sense. + if (mainSense.grammaticalInfo.catGroup === GramCatGroup.Unspecified) { + mainSense.grammaticalInfo = { ...dupSense.grammaticalInfo }; } - } -}; + // Put the duplicate's domains in the main sense. + dupSense.semanticDomains.forEach((dom) => { + if (!mainSense.semanticDomains.find((d) => d.id === dom.id)) { + mainSense.semanticDomains.push({ ...dom }); + } + }); + }); +} + +export const { + clearMergeWordsAction, + clearTreeAction, + combineSenseAction, + deleteSenseAction, + flagWordAction, + getMergeWordsAction, + moveDuplicateAction, + moveSenseAction, + orderDuplicateAction, + orderSenseAction, + setDataAction, + setSidebarAction, + setVernacularAction, +} = mergeDuplicatesSlice.actions; + +export default mergeDuplicatesSlice.reducer; diff --git a/src/goals/MergeDuplicates/Redux/MergeDupsReduxTypes.ts b/src/goals/MergeDuplicates/Redux/MergeDupsReduxTypes.ts index b679ec586c..9f526b59cb 100644 --- a/src/goals/MergeDuplicates/Redux/MergeDupsReduxTypes.ts +++ b/src/goals/MergeDuplicates/Redux/MergeDupsReduxTypes.ts @@ -1,98 +1,38 @@ -import { Flag, Word } from "api/models"; +import { Flag, MergeWords } from "api/models"; import { MergeData, MergeTree, MergeTreeReference, - Sidebar, } from "goals/MergeDuplicates/MergeDupsTreeTypes"; -export enum MergeTreeActionTypes { - CLEAR_TREE = "CLEAR_TREE", - COMBINE_SENSE = "COMBINE_SENSE", - DELETE_SENSE = "DELETE_SENSE", - FLAG_WORD = "FLAG_WORD", - MOVE_DUPLICATE = "MOVE_DUPLICATE", - MOVE_SENSE = "MOVE_SENSE", - ORDER_DUPLICATE = "ORDER_DUPLICATE", - ORDER_SENSE = "ORDER_SENSE", - SET_DATA = "SET_DATA", - SET_SIDEBAR = "SET_SIDEBAR", - SET_VERNACULAR = "SET_VERNACULAR", +export interface CombineSenseMergePayload { + src: MergeTreeReference; + dest: MergeTreeReference; +} + +export interface FlagWordPayload { + wordId: string; + flag: Flag; } export interface MergeTreeState { data: MergeData; tree: MergeTree; + mergeWords: MergeWords[]; } -export interface ClearTreeMergeAction { - type: MergeTreeActionTypes.CLEAR_TREE; -} - -export interface CombineSenseMergeAction { - type: MergeTreeActionTypes.COMBINE_SENSE; - payload: { src: MergeTreeReference; dest: MergeTreeReference }; -} - -export interface DeleteSenseMergeAction { - type: MergeTreeActionTypes.DELETE_SENSE; - payload: { src: MergeTreeReference }; -} - -export interface FlagWord { - type: MergeTreeActionTypes.FLAG_WORD; - payload: { wordId: string; flag: Flag }; -} - -export interface MoveDuplicateMergeAction { - type: MergeTreeActionTypes.MOVE_DUPLICATE; - payload: { ref: MergeTreeReference; destWordId: string; destOrder: number }; +export interface MoveSensePayload { + ref: MergeTreeReference; + destWordId: string; + destOrder: number; } -export interface MoveSenseMergeAction { - type: MergeTreeActionTypes.MOVE_SENSE; - payload: { - wordId: string; - mergeSenseId: string; - destWordId: string; - destOrder: number; - }; +export interface OrderSensePayload { + ref: MergeTreeReference; + order: number; } -export interface OrderDuplicateMergeAction { - type: MergeTreeActionTypes.ORDER_DUPLICATE; - payload: { ref: MergeTreeReference; order: number }; +export interface SetVernacularPayload { + wordId: string; + vern: string; } - -export interface OrderSenseMergeAction { - type: MergeTreeActionTypes.ORDER_SENSE; - payload: MergeTreeReference; -} - -export interface SetDataMergeAction { - type: MergeTreeActionTypes.SET_DATA; - payload: Word[]; -} - -export interface SetSidebarMergeAction { - type: MergeTreeActionTypes.SET_SIDEBAR; - payload: Sidebar; -} - -export interface SetVernacularMergeAction { - type: MergeTreeActionTypes.SET_VERNACULAR; - payload: { wordId: string; vern: string }; -} - -export type MergeTreeAction = - | ClearTreeMergeAction - | CombineSenseMergeAction - | DeleteSenseMergeAction - | FlagWord - | MoveDuplicateMergeAction - | MoveSenseMergeAction - | OrderDuplicateMergeAction - | OrderSenseMergeAction - | SetDataMergeAction - | SetSidebarMergeAction - | SetVernacularMergeAction; diff --git a/src/goals/MergeDuplicates/Redux/tests/MergeDupsActions.test.tsx b/src/goals/MergeDuplicates/Redux/tests/MergeDupsActions.test.tsx index e026ecf36e..c5fcccc0b2 100644 --- a/src/goals/MergeDuplicates/Redux/tests/MergeDupsActions.test.tsx +++ b/src/goals/MergeDuplicates/Redux/tests/MergeDupsActions.test.tsx @@ -1,42 +1,23 @@ -import configureMockStore from "redux-mock-store"; -import thunk from "redux-thunk"; - -import { GramCatGroup, MergeWords, Sense, Status, Word } from "api/models"; +import { MergeWords, Sense, Status, Word } from "api/models"; +import { defaultState } from "components/App/DefaultState"; import { defaultTree, MergeData, MergeTree, - MergeTreeReference, - MergeTreeSense, newMergeTreeSense, newMergeTreeWord, } from "goals/MergeDuplicates/MergeDupsTreeTypes"; import { MergeDups, newMergeWords } from "goals/MergeDuplicates/MergeDupsTypes"; import { - combineIntoFirstSense, + deferMerge, dispatchMergeStepData, mergeAll, - mergeDefinitionIntoSense, - moveSense, - orderSense, + setData, } from "goals/MergeDuplicates/Redux/MergeDupsActions"; -import { - MergeTreeAction, - MergeTreeActionTypes, - MergeTreeState, -} from "goals/MergeDuplicates/Redux/MergeDupsReduxTypes"; import { goalDataMock } from "goals/MergeDuplicates/Redux/tests/MergeDupsDataMock"; -import { GoalsState, GoalType } from "types/goals"; -import { newSemanticDomain } from "types/semanticDomain"; -import { - multiSenseWord, - newDefinition, - newFlag, - newGrammaticalInfo, - newSense, - newWord, -} from "types/word"; -import { Bcp47Code } from "types/writingSystem"; +import { setupStore } from "store"; +import { GoalType } from "types/goals"; +import { multiSenseWord, newFlag, newWord } from "types/word"; // Used when the guids don't matter. function wordAnyGuids(vern: string, senses: Sense[], id: string): Word { @@ -48,11 +29,13 @@ function wordAnyGuids(vern: string, senses: Sense[], id: string): Word { }; } +const mockGraylistAdd = jest.fn(); const mockMergeWords = jest.fn(); jest.mock("backend", () => ({ blacklistAdd: jest.fn(), getWord: jest.fn(), + graylistAdd: () => mockGraylistAdd(), mergeWords: (mergeWordsArray: MergeWords[]) => mockMergeWords(mergeWordsArray), })); @@ -60,12 +43,9 @@ jest.mock("backend", () => ({ const mockGoal = new MergeDups(); mockGoal.data = goalDataMock; mockGoal.steps = [{ words: [] }, { words: [] }]; -const createMockStore = configureMockStore([thunk]); -const mockStoreState: { - goalsState: GoalsState; - mergeDuplicateGoal: MergeTreeState; -} = { +const preloadedState = { + ...defaultState, goalsState: { allGoalTypes: [], currentGoal: new MergeDups(), @@ -73,7 +53,12 @@ const mockStoreState: { history: [mockGoal], previousGoalType: GoalType.Default, }, - mergeDuplicateGoal: { data: {} as MergeData, tree: {} as MergeTree }, + mergeDuplicateGoal: { + data: {} as MergeData, + tree: {} as MergeTree, + mergeWords: [], + }, + _persist: { version: 1, rehydrated: false }, }; const vernA = "AAA"; @@ -112,11 +97,11 @@ describe("MergeDupActions", () => { const WA = newMergeTreeWord(vernA, { ID1: [S1], ID2: [S2] }); const WB = newMergeTreeWord(vernB, { ID1: [S3], ID2: [S4] }); const tree: MergeTree = { ...defaultTree, words: { WA, WB } }; - const mockStore = createMockStore({ - ...mockStoreState, - mergeDuplicateGoal: { data, tree }, + const store = setupStore({ + ...preloadedState, + mergeDuplicateGoal: { data, tree, mergeWords: [] }, }); - await mockStore.dispatch(mergeAll()); + await store.dispatch(mergeAll()); expect(mockMergeWords).not.toHaveBeenCalled(); }); @@ -126,11 +111,11 @@ describe("MergeDupActions", () => { const WA = newMergeTreeWord(vernA, { ID1: [S1, S3], ID2: [S2] }); const WB = newMergeTreeWord(vernB, { ID1: [S4] }); const tree: MergeTree = { ...defaultTree, words: { WA, WB } }; - const mockStore = createMockStore({ - ...mockStoreState, - mergeDuplicateGoal: { data, tree }, + const store = setupStore({ + ...preloadedState, + mergeDuplicateGoal: { data, tree, mergeWords: [] }, }); - await mockStore.dispatch(mergeAll()); + await store.dispatch(mergeAll()); expect(mockMergeWords).toHaveBeenCalledTimes(1); const parentA = wordAnyGuids(vernA, [senses["S1"], senses["S2"]], idA); @@ -151,11 +136,11 @@ describe("MergeDupActions", () => { const WA = newMergeTreeWord(vernA, { ID1: [S1], ID2: [S2], ID3: [S3] }); const WB = newMergeTreeWord(vernB, { ID1: [S4] }); const tree: MergeTree = { ...defaultTree, words: { WA, WB } }; - const mockStore = createMockStore({ - ...mockStoreState, - mergeDuplicateGoal: { data, tree }, + const store = setupStore({ + ...preloadedState, + mergeDuplicateGoal: { data, tree, mergeWords: [] }, }); - await mockStore.dispatch(mergeAll()); + await store.dispatch(mergeAll()); expect(mockMergeWords).toHaveBeenCalledTimes(1); const parentA = wordAnyGuids( @@ -180,11 +165,11 @@ describe("MergeDupActions", () => { const WA = newMergeTreeWord(vernA, { ID1: [S1, S2] }); const WB = newMergeTreeWord(vernB, { ID1: [S3], ID2: [S4] }); const tree: MergeTree = { ...defaultTree, words: { WA, WB } }; - const mockStore = createMockStore({ - ...mockStoreState, - mergeDuplicateGoal: { data, tree }, + const store = setupStore({ + ...preloadedState, + mergeDuplicateGoal: { data, tree, mergeWords: [] }, }); - await mockStore.dispatch(mergeAll()); + await store.dispatch(mergeAll()); expect(mockMergeWords).toHaveBeenCalledTimes(1); @@ -199,11 +184,11 @@ describe("MergeDupActions", () => { const WA = newMergeTreeWord(vernA, { ID1: [S1] }); const WB = newMergeTreeWord(vernB, { ID1: [S3], ID2: [S4] }); const tree: MergeTree = { ...defaultTree, words: { WA, WB } }; - const mockStore = createMockStore({ - ...mockStoreState, - mergeDuplicateGoal: { data, tree }, + const store = setupStore({ + ...preloadedState, + mergeDuplicateGoal: { data, tree, mergeWords: [] }, }); - await mockStore.dispatch(mergeAll()); + await store.dispatch(mergeAll()); expect(mockMergeWords).toHaveBeenCalledTimes(1); const parent = wordAnyGuids(vernA, [senses["S1"]], idA); @@ -216,11 +201,11 @@ describe("MergeDupActions", () => { it("delete all senses from a word", async () => { const WA = newMergeTreeWord(vernA, { ID1: [S1], ID2: [S2] }); const tree: MergeTree = { ...defaultTree, words: { WA } }; - const mockStore = createMockStore({ - ...mockStoreState, - mergeDuplicateGoal: { data, tree }, + const store = setupStore({ + ...preloadedState, + mergeDuplicateGoal: { data, tree, mergeWords: [] }, }); - await mockStore.dispatch(mergeAll()); + await store.dispatch(mergeAll()); expect(mockMergeWords).toHaveBeenCalledTimes(1); const child = { srcWordId: idB, getAudio: false }; @@ -234,11 +219,11 @@ describe("MergeDupActions", () => { WA.flag = newFlag("New flag"); const WB = newMergeTreeWord(vernB, { ID1: [S3], ID2: [S4] }); const tree: MergeTree = { ...defaultTree, words: { WA, WB } }; - const mockStore = createMockStore({ - ...mockStoreState, - mergeDuplicateGoal: { data, tree }, + const store = setupStore({ + ...preloadedState, + mergeDuplicateGoal: { data, tree, mergeWords: [] }, }); - await mockStore.dispatch(mergeAll()); + await store.dispatch(mergeAll()); expect(mockMergeWords).toHaveBeenCalledTimes(1); @@ -251,158 +236,29 @@ describe("MergeDupActions", () => { }); describe("dispatchMergeStepData", () => { - it("creates an action to add MergeDups data", async () => { + it("creates an action to add MergeDups data", () => { const goal = new MergeDups(); goal.steps = [{ words: [...goalDataMock.plannedWords[0]] }]; - const mockStore = createMockStore(); - await mockStore.dispatch(dispatchMergeStepData(goal)); - const setWordData: MergeTreeAction = { - type: MergeTreeActionTypes.SET_DATA, - payload: [...goalDataMock.plannedWords[0]], - }; - expect(mockStore.getActions()).toEqual([setWordData]); - }); - }); - - describe("moveSense", () => { - const wordId = "mockWordId"; - const mergeSenseId = "mockSenseId"; - - it("creates a MOVE_SENSE action when going from word to word", () => { - const mockRef: MergeTreeReference = { wordId, mergeSenseId }; - const resultAction = moveSense(mockRef, wordId, -1); - expect(resultAction.type).toEqual(MergeTreeActionTypes.MOVE_SENSE); - }); - - it("creates a MOVE_DUPLICATE action when going from sidebar to word", () => { - const mockRef: MergeTreeReference = { wordId, mergeSenseId, order: 0 }; - const resultAction = moveSense(mockRef, wordId, -1); - expect(resultAction.type).toEqual(MergeTreeActionTypes.MOVE_DUPLICATE); + const store = setupStore(); + store.dispatch(dispatchMergeStepData(goal)); + const setDataAction = setData(goalDataMock.plannedWords[0]); + expect(setDataAction.type).toEqual("mergeDupStepReducer/setDataAction"); }); }); - describe("orderSense", () => { - const wordId = "mockWordId"; - const mergeSenseId = "mockSenseId"; - const mockOrder = 0; - - it("creates an ORDER_SENSE action when moving within a word", () => { - const mockRef: MergeTreeReference = { wordId, mergeSenseId }; - const resultAction = orderSense(mockRef, mockOrder); - expect(resultAction.type).toEqual(MergeTreeActionTypes.ORDER_SENSE); - }); - - it("creates an ORDER_DUPLICATE action when moving within the sidebar", () => { - const mockRef: MergeTreeReference = { wordId, mergeSenseId, order: 0 }; - const resultAction = orderSense(mockRef, mockOrder); - expect(resultAction.type).toEqual(MergeTreeActionTypes.ORDER_DUPLICATE); - }); - }); - - describe("mergeDefinitionIntoSense", () => { - const defAEn = newDefinition("a", Bcp47Code.En); - const defAFr = newDefinition("a", Bcp47Code.Fr); - const defBEn = newDefinition("b", Bcp47Code.En); - let sense: MergeTreeSense; - - beforeEach(() => { - sense = newSense() as MergeTreeSense; - }); - - it("ignores definitions with empty text", () => { - mergeDefinitionIntoSense(sense, newDefinition()); - expect(sense.definitions).toHaveLength(0); - mergeDefinitionIntoSense(sense, newDefinition("", Bcp47Code.En)); - expect(sense.definitions).toHaveLength(0); - }); - - it("adds definitions with new languages", () => { - mergeDefinitionIntoSense(sense, defAEn); - expect(sense.definitions).toHaveLength(1); - mergeDefinitionIntoSense(sense, defAFr); - expect(sense.definitions).toHaveLength(2); - }); - - it("only adds definitions with new text", () => { - sense.definitions.push({ ...defAEn }, { ...defAFr }); - - mergeDefinitionIntoSense(sense, defAFr); - expect(sense.definitions).toHaveLength(2); - expect( - sense.definitions.find((d) => d.language === Bcp47Code.Fr)!.text - ).toEqual(defAFr.text); - - const twoEnTexts = `${defAEn.text};${defBEn.text}`; - mergeDefinitionIntoSense(sense, defBEn); - expect(sense.definitions).toHaveLength(2); - expect( - sense.definitions.find((d) => d.language === Bcp47Code.En)!.text - ).toEqual(twoEnTexts); - mergeDefinitionIntoSense(sense, defAEn); - expect(sense.definitions).toHaveLength(2); - expect( - sense.definitions.find((d) => d.language === Bcp47Code.En)!.text - ).toEqual(twoEnTexts); - }); - }); - - describe("combineIntoFirstSense", () => { - it("sets all but the first sense to duplicate status", () => { - const s4 = [newSense(), newSense(), newSense(), newSense()].map( - (s) => s as MergeTreeSense - ); - combineIntoFirstSense(s4); - expect(s4[0].accessibility).not.toBe(Status.Duplicate); - expect( - s4.filter((s) => s.accessibility === Status.Duplicate) - ).toHaveLength(s4.length - 1); - }); - - it("gives the first sense the earliest part of speech found in all senses", () => { - const s3 = [newSense(), newSense(), newSense()].map( - (s) => s as MergeTreeSense - ); - const gramInfo = { - catGroup: GramCatGroup.Verb, - grammaticalCategory: "vt", - }; - s3[1].grammaticalInfo = { ...gramInfo }; - s3[2].grammaticalInfo = { - catGroup: GramCatGroup.Preverb, - grammaticalCategory: "prev", - }; - combineIntoFirstSense(s3); - expect(s3[0].grammaticalInfo).toEqual(gramInfo); - - // Ensure the first sense's grammaticalInfo doesn't get overwritten. - s3[1].grammaticalInfo = newGrammaticalInfo(); - combineIntoFirstSense(s3); - expect(s3[0].grammaticalInfo).toEqual(gramInfo); - }); - - it("adds domains to first sense from other senses", () => { - const s3 = [newSense(), newSense(), newSense()].map( - (s) => s as MergeTreeSense - ); - s3[1].semanticDomains = [ - newSemanticDomain("1", "uno"), - newSemanticDomain("2", "dos"), - ]; - s3[2].semanticDomains = [newSemanticDomain("3", "three")]; - combineIntoFirstSense(s3); - expect(s3[0].semanticDomains).toHaveLength(3); - }); - - it("doesn't adds domains it already has", () => { - const s2 = [newSense(), newSense()].map((s) => s as MergeTreeSense); - s2[0].semanticDomains = [newSemanticDomain("1", "one")]; - s2[1].semanticDomains = [ - newSemanticDomain("1", "uno"), - newSemanticDomain("2", "dos"), - ]; - combineIntoFirstSense(s2); - expect(s2[0].semanticDomains).toHaveLength(2); + describe("deferMerge", () => { + it("add merge to graylist", () => { + const WA = newMergeTreeWord(vernA, { ID1: [S1], ID2: [S2] }); + WA.flag = newFlag("New flag"); + const WB = newMergeTreeWord(vernB, { ID1: [S3], ID2: [S4] }); + const tree: MergeTree = { ...defaultTree, words: { WA, WB } }; + const store = setupStore({ + ...preloadedState, + mergeDuplicateGoal: { data, tree, mergeWords: [] }, + }); + store.dispatch(deferMerge()); + expect(mockGraylistAdd).toHaveBeenCalledTimes(1); }); }); }); diff --git a/src/goals/MergeDuplicates/Redux/tests/MergeDupsDataMock.ts b/src/goals/MergeDuplicates/Redux/tests/MergeDupsDataMock.ts index c4786a8cef..0f70b78534 100644 --- a/src/goals/MergeDuplicates/Redux/tests/MergeDupsDataMock.ts +++ b/src/goals/MergeDuplicates/Redux/tests/MergeDupsDataMock.ts @@ -1,6 +1,14 @@ -import { Word } from "api/models"; +import type { PreloadedState } from "@reduxjs/toolkit"; +import { Definition, SemanticDomain, Word } from "api/models"; +import { defaultState } from "components/App/DefaultState"; +import { + convertSenseToMergeTreeSense, + convertWordToMergeTreeWord, + newMergeTreeWord, +} from "goals/MergeDuplicates/MergeDupsTreeTypes"; import { MergeDupsData } from "goals/MergeDuplicates/MergeDupsTypes"; -import { simpleWord } from "types/word"; +import { RootState } from "store"; +import { newSense, newWord, simpleWord } from "types/word"; const wordsArrayMock = (): Word[] => [ // Each simpleWord() has a randomly generated id @@ -26,3 +34,271 @@ export const goalDataMock: MergeDupsData = { wordsArrayMock(), ], }; + +// Words/Senses to be used for a preloaded mergeDuplicateGoal state +// in the unit tests for MergeDuplicates Actions/Reducer +const semDomSocial: SemanticDomain = { + guid: "00000000-0000-0000-0000-000000000000", + name: "Social behavior", + id: "4", + lang: "", +}; + +const semDomLanguage: SemanticDomain = { + guid: "00000000-0000-0000-0000-000000000000", + name: "Language and thought", + id: "3", + lang: "", +}; + +const definitionBah = { language: "en", text: "defBah" }; +const definitionBag = { language: "en", text: "defBag" }; +const definitionBagBah = { language: "en", text: "defBag;defBah" }; + +const senseBag = { + ...newSense("bag"), + guid: "guid-sense-bag", + semanticDomains: [semDomLanguage], + definitions: [definitionBag], +}; +const senseBah = { + ...newSense("bah"), + guid: "guid-sense-bah", + semanticDomains: [semDomSocial], + definitions: [definitionBah], +}; +const senseBar = { + ...newSense("bar"), + guid: "guid-sense-bar", + semanticDomains: [semDomLanguage], +}; +const senseBaz = { ...newSense("baz"), guid: "guid-sense-baz" }; + +const wordFoo1 = { ...newWord("foo"), id: "wordId-foo1", senses: [senseBah] }; +const wordFoo2 = { + ...newWord("foo"), + id: "wordId-foo2", + senses: [senseBar, senseBaz], +}; + +// Preloaded values for store when testing the MergeDups Goal +const persistedDefaultState: PreloadedState = { + ...defaultState, + _persist: { version: 1, rehydrated: false }, +}; + +export type ExpectedScenarioResult = { + // wordId for the parent word + parent: string; + // sense guids in the parent word + senses: string[]; + // semantic domain ids in the parent word + semDoms: string[]; + // definitions in the merged sense + defs: Definition[][]; + // child source word ids + children: string[]; +}; + +export type GetMergeWordsScenario = { + initialState: () => PreloadedState; + expectedResult: ExpectedScenarioResult[]; +}; + +// Scenario: +// Word1: +// vern: foo +// senses: bah +// +// Word2: +// vern: foo +// senses: bar, baz +// +// Sense "bah" is dragged to "Word2" as an additional sense +export const mergeTwoWordsScenario: GetMergeWordsScenario = { + initialState: () => { + return { + ...persistedDefaultState, + mergeDuplicateGoal: { + data: { + senses: { + "guid-sense-bah": convertSenseToMergeTreeSense( + senseBah, + wordFoo1.id, + 0 + ), + "guid-sense-bar": convertSenseToMergeTreeSense( + senseBar, + wordFoo2.id, + 0 + ), + "guid-sense-baz": convertSenseToMergeTreeSense( + senseBaz, + wordFoo2.id, + 1 + ), + }, + words: { + "wordId-foo1": wordFoo1, + "wordId-foo2": wordFoo2, + }, + }, + tree: { + sidebar: { + senses: [], + wordId: "", + mergeSenseId: "", + }, + words: { + "wordId-foo2": convertWordToMergeTreeWord({ + ...wordFoo2, + senses: [senseBar, senseBaz, senseBah], + }), + }, + }, + mergeWords: [], + }, + }; + }, + expectedResult: [ + { + parent: "wordId-foo2", + senses: ["guid-sense-bah", "guid-sense-bar", "guid-sense-baz"], + semDoms: ["3", "4"], + defs: [[], [], [definitionBah]], + children: ["wordId-foo1", "wordId-foo2"], + }, + ], +}; + +// Scenario: +// Word1: +// vern: foo +// senses: bah +// +// Word2: +// vern: foo +// senses: bar, baz +// +// Sense "bah" is dragged to Word2 and merged with sense "bar" +export const mergeTwoSensesScenario: GetMergeWordsScenario = { + initialState: () => { + return { + ...persistedDefaultState, + mergeDuplicateGoal: { + data: { + senses: { + "guid-sense-bah": convertSenseToMergeTreeSense( + senseBah, + wordFoo1.id, + 0 + ), + "guid-sense-bar": convertSenseToMergeTreeSense( + senseBar, + wordFoo2.id, + 0 + ), + "guid-sense-baz": convertSenseToMergeTreeSense( + senseBaz, + wordFoo2.id, + 1 + ), + }, + words: { + "wordId-foo1": wordFoo1, + "wordId-foo2": wordFoo2, + }, + }, + tree: { + sidebar: { + senses: [], + wordId: "", + mergeSenseId: "", + }, + words: { + "wordId-foo2": newMergeTreeWord(wordFoo2.vernacular, { + word2_senseA: [senseBar.guid], + word2_senseB: [senseBaz.guid, senseBah.guid], + }), + }, + }, + mergeWords: [], + }, + }; + }, + expectedResult: [ + { + parent: "wordId-foo2", + senses: ["guid-sense-bar", "guid-sense-baz"], + semDoms: ["3", "4"], + defs: [[], [definitionBah]], + children: ["wordId-foo1", "wordId-foo2"], + }, + ], +}; + +// Scenario: +// Word1: +// vern: foo +// senses: bah +// +// Word2: +// vern: foo +// senses: bar, bag +// +// Sense "bah" is dragged to Word2 and merged with sense "bag" +export const mergeTwoDefinitionsScenario: GetMergeWordsScenario = { + initialState: () => { + return { + ...persistedDefaultState, + mergeDuplicateGoal: { + data: { + senses: { + "guid-sense-bah": convertSenseToMergeTreeSense( + senseBah, + wordFoo1.id, + 0 + ), + "guid-sense-bar": convertSenseToMergeTreeSense( + senseBar, + wordFoo2.id, + 0 + ), + "guid-sense-bag": convertSenseToMergeTreeSense( + senseBag, + wordFoo2.id, + 1 + ), + }, + words: { + "wordId-foo1": wordFoo1, + "wordId-foo2": { ...wordFoo2, senses: [senseBar, senseBag] }, + }, + }, + tree: { + sidebar: { + senses: [], + wordId: "", + mergeSenseId: "", + }, + words: { + "wordId-foo2": newMergeTreeWord(wordFoo2.vernacular, { + word2_senseA: [senseBar.guid], + word2_senseB: [senseBag.guid, senseBah.guid], + }), + }, + }, + mergeWords: [], + }, + }; + }, + expectedResult: [ + { + parent: "wordId-foo2", + senses: ["guid-sense-bag", "guid-sense-bar"], + semDoms: ["3", "3", "4"], + defs: [[], [definitionBagBah]], + children: ["wordId-foo1", "wordId-foo2"], + }, + ], +}; diff --git a/src/goals/MergeDuplicates/Redux/tests/MergeDupsReducer.test.tsx b/src/goals/MergeDuplicates/Redux/tests/MergeDupsReducer.test.tsx index d0ce755111..8147bfee19 100644 --- a/src/goals/MergeDuplicates/Redux/tests/MergeDupsReducer.test.tsx +++ b/src/goals/MergeDuplicates/Redux/tests/MergeDupsReducer.test.tsx @@ -1,3 +1,5 @@ +import { Action, PayloadAction } from "@reduxjs/toolkit"; + import { convertSenseToMergeTreeSense, defaultSidebar, @@ -5,17 +7,27 @@ import { MergeTreeWord, newMergeTreeWord, } from "goals/MergeDuplicates/MergeDupsTreeTypes"; -import * as Actions from "goals/MergeDuplicates/Redux/MergeDupsActions"; import { + clearTree, + combineSense, + deleteSense, + flagWord, + getMergeWords, + moveSense, + orderSense, + setData, +} from "goals/MergeDuplicates/Redux/MergeDupsActions"; +import mergeDupStepReducer, { defaultState, - mergeDupStepReducer, } from "goals/MergeDuplicates/Redux/MergeDupsReducer"; +import { MergeTreeState } from "goals/MergeDuplicates/Redux/MergeDupsReduxTypes"; import { - MergeTreeAction, - MergeTreeActionTypes, - MergeTreeState, -} from "goals/MergeDuplicates/Redux/MergeDupsReduxTypes"; + mergeTwoDefinitionsScenario, + mergeTwoSensesScenario, + mergeTwoWordsScenario, +} from "goals/MergeDuplicates/Redux/tests/MergeDupsDataMock"; import { StoreAction, StoreActionTypes } from "rootActions"; +import { setupStore } from "store"; import { Hash } from "types/hash"; import { newFlag, testWordList } from "types/word"; @@ -37,13 +49,7 @@ beforeEach(() => { mockUuid.v4.mockImplementation(getMockUuid); }); -describe("MergeDupReducer", () => { - // a state with no duplicate senses - const initState = mergeDupStepReducer( - undefined, - Actions.setWordData(testWordList()) - ); - +describe("MergeDupsReducer", () => { // helper functions for working with a tree const getRefByGuid = ( guid: string, @@ -62,8 +68,12 @@ describe("MergeDupReducer", () => { }; test("clearTree", () => { - const newState = mergeDupStepReducer(initState, Actions.clearTree()); - expect(JSON.stringify(newState)).toEqual(JSON.stringify(defaultState)); + const store = setupStore(); + store.dispatch(setData(testWordList())); + store.dispatch(clearTree()); + expect(JSON.stringify(store.getState().mergeDuplicateGoal)).toEqual( + JSON.stringify(defaultState) + ); }); function testTreeWords(): Hash { @@ -89,9 +99,10 @@ describe("MergeDupReducer", () => { sidebar: defaultSidebar, words: testTreeWords(), }, + mergeWords: [], }; function checkTreeWords( - action: MergeTreeAction, + action: Action | PayloadAction, expected: Hash ): void { const result = mergeDupStepReducer(mockState, action).tree.words; @@ -115,7 +126,7 @@ describe("MergeDupReducer", () => { mergeSenseId: `${destWordId}_senseA`, }; - const testAction = Actions.combineSense(srcRef, destRef); + const testAction = combineSense({ src: srcRef, dest: destRef }); const expectedWords = testTreeWords(); expectedWords[srcWordId].sensesGuids = { @@ -142,7 +153,7 @@ describe("MergeDupReducer", () => { mergeSenseId: `${destWordId}_senseA`, }; - const testAction = Actions.combineSense(srcRef, destRef); + const testAction = combineSense({ src: srcRef, dest: destRef }); const expectedWords = testTreeWords(); expectedWords[destWordId].sensesGuids = { @@ -169,7 +180,7 @@ describe("MergeDupReducer", () => { mergeSenseId: `${destWordId}_senseA`, }; - const testAction = Actions.combineSense(srcRef, destRef); + const testAction = combineSense({ src: srcRef, dest: destRef }); const expectedWords = testTreeWords(); delete expectedWords[srcWordId]; @@ -192,7 +203,7 @@ describe("MergeDupReducer", () => { mergeSenseId: `${wordId}_senseA`, }; - const testAction = Actions.combineSense(srcRef, destRef); + const testAction = combineSense({ src: srcRef, dest: destRef }); const expectedWords = testTreeWords(); expectedWords[wordId].sensesGuids = { @@ -215,7 +226,7 @@ describe("MergeDupReducer", () => { mergeSenseId: `${destWordId}_senseA`, }; - const testAction = Actions.combineSense(srcRef, destRef); + const testAction = combineSense({ src: srcRef, dest: destRef }); const expectedWords = testTreeWords(); expectedWords[srcWordId].sensesGuids = { @@ -241,7 +252,7 @@ describe("MergeDupReducer", () => { mergeSenseId: `${destWordId}_senseA`, }; - const testAction = Actions.combineSense(srcRef, destRef); + const testAction = combineSense({ src: srcRef, dest: destRef }); const expectedWords = testTreeWords(); delete expectedWords[srcWordId]; @@ -262,7 +273,7 @@ describe("MergeDupReducer", () => { mergeSenseId: `${wordId}_senseA`, }; - const testAction = Actions.deleteSense(testRef); + const testAction = deleteSense(testRef); const expectedWords = testTreeWords(); delete expectedWords[wordId].sensesGuids[testRef.mergeSenseId]; @@ -277,7 +288,7 @@ describe("MergeDupReducer", () => { mergeSenseId: `${wordId}_senseB`, }; - const testAction = Actions.deleteSense(testRef); + const testAction = deleteSense(testRef); const expectedWords = testTreeWords(); delete expectedWords[wordId].sensesGuids[testRef.mergeSenseId]; @@ -292,7 +303,7 @@ describe("MergeDupReducer", () => { mergeSenseId: `${wordId}_senseA`, }; - const testAction = Actions.deleteSense(testRef); + const testAction = deleteSense(testRef); const expectedWords = testTreeWords(); delete expectedWords[wordId]; @@ -308,7 +319,7 @@ describe("MergeDupReducer", () => { order: 0, }; - const testAction = Actions.deleteSense(testRef); + const testAction = deleteSense(testRef); const expectedWords = testTreeWords(); expectedWords[wordId].sensesGuids = { word2_senseA: ["word2_senseA_1"] }; @@ -324,7 +335,7 @@ describe("MergeDupReducer", () => { order: 0, }; - const testAction = Actions.deleteSense(srcRef); + const testAction = deleteSense(srcRef); const expectedWords = testTreeWords(); delete expectedWords[srcWordId]; @@ -337,7 +348,7 @@ describe("MergeDupReducer", () => { it("adds a flag to a word", () => { const wordId = "word1"; const testFlag = newFlag("flagged"); - const testAction = Actions.flagWord(wordId, testFlag); + const testAction = flagWord({ wordId: wordId, flag: testFlag }); const expectedWords = testTreeWords(); expectedWords[wordId].flag = testFlag; @@ -346,6 +357,59 @@ describe("MergeDupReducer", () => { }); }); + describe("getMergeWords", () => { + it("sense moved from one word to another", () => { + const store = setupStore(mergeTwoWordsScenario.initialState()); + store.dispatch(getMergeWords()); + const mergeArray = store.getState().mergeDuplicateGoal.mergeWords; + const expectedResult = mergeTwoWordsScenario.expectedResult; + expect(mergeArray.length).toEqual(1); + expect(mergeArray[0].parent.id).toEqual(expectedResult[0].parent); + const senses = mergeArray[0].parent.senses.map((s) => s.guid).sort(); + expect(senses).toEqual(expectedResult[0].senses); + const semDoms = mergeArray[0].parent.senses + .flatMap((s) => s.semanticDomains.map((d) => d.id)) + .sort(); + expect(semDoms).toEqual(expectedResult[0].semDoms); + const defs = mergeArray[0].parent.senses.map((s) => s.definitions); + expect(defs).toEqual(expectedResult[0].defs); + }); + + it("sense from one word combined with sense in another", () => { + const store = setupStore(mergeTwoSensesScenario.initialState()); + store.dispatch(getMergeWords()); + const mergeArray = store.getState().mergeDuplicateGoal.mergeWords; + const expectedResult = mergeTwoSensesScenario.expectedResult; + expect(mergeArray.length).toEqual(1); + expect(mergeArray[0].parent.id).toEqual(expectedResult[0].parent); + const senses = mergeArray[0].parent.senses.map((s) => s.guid).sort(); + expect(senses).toEqual(expectedResult[0].senses); + const semDoms = mergeArray[0].parent.senses + .flatMap((s) => s.semanticDomains.map((d) => d.id)) + .sort(); + expect(semDoms).toEqual(expectedResult[0].semDoms); + const defs = mergeArray[0].parent.senses.map((s) => s.definitions); + expect(defs).toEqual(expectedResult[0].defs); + }); + + it("combine senses with definitions", () => { + const store = setupStore(mergeTwoDefinitionsScenario.initialState()); + store.dispatch(getMergeWords()); + const mergeArray = store.getState().mergeDuplicateGoal.mergeWords; + const expectedResult = mergeTwoDefinitionsScenario.expectedResult; + expect(mergeArray.length).toEqual(1); + expect(mergeArray[0].parent.id).toEqual(expectedResult[0].parent); + const senses = mergeArray[0].parent.senses.map((s) => s.guid).sort(); + expect(senses).toEqual(expectedResult[0].senses); + const semDoms = mergeArray[0].parent.senses + .flatMap((s) => s.semanticDomains.map((d) => d.id)) + .sort(); + expect(semDoms).toEqual(expectedResult[0].semDoms); + const defs = mergeArray[0].parent.senses.map((s) => s.definitions); + expect(defs).toEqual(expectedResult[0].defs); + }); + }); + describe("moveSense", () => { it("moves a sense out from sidebar to same word", () => { const wordId = "word2"; @@ -358,7 +422,11 @@ describe("MergeDupReducer", () => { // Intercept the uuid that will be assigned. const nextGuid = getMockUuid(false); - const testAction = Actions.moveSense(testRef, wordId, 1); + const testAction = moveSense({ + ref: testRef, + destWordId: wordId, + destOrder: 1, + }); const expectedWords = testTreeWords(); expectedWords[wordId].sensesGuids = { word2_senseA: ["word2_senseA_1"] }; @@ -382,7 +450,11 @@ describe("MergeDupReducer", () => { // Intercept the uuid that will be assigned. const nextGuid = getMockUuid(false); - const testAction = Actions.moveSense(testRef, destWordId, 2); + const testAction = moveSense({ + ref: testRef, + destWordId: destWordId, + destOrder: 2, + }); const expectedWords = testTreeWords(); expectedWords[srcWordId].sensesGuids = { @@ -405,7 +477,11 @@ describe("MergeDupReducer", () => { const destWordId = "word1"; - const testAction = Actions.moveSense(testRef, destWordId, 1); + const testAction = moveSense({ + ref: testRef, + destWordId: destWordId, + destOrder: 1, + }); const expectedWords = testTreeWords(); expectedWords[srcWordId].sensesGuids = { @@ -428,7 +504,11 @@ describe("MergeDupReducer", () => { const destWordId = "word1"; - const testAction = Actions.moveSense(testRef, destWordId, 1); + const testAction = moveSense({ + ref: testRef, + destWordId: destWordId, + destOrder: 1, + }); const expectedWords = testTreeWords(); expectedWords[srcWordId].sensesGuids = { @@ -451,8 +531,12 @@ describe("MergeDupReducer", () => { const destWordId = "word2"; - const testAction = Actions.moveSense(testRef, destWordId, 1); - expect(testAction.type).toEqual(MergeTreeActionTypes.MOVE_SENSE); + const testAction = moveSense({ + ref: testRef, + destWordId: destWordId, + destOrder: 1, + }); + expect(testAction.type).toEqual("mergeDupStepReducer/moveSenseAction"); const expectedWords = testTreeWords(); delete expectedWords[srcWordId]; @@ -471,7 +555,7 @@ describe("MergeDupReducer", () => { const mergeSenseId = `${wordId}_senseA`; const testRef: MergeTreeReference = { wordId, mergeSenseId, order: 0 }; - const testAction = Actions.orderSense(testRef, 1); + const testAction = orderSense({ ref: testRef, order: 1 }); const expectedWords = testTreeWords(); expectedWords[wordId].sensesGuids[mergeSenseId] = [ @@ -487,7 +571,7 @@ describe("MergeDupReducer", () => { const mergeSenseId = `${wordId}_senseA`; const testRef: MergeTreeReference = { wordId, mergeSenseId }; - const testAction = Actions.orderSense(testRef, 1); + const testAction = orderSense({ ref: testRef, order: 1 }); const expectedWords = testTreeWords(); expectedWords[wordId].sensesGuids = { @@ -511,10 +595,7 @@ describe("MergeDupReducer", () => { test("setWordData", () => { const wordList = testWordList(); - const treeState = mergeDupStepReducer( - undefined, - Actions.setWordData(wordList) - ); + const treeState = mergeDupStepReducer(undefined, setData(wordList)); // check if data has all words present for (const word of wordList) { const srcWordId = word.id; diff --git a/src/rootReducer.ts b/src/rootReducer.ts index a617d965fc..709287dd1e 100644 --- a/src/rootReducer.ts +++ b/src/rootReducer.ts @@ -2,25 +2,25 @@ import { combineReducers, Reducer } from "redux"; import goalsReducer from "components/GoalTimeline/Redux/GoalReducer"; import loginReducer from "components/Login/Redux/LoginReducer"; -import { projectReducer } from "components/Project/ProjectReducer"; +import projectReducer from "components/Project/ProjectReducer"; import exportProjectReducer from "components/ProjectExport/Redux/ExportProjectReducer"; -import { pronunciationsReducer } from "components/Pronunciations/Redux/PronunciationsReducer"; -import { treeViewReducer } from "components/TreeView/Redux/TreeViewReducer"; -import { characterInventoryReducer } from "goals/CharacterInventory/Redux/CharacterInventoryReducer"; -import { mergeDupStepReducer } from "goals/MergeDuplicates/Redux/MergeDupsReducer"; +import pronunciationsReducer from "components/Pronunciations/Redux/PronunciationsReducer"; +import treeViewReducer from "components/TreeView/Redux/TreeViewReducer"; +import characterInventoryReducer from "goals/CharacterInventory/Redux/CharacterInventoryReducer"; +import mergeDupStepReducer from "goals/MergeDuplicates/Redux/MergeDupsReducer"; import { reviewEntriesReducer } from "goals/ReviewEntries/ReviewEntriesComponent/Redux/ReviewEntriesReducer"; import { StoreState } from "types"; -import { analyticsReducer } from "types/Redux/analytics"; +import analyticsReducer from "types/Redux/analytics"; export const rootReducer: Reducer = combineReducers({ - //login + //login and signup loginState: loginReducer, //project currentProjectState: projectReducer, exportProjectState: exportProjectReducer, - //data entry and review entries + //data entry and review entries goal treeViewState: treeViewReducer, reviewEntriesState: reviewEntriesReducer, pronunciationsState: pronunciationsReducer, @@ -28,7 +28,7 @@ export const rootReducer: Reducer = combineReducers({ //goal timeline and current goal goalsState: goalsReducer, - //merge duplicates goal + //merge duplicates goal and review deferred duplicates goal mergeDuplicateGoal: mergeDupStepReducer, //character inventory goal diff --git a/src/types/Redux/analytics.ts b/src/types/Redux/analytics.ts index 290bf04723..930d6af320 100644 --- a/src/types/Redux/analytics.ts +++ b/src/types/Redux/analytics.ts @@ -1,36 +1,30 @@ -import { - AnalyticsActionTypes, - AnalyticsChangePageAction, - AnalyticsState, - defaultState, -} from "types/Redux/analyticsReduxTypes"; +import { PayloadAction, createSlice } from "@reduxjs/toolkit"; -export function changePage(newPage: string): AnalyticsChangePageAction { - return { - type: AnalyticsActionTypes.ChangePage, - newPage, - }; -} +import { StoreActionTypes } from "rootActions"; +import { defaultState } from "types/Redux/analyticsReduxTypes"; -export const analyticsReducer = ( - //createStore() calls each reducer with undefined state - state: AnalyticsState = defaultState, - action: AnalyticsChangePageAction -): AnalyticsState => { - switch (action.type) { - case AnalyticsActionTypes.ChangePage: - if (action.newPage !== state.currentPage) { +const analyticsSlice = createSlice({ + name: "analyticsState", + initialState: defaultState, + reducers: { + changePageAction: (state, action) => { + if (action.payload !== state.currentPage) { analytics.track("navigate", { + destination: action.payload, source: state.currentPage, - destination: action.newPage, }); } - return { - ...state, - currentPage: action.newPage, - }; + state.currentPage = action.payload; + }, + }, + extraReducers: (builder) => + builder.addCase(StoreActionTypes.RESET, () => defaultState), +}); + +const { changePageAction } = analyticsSlice.actions; - default: - return state; - } -}; +export default analyticsSlice.reducer; + +export function changePage(newPage: string): PayloadAction { + return changePageAction(newPage); +} diff --git a/src/types/Redux/analyticsReduxTypes.ts b/src/types/Redux/analyticsReduxTypes.ts index a7210e11ac..036cd9b620 100644 --- a/src/types/Redux/analyticsReduxTypes.ts +++ b/src/types/Redux/analyticsReduxTypes.ts @@ -5,12 +5,3 @@ export interface AnalyticsState { export const defaultState: AnalyticsState = { currentPage: "", }; - -export enum AnalyticsActionTypes { - ChangePage = "CHANGE_CURRENT_PAGE", -} - -export interface AnalyticsChangePageAction { - type: AnalyticsActionTypes.ChangePage; - newPage: string; -} diff --git a/tsconfig.json b/tsconfig.json index f6222becae..9f9b6736f9 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,23 +1,23 @@ { "compilerOptions": { - "target": "es5", - "lib": ["dom", "dom.iterable", "esnext"], "allowJs": true, - "skipLibCheck": true, - "esModuleInterop": true, "allowSyntheticDefaultImports": true, - "strict": true, + "baseUrl": "src", + "downlevelIteration": true, + "esModuleInterop": true, "forceConsistentCasingInFileNames": true, + "isolatedModules": true, + "jsx": "react-jsx", + "lib": ["dom", "dom.iterable", "esnext"], "module": "esnext", "moduleResolution": "node", - "resolveJsonModule": true, - "isolatedModules": true, "noEmit": true, - "jsx": "react-jsx", - "downlevelIteration": true, - "baseUrl": "src", - "noFallthroughCasesInSwitch": true + "noFallthroughCasesInSwitch": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "strict": true, + "target": "es5" }, "exclude": ["*.dic.js"], - "include": ["src", "scripts"] + "include": [".eslintrc.cjs", "src", "scripts"] }