diff --git a/Backend.Tests/Controllers/StatisticsControllerTests.cs b/Backend.Tests/Controllers/StatisticsControllerTests.cs index 03f4fbcef3..e1ff0393e0 100644 --- a/Backend.Tests/Controllers/StatisticsControllerTests.cs +++ b/Backend.Tests/Controllers/StatisticsControllerTests.cs @@ -132,5 +132,21 @@ public async Task TestGetSemanticDomainUserCounts() var result = await _statsController.GetSemanticDomainUserCounts(_projId); Assert.That(result, Is.InstanceOf()); } + + [Test] + public async Task TestGetDomainProgressProportionNoPermission() + { + _statsController.ControllerContext.HttpContext = PermissionServiceMock.UnauthorizedHttpContext(); + + var result = await _statsController.GetDomainProgressProportion(_projId, "1"); + Assert.That(result, Is.InstanceOf()); + } + + [Test] + public async Task TestGetDomainProgressProportion() + { + var result = await _statsController.GetDomainProgressProportion(_projId, "1"); + Assert.That(result, Is.InstanceOf()); + } } } diff --git a/Backend.Tests/Mocks/StatisticsServiceMock.cs b/Backend.Tests/Mocks/StatisticsServiceMock.cs index 4e55c3abd6..a9899a6c2c 100644 --- a/Backend.Tests/Mocks/StatisticsServiceMock.cs +++ b/Backend.Tests/Mocks/StatisticsServiceMock.cs @@ -28,5 +28,9 @@ public Task> GetSemanticDomainUserCounts(string pr { return Task.FromResult(new List()); } + public Task GetDomainProgressProportion(string projectId, string domainId) + { + return Task.FromResult(0.0); + } } } diff --git a/Backend.Tests/Mocks/WordRepositoryMock.cs b/Backend.Tests/Mocks/WordRepositoryMock.cs index 40e2d80ae2..86f68d0e42 100644 --- a/Backend.Tests/Mocks/WordRepositoryMock.cs +++ b/Backend.Tests/Mocks/WordRepositoryMock.cs @@ -132,11 +132,16 @@ public Task Add(Word word) return Task.FromResult(word); } + public Task FrontierHasWordsWithDomain(string projectId, string domainId) + { + return Task.FromResult(_frontier.Any( + w => w.ProjectId == projectId && w.Senses.Any(s => s.SemanticDomains.Any(sd => sd.Id == domainId)))); + } + public Task CountFrontierWordsWithDomain(string projectId, string domainId) { - var count = _frontier.Count( - w => w.ProjectId == projectId && w.Senses.Any(s => s.SemanticDomains.Any(sd => sd.Id == domainId))); - return Task.FromResult(count); + return Task.FromResult(_frontier.Count( + w => w.ProjectId == projectId && w.Senses.Any(s => s.SemanticDomains.Any(sd => sd.Id == domainId)))); } } } diff --git a/Backend/Controllers/StatisticsController.cs b/Backend/Controllers/StatisticsController.cs index 092535f824..a0702ab990 100644 --- a/Backend/Controllers/StatisticsController.cs +++ b/Backend/Controllers/StatisticsController.cs @@ -118,5 +118,22 @@ public async Task GetSemanticDomainUserCounts(string projectId) return Ok(await _statService.GetSemanticDomainUserCounts(projectId)); } + + /// Get the proportion of descendant domains that have at least one entry + /// A double value between 0 and 1 + [HttpGet("GetDomainProgressProportion", Name = "GetDomainProgressProportion")] + [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(double))] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task GetDomainProgressProportion(string projectId, string domainId) + { + using var activity = OtelService.StartActivityWithTag(otelTagName, "getting domain progress proportion"); + + if (!await _permissionService.HasProjectPermission(HttpContext, Permission.WordEntry, projectId)) + { + return Forbid(); + } + + return Ok(await _statService.GetDomainProgressProportion(projectId, domainId)); + } } } diff --git a/Backend/Interfaces/IStatisticsService.cs b/Backend/Interfaces/IStatisticsService.cs index 09229d5289..047b42068e 100644 --- a/Backend/Interfaces/IStatisticsService.cs +++ b/Backend/Interfaces/IStatisticsService.cs @@ -12,6 +12,7 @@ public interface IStatisticsService Task GetProgressEstimationLineChartRoot(string projectId, List schedule); Task GetLineChartRootData(string projectId); Task> GetSemanticDomainUserCounts(string projectId); + Task GetDomainProgressProportion(string projectId, string domainId); } } diff --git a/Backend/Interfaces/IWordRepository.cs b/Backend/Interfaces/IWordRepository.cs index a5606c6f82..1cd79f02e4 100644 --- a/Backend/Interfaces/IWordRepository.cs +++ b/Backend/Interfaces/IWordRepository.cs @@ -23,6 +23,7 @@ public interface IWordRepository Task> AddFrontier(List words); Task DeleteFrontier(string projectId, string wordId); Task DeleteFrontier(string projectId, List wordIds); + Task FrontierHasWordsWithDomain(string projectId, string domainId); Task CountFrontierWordsWithDomain(string projectId, string domainId); } } diff --git a/Backend/Repositories/WordRepository.cs b/Backend/Repositories/WordRepository.cs index 5884e14c7b..a8aee23146 100644 --- a/Backend/Repositories/WordRepository.cs +++ b/Backend/Repositories/WordRepository.cs @@ -270,6 +270,24 @@ public async Task DeleteFrontier(string projectId, List wordIds) return deleted.DeletedCount; } + /// + /// Checks if the Frontier has any words that have the specified semantic domain. + /// + /// The project id + /// The semantic domain id + /// True if there is at least one word containing at least one sense with the specified domain. + public Task FrontierHasWordsWithDomain(string projectId, string domainId) + { + using var activity = OtelService.StartActivityWithTag(otelTagName, "checking frontier for words with domain"); + + var filterDef = new FilterDefinitionBuilder(); + var filter = filterDef.And( + filterDef.Eq(w => w.ProjectId, projectId), + filterDef.ElemMatch(w => w.Senses, s => s.SemanticDomains.Any(sd => sd.Id == domainId))); + + return _frontier.Find(filter).Limit(1).AnyAsync(); + } + /// /// Counts the number of Frontier words that have the specified semantic domain. /// diff --git a/Backend/Services/StatisticsService.cs b/Backend/Services/StatisticsService.cs index 2ad4d7a829..63b56db9db 100644 --- a/Backend/Services/StatisticsService.cs +++ b/Backend/Services/StatisticsService.cs @@ -367,5 +367,46 @@ public async Task> GetSemanticDomainUserCounts(str // return descending order by senseCount return resUserMap.Values.ToList().OrderByDescending(t => t.WordCount).ToList(); } + + /// + /// Get the proportion of descendant domains that have at least one entry + /// + /// The project id + /// The semantic domain id + /// A proportion value between 0 and 1 + public async Task GetDomainProgressProportion(string projectId, string domainId) + { + using var activity = OtelService.StartActivityWithTag(otelTagName, "getting domain progress proportion"); + + if (string.IsNullOrEmpty(projectId) || string.IsNullOrEmpty(domainId) || !char.IsDigit(domainId[0])) + { + return 0.0; + } + + var domains = await _domainRepo.GetAllSemanticDomainTreeNodes("en"); + if (domains is null || domains.Count == 0) + { + return 0.0; + } + + var domainAndDescendants = domains + .Where(dom => dom.Id.StartsWith(domainId, StringComparison.Ordinal)).ToList(); + + if (domainAndDescendants.Count == 0) + { + return 0.0; + } + + var count = 0.0; + foreach (var dom in domainAndDescendants) + { + if (await _wordRepo.FrontierHasWordsWithDomain(projectId, dom.Id)) + { + count++; + } + } + + return count / domainAndDescendants.Count; + } } } diff --git a/src/api/api/statistics-api.ts b/src/api/api/statistics-api.ts index 96205a7008..11685e3cab 100644 --- a/src/api/api/statistics-api.ts +++ b/src/api/api/statistics-api.ts @@ -52,6 +52,58 @@ export const StatisticsApiAxiosParamCreator = function ( configuration?: Configuration ) { return { + /** + * + * @param {string} projectId + * @param {string} [domainId] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getDomainProgressProportion: async ( + projectId: string, + domainId?: string, + options: any = {} + ): Promise => { + // verify required parameter 'projectId' is not null or undefined + assertParamExists("getDomainProgressProportion", "projectId", projectId); + const localVarPath = + `/v1/projects/{projectId}/statistics/GetDomainProgressProportion`.replace( + `{${"projectId"}}`, + encodeURIComponent(String(projectId)) + ); + // 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 (domainId !== undefined) { + localVarQueryParameter["domainId"] = domainId; + } + + 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} projectId @@ -303,6 +355,33 @@ export const StatisticsApiFp = function (configuration?: Configuration) { const localVarAxiosParamCreator = StatisticsApiAxiosParamCreator(configuration); return { + /** + * + * @param {string} projectId + * @param {string} [domainId] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async getDomainProgressProportion( + projectId: string, + domainId?: string, + options?: any + ): Promise< + (axios?: AxiosInstance, basePath?: string) => AxiosPromise + > { + const localVarAxiosArgs = + await localVarAxiosParamCreator.getDomainProgressProportion( + projectId, + domainId, + options + ); + return createRequestFunction( + localVarAxiosArgs, + globalAxios, + BASE_PATH, + configuration + ); + }, /** * * @param {string} projectId @@ -449,6 +528,22 @@ export const StatisticsApiFactory = function ( ) { const localVarFp = StatisticsApiFp(configuration); return { + /** + * + * @param {string} projectId + * @param {string} [domainId] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getDomainProgressProportion( + projectId: string, + domainId?: string, + options?: any + ): AxiosPromise { + return localVarFp + .getDomainProgressProportion(projectId, domainId, options) + .then((request) => request(axios, basePath)); + }, /** * * @param {string} projectId @@ -524,6 +619,27 @@ export const StatisticsApiFactory = function ( }; }; +/** + * Request parameters for getDomainProgressProportion operation in StatisticsApi. + * @export + * @interface StatisticsApiGetDomainProgressProportionRequest + */ +export interface StatisticsApiGetDomainProgressProportionRequest { + /** + * + * @type {string} + * @memberof StatisticsApiGetDomainProgressProportion + */ + readonly projectId: string; + + /** + * + * @type {string} + * @memberof StatisticsApiGetDomainProgressProportion + */ + readonly domainId?: string; +} + /** * Request parameters for getLineChartRootData operation in StatisticsApi. * @export @@ -608,6 +724,26 @@ export interface StatisticsApiGetWordsPerDayPerUserCountsRequest { * @extends {BaseAPI} */ export class StatisticsApi extends BaseAPI { + /** + * + * @param {StatisticsApiGetDomainProgressProportionRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof StatisticsApi + */ + public getDomainProgressProportion( + requestParameters: StatisticsApiGetDomainProgressProportionRequest, + options?: any + ) { + return StatisticsApiFp(this.configuration) + .getDomainProgressProportion( + requestParameters.projectId, + requestParameters.domainId, + options + ) + .then((request) => request(this.axios, this.basePath)); + } + /** * * @param {StatisticsApiGetLineChartRootDataRequest} requestParameters Request parameters. diff --git a/src/backend/index.ts b/src/backend/index.ts index a5f2cff803..530352b7c5 100644 --- a/src/backend/index.ts +++ b/src/backend/index.ts @@ -689,6 +689,14 @@ export async function getProgressEstimationLineChartRoot( return response.data ?? undefined; } +export async function getDomainProgress(domainId: string): Promise { + const response = await statisticsApi.getDomainProgressProportion( + { projectId: LocalStorage.getProjectId(), domainId }, + defaultOptions() + ); + return response.data; +} + /* UserController.cs */ export async function verifyCaptchaToken(token: string): Promise { diff --git a/src/components/TreeView/TreeDepiction/DomainTileButton.tsx b/src/components/TreeView/TreeDepiction/DomainTileButton.tsx index 5452ec9583..b16f6f5592 100644 --- a/src/components/TreeView/TreeDepiction/DomainTileButton.tsx +++ b/src/components/TreeView/TreeDepiction/DomainTileButton.tsx @@ -4,11 +4,19 @@ import { KeyboardArrowDown, KeyboardArrowUp, } from "@mui/icons-material"; -import { Button, Stack, SxProps, Typography } from "@mui/material"; -import { ReactElement } from "react"; +import { + Box, + Button, + Stack, + SxProps, + Typography, + useTheme, +} from "@mui/material"; +import { ReactElement, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { SemanticDomain } from "api/models"; +import { getDomainProgress } from "backend"; import DomainCountBadge from "components/TreeView/TreeDepiction/DomainCountBadge"; import { Direction } from "components/TreeView/TreeDepiction/TreeDepictionTypes"; import { rootId } from "types/semanticDomain"; @@ -92,6 +100,21 @@ export default function DomainTileButton( props: DomainTileButtonProps ): ReactElement { const { onClick, ...domainTileProps } = props; + + const [progress, setProgress] = useState(0); + const theme = useTheme(); + + const shouldShowProgress = domainTileProps.direction !== Direction.Up; + + useEffect(() => { + if (shouldShowProgress) { + setProgress(0); + getDomainProgress(props.domain.id) + .then(setProgress) + .catch(() => {}); // Silently fail + } + }, [shouldShowProgress, props.domain.id]); + return ( ); } diff --git a/src/components/TreeView/TreeDepiction/tests/CurrentRow.test.tsx b/src/components/TreeView/TreeDepiction/tests/CurrentRow.test.tsx index a6b6446be3..3ea3539e97 100644 --- a/src/components/TreeView/TreeDepiction/tests/CurrentRow.test.tsx +++ b/src/components/TreeView/TreeDepiction/tests/CurrentRow.test.tsx @@ -9,15 +9,14 @@ import testDomainMap, { } from "components/TreeView/tests/SemanticDomainMock"; jest.mock("backend", () => ({ - getDomainWordCount: () => mockGetDomainWordCount(), + getDomainProgress: () => Promise.resolve(0.5), + getDomainWordCount: () => Promise.resolve(0), })); const mockAnimate = jest.fn(); -const mockGetDomainWordCount = jest.fn(); beforeEach(() => { jest.clearAllMocks(); - mockGetDomainWordCount.mockResolvedValue(0); }); describe("CurrentRow", () => { diff --git a/src/components/TreeView/TreeDepiction/tests/DomainTileButton.test.tsx b/src/components/TreeView/TreeDepiction/tests/DomainTileButton.test.tsx index d0b1f14782..cc33d90459 100644 --- a/src/components/TreeView/TreeDepiction/tests/DomainTileButton.test.tsx +++ b/src/components/TreeView/TreeDepiction/tests/DomainTileButton.test.tsx @@ -4,8 +4,17 @@ import DomainTileButton from "components/TreeView/TreeDepiction/DomainTileButton import { Direction } from "components/TreeView/TreeDepiction/TreeDepictionTypes"; import domMap, { mapIds } from "components/TreeView/tests/SemanticDomainMock"; +jest.mock("backend", () => ({ + getDomainProgress: () => Promise.resolve(0.5), + getDomainWordCount: () => Promise.resolve(0), +})); + const MOCK_ANIMATE = jest.fn(); +beforeEach(() => { + jest.clearAllMocks(); +}); + describe("DomainTileButton", () => { it("calls function on click", async () => { await createTile(); diff --git a/src/components/TreeView/TreeDepiction/tests/index.test.tsx b/src/components/TreeView/TreeDepiction/tests/index.test.tsx index 95e586096f..a28cdd2b1a 100644 --- a/src/components/TreeView/TreeDepiction/tests/index.test.tsx +++ b/src/components/TreeView/TreeDepiction/tests/index.test.tsx @@ -6,15 +6,10 @@ import testDomainMap, { } from "components/TreeView/tests/SemanticDomainMock"; jest.mock("backend", () => ({ - getDomainWordCount: () => mockGetDomainWordCount(), + getDomainProgress: () => Promise.resolve(0.5), + getDomainWordCount: () => Promise.resolve(0), })); -const mockGetDomainWordCount = jest.fn(); - -beforeEach(() => { - mockGetDomainWordCount.mockResolvedValue(0); -}); - describe("TreeDepiction", () => { for (const small of [false, true]) { describe(small ? "renders narrow" : "renders wide", () => {