diff --git a/Backend.Tests/Controllers/SemanticDomainControllerTests.cs b/Backend.Tests/Controllers/SemanticDomainControllerTests.cs index 35ab90d3ba..cdf611d4b5 100644 --- a/Backend.Tests/Controllers/SemanticDomainControllerTests.cs +++ b/Backend.Tests/Controllers/SemanticDomainControllerTests.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using Backend.Tests.Mocks; using BackendFramework.Controllers; using BackendFramework.Interfaces; @@ -39,6 +40,23 @@ public void Setup() _semDomController = new SemanticDomainController(_semDomRepository); } + [Test] + public void GetAllSemanticDomainNamesFound() + { + var treeNodes = new List { new(_semDom) }; + ((SemanticDomainRepositoryMock)_semDomRepository).SetNextResponse(treeNodes); + var names = ((OkObjectResult)_semDomController.GetAllSemanticDomainNames(Lang).Result).Value; + Assert.That(names, Has.Count.EqualTo(1)); + Assert.That(((Dictionary)names!)[Id], Is.EqualTo(Name)); + } + + [Test] + public void GetAllSemanticDomainNamesNotFound() + { + var names = ((OkObjectResult)_semDomController.GetAllSemanticDomainNames(Lang).Result).Value; + Assert.That(names, Has.Count.EqualTo(0)); + } + [Test] public void GetSemanticDomainFullDomainFound() { diff --git a/Backend.Tests/Mocks/SemanticDomainRepositoryMock.cs b/Backend.Tests/Mocks/SemanticDomainRepositoryMock.cs index 2c8c9f67df..42da227bae 100644 --- a/Backend.Tests/Mocks/SemanticDomainRepositoryMock.cs +++ b/Backend.Tests/Mocks/SemanticDomainRepositoryMock.cs @@ -8,21 +8,10 @@ namespace Backend.Tests.Mocks public class SemanticDomainRepositoryMock : ISemanticDomainRepository { private object? _responseObj; - private List? _validLangs; public Task?> GetAllSemanticDomainTreeNodes(string lang) { - if (_validLangs is null) - { - return Task.FromResult((List?)_responseObj); - } - - List? semDoms = null; - if (_validLangs.Contains(lang)) - { - semDoms = new() { new(new SemanticDomain { Lang = lang }) }; - } - return Task.FromResult(semDoms); + return Task.FromResult((List?)_responseObj); } public Task GetSemanticDomainFull(string id, string lang) @@ -44,10 +33,5 @@ internal void SetNextResponse(object? response) { _responseObj = response; } - - internal void SetValidLangs(List? validLangs) - { - _validLangs = validLangs; - } } } diff --git a/Backend.Tests/Services/LiftServiceTests.cs b/Backend.Tests/Services/LiftServiceTests.cs index 2a82b0922a..623b59f6da 100644 --- a/Backend.Tests/Services/LiftServiceTests.cs +++ b/Backend.Tests/Services/LiftServiceTests.cs @@ -1,8 +1,5 @@ -using System.Collections.Generic; -using System.IO; using Backend.Tests.Mocks; using BackendFramework.Interfaces; -using BackendFramework.Models; using BackendFramework.Services; using NUnit.Framework; @@ -64,34 +61,5 @@ public void StoreRetrieveDeleteImportTest() Assert.That(_liftService.DeleteImport(UserId), Is.True); Assert.That(_liftService.RetrieveImport(UserId), Is.Null); } - - [Test] - public void CreateLiftRangesTest() - { - List frDoms = new() { new() { Lang = "fr" }, new() }; - List ptDoms = new() { new(), new() { Lang = "pt" } }; - List zzDoms = new() { new() { Lang = "zz" } }; - List projWords = new() - { - // First semantic domain of the second sense of a word - new() { Senses = new() { new(), new() { SemanticDomains = frDoms } } }, - // Second semantic domain of the first sense of a word - new() { Senses = new() { new() { SemanticDomains = ptDoms }, new() } }, - // Semantic domain with unsupported language - new() { Senses = new() { new() { SemanticDomains = zzDoms } } } - }; - - ((SemanticDomainRepositoryMock)_semDomRepo).SetValidLangs(new() { "en", "fr", "pt" }); - var langs = _liftService.CreateLiftRanges(projWords, new(), FileName).Result; - Assert.That(langs, Has.Count.EqualTo(2)); - Assert.That(langs, Does.Contain("fr")); - Assert.That(langs, Does.Contain("pt")); - - var liftRangesText = File.ReadAllText(FileName); - Assert.That(liftRangesText, Does.Not.Contain("\"en\"")); - Assert.That(liftRangesText, Does.Contain("\"fr\"")); - Assert.That(liftRangesText, Does.Contain("\"pt\"")); - Assert.That(liftRangesText, Does.Not.Contain("\"zz\"")); - } } } diff --git a/Backend/Controllers/SemanticDomainController.cs b/Backend/Controllers/SemanticDomainController.cs index 790e4d156b..cf24e03672 100644 --- a/Backend/Controllers/SemanticDomainController.cs +++ b/Backend/Controllers/SemanticDomainController.cs @@ -1,3 +1,5 @@ +using System.Collections.Generic; +using System.Linq; using System.Threading.Tasks; using BackendFramework.Interfaces; using BackendFramework.Models; @@ -19,6 +21,19 @@ public SemanticDomainController(ISemanticDomainRepository semDomRepo) _semDomRepo = semDomRepo; } + /// + /// Returns a dictionary mapping domain ids to names in the specified language (fallback: "en"). + /// + [HttpGet("allDomainNames", Name = "GetAllSemanticDomainNames")] + [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(Dictionary))] + public async Task GetAllSemanticDomainNames(string lang) + { + var semDoms = await _semDomRepo.GetAllSemanticDomainTreeNodes(lang) + ?? await _semDomRepo.GetAllSemanticDomainTreeNodes("en") + ?? new(); + return Ok(semDoms.ToDictionary(x => x.Id, x => x.Name)); + } + /// Returns with specified id and in specified language [HttpGet("domainFull", Name = "GetSemanticDomainFull")] [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(SemanticDomainFull))] diff --git a/Backend/Interfaces/ILiftService.cs b/Backend/Interfaces/ILiftService.cs index 1ab0404ddd..55c68d8f36 100644 --- a/Backend/Interfaces/ILiftService.cs +++ b/Backend/Interfaces/ILiftService.cs @@ -10,7 +10,7 @@ public interface ILiftService ILiftMerger GetLiftImporterExporter(string projectId, string vernLang, IWordRepository wordRepo); Task LdmlImport(string dirPath, IProjectRepository projRepo, Project project); Task LiftExport(string projectId, IWordRepository wordRepo, IProjectRepository projRepo); - Task> CreateLiftRanges(List projWords, List projDoms, string rangesDest); + Task CreateLiftRanges(List projDoms, string rangesDest); // Methods to store, retrieve, and delete an export string in a common dictionary. void StoreExport(string userId, string filePath); diff --git a/Backend/Services/LiftService.cs b/Backend/Services/LiftService.cs index d9f082b2d0..670daa2282 100644 --- a/Backend/Services/LiftService.cs +++ b/Backend/Services/LiftService.cs @@ -300,6 +300,8 @@ public async Task LiftExport( // So the words found in allWords with no matching guid in activeWords are exported as 'deleted'. var deletedWords = allWords.Where( x => activeWords.All(w => w.Guid != x.Guid)).DistinctBy(w => w.Guid).ToList(); + var semDomNames = (await _semDomRepo.GetAllSemanticDomainTreeNodes("en") ?? new()) + .ToDictionary(x => x.Id, x => x.Name); foreach (var wordEntry in activeWords) { var id = MakeSafeXmlAttribute(wordEntry.Vernacular) + "_" + wordEntry.Guid; @@ -321,7 +323,7 @@ public async Task LiftExport( AddNote(entry, wordEntry); AddVern(entry, wordEntry, proj.VernacularWritingSystem.Bcp47); - AddSenses(entry, wordEntry); + AddSenses(entry, wordEntry, semDomNames); await AddAudio(entry, wordEntry.Audio, audioDir, projectId, projSpeakers); liftWriter.Add(entry); @@ -334,7 +336,7 @@ public async Task LiftExport( AddNote(entry, wordEntry); AddVern(entry, wordEntry, proj.VernacularWritingSystem.Bcp47); - AddSenses(entry, wordEntry); + AddSenses(entry, wordEntry, semDomNames); await AddAudio(entry, wordEntry.Audio, audioDir, projectId, projSpeakers); liftWriter.AddDeletedEntry(entry); @@ -367,7 +369,7 @@ public async Task LiftExport( // Export semantic domains to lift-ranges if (proj.SemanticDomains.Count != 0 || CopyLiftRanges(proj.Id, rangesDest) is null) { - await CreateLiftRanges(allWords, proj.SemanticDomains, rangesDest); + await CreateLiftRanges(proj.SemanticDomains, rangesDest); } // Export character set to ldml. @@ -408,11 +410,8 @@ public async Task LiftExport( return rangesSrc; } - /// Export semantic domains to lift-ranges - /// If fails to load needed semantic domain list - /// List of languages found in project sem-doms and included in the lift-ranges file - public async Task> CreateLiftRanges( - List projWords, List projDoms, string rangesDest) + /// Export English semantic domains (along with any custom domains) to lift-ranges. + public async Task CreateLiftRanges(List projDoms, string rangesDest) { await using var liftRangesWriter = XmlWriter.Create(rangesDest, new XmlWriterSettings { @@ -425,21 +424,8 @@ public async Task> CreateLiftRanges( liftRangesWriter.WriteStartElement("range"); liftRangesWriter.WriteAttributeString("id", "semantic-domain-ddp4"); - var wordLangs = projWords - .SelectMany(w => w.Senses.SelectMany(s => s.SemanticDomains.Select(d => d.Lang))).Distinct(); - var exportLangs = new List(); - foreach (var lang in wordLangs) - { - var semDoms = await _semDomRepo.GetAllSemanticDomainTreeNodes(lang); - if (semDoms is not null && semDoms.Count > 0) - { - exportLangs.Add(lang); - foreach (var sd in semDoms) - { - WriteRangeElement(liftRangesWriter, sd.Id, sd.Guid, sd.Name, sd.Lang); - } - } - } + (await _semDomRepo.GetAllSemanticDomainTreeNodes("en"))? + .ForEach(sd => { WriteRangeElement(liftRangesWriter, sd.Id, sd.Guid, sd.Name, sd.Lang); }); // Pull from new semantic domains in project foreach (var sd in projDoms) @@ -456,7 +442,6 @@ public async Task> CreateLiftRanges( await liftRangesWriter.FlushAsync(); liftRangesWriter.Close(); - return exportLangs; } /// Adds of a word to be written out to lift @@ -486,7 +471,7 @@ private static void AddVern(LexEntry entry, Word wordEntry, string vernacularBcp } /// Adds each of a word to be written out to lift - private static void AddSenses(LexEntry entry, Word wordEntry) + private static void AddSenses(LexEntry entry, Word wordEntry, Dictionary semDomNames) { var activeSenses = wordEntry.Senses.Where( s => s.Accessibility == Status.Active || s.Accessibility == Status.Protected).ToList(); @@ -540,7 +525,8 @@ private static void AddSenses(LexEntry entry, Word wordEntry) foreach (var semDom in currentSense.SemanticDomains) { var orc = new OptionRefCollection(); - orc.Add(semDom.Id + " " + semDom.Name); + semDomNames.TryGetValue(semDom.Id, out string? name); + orc.Add(semDom.Id + " " + name ?? semDom.Name); lexSense.Properties.Add(new KeyValuePair( LexSense.WellKnownProperties.SemanticDomainDdp4, orc)); } @@ -658,16 +644,6 @@ private static void WriteRangeElement( liftRangesWriter.WriteEndElement(); //end range element } - [Serializable] - public class ExportException : Exception - { - public ExportException() { } - - public ExportException(string msg) : base(msg) { } - - protected ExportException(SerializationInfo info, StreamingContext context) : base(info, context) { } - } - private sealed class LiftMerger : ILiftMerger { private readonly string _projectId; diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json index e579df56cd..96522e60b7 100644 --- a/public/locales/en/translation.json +++ b/public/locales/en/translation.json @@ -151,7 +151,7 @@ "analysis": "Analysis", "analysisLanguage": "Analysis Language", "semanticDomains": "Semantic Domains", - "semanticDomainsDefault": "(Default to browser language)", + "semanticDomainsDefault": "(Default to user-interface language)", "languages": "Project Languages", "bcp47": "BCP 47 Code", "name": "Name", diff --git a/src/api/api/semantic-domain-api.ts b/src/api/api/semantic-domain-api.ts index 8e531d1f17..890fd751cb 100644 --- a/src/api/api/semantic-domain-api.ts +++ b/src/api/api/semantic-domain-api.ts @@ -48,6 +48,50 @@ export const SemanticDomainApiAxiosParamCreator = function ( configuration?: Configuration ) { return { + /** + * + * @param {string} [lang] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getAllSemanticDomainNames: async ( + lang?: string, + options: any = {} + ): Promise => { + const localVarPath = `/v1/semanticdomain/allDomainNames`; + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { + method: "GET", + ...baseOptions, + ...options, + }; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + if (lang !== undefined) { + localVarQueryParameter["lang"] = lang; + } + + setSearchParams(localVarUrlObj, localVarQueryParameter, options.query); + let headersFromBaseOptions = + baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = { + ...localVarHeaderParameter, + ...headersFromBaseOptions, + ...options.headers, + }; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, /** * * @param {string} [lang] @@ -253,6 +297,33 @@ export const SemanticDomainApiFp = function (configuration?: Configuration) { const localVarAxiosParamCreator = SemanticDomainApiAxiosParamCreator(configuration); return { + /** + * + * @param {string} [lang] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async getAllSemanticDomainNames( + lang?: string, + options?: any + ): Promise< + ( + axios?: AxiosInstance, + basePath?: string + ) => AxiosPromise<{ [key: string]: string }> + > { + const localVarAxiosArgs = + await localVarAxiosParamCreator.getAllSemanticDomainNames( + lang, + options + ); + return createRequestFunction( + localVarAxiosArgs, + globalAxios, + BASE_PATH, + configuration + ); + }, /** * * @param {string} [lang] @@ -384,6 +455,20 @@ export const SemanticDomainApiFactory = function ( ) { const localVarFp = SemanticDomainApiFp(configuration); return { + /** + * + * @param {string} [lang] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getAllSemanticDomainNames( + lang?: string, + options?: any + ): AxiosPromise<{ [key: string]: string }> { + return localVarFp + .getAllSemanticDomainNames(lang, options) + .then((request) => request(axios, basePath)); + }, /** * * @param {string} [lang] @@ -449,6 +534,20 @@ export const SemanticDomainApiFactory = function ( }; }; +/** + * Request parameters for getAllSemanticDomainNames operation in SemanticDomainApi. + * @export + * @interface SemanticDomainApiGetAllSemanticDomainNamesRequest + */ +export interface SemanticDomainApiGetAllSemanticDomainNamesRequest { + /** + * + * @type {string} + * @memberof SemanticDomainApiGetAllSemanticDomainNames + */ + readonly lang?: string; +} + /** * Request parameters for getAllSemanticDomainTreeNodes operation in SemanticDomainApi. * @export @@ -533,6 +632,22 @@ export interface SemanticDomainApiGetSemanticDomainTreeNodeByNameRequest { * @extends {BaseAPI} */ export class SemanticDomainApi extends BaseAPI { + /** + * + * @param {SemanticDomainApiGetAllSemanticDomainNamesRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof SemanticDomainApi + */ + public getAllSemanticDomainNames( + requestParameters: SemanticDomainApiGetAllSemanticDomainNamesRequest = {}, + options?: any + ) { + return SemanticDomainApiFp(this.configuration) + .getAllSemanticDomainNames(requestParameters.lang, options) + .then((request) => request(this.axios, this.basePath)); + } + /** * * @param {SemanticDomainApiGetAllSemanticDomainTreeNodesRequest} requestParameters Request parameters. diff --git a/src/backend/index.ts b/src/backend/index.ts index 398dbb5aac..339fed3cc3 100644 --- a/src/backend/index.ts +++ b/src/backend/index.ts @@ -29,6 +29,7 @@ import * as LocalStorage from "backend/localStorage"; import router from "browserRouter"; import authHeader from "components/Login/AuthHeaders"; import { Goal, GoalStep } from "types/goals"; +import { Hash } from "types/hash"; import { Path } from "types/path"; import { RuntimeConfig } from "types/runtimeConfig"; import { FileWithSpeakerId } from "types/word"; @@ -422,6 +423,16 @@ export async function projectDuplicateCheck( /* SemanticDomainController.cs */ +export async function getAllSemanticDomainNames( + lang = "" +): Promise> { + const resp = await semanticDomainApi.getAllSemanticDomainNames( + { lang }, + defaultOptions() + ); + return resp.data; +} + export async function getSemanticDomainFull( id: string, lang?: string diff --git a/src/components/App/AppLoggedIn.tsx b/src/components/App/AppLoggedIn.tsx index 3090bb416f..2c49a3d655 100644 --- a/src/components/App/AppLoggedIn.tsx +++ b/src/components/App/AppLoggedIn.tsx @@ -46,7 +46,9 @@ export default function AppWithBar(): ReactElement { const [styleOverrides, setStyleOverrides] = useState(); - useEffect(updateLangFromUser, []); + useEffect(() => { + updateLangFromUser(); + }, []); useEffect(() => { if (proj.id) { diff --git a/src/components/AppBar/tests/ProjectButtons.test.tsx b/src/components/AppBar/tests/ProjectButtons.test.tsx index 52351f15a1..b5ea3f7b36 100644 --- a/src/components/AppBar/tests/ProjectButtons.test.tsx +++ b/src/components/AppBar/tests/ProjectButtons.test.tsx @@ -16,13 +16,15 @@ import { Goal, GoalStatus } from "types/goals"; import { Path } from "types/path"; import { themeColors } from "types/theme"; +jest.mock("react-router-dom", () => ({ + useNavigate: jest.fn(), +})); + jest.mock("backend", () => ({ hasPermission: (perm: Permission) => mockHasPermission(perm), isSiteAdmin: () => mockIsSiteAdmin(), })); -jest.mock("react-router-dom", () => ({ - useNavigate: jest.fn(), -})); +jest.mock("components/Project/ProjectActions", () => ({})); const mockHasPermission = jest.fn(); const mockIsSiteAdmin = jest.fn(); diff --git a/src/components/AppBar/tests/SpeakerMenu.test.tsx b/src/components/AppBar/tests/SpeakerMenu.test.tsx index 6fc1e2bb2f..97fec9c679 100644 --- a/src/components/AppBar/tests/SpeakerMenu.test.tsx +++ b/src/components/AppBar/tests/SpeakerMenu.test.tsx @@ -13,6 +13,7 @@ import { randomSpeaker } from "types/project"; jest.mock("backend", () => ({ getAllSpeakers: () => mockGetAllSpeakers(), })); +jest.mock("components/Project/ProjectActions", () => ({})); const mockProjId = "mock-project-id"; const mockGetAllSpeakers = jest.fn(); diff --git a/src/components/AppBar/tests/UserMenu.test.tsx b/src/components/AppBar/tests/UserMenu.test.tsx index fb48130f5f..853885fd5d 100644 --- a/src/components/AppBar/tests/UserMenu.test.tsx +++ b/src/components/AppBar/tests/UserMenu.test.tsx @@ -13,6 +13,7 @@ jest.mock("backend/localStorage", () => ({ getAvatar: jest.fn(), getCurrentUser: jest.fn(), })); +jest.mock("components/Project/ProjectActions", () => ({})); jest.mock("react-router-dom", () => ({ useNavigate: jest.fn(), })); diff --git a/src/components/DataEntry/DataEntryTable/NewEntry/tests/index.test.tsx b/src/components/DataEntry/DataEntryTable/NewEntry/tests/index.test.tsx index f3c8f171e8..f7b58278b2 100644 --- a/src/components/DataEntry/DataEntryTable/NewEntry/tests/index.test.tsx +++ b/src/components/DataEntry/DataEntryTable/NewEntry/tests/index.test.tsx @@ -17,6 +17,7 @@ jest.mock( () => (props: any) => mockAutocomplete(props) ); +jest.mock("components/Project/ProjectActions", () => ({})); jest.mock("components/Pronunciations/PronunciationsFrontend", () => "div"); /** Bypass the Autocomplete and render its internal input with the props of both. */ diff --git a/src/components/DataEntry/DataEntryTable/tests/RecentEntry.test.tsx b/src/components/DataEntry/DataEntryTable/tests/RecentEntry.test.tsx index 81f9a77944..60ce83efc1 100644 --- a/src/components/DataEntry/DataEntryTable/tests/RecentEntry.test.tsx +++ b/src/components/DataEntry/DataEntryTable/tests/RecentEntry.test.tsx @@ -27,6 +27,8 @@ import { newWritingSystem } from "types/writingSystem"; jest.mock("@mui/material/Autocomplete", () => "div"); +jest.mock("components/Project/ProjectActions", () => ({})); + const mockStore = configureMockStore()(defaultState); const mockVern = "Vernacular"; const mockGloss = "Gloss"; diff --git a/src/components/DataEntry/DataEntryTable/tests/index.test.tsx b/src/components/DataEntry/DataEntryTable/tests/index.test.tsx index a19da7c853..a4b1176a91 100644 --- a/src/components/DataEntry/DataEntryTable/tests/index.test.tsx +++ b/src/components/DataEntry/DataEntryTable/tests/index.test.tsx @@ -55,6 +55,7 @@ jest.mock( "components/DataEntry/DataEntryTable/RecentEntry", () => MockRecentEntry ); +jest.mock("components/Project/ProjectActions", () => ({})); jest.mock("components/Pronunciations/PronunciationsFrontend", () => "div"); jest.spyOn(window, "alert").mockImplementation(() => {}); diff --git a/src/components/GoalTimeline/tests/GoalRedux.test.tsx b/src/components/GoalTimeline/tests/GoalRedux.test.tsx index 4c2c28432f..e825ad571a 100644 --- a/src/components/GoalTimeline/tests/GoalRedux.test.tsx +++ b/src/components/GoalTimeline/tests/GoalRedux.test.tsx @@ -48,6 +48,7 @@ jest.mock("backend", () => ({ jest.mock("browserRouter", () => ({ navigate: (path: Path) => mockNavigate(path), })); +jest.mock("components/Project/ProjectActions", () => ({})); jest.mock("components/Pronunciations/Recorder"); const mockAddGoalToUserEdit = jest.fn(); diff --git a/src/components/GoalTimeline/tests/index.test.tsx b/src/components/GoalTimeline/tests/index.test.tsx index 5ef0485d95..ebf1024ffa 100644 --- a/src/components/GoalTimeline/tests/index.test.tsx +++ b/src/components/GoalTimeline/tests/index.test.tsx @@ -18,6 +18,7 @@ jest.mock("goals/Redux/GoalActions", () => ({ asyncAddGoal: (goal: Goal) => mockChooseGoal(goal), asyncGetUserEdits: () => jest.fn(), })); +jest.mock("components/Project/ProjectActions", () => ({})); jest.mock("types/hooks", () => { return { ...jest.requireActual("types/hooks"), diff --git a/src/components/Project/ProjectActions.ts b/src/components/Project/ProjectActions.ts index 17d2e2e939..18a889fd86 100644 --- a/src/components/Project/ProjectActions.ts +++ b/src/components/Project/ProjectActions.ts @@ -1,15 +1,23 @@ -import { Action, PayloadAction } from "@reduxjs/toolkit"; +import { type Action, type PayloadAction } from "@reduxjs/toolkit"; -import { Project, Speaker, User } from "api/models"; -import { getAllProjectUsers, updateProject } from "backend"; +import { type Project, type Speaker, type User } from "api/models"; +import { + getAllProjectUsers, + getAllSemanticDomainNames, + updateProject, +} from "backend"; import { setProjectId } from "backend/localStorage"; import { resetAction, setProjectAction, + setSemanticDomainsAction, setSpeakerAction, setUsersAction, } from "components/Project/ProjectReducer"; -import { StoreStateDispatch } from "types/Redux/actions"; +import i18n from "i18n"; +import { type StoreState } from "types"; +import { type StoreStateDispatch } from "types/Redux/actions"; +import { type Hash } from "types/hash"; import { newProject } from "types/project"; // Action Creation Functions @@ -22,6 +30,10 @@ export function setCurrentProject(project?: Project): PayloadAction { return setProjectAction(project ?? newProject()); } +export function setCurrentSemDoms(semDoms?: Hash): PayloadAction { + return setSemanticDomainsAction(semDoms); +} + export function setCurrentSpeaker(speaker?: Speaker): PayloadAction { return setSpeakerAction(speaker); } @@ -32,29 +44,44 @@ export function setCurrentUsers(users?: User[]): PayloadAction { // Dispatch Functions +export function asyncLoadSemanticDomains() { + return async (dispatch: StoreStateDispatch, getState: () => StoreState) => { + const projLang = + getState().currentProjectState.project.semDomWritingSystem.bcp47; + const lang = (projLang || i18n.language).split("-")[0]; + dispatch(setCurrentSemDoms(await getAllSemanticDomainNames(lang))); + }; +} + export function asyncRefreshProjectUsers(projectId: string) { return async (dispatch: StoreStateDispatch) => { dispatch(setCurrentUsers(await getAllProjectUsers(projectId))); }; } -export function asyncUpdateCurrentProject(project: Project) { +export function asyncSetNewCurrentProject(proj?: Project) { return async (dispatch: StoreStateDispatch) => { - await updateProject(project); - dispatch(setCurrentProject(project)); + setProjectId(proj?.id); + dispatch(setCurrentProject(proj)); + await dispatch(asyncLoadSemanticDomains()); }; } -export function clearCurrentProject() { - return (dispatch: StoreStateDispatch) => { - setProjectId(); - dispatch(resetCurrentProject()); +export function asyncUpdateCurrentProject(proj: Project) { + return async (dispatch: StoreStateDispatch, getState: () => StoreState) => { + const prevLang = + getState().currentProjectState.project.semDomWritingSystem.bcp47; + await updateProject(proj); + dispatch(setCurrentProject(proj)); + if (prevLang !== proj.semDomWritingSystem.bcp47) { + await dispatch(asyncLoadSemanticDomains()); + } }; } -export function setNewCurrentProject(project?: Project) { +export function clearCurrentProject() { return (dispatch: StoreStateDispatch) => { - setProjectId(project?.id); - dispatch(setCurrentProject(project)); + setProjectId(); + dispatch(resetCurrentProject()); }; } diff --git a/src/components/Project/ProjectReducer.ts b/src/components/Project/ProjectReducer.ts index ba5977db51..18bcb890ab 100644 --- a/src/components/Project/ProjectReducer.ts +++ b/src/components/Project/ProjectReducer.ts @@ -15,6 +15,9 @@ const projectSlice = createSlice({ } state.project = action.payload; }, + setSemanticDomainsAction: (state, action) => { + state.semanticDomains = action.payload; + }, setSpeakerAction: (state, action) => { state.speaker = action.payload; }, @@ -29,6 +32,7 @@ const projectSlice = createSlice({ export const { resetAction, setProjectAction, + setSemanticDomainsAction, setSpeakerAction, setUsersAction, } = projectSlice.actions; diff --git a/src/components/Project/ProjectReduxTypes.ts b/src/components/Project/ProjectReduxTypes.ts index 51a4579cff..f1335688e4 100644 --- a/src/components/Project/ProjectReduxTypes.ts +++ b/src/components/Project/ProjectReduxTypes.ts @@ -1,8 +1,10 @@ -import { Project, Speaker, User } from "api/models"; +import { type Project, type Speaker, type User } from "api/models"; +import { type Hash } from "types/hash"; import { newProject } from "types/project"; export interface CurrentProjectState { project: Project; + semanticDomains?: Hash; speaker?: Speaker; users: User[]; } diff --git a/src/components/Project/tests/ProjectActions.test.tsx b/src/components/Project/tests/ProjectActions.test.tsx index 3fc4fc0246..2243d3fe82 100644 --- a/src/components/Project/tests/ProjectActions.test.tsx +++ b/src/components/Project/tests/ProjectActions.test.tsx @@ -1,23 +1,28 @@ -import { PreloadedState } from "redux"; +import { type PreloadedState } from "redux"; -import { Project, Speaker } from "api/models"; +import { type Project, type Speaker } from "api/models"; import { defaultState } from "components/App/DefaultState"; import { asyncRefreshProjectUsers, + asyncSetNewCurrentProject, asyncUpdateCurrentProject, clearCurrentProject, - setNewCurrentProject, } from "components/Project/ProjectActions"; -import { RootState, setupStore } from "store"; +import { defaultState as currentProjectState } from "components/Project/ProjectReduxTypes"; +import { type 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), + getAllProjectUsers: (projId?: string) => mockGetAllProjectUsers(projId), + getAllSemanticDomainNames: (lang?: string) => mockGetAllSemDomNames(lang), + updateProject: (proj: Project) => mockUpdateProject(proj), })); +// Mock "i18n", else `thrown: "Error: Error: connect ECONNREFUSED ::1:80 [...]` +jest.mock("i18n", () => ({ language: "" })); const mockGetAllProjectUsers = jest.fn(); +const mockGetAllSemDomNames = jest.fn(); const mockUpdateProject = jest.fn(); const mockProjId = "project-id"; @@ -27,6 +32,10 @@ const persistedDefaultState: PreloadedState = { _persist: { version: 1, rehydrated: false }, }; +beforeEach(() => { + jest.resetAllMocks(); +}); + describe("ProjectActions", () => { describe("asyncUpdateCurrentProject", () => { it("updates the backend and correctly affects state for different id", async () => { @@ -34,6 +43,7 @@ describe("ProjectActions", () => { const store = setupStore({ ...persistedDefaultState, currentProjectState: { + ...currentProjectState, project: proj, speaker: {} as Speaker, users: [newUser()], @@ -53,6 +63,7 @@ describe("ProjectActions", () => { const store = setupStore({ ...persistedDefaultState, currentProjectState: { + ...currentProjectState, project: proj, speaker: {} as Speaker, users: [newUser()], @@ -66,53 +77,83 @@ describe("ProjectActions", () => { expect(speaker).not.toBeUndefined(); expect(users).toHaveLength(1); }); + + it("fetches semantic domain names when semDomWritingSystem changes", async () => { + const proj: Project = { ...newProject(), id: mockProjId }; + const store = setupStore({ + ...persistedDefaultState, + currentProjectState: { + ...currentProjectState, + project: { ...proj }, + semanticDomains: { ["1"]: "one" }, + }, + }); + + // Project update but same sem dom language + proj.liftImported = !proj.liftImported; + await store.dispatch(asyncUpdateCurrentProject({ ...proj })); + expect(mockUpdateProject).toHaveBeenCalledTimes(1); + expect(mockGetAllSemDomNames).not.toHaveBeenCalled(); + + // Project update with different sem dom language + const lang = "es"; + proj.semDomWritingSystem = { ...proj.semDomWritingSystem, bcp47: lang }; + await store.dispatch(asyncUpdateCurrentProject({ ...proj })); + expect(mockUpdateProject).toHaveBeenCalledTimes(2); + expect(mockGetAllSemDomNames).toHaveBeenCalledTimes(1); + expect(mockGetAllSemDomNames).toHaveBeenCalledWith(lang); + }); }); describe("asyncRefreshProjectUsers", () => { it("correctly affects state", async () => { - const proj: Project = { ...newProject(), id: mockProjId }; const store = setupStore({ ...persistedDefaultState, currentProjectState: { - project: proj, + ...currentProjectState, + project: { ...newProject(), id: mockProjId }, + semanticDomains: { ["1"]: "one" }, speaker: {} as Speaker, - users: [], }, }); const mockUsers = [newUser(), newUser(), newUser()]; mockGetAllProjectUsers.mockResolvedValueOnce(mockUsers); await store.dispatch(asyncRefreshProjectUsers("mockProjId")); - const { project, speaker, users } = store.getState().currentProjectState; - expect(project.id).toEqual(mockProjId); - expect(speaker).not.toBeUndefined(); - expect(users).toHaveLength(mockUsers.length); + const projState = store.getState().currentProjectState; + expect(projState.project.id).toEqual(mockProjId); + expect(projState.semanticDomains).not.toBeUndefined(); + expect(projState.speaker).not.toBeUndefined(); + expect(projState.users).toHaveLength(mockUsers.length); }); }); describe("clearCurrentProject", () => { it("correctly affects state", () => { - const nonDefaultState = { - project: { ...newProject(), id: "nonempty-string" }, - speaker: {} as Speaker, - users: [newUser()], - }; const store = setupStore({ ...persistedDefaultState, - currentProjectState: nonDefaultState, + currentProjectState: { + ...currentProjectState, + project: { ...newProject(), id: mockProjId }, + semanticDomains: { ["1"]: "one" }, + speaker: {} as Speaker, + users: [newUser()], + }, }); store.dispatch(clearCurrentProject()); - const { project, speaker, users } = store.getState().currentProjectState; - expect(project.id).toEqual(""); - expect(speaker).toBeUndefined(); - expect(users).toHaveLength(0); + const projState = store.getState().currentProjectState; + expect(projState.project.id).toEqual(""); + expect(projState.semanticDomains).toBeUndefined(); + expect(projState.speaker).toBeUndefined(); + expect(projState.users).toHaveLength(0); }); }); - describe("setNewCurrentProject", () => { - it("correctly affects state and doesn't update the backend", () => { + describe("asyncSetNewCurrentProject", () => { + it("correctly affects state and doesn't update the backend", async () => { const proj: Project = { ...newProject(), id: mockProjId }; const store = setupStore(); - store.dispatch(setNewCurrentProject(proj)); + await store.dispatch(asyncSetNewCurrentProject(proj)); + expect(mockGetAllSemDomNames).toHaveBeenCalledTimes(1); expect(mockUpdateProject).not.toHaveBeenCalled(); const { project } = store.getState().currentProjectState; expect(project.id).toEqual(mockProjId); diff --git a/src/components/ProjectExport/Redux/tests/ExportProjectActions.test.tsx b/src/components/ProjectExport/Redux/tests/ExportProjectActions.test.tsx index 5ff440a77a..55b6d2b109 100644 --- a/src/components/ProjectExport/Redux/tests/ExportProjectActions.test.tsx +++ b/src/components/ProjectExport/Redux/tests/ExportProjectActions.test.tsx @@ -14,6 +14,7 @@ jest.mock("backend", () => ({ downloadLift: (...args: any[]) => mockDownloadList(...args), exportLift: (...args: any[]) => mockExportLift(...args), })); +jest.mock("components/Project/ProjectActions", () => ({})); const mockDownloadList = jest.fn(); const mockExportLift = jest.fn(); diff --git a/src/components/ProjectScreen/ChooseProject.tsx b/src/components/ProjectScreen/ChooseProject.tsx index 8abb790a58..b77fba67a5 100644 --- a/src/components/ProjectScreen/ChooseProject.tsx +++ b/src/components/ProjectScreen/ChooseProject.tsx @@ -5,14 +5,14 @@ import { ListItemButton, Typography, } from "@mui/material"; -import { ReactElement, useEffect, useState } from "react"; +import { type ReactElement, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { useNavigate } from "react-router-dom"; -import { Project } from "api/models"; +import { type Project } from "api/models"; import { getAllActiveProjectsByUser } from "backend"; import { getUserId } from "backend/localStorage"; -import { setNewCurrentProject } from "components/Project/ProjectActions"; +import { asyncSetNewCurrentProject } from "components/Project/ProjectActions"; import { useAppDispatch } from "types/hooks"; import { Path } from "types/path"; @@ -34,8 +34,8 @@ export default function ChooseProject(): ReactElement { } }, []); - const selectProject = (project: Project): void => { - dispatch(setNewCurrentProject(project)); + const selectProject = async (project: Project): Promise => { + await dispatch(asyncSetNewCurrentProject(project)); navigate(Path.DataEntry); }; diff --git a/src/components/ProjectScreen/CreateProjectActions.ts b/src/components/ProjectScreen/CreateProjectActions.ts index 25dedabca9..3ec4d323e9 100644 --- a/src/components/ProjectScreen/CreateProjectActions.ts +++ b/src/components/ProjectScreen/CreateProjectActions.ts @@ -1,9 +1,9 @@ -import { WritingSystem } from "api/models"; +import { type WritingSystem } from "api/models"; import { createProject, finishUploadLift, getProject } from "backend"; import router from "browserRouter"; -import { setNewCurrentProject } from "components/Project/ProjectActions"; +import { asyncSetNewCurrentProject } from "components/Project/ProjectActions"; import { asyncCreateUserEdits } from "goals/Redux/GoalActions"; -import { StoreStateDispatch } from "types/Redux/actions"; +import { type StoreStateDispatch } from "types/Redux/actions"; import { Path } from "types/path"; import { newProject } from "types/project"; @@ -21,7 +21,7 @@ export function asyncCreateProject( project.analysisWritingSystems = analysisWritingSystems; const createdProject = await createProject(project); - dispatch(setNewCurrentProject(createdProject)); + await dispatch(asyncSetNewCurrentProject(createdProject)); // Manually pause so they have a chance to see the success message. setTimeout(() => { @@ -43,7 +43,7 @@ export function asyncFinishProject( const projId = (await createProject(project)).id; await finishUploadLift(projId); const createdProject = await getProject(projId); - dispatch(setNewCurrentProject(createdProject)); + await dispatch(asyncSetNewCurrentProject(createdProject)); // Manually pause so they have a chance to see the success message. setTimeout(() => { diff --git a/src/components/ProjectScreen/tests/CreateProject.test.tsx b/src/components/ProjectScreen/tests/CreateProject.test.tsx index 9d1c67bb14..effdbe48f9 100644 --- a/src/components/ProjectScreen/tests/CreateProject.test.tsx +++ b/src/components/ProjectScreen/tests/CreateProject.test.tsx @@ -21,6 +21,8 @@ jest.mock("backend", () => ({ projectDuplicateCheck: () => mockProjectDuplicateCheck(), uploadLiftAndGetWritingSystems: () => mockUploadLiftAndGetWritingSystems(), })); +// Mock "i18n", else `thrown: "Error: Error: connect ECONNREFUSED ::1:80 [...]` +jest.mock("i18n", () => ({ language: "" })); const mockProjectDuplicateCheck = jest.fn(); const mockUploadLiftAndGetWritingSystems = jest.fn(); @@ -40,7 +42,6 @@ const mockSubmitEvent = (): Partial> => ({ let projectMaster: ReactTestRenderer; let projectHandle: ReactTestInstance; -4; beforeAll(async () => { await act(async () => { diff --git a/src/components/ProjectSettings/ProjectAutocomplete.tsx b/src/components/ProjectSettings/ProjectAutocomplete.tsx index 72d141e6c7..2f6dce3259 100644 --- a/src/components/ProjectSettings/ProjectAutocomplete.tsx +++ b/src/components/ProjectSettings/ProjectAutocomplete.tsx @@ -1,20 +1,20 @@ import { HelpOutline } from "@mui/icons-material"; import { Grid, MenuItem, Select, Tooltip } from "@mui/material"; -import { ReactElement } from "react"; +import { type ReactElement } from "react"; import { useTranslation } from "react-i18next"; import { AutocompleteSetting } from "api/models"; -import { ProjectSettingPropsWithUpdate } from "components/ProjectSettings/ProjectSettingsTypes"; +import { type ProjectSettingProps } from "components/ProjectSettings/ProjectSettingsTypes"; export default function ProjectAutocomplete( - props: ProjectSettingPropsWithUpdate + props: ProjectSettingProps ): ReactElement { const { t } = useTranslation(); const updateAutocompleteSetting = async ( autocompleteSetting: AutocompleteSetting ): Promise => { - props.updateProject({ ...props.project, autocompleteSetting }); + await props.setProject({ ...props.project, autocompleteSetting }); }; return ( diff --git a/src/components/ProjectSettings/ProjectImport.tsx b/src/components/ProjectSettings/ProjectImport.tsx index 9ae83f40de..1ad927132c 100644 --- a/src/components/ProjectSettings/ProjectImport.tsx +++ b/src/components/ProjectSettings/ProjectImport.tsx @@ -1,10 +1,10 @@ import { Grid, Typography } from "@mui/material"; -import { ReactElement, useState } from "react"; +import { type ReactElement, useState } from "react"; import { Trans, useTranslation } from "react-i18next"; import { getProject, uploadLift } from "backend"; import { FileInputButton, LoadingDoneButton } from "components/Buttons"; -import { ProjectSettingPropsWithSet } from "components/ProjectSettings/ProjectSettingsTypes"; +import { type ProjectSettingProps } from "components/ProjectSettings/ProjectSettingsTypes"; enum UploadState { Awaiting, @@ -16,7 +16,7 @@ const selectFileButtonId = "project-import-select-file"; export const uploadFileButtonId = "project-import-upload-file"; export default function ProjectImport( - props: ProjectSettingPropsWithSet + props: ProjectSettingProps ): ReactElement { const [liftFile, setLiftFile] = useState(); const [uploadState, setUploadState] = useState(UploadState.Awaiting); diff --git a/src/components/ProjectSettings/ProjectLanguages.tsx b/src/components/ProjectSettings/ProjectLanguages.tsx index db6da1a401..47db8ead10 100644 --- a/src/components/ProjectSettings/ProjectLanguages.tsx +++ b/src/components/ProjectSettings/ProjectLanguages.tsx @@ -18,14 +18,14 @@ import { Typography, } from "@mui/material"; import { LanguagePicker, languagePickerStrings_en } from "mui-language-picker"; -import { Fragment, ReactElement, useEffect, useState } from "react"; +import { Fragment, type ReactElement, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { toast } from "react-toastify"; -import { WritingSystem } from "api/models"; +import { type WritingSystem } from "api/models"; import { getFrontierWords } from "backend"; import { IconButtonWithTooltip } from "components/Buttons"; -import { ProjectSettingPropsWithUpdate } from "components/ProjectSettings/ProjectSettingsTypes"; +import { type ProjectSettingProps } from "components/ProjectSettings/ProjectSettingsTypes"; import theme from "types/theme"; import { newWritingSystem, semDomWritingSystems } from "types/writingSystem"; import { getAnalysisLangsFromWords } from "utilities/wordUtilities"; @@ -40,7 +40,7 @@ const getProjAnalysisLangsButtonId = "analysis-language-get"; const semDomLangSelectId = "semantic-domains-language"; export default function ProjectLanguages( - props: ProjectSettingPropsWithUpdate + props: ProjectSettingProps ): ReactElement { const [add, setAdd] = useState(false); const [changeVernName, setChangeVernName] = useState(false); @@ -68,7 +68,7 @@ export default function ProjectLanguages( const newDefault = analysisWritingSystems.splice(index, 1)[0]; analysisWritingSystems.splice(0, 0, newDefault); await props - .updateProject({ ...props.project, analysisWritingSystems }) + .setProject({ ...props.project, analysisWritingSystems }) .then(() => resetState()) .catch((err) => { console.error(err); @@ -82,7 +82,7 @@ export default function ProjectLanguages( const analysisWritingSystems = [...props.project.analysisWritingSystems]; analysisWritingSystems.splice(index, 1); await props - .updateProject({ ...props.project, analysisWritingSystems }) + .setProject({ ...props.project, analysisWritingSystems }) .then(() => resetState()) .catch((err) => { console.error(err); @@ -98,7 +98,7 @@ export default function ProjectLanguages( const analysisWritingSystems = [...props.project.analysisWritingSystems]; analysisWritingSystems.push(newLang); await props - .updateProject({ ...props.project, analysisWritingSystems }) + .setProject({ ...props.project, analysisWritingSystems }) .then(() => resetState()) .catch((err) => { console.error(err); @@ -141,7 +141,7 @@ export default function ProjectLanguages( semDomWritingSystems.find((ws) => ws.bcp47 === lang) ?? newWritingSystem(); await props - .updateProject({ ...props.project, semDomWritingSystem }) + .setProject({ ...props.project, semDomWritingSystem }) .then(() => resetState()) .catch((err) => { console.error(err); @@ -170,7 +170,7 @@ export default function ProjectLanguages( name: newVernName, }; await props - .updateProject({ ...props.project, vernacularWritingSystem }) + .setProject({ ...props.project, vernacularWritingSystem }) .then(() => resetState()) .catch((err) => { console.error(err); diff --git a/src/components/ProjectSettings/ProjectName.tsx b/src/components/ProjectSettings/ProjectName.tsx index 2cd8e3e811..3b760d77e8 100644 --- a/src/components/ProjectSettings/ProjectName.tsx +++ b/src/components/ProjectSettings/ProjectName.tsx @@ -1,13 +1,11 @@ import { Button, Grid, TextField } from "@mui/material"; -import { ReactElement, useEffect, useState } from "react"; +import { type ReactElement, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { toast } from "react-toastify"; -import { ProjectSettingPropsWithUpdate } from "components/ProjectSettings/ProjectSettingsTypes"; +import { type ProjectSettingProps } from "components/ProjectSettings/ProjectSettingsTypes"; -export default function ProjectName( - props: ProjectSettingPropsWithUpdate -): ReactElement { +export default function ProjectName(props: ProjectSettingProps): ReactElement { const [projName, setProjName] = useState(""); const { t } = useTranslation(); @@ -17,11 +15,9 @@ export default function ProjectName( const updateProjectName = async (): Promise => { if (projName !== props.project.name) { - await props - .updateProject({ ...props.project, name: projName }) - .catch(() => { - toast.error(t("projectSettings.nameUpdateFailed")); - }); + await props.setProject({ ...props.project, name: projName }).catch(() => { + toast.error(t("projectSettings.nameUpdateFailed")); + }); } }; diff --git a/src/components/ProjectSettings/ProjectSchedule/index.tsx b/src/components/ProjectSettings/ProjectSchedule/index.tsx index 8b63e00511..8621818ba7 100644 --- a/src/components/ProjectSettings/ProjectSchedule/index.tsx +++ b/src/components/ProjectSettings/ProjectSchedule/index.tsx @@ -1,6 +1,6 @@ import { CalendarMonth, DateRange, EventRepeat } from "@mui/icons-material"; import { Button, Grid, Typography } from "@mui/material"; -import { ReactElement, useCallback, useEffect, useState } from "react"; +import { type ReactElement, useCallback, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import Modal from "react-modal"; @@ -8,7 +8,7 @@ import { IconButtonWithTooltip } from "components/Buttons"; import CalendarView from "components/ProjectSettings/ProjectSchedule/CalendarView"; import DateScheduleEdit from "components/ProjectSettings/ProjectSchedule/DateScheduleEdit"; import DateSelector from "components/ProjectSettings/ProjectSchedule/DateSelector"; -import { ProjectSettingPropsWithUpdate } from "components/ProjectSettings/ProjectSettingsTypes"; +import { type ProjectSettingProps } from "components/ProjectSettings/ProjectSettingsTypes"; const customStyles = { content: { @@ -22,7 +22,7 @@ const customStyles = { }; export default function ProjectSchedule( - props: ProjectSettingPropsWithUpdate + props: ProjectSettingProps ): ReactElement { const [projectSchedule, setProjectSchedule] = useState([]); const [showEdit, setShowEdit] = useState(false); @@ -37,7 +37,7 @@ export default function ProjectSchedule( /** Remove all elements from workshopSchedule in project settings */ async function handleRemoveAll(): Promise { - await props.updateProject({ ...props.project, workshopSchedule: [] }); + await props.setProject({ ...props.project, workshopSchedule: [] }); setProjectSchedule([]); setShowRemove(false); } @@ -114,7 +114,7 @@ export default function ProjectSchedule( setShowSelector(false)} project={props.project} - updateProject={props.updateProject} + updateProject={props.setProject} /> setShowEdit(false)} project={props.project} projectSchedule={projectSchedule} - updateProject={props.updateProject} + updateProject={props.setProject} /> => { - mockUpdateProject.mockResolvedValue(undefined); + mockSetProject.mockResolvedValue(undefined); await renderer.act(async () => { projectMaster = renderer.create( ); }); diff --git a/src/components/ProjectSettings/ProjectSelect.tsx b/src/components/ProjectSettings/ProjectSelect.tsx index 54d291e1a9..606dc4d7c2 100644 --- a/src/components/ProjectSettings/ProjectSelect.tsx +++ b/src/components/ProjectSettings/ProjectSelect.tsx @@ -1,13 +1,13 @@ import { MenuItem, Select, SelectChangeEvent } from "@mui/material"; -import { ReactElement, useEffect, useState } from "react"; +import { type ReactElement, useEffect, useState } from "react"; -import { Project } from "api/models"; +import { type Project } from "api/models"; import { getAllActiveProjectsByUser } from "backend"; import { getUserId } from "backend/localStorage"; -import { ProjectSettingPropsWithSet } from "components/ProjectSettings/ProjectSettingsTypes"; +import { type ProjectSettingProps } from "components/ProjectSettings/ProjectSettingsTypes"; export default function ProjectSelect( - props: ProjectSettingPropsWithSet + props: ProjectSettingProps ): ReactElement { const [projList, setProjList] = useState([]); diff --git a/src/components/ProjectSettings/ProjectSettingsTypes.ts b/src/components/ProjectSettings/ProjectSettingsTypes.ts index 40d9273791..8267336a53 100644 --- a/src/components/ProjectSettings/ProjectSettingsTypes.ts +++ b/src/components/ProjectSettings/ProjectSettingsTypes.ts @@ -1,12 +1,7 @@ -import { Project } from "api/models"; +import { type Project } from "api/models"; -export interface ProjectSettingPropsWithSet { - project: Project; - setProject: (project: Project) => void; -} - -export interface ProjectSettingPropsWithUpdate { +export interface ProjectSettingProps { project: Project; readOnly?: boolean; - updateProject: (project: Project) => Promise; + setProject: (project: Project) => Promise; } diff --git a/src/components/ProjectSettings/index.tsx b/src/components/ProjectSettings/index.tsx index 05090a3797..d5708a0aac 100644 --- a/src/components/ProjectSettings/index.tsx +++ b/src/components/ProjectSettings/index.tsx @@ -38,8 +38,8 @@ import { canUploadLift, getCurrentPermissions } from "backend"; import BaseSettings from "components/BaseSettings"; import { asyncRefreshProjectUsers, + asyncSetNewCurrentProject, asyncUpdateCurrentProject, - setNewCurrentProject, } from "components/Project/ProjectActions"; import ExportButton from "components/ProjectExport/ExportButton"; import ProjectArchive from "components/ProjectSettings/ProjectArchive"; @@ -111,8 +111,8 @@ export default function ProjectSettingsComponent(): ReactElement { }, 2000); }; - const setProject = useCallback( - (proj: Project) => dispatch(setNewCurrentProject(proj)), + const setNewProject = useCallback( + async (proj: Project) => await dispatch(asyncSetNewCurrentProject(proj)), [dispatch] ); @@ -127,7 +127,7 @@ export default function ProjectSettingsComponent(): ReactElement { {t("projectSettings.project")} - + @@ -157,7 +157,7 @@ export default function ProjectSettingsComponent(): ReactElement { icon={} title={t("projectSettings.name")} body={ - + } /> )} @@ -170,7 +170,7 @@ export default function ProjectSettingsComponent(): ReactElement { body={ } /> @@ -206,7 +206,7 @@ export default function ProjectSettingsComponent(): ReactElement { readOnly={ !permissions.includes(Permission.DeleteEditSettingsAndUsers) } - updateProject={updateProject} + setProject={updateProject} /> } /> @@ -253,7 +253,7 @@ export default function ProjectSettingsComponent(): ReactElement { title={t("projectSettings.import.header")} body={ imports ? ( - + ) : ( {t("projectSettings.import.notAllowed")} @@ -284,7 +284,7 @@ export default function ProjectSettingsComponent(): ReactElement { } /> diff --git a/src/components/ProjectSettings/tests/ProjectAutocomplete.test.tsx b/src/components/ProjectSettings/tests/ProjectAutocomplete.test.tsx index 1defa89dc2..5b3b179158 100644 --- a/src/components/ProjectSettings/tests/ProjectAutocomplete.test.tsx +++ b/src/components/ProjectSettings/tests/ProjectAutocomplete.test.tsx @@ -5,7 +5,7 @@ import { AutocompleteSetting } from "api/models"; import ProjectAutocomplete from "components/ProjectSettings/ProjectAutocomplete"; import { randomProject } from "types/project"; -const mockUpdateProject = jest.fn(); +const mockSetProject = jest.fn(); const mockProject = randomProject(); @@ -14,10 +14,7 @@ let testRenderer: renderer.ReactTestRenderer; const renderAutocomplete = async (): Promise => { await renderer.act(async () => { testRenderer = renderer.create( - + ); }); }; @@ -27,12 +24,12 @@ describe("ProjectAutocomplete", () => { await renderAutocomplete(); const selectChange = testRenderer.root.findByType(Select).props.onChange; await renderer.act(async () => selectChange({ target: { value: "Off" } })); - expect(mockUpdateProject).toHaveBeenCalledWith({ + expect(mockSetProject).toHaveBeenCalledWith({ ...mockProject, autocompleteSetting: AutocompleteSetting.Off, }); await renderer.act(async () => selectChange({ target: { value: "On" } })); - expect(mockUpdateProject).toHaveBeenCalledWith({ + expect(mockSetProject).toHaveBeenCalledWith({ ...mockProject, autocompleteSetting: AutocompleteSetting.On, }); diff --git a/src/components/ProjectSettings/tests/ProjectLanguages.test.tsx b/src/components/ProjectSettings/tests/ProjectLanguages.test.tsx index 440f92617f..9a6cc24e9f 100644 --- a/src/components/ProjectSettings/tests/ProjectLanguages.test.tsx +++ b/src/components/ProjectSettings/tests/ProjectLanguages.test.tsx @@ -2,7 +2,7 @@ import { Button, IconButton, Select } from "@mui/material"; import { LanguagePicker } from "mui-language-picker"; import renderer from "react-test-renderer"; -import { Project, WritingSystem } from "api/models"; +import { type Project, type WritingSystem } from "api/models"; import ProjectLanguages, { editVernacularNameButtonId, editVernacularNameFieldId, @@ -15,7 +15,7 @@ const mockAnalysisWritingSystems = [ newWritingSystem("a", "a"), newWritingSystem("b", "b"), ]; -const mockUpdateProject = jest.fn(); +const mockSetProject = jest.fn(); let projectMaster: renderer.ReactTestRenderer; let pickerHandle: renderer.ReactTestInstance; @@ -29,13 +29,13 @@ const renderProjLangs = async ( project: Project, readOnly = false ): Promise => { - mockUpdateProject.mockResolvedValue(undefined); + mockSetProject.mockResolvedValue(undefined); await renderer.act(async () => { projectMaster = renderer.create( ); }); @@ -79,7 +79,7 @@ describe("ProjectLanguages", () => { .props.onClick(); }); expect( - mockUpdateProject.mock.calls[0][0].vernacularWritingSystem.name + mockSetProject.mock.calls[0][0].vernacularWritingSystem.name ).toEqual(newName); }); @@ -101,7 +101,7 @@ describe("ProjectLanguages", () => { .findByProps({ id: "analysis-language-new-confirm" }) .props.onClick(); }); - expect(mockUpdateProject).toHaveBeenCalledWith( + expect(mockSetProject).toHaveBeenCalledWith( mockProject([...mockAnalysisWritingSystems, newLang]) ); }); diff --git a/src/components/ProjectSettings/tests/ProjectName.test.tsx b/src/components/ProjectSettings/tests/ProjectName.test.tsx index 7e6f620cfc..9e800761f8 100644 --- a/src/components/ProjectSettings/tests/ProjectName.test.tsx +++ b/src/components/ProjectSettings/tests/ProjectName.test.tsx @@ -10,7 +10,7 @@ jest.mock("react-toastify", () => ({ const mockToastError = jest.fn(); -const mockUpdateProject = jest.fn(); +const mockSetProject = jest.fn(); const mockProject = randomProject(); @@ -19,7 +19,7 @@ let testRenderer: renderer.ReactTestRenderer; const renderName = async (): Promise => { await renderer.act(async () => { testRenderer = renderer.create( - + ); }); }; @@ -30,12 +30,12 @@ describe("ProjectName", () => { const textField = testRenderer.root.findByType(TextField); const saveButton = testRenderer.root.findByType(Button); const name = "new-project-name"; - mockUpdateProject.mockResolvedValueOnce({}); + mockSetProject.mockResolvedValueOnce({}); await renderer.act(async () => textField.props.onChange({ target: { value: name } }) ); await renderer.act(async () => saveButton.props.onClick()); - expect(mockUpdateProject).toHaveBeenCalledWith({ ...mockProject, name }); + expect(mockSetProject).toHaveBeenCalledWith({ ...mockProject, name }); }); it("toasts on error", async () => { @@ -45,7 +45,7 @@ describe("ProjectName", () => { await renderer.act(async () => textField.props.onChange({ target: { value: "new-name" } }) ); - mockUpdateProject.mockRejectedValueOnce({}); + mockSetProject.mockRejectedValueOnce({}); expect(mockToastError).not.toHaveBeenCalled(); await renderer.act(async () => saveButton.props.onClick()); expect(mockToastError).toHaveBeenCalledTimes(1); diff --git a/src/components/ProjectSettings/tests/index.test.tsx b/src/components/ProjectSettings/tests/index.test.tsx index ed866c9496..0360e13601 100644 --- a/src/components/ProjectSettings/tests/index.test.tsx +++ b/src/components/ProjectSettings/tests/index.test.tsx @@ -32,6 +32,8 @@ jest.mock("backend", () => ({ getUserRoles: () => Promise.resolve([]), })); jest.mock("components/Project/ProjectActions"); +// Mock "i18n", else `thrown: "Error: Error: connect ECONNREFUSED ::1:80 [...]` +jest.mock("i18n", () => ({ language: "" })); jest.mock("types/hooks", () => { return { ...jest.requireActual("types/hooks"), diff --git a/src/components/SiteSettings/tests/index.test.tsx b/src/components/SiteSettings/tests/index.test.tsx index a15c6630c2..71b674813c 100644 --- a/src/components/SiteSettings/tests/index.test.tsx +++ b/src/components/SiteSettings/tests/index.test.tsx @@ -13,6 +13,7 @@ jest.mock("backend", () => ({ getAllUsers: (...args: any[]) => mockGetAllUsers(...args), getBannerText: (...args: any[]) => mockGetBannerText(...args), })); +jest.mock("components/Project/ProjectActions", () => ({})); const setupMocks = (): void => { mockGetAllProjects.mockResolvedValue([]); diff --git a/src/components/UserSettings/UserSettings.tsx b/src/components/UserSettings/UserSettings.tsx index c27e1631b6..06a65666c4 100644 --- a/src/components/UserSettings/UserSettings.tsx +++ b/src/components/UserSettings/UserSettings.tsx @@ -16,8 +16,10 @@ import { useTranslation } from "react-i18next"; import { User } from "api/models"; import { isEmailTaken, updateUser } from "backend"; import { getAvatar, getCurrentUser } from "backend/localStorage"; +import { asyncLoadSemanticDomains } from "components/Project/ProjectActions"; import ClickableAvatar from "components/UserSettings/ClickableAvatar"; import { updateLangFromUser } from "i18n"; +import { useAppDispatch } from "types/hooks"; import theme from "types/theme"; import { uiWritingSystems } from "types/writingSystem"; @@ -49,6 +51,8 @@ export function UserSettings(props: { user: User; setUser: (user?: User) => void; }): ReactElement { + const dispatch = useAppDispatch(); + const [name, setName] = useState(props.user.name); const [phone, setPhone] = useState(props.user.phone); const [email, setEmail] = useState(props.user.email); @@ -81,7 +85,12 @@ export function UserSettings(props: { uiLang, hasAvatar: !!avatar, }); - updateLangFromUser(); + + // Update the i18n language and in-state semantic domains as needed. + if (await updateLangFromUser()) { + await dispatch(asyncLoadSemanticDomains()); + } + enqueueSnackbar(t("userSettings.updateSuccess")); props.setUser(getCurrentUser()); } else { diff --git a/src/components/UserSettings/tests/UserSettings.test.tsx b/src/components/UserSettings/tests/UserSettings.test.tsx index df8d392d93..8e189d67f5 100644 --- a/src/components/UserSettings/tests/UserSettings.test.tsx +++ b/src/components/UserSettings/tests/UserSettings.test.tsx @@ -28,6 +28,12 @@ jest.mock("backend/localStorage", () => ({ getAvatar: (...args: any[]) => mockGetAvatar(...args), getCurrentUser: (...args: any[]) => mockGetCurrentUser(...args), })); +jest.mock("components/Project/ProjectActions", () => ({ + asyncLoadSemanticDomains: jest.fn(), +})); +jest.mock("types/hooks", () => ({ + useAppDispatch: () => jest.fn(), +})); // Mock "i18n", else `thrown: "Error: Error: connect ECONNREFUSED ::1:80 [...]` jest.mock("i18n", () => ({ diff --git a/src/components/WordCard/DomainChipsGrid.tsx b/src/components/WordCard/DomainChipsGrid.tsx new file mode 100644 index 0000000000..667605ec09 --- /dev/null +++ b/src/components/WordCard/DomainChipsGrid.tsx @@ -0,0 +1,37 @@ +import { Grid } from "@mui/material"; +import { type ReactElement } from "react"; + +import { type SemanticDomain } from "api/models"; +import DomainChip from "components/WordCard/DomainChip"; +import { type StoreState } from "types"; +import { useAppSelector } from "types/hooks"; + +interface DomainChipsGridProps { + semDoms: SemanticDomain[]; + provenance?: boolean; +} + +export default function DomainChipsGrid( + props: DomainChipsGridProps +): ReactElement { + const semDomNames = useAppSelector( + (state: StoreState) => state.currentProjectState.semanticDomains + ); + + /** Change the domain name into the project's sem dom language; + * if not available, fall back to the given domain's name. */ + const updateName = (dom: SemanticDomain): SemanticDomain => { + const name = semDomNames ? semDomNames[dom.id] ?? dom.name : dom.name; + return { ...dom, name }; + }; + + return ( + + {props.semDoms.map((d) => ( + + + + ))} + + ); +} diff --git a/src/components/WordCard/SenseCard.tsx b/src/components/WordCard/SenseCard.tsx index 088c8a517b..10d8e3ac27 100644 --- a/src/components/WordCard/SenseCard.tsx +++ b/src/components/WordCard/SenseCard.tsx @@ -1,9 +1,9 @@ -import { Card, CardContent, Grid } from "@mui/material"; -import { ReactElement } from "react"; +import { Card, CardContent } from "@mui/material"; +import { type ReactElement } from "react"; -import { GramCatGroup, Sense } from "api/models"; +import { GramCatGroup, type Sense } from "api/models"; import { PartOfSpeechButton } from "components/Buttons"; -import DomainChip from "components/WordCard/DomainChip"; +import DomainChipsGrid from "components/WordCard/DomainChipsGrid"; import SenseCardText from "components/WordCard/SenseCardText"; interface SenseCardProps { @@ -14,17 +14,18 @@ interface SenseCardProps { } export default function SenseCard(props: SenseCardProps): ReactElement { - const { grammaticalInfo, semanticDomains } = props.sense; + const gramInfo = props.sense.grammaticalInfo; + const semDoms = props.sense.semanticDomains; return ( {/* Part of speech (if any) */}
- {grammaticalInfo.catGroup !== GramCatGroup.Unspecified && ( + {gramInfo.catGroup !== GramCatGroup.Unspecified && ( )} @@ -38,13 +39,7 @@ export default function SenseCard(props: SenseCardProps): ReactElement { /> {/* Semantic domains */} - - {semanticDomains.map((d) => ( - - - - ))} - + ); diff --git a/src/components/WordCard/tests/SenseCard.test.tsx b/src/components/WordCard/tests/SenseCard.test.tsx new file mode 100644 index 0000000000..ef34c3db80 --- /dev/null +++ b/src/components/WordCard/tests/SenseCard.test.tsx @@ -0,0 +1,70 @@ +import { Provider } from "react-redux"; +import { type ReactTestRenderer, act, create } from "react-test-renderer"; +import configureMockStore from "redux-mock-store"; + +import { GramCatGroup, type Sense } from "api"; +import { PartOfSpeechButton } from "components/Buttons"; +import { defaultState } from "components/Project/ProjectReduxTypes"; +import DomainChip from "components/WordCard/DomainChip"; +import SenseCard from "components/WordCard/SenseCard"; +import { type StoreState } from "types"; +import { Hash } from "types/hash"; +import { newSemanticDomain } from "types/semanticDomain"; +import { newSense } from "types/word"; + +const mockSemDomNames: Hash = { ["1"]: "I", ["2"]: "II" }; +const mockState = (): Partial => ({ + currentProjectState: { ...defaultState, semanticDomains: mockSemDomNames }, +}); + +let renderer: ReactTestRenderer; + +const renderSenseCard = async (sense?: Sense): Promise => { + await act(async () => { + renderer = create( + + + + ); + }); +}; + +beforeEach(() => { + jest.clearAllMocks(); +}); + +describe("SenseCard", () => { + it("has an icon for part of speech (if not GramCatGroup.Unspecified)", async () => { + const sense = newSense("gloss"); + await renderSenseCard(sense); + expect(() => renderer.root.findByType(PartOfSpeechButton)).toThrow(); + + sense.grammaticalInfo = { + catGroup: GramCatGroup.Noun, + grammaticalCategory: "n", + }; + await renderSenseCard(sense); + renderer.root.findByType(PartOfSpeechButton); + }); + + it("uses in-redux-state domain names when available", async () => { + const sense = newSense("gloss"); + const name0 = "not in state"; + const name1 = "different from in state"; + sense.semanticDomains = [ + newSemanticDomain("0", name0), + newSemanticDomain("1", name1), + ]; + await renderSenseCard(sense); + expect(renderer.root.findAllByType(DomainChip)).toHaveLength(2); + + // Ensure 0's name is used since its id is not in-state + renderer.root.findByProps({ label: `0: ${name0}` }); + + // Ensure 1's name is replace by the in-state name + const label1no = `1: ${name1}`; + expect(() => renderer.root.findByProps({ label: label1no })).toThrow(); + const label1yes = `1: ${mockSemDomNames["1"]}`; + renderer.root.findByProps({ label: label1yes }); + }); +}); diff --git a/src/components/WordCard/tests/index.test.tsx b/src/components/WordCard/tests/index.test.tsx index 62a052a09e..1dac79044c 100644 --- a/src/components/WordCard/tests/index.test.tsx +++ b/src/components/WordCard/tests/index.test.tsx @@ -12,6 +12,7 @@ jest .mockImplementation(() => {}); jest.mock("components/Pronunciations/AudioPlayer", () => "div"); jest.mock("components/Pronunciations/Recorder"); +jest.mock("components/WordCard/DomainChipsGrid", () => "div"); const mockWordId = "mock-id"; const buttonId = buttonIdFull(mockWordId); diff --git a/src/goals/CharacterInventory/CharInv/CharacterDetail/tests/index.test.tsx b/src/goals/CharacterInventory/CharInv/CharacterDetail/tests/index.test.tsx index 5c8ab037f6..0bdc68eb3e 100644 --- a/src/goals/CharacterInventory/CharInv/CharacterDetail/tests/index.test.tsx +++ b/src/goals/CharacterInventory/CharInv/CharacterDetail/tests/index.test.tsx @@ -22,6 +22,7 @@ jest.mock("@mui/material", () => { }; }); +jest.mock("components/Project/ProjectActions", () => ({})); jest.mock("goals/CharacterInventory/Redux/CharacterInventoryActions", () => ({ findAndReplace: () => mockFindAndReplace(), })); diff --git a/src/goals/MergeDuplicates/MergeDupsStep/SenseCardContent.tsx b/src/goals/MergeDuplicates/MergeDupsStep/SenseCardContent.tsx index 2dc1157f2c..255684ab80 100644 --- a/src/goals/MergeDuplicates/MergeDupsStep/SenseCardContent.tsx +++ b/src/goals/MergeDuplicates/MergeDupsStep/SenseCardContent.tsx @@ -1,5 +1,5 @@ import { ArrowForwardIos, WarningOutlined } from "@mui/icons-material"; -import { CardContent, Chip, Grid, IconButton } from "@mui/material"; +import { CardContent, IconButton } from "@mui/material"; import { type ReactElement } from "react"; import { useTranslation } from "react-i18next"; @@ -12,6 +12,7 @@ import { } from "api/models"; import { IconButtonWithTooltip, PartOfSpeechButton } from "components/Buttons"; import MultilineTooltipTitle from "components/MultilineTooltipTitle"; +import DomainChipsGrid from "components/WordCard/DomainChipsGrid"; import SenseCardText from "components/WordCard/SenseCardText"; interface SenseCardContentProps { @@ -21,26 +22,23 @@ interface SenseCardContentProps { toggleFunction?: () => void; } -// Only show first sense's glosses/definitions; in merging, others deleted as duplicates. -// Show first part of speech, if any. -// Show semantic domains from all senses. -// In merging, user can select a different one by reordering in the sidebar. +/** Only show first sense's glosses, definitions, and part of speech. + * In merging, others deleted as duplicates; + * user can select a different first sense by reordering in the sidebar. + * Show semantic domains from all senses. */ export default function SenseCardContent( props: SenseCardContentProps ): ReactElement { const { t } = useTranslation(); - const semDoms = [ - ...new Set( - props.senses.flatMap((s) => - s.semanticDomains.map((dom) => `${dom.id}: ${dom.name}`) - ) - ), - ]; const gramInfo = props.senses .map((s) => s.grammaticalInfo) .find((g) => g.catGroup !== GramCatGroup.Unspecified); + const semDoms = props.senses + .flatMap((s) => s.semanticDomains) + .sort((a, b) => a.id.localeCompare(b.id)); + const reasonText = (reason: ProtectReason): string => { // Backend/Helper/LiftHelper.cs > GetProtectedReasons(LiftSense sense) switch (reason.type) { @@ -156,13 +154,7 @@ export default function SenseCardContent( {/* List glosses and (if any) definitions. */} {/* List semantic domains. */} - - {semDoms.map((dom) => ( - - - - ))} - + ); } diff --git a/src/goals/ReviewEntries/ReviewEntriesTable/CellComponents/tests/PronunciationsCell.test.tsx b/src/goals/ReviewEntries/ReviewEntriesTable/CellComponents/tests/PronunciationsCell.test.tsx index cd90489d4a..c2a7cbc92c 100644 --- a/src/goals/ReviewEntries/ReviewEntriesTable/CellComponents/tests/PronunciationsCell.test.tsx +++ b/src/goals/ReviewEntries/ReviewEntriesTable/CellComponents/tests/PronunciationsCell.test.tsx @@ -13,6 +13,7 @@ import { StoreState } from "types"; import theme from "types/theme"; import { newPronunciation } from "types/word"; +jest.mock("components/Project/ProjectActions", () => ({})); // Mock the store interactions jest.mock("goals/ReviewEntries/Redux/ReviewEntriesActions", () => ({ deleteAudio: (...args: any[]) => mockDeleteAudio(...args), diff --git a/src/i18n.ts b/src/i18n.ts index cbfaf22d8c..98f531cc23 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -39,11 +39,16 @@ function setDir(): void { document.body.dir = i18n.dir(); } -export function updateLangFromUser(): void { +/** Updates `i18n`'s resolved language to the user-specified ui language (if different). + * + * Returns `boolean` of whether the resolved language was updated. */ +export async function updateLangFromUser(): Promise { const uiLang = getCurrentUser()?.uiLang; if (uiLang && uiLang !== i18n.resolvedLanguage) { - i18n.changeLanguage(uiLang, setDir); + await i18n.changeLanguage(uiLang, setDir); + return true; } + return false; } export default i18n;