diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 87034eea..54a558ca 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -1,47 +1,12 @@ -name: Application testing -on: - push: - workflow_dispatch: -jobs: - unit-tests: - name: Unit tests - runs-on: ubuntu-latest - steps: - - name: Checkout repository - uses: actions/checkout@v2 - - - name: Install apt libraries - run: sudo apt install gettext -y - - - name: Setup Node - uses: actions/setup-node@v3 - with: - node-version: "18" - - - name: Install yarn - run: npm install -g yarn - - - name: Get yarn cache directory path - id: yarn-cache-dir-path - run: echo "::set-output name=dir::$(yarn cache dir)" +name: "Shared Master Workflow" - - name: Cache yarn dependencies - uses: actions/cache@v2 - id: yarn-cache - with: - path: ${{ steps.yarn-cache-dir-path.outputs.dir }} - key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} - restore-keys: | - ${{ runner.os }}-yarn- - - - name: Install dependencies - run: yarn install --frozen-lockfile --silent - - - name: Install translations - run: yarn localize - - - name: Run jest tests - run: yarn test +on: + push: + branches: ["master", "development"] + pull_request: + branches: [ "master", "development" ] + workflow_dispatch: - - name: Build typescript - run: npx tsc +jobs: + master-workflow: + uses: EyeSeeTea/github-workflows/.github/workflows/master.yml@master diff --git a/i18n/en.pot b/i18n/en.pot index 842a42c3..c06f05ba 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2024-11-04T15:12:23.322Z\n" -"PO-Revision-Date: 2024-11-04T15:12:23.322Z\n" +"POT-Creation-Date: 2025-08-05T05:01:40.620Z\n" +"PO-Revision-Date: 2025-08-05T05:01:40.620Z\n" msgid "ID" msgstr "" @@ -68,6 +68,15 @@ msgstr "" msgid "This analysis is not available for this module" msgstr "" +msgid "Add Issue" +msgstr "" + +msgid "Periods" +msgstr "" + +msgid "Category Options" +msgstr "" + msgid "Open analysis" msgstr "" @@ -136,9 +145,6 @@ msgstr "" msgid "Countries" msgstr "" -msgid "Periods" -msgstr "" - msgid "Step" msgstr "" @@ -226,6 +232,15 @@ msgstr "" msgid "Validation Rule Group" msgstr "" +msgid "Adding issues..." +msgstr "" + +msgid "New Issue" +msgstr "" + +msgid "No Issues Found" +msgstr "" + msgid "Run" msgstr "" diff --git a/i18n/es.po b/i18n/es.po index db94fec8..3f1daf5e 100644 --- a/i18n/es.po +++ b/i18n/es.po @@ -1,7 +1,7 @@ msgid "" msgstr "" "Project-Id-Version: i18next-conv\n" -"POT-Creation-Date: 2024-11-04T15:12:23.322Z\n" +"POT-Creation-Date: 2025-08-05T05:01:40.620Z\n" "PO-Revision-Date: 2018-10-25T09:02:35.143Z\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" @@ -68,6 +68,15 @@ msgstr "" msgid "This analysis is not available for this module" msgstr "" +msgid "Add Issue" +msgstr "" + +msgid "Periods" +msgstr "" + +msgid "Category Options" +msgstr "" + msgid "Open analysis" msgstr "" @@ -136,9 +145,6 @@ msgstr "" msgid "Countries" msgstr "" -msgid "Periods" -msgstr "" - msgid "Step" msgstr "" @@ -226,6 +232,15 @@ msgstr "" msgid "Validation Rule Group" msgstr "" +msgid "Adding issues..." +msgstr "" + +msgid "New Issue" +msgstr "" + +msgid "No Issues Found" +msgstr "" + msgid "Run" msgstr "" diff --git a/package.json b/package.json index 7519ff1f..5efa69fd 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "data-quality", "description": "Data Quality", - "version": "1.2.3", + "version": "1.3.0", "license": "GPL-3.0", "author": "EyeSeeTea team", "homepage": ".", @@ -18,7 +18,7 @@ "@dhis2/ui": "6.12.0", "@eyeseetea/d2-api": "1.14.0", "@eyeseetea/d2-logger": "1.2.0-beta.1", - "@eyeseetea/d2-ui-components": "2.9.0-beta.2", + "@eyeseetea/d2-ui-components": "2.10.1", "@eyeseetea/feedback-component": "0.0.3", "@material-ui/core": "4.12.4", "@material-ui/icons": "4.11.3", diff --git a/src/CompositionRoot.ts b/src/CompositionRoot.ts index 61bc0a15..5032dfc9 100644 --- a/src/CompositionRoot.ts +++ b/src/CompositionRoot.ts @@ -66,6 +66,7 @@ import { ExportIssuesUseCase } from "$/domain/usecases/ExportIssuesUseCase"; import { IssueExportRepository } from "$/domain/repositories/IssueExportRepository"; import { IssueSpreadSheetRepository } from "./data/repositories/IssueSpreadSheetRepository"; import { IssueSpreadSheetTestRepository } from "./data/repositories/IssueSpreadSheetTestRepository"; +import { CreateIssueUseCase } from "$/domain/usecases/CreateIssueUseCase"; export type CompositionRoot = ReturnType; @@ -159,6 +160,10 @@ function getCompositionRoot(repositories: Repositories, metadata: MetadataItem) repositories.issueRepository, repositories.issueExportRepository ), + create: new CreateIssueUseCase( + repositories.qualityAnalysisRepository, + repositories.issueRepository + ), }, settings: { get: new GetSettingsUseCase(repositories.settingsRepository) }, summary: { diff --git a/src/data/repositories/IssueD2Repository.ts b/src/data/repositories/IssueD2Repository.ts index 11505863..ff526bba 100644 --- a/src/data/repositories/IssueD2Repository.ts +++ b/src/data/repositories/IssueD2Repository.ts @@ -205,7 +205,7 @@ export class IssueD2Repository implements IssueRepository { }, { id: this.metadata.dataElements.period.id, - value: issue.period, + value: issue.period || "", }, { id: this.metadata.dataElements.categoryOption.id, diff --git a/src/data/repositories/QualityAnalysisD2Repository.ts b/src/data/repositories/QualityAnalysisD2Repository.ts index a397d45e..59199460 100644 --- a/src/data/repositories/QualityAnalysisD2Repository.ts +++ b/src/data/repositories/QualityAnalysisD2Repository.ts @@ -64,7 +64,7 @@ export class QualityAnalysisD2Repository implements QualityAnalysisRepository { // is deprecated // @ts-ignore trackedEntities: options.filters.ids ? options.filters.ids.join(";") : undefined, - attribute: this.buildFilters(options.filters)?.join(",") || undefined, + filter: this.buildFilters(options.filters)?.join(",") || undefined, // @ts-ignore order: this.buildOrder(options.sorting) || undefined, totalPages: true, diff --git a/src/domain/entities/DismissedAnalysis.ts b/src/domain/entities/DismissedAnalysis.ts index e15a4689..35b1f36b 100644 --- a/src/domain/entities/DismissedAnalysis.ts +++ b/src/domain/entities/DismissedAnalysis.ts @@ -8,7 +8,7 @@ export type DismissedAnalysisAttrs = { }; export class DismissedAnalysis extends Struct() { - mergeDuplicates(): QualityAnalysisIssue[] { + inheritDismissedDuplicates(): QualityAnalysisIssue[] { const dismissedIssues = this.existingIssues.filter(issue => issue.status?.code === "4"); const issues = _(this.newIssues) .map(issue => { diff --git a/src/domain/entities/QualityAnalysisIssue.ts b/src/domain/entities/QualityAnalysisIssue.ts index 43b13ee6..82f2a5a2 100644 --- a/src/domain/entities/QualityAnalysisIssue.ts +++ b/src/domain/entities/QualityAnalysisIssue.ts @@ -11,7 +11,7 @@ export interface QualityAnalysisIssueAttrs { id: Id; number: IssueNumber; azureUrl: string; - period: IssuePeriod; + period: Maybe; country: Maybe; dataElement: Maybe; categoryOption: Maybe; diff --git a/src/domain/usecases/CreateIssueUseCase.ts b/src/domain/usecases/CreateIssueUseCase.ts new file mode 100644 index 00000000..de528b65 --- /dev/null +++ b/src/domain/usecases/CreateIssueUseCase.ts @@ -0,0 +1,109 @@ +import { FutureData } from "$/data/api-futures"; +import { IssueRepository } from "$/domain/repositories/IssueRepository"; +import { UCIssue } from "$/domain/usecases/common/UCIssue"; +import { QualityAnalysisIssue } from "$/domain/entities/QualityAnalysisIssue"; +import { Id, Period } from "$/domain/entities/Ref"; +import { Future } from "$/domain/entities/generic/Future"; +import { UCAnalysis } from "$/domain/usecases/common/UCAnalysis"; +import { QualityAnalysisRepository } from "$/domain/repositories/QualityAnalysisRepository"; +import { QualityAnalysis } from "$/domain/entities/QualityAnalysis"; +import { Maybe } from "$/utils/ts-utils"; + +export type IssueTemplate = { + categoryOptionComboId: Maybe; + countryId: Maybe; + dataElementId: Maybe; + description: string; + period: Maybe; +}; + +export class CreateIssueUseCase { + private issueUseCase: UCIssue; + private analysisUseCase: UCAnalysis; + + constructor( + private analysisRepository: QualityAnalysisRepository, + private issueRepository: IssueRepository + ) { + this.issueUseCase = new UCIssue(this.issueRepository); + this.analysisUseCase = new UCAnalysis(this.analysisRepository); + } + + execute(options: CreateIssueUseCaseOptions): FutureData> { + const { qualityAnalysisId, sectionId, issues } = options; + + if (issues.length <= 0) return Future.success(undefined); + + return this.fetchAnalysisAndTotalIssues(qualityAnalysisId, sectionId).flatMap( + analysisAndTotalIssues => { + const issuesToCreate = this.buildIssues({ + issues, + sectionId, + ...analysisAndTotalIssues, + }); + const analysisUpdate = this.analysisUseCase.updateAnalysis( + analysisAndTotalIssues.analysis, + sectionId, + issues.length + analysisAndTotalIssues.totalIssues + ); + return this.saveIssuesAndUpdateAnalysis(issuesToCreate, analysisUpdate); + } + ); + } + + private fetchAnalysisAndTotalIssues( + qualityAnalysisId: Id, + sectionId: Id + ): FutureData { + return this.analysisUseCase + .getById(qualityAnalysisId) + .flatMap(analysis => + this.issueUseCase + .getTotalIssuesBySection(analysis, sectionId) + .map(totalIssues => ({ analysis, totalIssues })) + ); + } + + private buildIssues( + params: Omit & AnalysisAndTotalIssues + ): QualityAnalysisIssue[] { + const { issues, sectionId, analysis, totalIssues } = params; + + const sectionNumber = this.issueUseCase.getSectionNumber(analysis.sections, sectionId); + const prefix = `${analysis.sequential.value}-${sectionNumber}`; + + return issues.map((issue, index) => { + const currentNumber = totalIssues + 1 + index; + const issueNumber = this.issueUseCase.generateIssueNumber(currentNumber, prefix); + return this.issueUseCase.buildDefaultIssue( + { + categoryOptionComboId: issue.categoryOptionComboId, + countryId: issue.countryId, + dataElementId: issue.dataElementId, + period: issue.period, + description: issue.description, + correlative: String(currentNumber), + issueNumber: issueNumber, + }, + sectionId + ); + }); + } + + private saveIssuesAndUpdateAnalysis( + issues: QualityAnalysisIssue[], + analysis: QualityAnalysis + ): FutureData { + return this.issueUseCase + .save(issues, analysis.id) + .flatMap(() => this.analysisRepository.save([analysis]).map(() => analysis)); + } +} + +type CreateIssueUseCaseOptions = { + issues: IssueTemplate[]; + qualityAnalysisId: Id; + sectionId: Id; +}; + +type AnalysisAndTotalIssues = { analysis: QualityAnalysis; totalIssues: number }; diff --git a/src/domain/usecases/GetModulesUseCase.ts b/src/domain/usecases/GetModulesUseCase.ts index b5cc294b..e2c10fc9 100644 --- a/src/domain/usecases/GetModulesUseCase.ts +++ b/src/domain/usecases/GetModulesUseCase.ts @@ -1,11 +1,12 @@ import { FutureData } from "$/data/api-futures"; import { Module } from "$/domain/entities/Module"; import { ModuleRepository } from "$/domain/repositories/ModuleRepository"; +import { Id } from "$/domain/entities/Ref"; export class GetModulesUseCase { constructor(private moduleRepository: ModuleRepository) {} - execute(): FutureData { - return this.moduleRepository.get(); + execute(ids?: Id[]): FutureData { + return ids?.length ? this.moduleRepository.getByIds(ids) : this.moduleRepository.get(); } } diff --git a/src/domain/usecases/RunPractitionersValidationUseCase.ts b/src/domain/usecases/RunPractitionersValidationUseCase.ts index e1a9d7e1..af353e35 100644 --- a/src/domain/usecases/RunPractitionersValidationUseCase.ts +++ b/src/domain/usecases/RunPractitionersValidationUseCase.ts @@ -6,7 +6,7 @@ import { Future } from "$/domain/entities/generic/Future"; import { DataElement } from "$/domain/entities/DataElement"; import { ModuleRepository } from "$/domain/repositories/ModuleRepository"; import { QualityAnalysisRepository } from "$/domain/repositories/QualityAnalysisRepository"; -import { convertToNumberOrZero, getCurrentSection } from "./common/utils"; +import { convertToNumberOrZero, getCurrentSection, isNumerical } from "./common/utils"; import { Maybe } from "$/utils/ts-utils"; import { DataValue } from "$/domain/entities/DataValue"; import { DataValueRepository } from "$/domain/repositories/DataValueRepository"; @@ -140,11 +140,27 @@ export class RunPractitionersValidationUseCase { if (isParentEqualToChildren || isThirdValueTotal) return undefined; - return { ...dataElement, result: "issue" }; + const baseIssue = { ...dataElement, result: "issue" }; + if (!isParentEqualToChildren) { + return { + ...baseIssue, + issueMessage: `Value for autocalculated ${dataElement.dataElementParent.name} is incorrect`, + }; + } else { + return { + ...baseIssue, + issueMessage: `Values for ${dataElement.dataElementParent.name} subcategories are missing`, + }; + } }) .compact() .value(); + const child1_1XORChild1_2DataElements = _(dataElements) + .map(dataElement => this.isChild1XORChild2(dataElement, 1)) + .compact() + .value(); + const doubleCountDataElements = _(dataElements) .map(dataElement => { const { children } = dataElement; @@ -177,7 +193,7 @@ export class RunPractitionersValidationUseCase { } return this.buildIssueFromDataValue( - `Values for ${dataElement.dataElementParent.name} subcategories are missing`, + dataElement.issueMessage, dataValue, totalIssues + (index + 1), analysis, @@ -185,6 +201,29 @@ export class RunPractitionersValidationUseCase { ); }); + const child1XOR2Issues = child1_1XORChild1_2DataElements.map((dataElement, index) => { + const childDataElement = dataElement.child.dataElement; + const childDataValue = dataElement.child.dataValue; + if (!childDataValue || !childDataElement) { + const de = childDataElement || dataElement.dataElementParent; + + const message = childDataElement + ? `Cannot get dataValue for dataElement: ${de.id}-${de.name}` + : `Cannot get child dataElement or dataValue for parent dataElement: ${de.id}-${de.name}`; + + console.warn(message); + return undefined; + } + + return this.buildIssueFromDataValue( + `Values for ${childDataElement.name} are missing`, + childDataValue, + totalIssues + missingIssues.length + (index + 1), + analysis, + options + ); + }); + const doubleCountedIssues = doubleCountDataElements.map((dataElement, index) => { const dataValue = dataElement.dataValue ? dataElement.dataValue @@ -204,13 +243,13 @@ export class RunPractitionersValidationUseCase { return this.buildIssueFromDataValue( description, dataValue, - totalIssues + missingIssues.length + (index + 1), + totalIssues + child1XOR2Issues.length + missingIssues.length + (index + 1), analysis, options ); }); - return _([...missingIssues, ...doubleCountedIssues]) + return _([...missingIssues, ...child1XOR2Issues, ...doubleCountedIssues]) .compact() .value(); } @@ -264,6 +303,75 @@ export class RunPractitionersValidationUseCase { return sumChildren === convertToNumberOrZero(value); } + private isChild1XORChild2( + dataElement: DataElementsLevelWithValues, + parent?: number + ): Maybe< + DataElementsLevelWithValues & { + result: string; + child: DataElementWithValue; + } + > { + const x_1 = this.findChild(1, dataElement, parent); + const x_2 = this.findChild(2, dataElement, parent); + + if (!x_1) return undefined; + if (!x_2) return undefined; + + const firstHasValue = isNumerical(x_1.dataValue?.value); + const secondHasValue = isNumerical(x_2.dataValue?.value); + + if (x_1.dataValue && firstHasValue && !secondHasValue) { + return { + ...dataElement, + result: "issue", + child: this.buildChildFromSibling(x_2, x_1.dataValue), + }; + } else if (!firstHasValue && x_2.dataValue && secondHasValue) { + return { + ...dataElement, + result: "issue", + child: this.buildChildFromSibling(x_1, x_2.dataValue), + }; + } else { + return undefined; + } + } + + private findChild( + childLevel: number, + dataElementGroup: DataElementsLevelWithValues, + parent?: number + ): Maybe { + const { children, dataElementParent } = dataElementGroup; + const parentLevel = dataElementParent.name.split(" - ")[0]; + + if (parent !== undefined && String(parent) !== parentLevel) return undefined; + + return children.find(de => { + const level = de.dataElement.name.split(" - ")[0]; + return level === `${parentLevel}.${childLevel}`; + }); + } + + private buildChildFromSibling( + childDataElement: DataElementWithValue, + siblingDataValue: DataValue + ): DataElementWithValue { + const dataValue = childDataElement.dataValue + ? childDataElement.dataValue + : { + ...siblingDataValue, + dataElementId: childDataElement.dataElement.id, + value: "", + }; + + return { + ...childDataElement, + dataValue, + }; + } + private buildDataElementsWithDataValues( countryId: Id, period: Period, diff --git a/src/domain/usecases/common/UCIssue.ts b/src/domain/usecases/common/UCIssue.ts index de5fa671..cf5a9beb 100644 --- a/src/domain/usecases/common/UCIssue.ts +++ b/src/domain/usecases/common/UCIssue.ts @@ -11,6 +11,7 @@ import { RowsPaginated } from "$/domain/entities/Pagination"; import { IssueAction } from "$/domain/entities/IssueAction"; import { QualityAnalysisSection } from "$/domain/entities/QualityAnalysisSection"; import { DismissedAnalysis } from "$/domain/entities/DismissedAnalysis"; +import { Maybe } from "$/utils/ts-utils"; export class UCIssue { constructor(private issueRepository: IssueRepository) {} @@ -43,9 +44,13 @@ export class UCIssue { number: issueNumber, azureUrl: "", period: period, - country: { id: countryId, name: "", path: "", writeAccess: false }, + country: countryId + ? { id: countryId, name: "", path: "", writeAccess: false } + : undefined, dataElement: dataElementId ? { id: dataElementId, name: "" } : undefined, - categoryOption: { id: categoryOptionComboId, name: "" }, + categoryOption: categoryOptionComboId + ? { id: categoryOptionComboId, name: "" } + : undefined, description: description, followUp: false, status: IssueStatus.create({ @@ -166,17 +171,17 @@ export class UCIssue { existingIssues: existingIssues, newIssues: issues, }); - return dimissedAnalysis.mergeDuplicates(); + return dimissedAnalysis.inheritDismissedDuplicates(); }); } } type DefaultIssue = { - categoryOptionComboId: Id; - countryId: Id; - dataElementId: Id; + categoryOptionComboId: Maybe; + countryId: Maybe; + dataElementId: Maybe; description: string; issueNumber: string; - period: Period; + period: Maybe; correlative: string; }; diff --git a/src/domain/usecases/common/utils.ts b/src/domain/usecases/common/utils.ts index d2bc876e..733d5f52 100644 --- a/src/domain/usecases/common/utils.ts +++ b/src/domain/usecases/common/utils.ts @@ -69,3 +69,7 @@ export function getIssues( export function convertToNumberOrZero(value: Maybe): number { return Number(value) || 0; } + +export function isNumerical(value: Maybe): boolean { + return !isNaN(Number(value)); +} diff --git a/src/webapp/components/add-issue-dialog/AddIssueDialog.tsx b/src/webapp/components/add-issue-dialog/AddIssueDialog.tsx new file mode 100644 index 00000000..c683e72d --- /dev/null +++ b/src/webapp/components/add-issue-dialog/AddIssueDialog.tsx @@ -0,0 +1,171 @@ +import React from "react"; +import { + ConfirmationDialog, + Dropdown, + MultipleDropdown, + OrgUnitsSelector, +} from "@eyeseetea/d2-ui-components"; +import styled from "styled-components"; +import { TextField } from "@material-ui/core"; + +import { useAppContext } from "$/webapp/contexts/app-context"; +import { QualityAnalysis } from "$/domain/entities/QualityAnalysis"; +import { useAddIssueDialog } from "$/webapp/components/add-issue-dialog/useAddIssueDialog"; +import { Maybe } from "$/utils/ts-utils"; +import i18n from "$/utils/i18n"; +import { IssueTemplate } from "$/domain/usecases/CreateIssueUseCase"; +import { ORG_UNIT_LEVELS, ORG_UNIT_SELECTABLE_LEVELS } from "$/webapp/utils/form"; +import { Ref } from "$/domain/entities/Ref"; + +export type AddIssueDialogProps = { + onAddIssue: (issues: IssueTemplate[]) => void; + onClose: () => void; + analysis: QualityAnalysis; +}; + +export const AddIssueDialog: React.FC = props => { + const { onAddIssue, onClose, analysis } = props; + + const { api, currentUser } = useAppContext(); + const { + addIssueForm, + updateForm, + dataElementOptions, + categoryOptionOptions, + periodOptions, + onSave, + issuesToAddCount, + } = useAddIssueDialog({ analysis, onAddIssue }); + + const [loadedSelectableCountries, setLoadedSelectableCountries] = React.useState([]); + + const onUpdateDataElement = React.useCallback( + (value: Maybe) => + updateForm("dataElementId")(value !== undefined ? [value] : value), + [updateForm] + ); + + const onUpdateDescription = React.useCallback( + (ev: React.ChangeEvent) => updateForm("description")([ev.target.value]), + [updateForm] + ); + + const selectableCountries = React.useMemo( + () => [...analysis.countriesAnalysis, ...loadedSelectableCountries], + [analysis, loadedSelectableCountries] + ); + + const addChildrenOuToSelectable = React.useCallback( + (children: OrgUnit[]) => { + //all children have the same parent + const parent = children[0]?.parent; + if (parent && parent.id && analysis.countriesAnalysis.includes(parent.id)) { + const childrenIds = children.map(child => child.id); + setLoadedSelectableCountries(prev => [...prev, ...childrenIds]); + } + }, + [analysis] + ); + + const disableCategoryOptions = !addIssueForm.dataElementId; + const disableSave = issuesToAddCount <= 0 || !addIssueForm.description.trim(); + const saveText = i18n.t("Add Issues ({{count}})", { count: issuesToAddCount }); + + return ( + + + + + + + + + + + + + + + + country.id)} + withElevation={false} + selectableIds={selectableCountries} + onChildrenLoaded={{ + fn: addChildrenOuToSelectable, + }} + /> + + ); +}; + +type OrgUnit = { parent: Ref; id: string }; + +const FormControlsContainer = styled.div` + display: flex; + gap: 1.5rem; + width: 100%; + margin-block-end: 1.25rem; + flex-wrap: wrap; + flex-direction: column; +`; + +const FieldWrapper = styled.div` + display: flex; + gap: 1rem; +`; + +const StyledMultipleDropdown = styled(MultipleDropdown)` + width: 50%; +`; + +const StyledDropdown = styled(Dropdown)` + width: 50%; +`; + +const StyledTextField = styled(TextField)` + width: 100%; +`; + +const Field = styled.div<{ $disabled?: boolean }>` + pointer-events: ${props => (props.$disabled ? "none" : "auto")}; + opacity: ${props => (props.$disabled ? "0.7" : "1")}; + width: 100%; +`; diff --git a/src/webapp/components/add-issue-dialog/useAddIssueDialog.ts b/src/webapp/components/add-issue-dialog/useAddIssueDialog.ts new file mode 100644 index 00000000..43045ded --- /dev/null +++ b/src/webapp/components/add-issue-dialog/useAddIssueDialog.ts @@ -0,0 +1,145 @@ +import React from "react"; +import { useSnackbar } from "@eyeseetea/d2-ui-components"; + +import { useAppContext } from "$/webapp/contexts/app-context"; +import { Id } from "$/domain/entities/Ref"; +import { QualityAnalysis } from "$/domain/entities/QualityAnalysis"; +import { DataElement } from "$/domain/entities/DataElement"; +import _ from "$/domain/entities/generic/Collection"; +import { Maybe } from "$/utils/ts-utils"; +import { IssueTemplate } from "$/domain/usecases/CreateIssueUseCase"; +import { getIdFromCountriesPaths } from "$/webapp/components/configuration-form/ConfigurationForm"; +import { generatePeriodYearOptions } from "$/webapp/utils/form"; + +type UseAddIssueDialogProps = { + analysis: QualityAnalysis; + onAddIssue: (issues: IssueTemplate[]) => void; +}; + +export function useAddIssueDialog(props: UseAddIssueDialogProps) { + const { analysis, onAddIssue } = props; + const { compositionRoot } = useAppContext(); + const snackBar = useSnackbar(); + + const [addIssueForm, updateAddIssueForm] = React.useState({ + orgUnitPaths: [], + periods: [], + dataElementId: undefined, + categoryOptionIds: [], + description: "", + }); + const [dataElements, setDataElements] = React.useState([]); + + const issues = React.useMemo( + () => + buildAllIssues({ + ...addIssueForm, + orgUnitIds: getIdFromCountriesPaths(addIssueForm.orgUnitPaths), + }), + [addIssueForm] + ); + + const dataElementOptions = React.useMemo( + () => + _(dataElements) + .sortBy(dataElement => dataElement.name) + .map(({ name, id }) => ({ text: name, value: id })) + .value(), + [dataElements] + ); + const dataElementsMap = React.useMemo( + () => _(dataElements).keyBy(dataElement => dataElement.id), + [dataElements] + ); + const categoryOptionOptions = React.useMemo(() => { + if (addIssueForm.dataElementId) { + return ( + dataElementsMap + .get(addIssueForm.dataElementId) + ?.disaggregation?.options.map(({ id, name }) => ({ text: name, value: id })) ?? + [] + ); + } else { + return []; + } + }, [dataElementsMap, addIssueForm]); + + const periodOptions = React.useMemo(() => { + const { startDate, endDate } = analysis; + const startYear = parseInt(startDate); + const endYear = parseInt(endDate); + + return generatePeriodYearOptions(startYear, endYear); + }, [analysis]); + + const onSave = React.useCallback(() => { + onAddIssue(issues); + }, [onAddIssue, issues]); + + const updateForm = React.useCallback( + (field: keyof AddIssueForm) => (value: Maybe) => { + const newValue = + ["dataElementId", "description"].includes(field) && value ? value[0] : value; + updateAddIssueForm(prev => ({ + ...prev, + [field]: newValue, + })); + }, + [] + ); + + React.useEffect(() => { + compositionRoot.modules.get.execute([analysis.module.id]).run( + modules => { + const module = modules[0]; + if (module) { + setDataElements(module.dataElements); + } + }, + err => { + snackBar.error(err.message); + } + ); + }, [compositionRoot, analysis, snackBar]); + + return { + addIssueForm, + updateForm, + dataElementOptions, + categoryOptionOptions, + periodOptions, + onSave, + issuesToAddCount: issues.length, + }; +} + +type BuildIssueProps = Omit & { orgUnitIds: Id[] }; +function buildAllIssues(props: BuildIssueProps): IssueTemplate[] { + const { orgUnitIds, periods, dataElementId, categoryOptionIds, description } = props; + + if (!orgUnitIds.length && !periods.length && !dataElementId && !description) { + return []; + } + + const periodsList = periods.length > 0 ? periods : [""]; + const categoryList = categoryOptionIds.length > 0 ? categoryOptionIds : [""]; + const orgUnitList = orgUnitIds.length > 0 ? orgUnitIds : [""]; + + const product = _([periodsList, categoryList, orgUnitList]).cartesian().value(); + + return product.map(([period, categoryOption, orgUnit]) => ({ + period, + categoryOptionComboId: categoryOption, + countryId: orgUnit, + description: description, + dataElementId: dataElementId, + })); +} + +type AddIssueForm = { + orgUnitPaths: Id[]; + periods: Id[]; + dataElementId: Maybe; + categoryOptionIds: Id[]; + description: string; +}; diff --git a/src/webapp/components/analysis-filter/AnalysisFilter.tsx b/src/webapp/components/analysis-filter/AnalysisFilter.tsx index e46f7a31..295cd796 100644 --- a/src/webapp/components/analysis-filter/AnalysisFilter.tsx +++ b/src/webapp/components/analysis-filter/AnalysisFilter.tsx @@ -2,21 +2,16 @@ import React from "react"; import { Dropdown } from "@eyeseetea/d2-ui-components"; import i18n from "$/utils/i18n"; -import _, { Collection } from "$/domain/entities/generic/Collection"; import { Module } from "$/domain/entities/Module"; import { qualityAnalysisStatus } from "$/domain/entities/QualityAnalysisStatus"; import { Maybe } from "$/utils/ts-utils"; import { MenuButton } from "$/webapp/components/menu-button/MenuButton"; import { Id } from "$/domain/entities/Ref"; +import { generatePeriodYearOptions } from "$/webapp/utils/form"; const currentYear = new Date().getFullYear(); -export const periods = Collection.range(2000, currentYear + 1) - .map(period => { - return { value: period.toString(), text: period.toString() }; - }) - .reverse() - .value(); +export const periods = generatePeriodYearOptions(2000, currentYear); type AnalysisFiltersProps = { modules: Module[]; diff --git a/src/webapp/components/configuration-form/ConfigurationForm.tsx b/src/webapp/components/configuration-form/ConfigurationForm.tsx index dc84c59e..5f86a57e 100644 --- a/src/webapp/components/configuration-form/ConfigurationForm.tsx +++ b/src/webapp/components/configuration-form/ConfigurationForm.tsx @@ -13,6 +13,7 @@ import { Country } from "$/domain/entities/Country"; import styled from "styled-components"; import { getDefaultModules } from "$/data/common/D2Module"; import { Alert } from "@material-ui/lab"; +import { ORG_UNIT_LEVELS, ORG_UNIT_SELECTABLE_LEVELS } from "$/webapp/utils/form"; export function getIdFromCountriesPaths(paths: string[]): string[] { return _(paths) @@ -159,8 +160,8 @@ export const ConfigurationForm: React.FC = React.memo(pr api={api} onChange={onOrgUnitsChange} selected={selectedOrgUnits} - levels={[1, 2, 3]} - selectableLevels={[2, 3]} + levels={ORG_UNIT_LEVELS} + selectableLevels={ORG_UNIT_SELECTABLE_LEVELS} rootIds={currentUser.countries.map(country => country.id)} withElevation={false} /> diff --git a/src/webapp/components/country-selector/CountrySelector.tsx b/src/webapp/components/country-selector/CountrySelector.tsx index d933feed..1d9b5ea8 100644 --- a/src/webapp/components/country-selector/CountrySelector.tsx +++ b/src/webapp/components/country-selector/CountrySelector.tsx @@ -3,7 +3,7 @@ import { OrgUnitsSelector } from "@eyeseetea/d2-ui-components"; import { D2Api } from "$/types/d2-api"; import { Id } from "$/domain/entities/Ref"; -import _ from "$/domain/entities/generic/Collection"; +import { ORG_UNIT_LEVELS } from "$/webapp/utils/form"; export const CountrySelector: React.FC = props => { const { api, onChange, rootIds, selectedCountriesIds: selectedOrgUnits } = props; @@ -17,8 +17,8 @@ export const CountrySelector: React.FC = props => { api={api} onChange={onOrgUnitsChange} selected={selectedOrgUnits} - levels={[1, 2, 3]} - selectableLevels={[1, 2, 3]} + levels={ORG_UNIT_LEVELS} + selectableLevels={ORG_UNIT_LEVELS} rootIds={rootIds} withElevation={false} /> diff --git a/src/webapp/components/user-feedback-container/UserFeedbackContainer.tsx b/src/webapp/components/user-feedback-container/UserFeedbackContainer.tsx index 46e14654..574eba96 100644 --- a/src/webapp/components/user-feedback-container/UserFeedbackContainer.tsx +++ b/src/webapp/components/user-feedback-container/UserFeedbackContainer.tsx @@ -8,17 +8,19 @@ export type UserFeedbackProps = { isLoading: boolean; error: Maybe; children: ReactNode; + loadingText?: string; }; export const UserFeedbackContainer: React.FC = React.memo(props => { const { isLoading, error, children } = props; + const loadingText = props.loadingText || i18n.t("Running analysis..."); const loading = useLoading(); const snackbar = useSnackbar(); React.useEffect(() => { - if (isLoading) loading.show(isLoading, i18n.t("Running analysis...")); + if (isLoading) loading.show(isLoading, loadingText); else loading.hide(); - }, [isLoading, loading]); + }, [isLoading, loading, loadingText]); React.useEffect(() => { if (error) snackbar.error(error); diff --git a/src/webapp/pages/analysis/steps.ts b/src/webapp/pages/analysis/steps.ts index b8ba6ea1..61fe0013 100644 --- a/src/webapp/pages/analysis/steps.ts +++ b/src/webapp/pages/analysis/steps.ts @@ -3,6 +3,7 @@ import { DisaggregatesStep } from "./steps/2-disaggregates/DisaggregatesStep"; import { NursingMidwiferyStep } from "./steps/4-nursingMidwifery/NursingMidwiferyStep"; import { OutliersStep } from "./steps/1-outliers/OutliersStep"; import { ValidationStep } from "./steps/5-validation/ValidationStep"; +import { ManualIssuesStep } from "$/webapp/pages/analysis/steps/6-manual-issues/ManualIssuesStep"; const sectionsComponents = [ { @@ -14,17 +15,21 @@ const sectionsComponents = [ component: DisaggregatesStep, }, { - name: "General Practitioners", + name: "Double counts and missing GP", component: GeneralPractitionersStep, }, { - name: "Nursing/Midwifery", + name: "Missing Nurses", component: NursingMidwiferyStep, }, { name: "Validation", component: ValidationStep, }, + { + name: "Manual Issues", + component: ManualIssuesStep, + }, ]; export function getComponentFromSectionName(code: string) { diff --git a/src/webapp/pages/analysis/steps/4-nursingMidwifery/useNursingMidwiferyStep.tsx b/src/webapp/pages/analysis/steps/4-nursingMidwifery/useNursingMidwiferyStep.tsx index 36658b46..eec3e447 100644 --- a/src/webapp/pages/analysis/steps/4-nursingMidwifery/useNursingMidwiferyStep.tsx +++ b/src/webapp/pages/analysis/steps/4-nursingMidwifery/useNursingMidwiferyStep.tsx @@ -24,12 +24,7 @@ export function useNursingMidwiferyStep(props: UseNursingMidwiferyStepProps) { text: item.name, })); setDisaggregations(selectedDisaggregations); - setSelectedDissagregations( - _(selectedDisaggregations) - .map(item => (item.text === "Total" ? item.value : undefined)) - .compact() - .value() - ); + setSelectedDissagregations(selectedDisaggregations.map(item => item.value)); }, error => { setError(error.message); diff --git a/src/webapp/pages/analysis/steps/6-manual-issues/ManualIssuesStep.tsx b/src/webapp/pages/analysis/steps/6-manual-issues/ManualIssuesStep.tsx new file mode 100644 index 00000000..fc14034a --- /dev/null +++ b/src/webapp/pages/analysis/steps/6-manual-issues/ManualIssuesStep.tsx @@ -0,0 +1,37 @@ +import React from "react"; + +import { UserFeedbackContainer } from "$/webapp/components/user-feedback-container/UserFeedbackContainer"; +import { StepAnalysis } from "$/webapp/pages/analysis/steps/StepAnalysis"; +import { useManualIssuesStep } from "$/webapp/pages/analysis/steps/6-manual-issues/useManualIssuesStep"; +import { PageStepProps } from "$/webapp/pages/analysis/AnalysisPage"; +import i18n from "$/utils/i18n"; +import { AddIssueDialog } from "$/webapp/components/add-issue-dialog/AddIssueDialog"; + +export const ManualIssuesStep: React.FC = props => { + const { analysis, section, title, updateAnalysis } = props; + + const { isLoading, error, openAddIssueDialog, addIssueDialogProps } = useManualIssuesStep({ + analysis, + section, + updateAnalysis, + }); + return ( + + {addIssueDialogProps && } + + + ); +}; diff --git a/src/webapp/pages/analysis/steps/6-manual-issues/useManualIssuesStep.ts b/src/webapp/pages/analysis/steps/6-manual-issues/useManualIssuesStep.ts new file mode 100644 index 00000000..063bf5a7 --- /dev/null +++ b/src/webapp/pages/analysis/steps/6-manual-issues/useManualIssuesStep.ts @@ -0,0 +1,72 @@ +import React from "react"; + +import { Maybe } from "$/utils/ts-utils"; +import { QualityAnalysis } from "$/domain/entities/QualityAnalysis"; +import { QualityAnalysisSection } from "$/domain/entities/QualityAnalysisSection"; +import { UpdateAnalysisState } from "$/webapp/pages/analysis/AnalysisPage"; +import { AddIssueDialogProps } from "$/webapp/components/add-issue-dialog/AddIssueDialog"; +import { IssueTemplate } from "$/domain/usecases/CreateIssueUseCase"; +import { useAppContext } from "$/webapp/contexts/app-context"; + +type UseManualStepProps = { + analysis: QualityAnalysis; + section: QualityAnalysisSection; + updateAnalysis: UpdateAnalysisState; +}; + +export function useManualIssuesStep(props: UseManualStepProps) { + const { analysis, section, updateAnalysis } = props; + const { compositionRoot } = useAppContext(); + + const [isLoading, setIsLoading] = React.useState(false); + const [error, setError] = React.useState>(undefined); + const [addIssueDialogProps, setAddIssueDialogProps] = React.useState(); + + const onAddIssues = React.useCallback( + (issues: IssueTemplate[]) => { + setIsLoading(true); + compositionRoot.issues.create + .execute({ + qualityAnalysisId: analysis.id, + issues: issues, + sectionId: section.id, + }) + .run( + analysis => { + if (analysis) { + updateAnalysis(analysis); + } + setIsLoading(false); + }, + err => { + setError(err.message); + setIsLoading(false); + } + ); + }, + [compositionRoot, section, analysis, updateAnalysis] + ); + + const closeAddIssueDialog = React.useCallback(() => { + setAddIssueDialogProps(undefined); + }, []); + + const openAddIssueDialog = React.useCallback(() => { + setAddIssueDialogProps({ + onAddIssue: (issues: IssueTemplate[]) => { + onAddIssues(issues); + closeAddIssueDialog(); + }, + onClose: closeAddIssueDialog, + analysis: analysis, + }); + }, [onAddIssues, closeAddIssueDialog, analysis]); + + return { + isLoading, + error, + addIssueDialogProps, + openAddIssueDialog, + onAddIssues, + }; +} diff --git a/src/webapp/pages/analysis/steps/StepAnalysis.tsx b/src/webapp/pages/analysis/steps/StepAnalysis.tsx index 1514f193..f921272d 100644 --- a/src/webapp/pages/analysis/steps/StepAnalysis.tsx +++ b/src/webapp/pages/analysis/steps/StepAnalysis.tsx @@ -11,6 +11,8 @@ import { QualityAnalysisSection } from "$/domain/entities/QualityAnalysisSection export const StepAnalysis: React.FC = React.memo(props => { const { children, id, onRun, reload, section, title, allowRerun } = props; + const runButtonText = props.runButtonText || i18n.t("Run"); + const emptyMessage = props.emptyMessage || i18n.t("Run to get results"); const isPending = QualityAnalysisSection.isPending(section); const showRunButton = isPending || allowRerun; @@ -28,14 +30,12 @@ export const StepAnalysis: React.FC = React.memo(props => { size="small" onClick={() => onRun()} > - {i18n.t("Run")} + {runButtonText} )} - {isPending && ( - - )} + {isPending && } {section.status === "success" && ( )} @@ -77,4 +77,6 @@ type StepContainerProps = { title: string; onRun: () => void; allowRerun?: boolean; + emptyMessage?: string; + runButtonText?: string; }; diff --git a/src/webapp/utils/form.ts b/src/webapp/utils/form.ts new file mode 100644 index 00000000..4310c3ce --- /dev/null +++ b/src/webapp/utils/form.ts @@ -0,0 +1,11 @@ +import { Collection } from "$/domain/entities/generic/Collection"; + +export const ORG_UNIT_LEVELS = [1, 2, 3]; +export const ORG_UNIT_SELECTABLE_LEVELS = [2, 3]; + +export function generatePeriodYearOptions(startYear: number, endYear: number) { + return Collection.range(startYear, endYear + 1) + .map(year => ({ value: year.toString(), text: year.toString() })) + .reverse() + .value(); +} diff --git a/yarn.lock b/yarn.lock index 06d7a925..b9db4055 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2770,20 +2770,21 @@ i18next "^10.3" moment "^2.24.0" -"@dhis2/d2-ui-core@6.3.0": - version "6.3.0" - resolved "https://registry.yarnpkg.com/@dhis2/d2-ui-core/-/d2-ui-core-6.3.0.tgz#ec0ef63978a34d5b2330303c426bf7d59e0836fc" - integrity sha512-ZFthluJBkmbi1F0vNaIvx2Zbjioapo+Ewn1vNqb2MsUadTHOlPcrWQjDz3JCB0qce2ZW2avZ+spasEOytPNsFA== +"@dhis2/d2-ui-core@7.3.4": + version "7.3.4" + resolved "https://registry.yarnpkg.com/@dhis2/d2-ui-core/-/d2-ui-core-7.3.4.tgz#88fe3aca89999e4bec11124dcfc2963f54b1e24a" + integrity sha512-SYhr9iioZQ475ru92d1l8NXPWlsEcPv0XqADDGSV0vJol9gE45axanmODjGhdqeigMzc4MeFgPKgJ2RFwonqJQ== dependencies: babel-runtime "^6.26.0" d2 "~31.7" lodash "^4.17.10" material-ui "^0.20.0" + rxjs "^5.5.7" -"@dhis2/d2-ui-core@7.3.4": - version "7.3.4" - resolved "https://registry.yarnpkg.com/@dhis2/d2-ui-core/-/d2-ui-core-7.3.4.tgz#88fe3aca89999e4bec11124dcfc2963f54b1e24a" - integrity sha512-SYhr9iioZQ475ru92d1l8NXPWlsEcPv0XqADDGSV0vJol9gE45axanmODjGhdqeigMzc4MeFgPKgJ2RFwonqJQ== +"@dhis2/d2-ui-core@7.4.3": + version "7.4.3" + resolved "https://registry.yarnpkg.com/@dhis2/d2-ui-core/-/d2-ui-core-7.4.3.tgz#d880ad82f0ce28833db02fe64016242c3031610d" + integrity sha512-X+ZlTVB4IbAaQlKKWoXjHXCaTfw5jDxHy2KRIWRskIVPhXfiTiyqzdKN/DSi2/99HDQ6PSq9eqmCY4AeTJb3Kw== dependencies: babel-runtime "^6.26.0" d2 "~31.7" @@ -3372,10 +3373,10 @@ react "^16.12.0" yargs "^14.0.0" -"@eyeseetea/d2-logger@1.0.0": - version "1.0.0" - resolved "https://registry.yarnpkg.com/@eyeseetea/d2-logger/-/d2-logger-1.0.0.tgz#392ce65c395fdad045db6fc2462ec1b133809984" - integrity sha512-rfe6HwB2hlI2TRYW98pvWPFDiSnK4h1LguySRrUpaCjIy2WG/CPilMh2NqNNA4Y81NrWbgzNjtqS9jk8+x/ihw== +"@eyeseetea/d2-logger@1.2.0-beta.1": + version "1.2.0-beta.1" + resolved "https://registry.yarnpkg.com/@eyeseetea/d2-logger/-/d2-logger-1.2.0-beta.1.tgz#59b7683731311e183d5d0f77cd8e6a4738a8dda9" + integrity sha512-yEQ+Vtfm7RrqKX5+ssL5BFN+atKPYPol1LyR+P7OYgIG8/FHSW1D79dROwqiRgEQwOOXkUGQXqAvDr1tPtoZxw== dependencies: "@babel/runtime" "^7.5.4" "@eyeseetea/d2-api" "1.14.0" @@ -3383,21 +3384,21 @@ real-cancellable-promise "^1.1.2" typed-immutable-map "0.2.0" -"@eyeseetea/d2-ui-components@2.9.0-beta.2": - version "2.9.0-beta.2" - resolved "https://registry.yarnpkg.com/@eyeseetea/d2-ui-components/-/d2-ui-components-2.9.0-beta.2.tgz#7df5ea659ed1d487d78301f8e0c1f735dcb9f6e0" - integrity sha512-Sc6itN7pUB38OzcHMfc4JcML+8g9WnTQfNpwHhFg7vuyF15VzcfYQ+vq0JpNl3epNkABfdP9QVvrtf9IO9ORjQ== +"@eyeseetea/d2-ui-components@2.10.1": + version "2.10.1" + resolved "https://registry.yarnpkg.com/@eyeseetea/d2-ui-components/-/d2-ui-components-2.10.1.tgz#bc83a431ae72a796f46b4a129ed4a5946777a7d1" + integrity sha512-Luu+2nbmAMbVXrsoX9v2y6HvbUt2JvWQoyy5t1eMeDdutxGx0Wo3sM/7CyqNpFikNIxwPh3azGc6OinCfov+sg== dependencies: "@date-io/core" "1.3.6" "@date-io/moment" "1.0.2" "@dhis2/d2-i18n" "1.0.6" - "@dhis2/d2-ui-core" "6.3.0" + "@dhis2/d2-ui-core" "7.4.3" "@dhis2/ui" "6.15.2" "@material-ui/pickers" "3.2.10" classnames "2.2.6" downshift "5.4.2" - lodash "4.17.20" - moment "2.22.2" + lodash "4.17.21" + moment "2.29.4" nano-memoize "1.2.1" react-linkify "1.0.0-alpha" rxjs-compat "6.6.3" @@ -8231,11 +8232,6 @@ lodash.throttle@^4.1.1: resolved "https://registry.yarnpkg.com/lodash.throttle/-/lodash.throttle-4.1.1.tgz#c23e91b710242ac70c37f1e1cda9274cc39bf2f4" integrity sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ== -lodash@4.17.20: - version "4.17.20" - resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.20.tgz#b44a9b6297bcb698f1c51a3545a2b3b368d59c52" - integrity sha512-PlhdFcillOINfeV7Ni6oF1TAEayyZBoZ8bcshTHqOYJYlrqzRK5hagpagky5o4HfCzzd1TRkXPMFq6cKk9rGmA== - lodash@4.17.21, lodash@^4.0.0, lodash@^4.17.10, lodash@^4.17.13, lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.19, lodash@^4.17.20, lodash@^4.17.21: version "4.17.21" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" @@ -8522,17 +8518,12 @@ moment-timezone@^0.5.31: dependencies: moment "^2.29.4" -moment@2.22.2: - version "2.22.2" - resolved "https://registry.yarnpkg.com/moment/-/moment-2.22.2.tgz#3c257f9839fc0e93ff53149632239eb90783ff66" - integrity sha512-LRvkBHaJGnrcWvqsElsOhHCzj8mU39wLx5pQ0pc6s153GynCTsPdGdqsVNKAQD9sKnWj11iF7TZx9fpLwdD3fw== - moment@2.29.3: version "2.29.3" resolved "https://registry.yarnpkg.com/moment/-/moment-2.29.3.tgz#edd47411c322413999f7a5940d526de183c031f3" integrity sha512-c6YRvhEo//6T2Jz/vVtYzqBzwvPT95JBQ+smCytzf7c50oMZRsR/a4w88aD34I+/QVSfnoAnSBFPJHItlOMJVw== -moment@^2.22.1, moment@^2.24.0, moment@^2.29.1, moment@^2.29.4: +moment@2.29.4, moment@^2.22.1, moment@^2.24.0, moment@^2.29.1, moment@^2.29.4: version "2.29.4" resolved "https://registry.yarnpkg.com/moment/-/moment-2.29.4.tgz#3dbe052889fe7c1b2ed966fcb3a77328964ef108" integrity sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w==